mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
refactor(agent): V2-Agent-Split skeleton + infrastructure copy (M2)
Three new projects as a clean-slate V2 fork of the Legacy Agent:
AutopilotMonitor.Agent.V2 — net48 exe, minimal Program.cs,
buildcounter 2.0.N, version-v2.json
AutopilotMonitor.Agent.V2.Core — net48 library, 94 files copied from
Agent.Core via bulk sed namespace
rewrite; same folder layout
AutopilotMonitor.DecisionCore.Tests — xUnit net8.0 with ReplayHarness
+ 13 smoke/harness tests
V2 isolation is build-time-enforced via V2Isolation.targets imported from
both V2 csproj files — any Legacy ProjectReference fails the build with a
clear error. Grep-verified at commit time: zero `using
AutopilotMonitor.Agent.Core` statements and zero Legacy ProjectReferences
in the V2 tree.
Per plan §2.11, kernel items (CompletionStateMachine, CompletionGuards,
EnrollmentTracker*, Flows/*, WhiteGloveClassifier, WhiteGloveSignals,
EnrollmentStatePersistence) are transitionally retained so the V2 tree
compiles; M4 removes them when SignalAdapters + V2Orchestrator take over.
Per user direction (feedback_v2_kernel_only_refactor): the V2 refactor
replaces only the decision kernel. All peripheral capabilities — gather
rules, analyzers, diagnostics upload, timezone, server-driven actions,
summary dialog launch, env-var resolution, version checks, registry
watchers, network/DO/performance telemetry, self-update, bootstrap→cert
handover, self-destruct — are preserved verbatim via the bulk copy.
Supporting M3 bring-up platform:
- ReplayHarness (empty-stream deterministic, SHA256 hash over terminal
state fields) in DecisionCore.Tests/Harness/
- Fixture scaffold under tests/fixtures/: committed anonymized directory
with commit-policy README, plus gitignored enrollment-sessions-local/
for raw exports
- tools/export-session-fixture.mjs with --help / --dry-run / --validate
modes; real MCP wiring marked TODO(v2-m3)
V2 bootstrap scripts scripts/Bootstrap/Install-AutopilotMonitor-v2(.|-dev).ps1
(committed, pure ASCII verified per feedback_ps1_ascii_only). V2 deployment
scripts under scripts/Deployment/V2/ are gitignored (analog to Legacy).
Test totals at M2 gate: 13 DecisionCore + 507 Agent.Core (unchanged) +
557 Functions (unchanged) = 1077 green.
Plan: plans/REFACTOR_AGENT_V2.md §4 M2 + §2.16 V2-Agent-Split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -27,6 +27,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore", "src\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj", "{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2", "src\Agent\AutopilotMonitor.Agent.V2\AutopilotMonitor.Agent.V2.csproj", "{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.Core", "src\Agent\AutopilotMonitor.Agent.V2.Core\AutopilotMonitor.Agent.V2.Core.csproj", "{22CB0E75-793A-4FC9-ADE4-48580BCE6321}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.DecisionCore.Tests", "src\Agent\AutopilotMonitor.DecisionCore.Tests\AutopilotMonitor.DecisionCore.Tests.csproj", "{11B36E5C-B512-4D1E-A76B-E5AD866B033C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -133,6 +139,42 @@ Global
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054}.Release|x86.Build.0 = Release|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x64.Build.0 = Release|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321}.Release|x86.Build.0 = Release|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -149,5 +191,8 @@ Global
|
||||
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
|
||||
{F9DCEFA0-127B-4E3F-BF52-4680DB4CA054} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{22CB0E75-793A-4FC9-ADE4-48580BCE6321} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent.
|
||||
|
||||
.DESCRIPTION
|
||||
Funktionally identical to Install-AutopilotMonitor.ps1 with two differences:
|
||||
1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip
|
||||
2. Integrity check is read from version-dev.json (NOT version.json)
|
||||
This enables parallel lab testing of unreleased agent builds via a dedicated
|
||||
Intune Platform Script assignment, without touching any production artefacts.
|
||||
|
||||
See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256
|
||||
integrity verification, scheduled task registration via --install, etc.).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the DEV agent binaries from (ZIP file). Defaults to
|
||||
AutopilotMonitor-Agent-dev.zip in the same blob container as production.
|
||||
|
||||
.NOTES
|
||||
- ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM
|
||||
as ANSI and corrupts multi-byte chars.
|
||||
- Guards are intentionally identical to Prod bootstrapper so lab behaviour
|
||||
matches production behaviour on fresh images.
|
||||
|
||||
.CHANGELOG
|
||||
2026-04-20 v2.0-pre-dev Forked from Install-AutopilotMonitor-Dev.ps1 v1.1-dev for V2-Agent.
|
||||
Default URL -> AutopilotMonitor-Agent-V2-dev.zip,
|
||||
integrity file -> version-v2-dev.json,
|
||||
agent exe -> AutopilotMonitor.Agent.V2.exe.
|
||||
Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
|
||||
2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1.
|
||||
Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2-dev.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.0-pre-dev"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
# Ensure directories exist
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Output $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-FileMd5Base64 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$hashBytes = $md5.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
return [Convert]::ToBase64String($hashBytes)
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
Write-Log "Agent download URL: $AgentDownloadUrl"
|
||||
|
||||
# -- Pre-flight: Multi-signal guard (identical to production bootstrapper) --
|
||||
# Guard 1: Ghost re-installs (registry marker from previous deployment)
|
||||
# Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
|
||||
# Guard 3: Productive devices (a real user has logged on before)
|
||||
# Guard 4: Bootstrap window expired (device uptime > 12h without agent)
|
||||
# Guard 5: Agent binary already present from a previous run
|
||||
|
||||
# Guard 1
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 2
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
|
||||
$profileNames = @(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { Split-Path $_.LocalPath -Leaf }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
|
||||
if ($profileNames) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 4
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 5
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading DEV agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2-dev.json'
|
||||
$expectedSha256 = $null
|
||||
|
||||
try {
|
||||
Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2-dev.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$downloadResponse = $null
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadResponse = Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop `
|
||||
-PassThru
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
if ($expectedSha256) {
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against version-dev.json"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
}
|
||||
else {
|
||||
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
|
||||
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
|
||||
if ($expectedMd5 -notmatch '\S') {
|
||||
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
|
||||
}
|
||||
else {
|
||||
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
|
||||
Write-Log "Validating Content-MD5 header against downloaded ZIP"
|
||||
if ($actualMd5 -ne $expectedMd5) {
|
||||
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
|
||||
}
|
||||
Write-Log "MD5 integrity check passed"
|
||||
}
|
||||
}
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "DEV agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
Write-Log "===== Bootstrap Completed Successfully (DEV) ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED (DEV) ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent.
|
||||
|
||||
.DESCRIPTION
|
||||
This script is designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
It runs VERY EARLY in the enrollment process (first Intune action) and:
|
||||
1. Creates a unique session ID for this enrollment
|
||||
2. Downloads the monitoring agent binaries
|
||||
3. Verifies integrity via SHA-256 hash from version.json
|
||||
4. Installs agent binaries (agent uses built-in backend URL)
|
||||
5. Registers agent as Scheduled Task (runs on computer startup)
|
||||
6. Agent self-destructs when enrollment completes
|
||||
|
||||
INTEGRITY VERIFICATION:
|
||||
The script downloads version.json from the same blob container as the agent ZIP.
|
||||
If version.json contains a "sha256" field, the downloaded ZIP is verified against it
|
||||
using SHA-256. This prevents tampering during download (MITM) and detects corrupted
|
||||
downloads. If the hash does not match, installation is aborted.
|
||||
|
||||
For backward compatibility: if version.json does not contain a "sha256" field (older
|
||||
builds), the script falls back to the legacy Content-MD5 header check.
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent binaries from (ZIP file)
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-AutopilotMonitor.ps1
|
||||
(Uses built-in backend URL from the agent)
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment
|
||||
- Everything in C:\ProgramData\AutopilotMonitor (easy cleanup)
|
||||
- One Registry key left after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed (prevents ghost re-installs)
|
||||
- Scheduled Task survives reboots during enrollment
|
||||
- SHA-256 integrity verification since v1.0.706+
|
||||
- IMPORTANT: This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-04-20 v2.0-pre Forked from Install-AutopilotMonitor.ps1 v1.1 for V2-Agent.
|
||||
URL -> AutopilotMonitor-Agent-V2.zip, integrity file -> version-v2.json,
|
||||
agent exe -> AutopilotMonitor.Agent.V2.exe. Same guards + install flow.
|
||||
Plan: plans/REFACTOR_AGENT_V2.md section 4 M2.
|
||||
2026-04-09 v1.1 Introduced explicit script version and log it on startup
|
||||
(all entries above were v1.0)
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard:
|
||||
registry deployment marker, WMI/filesystem user profile detection,
|
||||
lastloggedonUser set, and 12h bootstrap window.
|
||||
2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke
|
||||
script parsing under PowerShell 5.1 / IME AgentExecutor
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json
|
||||
2026-02-16 Extended OS age default threshold
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent
|
||||
2026-02-12 More robust download with integrity check (Content-MD5)
|
||||
2026-02-12 Enhanced pre-flight checks, boot time support
|
||||
2026-02-12 Added pre-flight check: skip if agent already installed
|
||||
2026-02-05 Initial version
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-V2.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.0-pre"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
|
||||
# Ensure directories exist
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Output $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Get-FileMd5Base64 {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
try {
|
||||
$stream = [System.IO.File]::OpenRead($Path)
|
||||
try {
|
||||
$hashBytes = $md5.ComputeHash($stream)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
|
||||
return [Convert]::ToBase64String($hashBytes)
|
||||
}
|
||||
finally {
|
||||
$md5.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
# -- Pre-flight: Multi-signal guard to prevent installation on non-provisioning devices --
|
||||
# Layered approach: each guard catches a different scenario.
|
||||
# Guard 1: Ghost re-installs (registry marker from previous deployment)
|
||||
# Guard 2: Productive devices (real user profile exists -- WMI + filesystem)
|
||||
# Guard 3: Productive devices (a real user has logged on before)
|
||||
# Guard 4: Bootstrap window expired (device uptime > 12h without agent)
|
||||
# Guard 5: Agent binary already present from a previous run
|
||||
# NOTE: OOBEInProgress is NOT used -- it is unreliable (observed =0 during active enrollment).
|
||||
# NOTE: explorer.exe is NOT used -- it runs even in early OOBE right after script execution.
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard)
|
||||
# Combines WMI (Win32_UserProfile.Special flag) and filesystem for maximum reliability.
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$'
|
||||
|
||||
$profileNames = @(
|
||||
# WMI/CIM view -- Special flag reliably excludes SYSTEM/LocalService/NetworkService
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { Split-Path $_.LocalPath -Leaf }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
# Filesystem view -- catches profiles WMI might miss
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name
|
||||
) | Where-Object { $_ -and $_ -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
|
||||
if ($profileNames) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser -- during Device ESP no real user has logged on yet
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check (no key, no user profiles, but device running too long)
|
||||
# NOT "how long may enrollment take" -- agent handles that internally (6h emergency break).
|
||||
# This is "how old can the OOBE state be before I no longer trust it for initial install".
|
||||
# 12h bootstrap window vs 6h agent emergency break = consistent layered approach.
|
||||
# Sleep/standby does NOT reset uptime -- only real boot/restart does.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.V2.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# Derive version.json URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-v2.json'
|
||||
$expectedSha256 = $null
|
||||
|
||||
# Download version.json for SHA-256 integrity verification
|
||||
try {
|
||||
Write-Log "Fetching version.json from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from version.json: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "version.json has no sha256 field - falling back to legacy MD5 check (older build)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch version.json - falling back to legacy MD5 check: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-V2.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$downloadResponse = $null
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadResponse = Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop `
|
||||
-PassThru
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Integrity check: SHA-256 (preferred) or legacy Content-MD5 (fallback)
|
||||
if ($expectedSha256) {
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against version.json"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
}
|
||||
else {
|
||||
# Legacy fallback: Content-MD5 header check
|
||||
$expectedMd5Header = $downloadResponse.Headers["Content-MD5"]
|
||||
$expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() }
|
||||
if ($expectedMd5 -notmatch '\S') {
|
||||
Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation"
|
||||
}
|
||||
else {
|
||||
$actualMd5 = Get-FileMd5Base64 -Path $zipPath
|
||||
Write-Log "Validating Content-MD5 header against downloaded ZIP"
|
||||
if ($actualMd5 -ne $expectedMd5) {
|
||||
throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'"
|
||||
}
|
||||
Write-Log "MD5 integrity check passed"
|
||||
}
|
||||
}
|
||||
|
||||
# Extract to agent bin path
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
# Cleanup
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
# Verify extraction
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# Let the agent install/deploy itself and manage its own Scheduled Task
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
# some errors are captured by Intune but not fully visible in the UI (truncated, no stack trace), so also write critical info to stderr for better visibility.
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<RootNamespace>AutopilotMonitor.Agent.V2.Core</RootNamespace>
|
||||
<!--
|
||||
V2-Agent-Core library. Plan §2.16 strict separation rule:
|
||||
references ONLY AutopilotMonitor.Shared + AutopilotMonitor.DecisionCore.
|
||||
NEVER AutopilotMonitor.Agent.Core or AutopilotMonitor.Agent.
|
||||
Infra + collectors are copied in M2 from Legacy and pflegen sich ab dann hier eigenständig.
|
||||
-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.Agent.V2</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.DecisionCore.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.DecisionCore\AutopilotMonitor.DecisionCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
|
||||
<Import Project="V2Isolation.targets" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Embedded guardrails.json — identical source path as Legacy Agent.Core; LogicalName
|
||||
carries the V2 assembly namespace so GatherRuleGuards locates it under
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json". -->
|
||||
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Management" />
|
||||
<Reference Include="System.Management.Automation">
|
||||
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.ServiceProcess" />
|
||||
<Reference Include="System.IO.Compression" />
|
||||
<Reference Include="System.IO.Compression.FileSystem" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent configuration settings
|
||||
/// </summary>
|
||||
public class AgentConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Backend API base URL
|
||||
/// </summary>
|
||||
public string ApiBaseUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session identifier for this enrollment
|
||||
/// </summary>
|
||||
public string SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier
|
||||
/// </summary>
|
||||
public string TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Local spool directory path
|
||||
/// </summary>
|
||||
public string SpoolDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Agent log directory path
|
||||
/// </summary>
|
||||
public string LogDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Upload interval in seconds
|
||||
/// </summary>
|
||||
public int UploadIntervalSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum batch size for events
|
||||
/// </summary>
|
||||
public int MaxBatchSize { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use client certificate authentication
|
||||
/// </summary>
|
||||
public bool UseClientCertAuth { get; set; } = true;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity level: Info (default), Debug, Verbose.
|
||||
/// Overridable via remote config.
|
||||
/// </summary>
|
||||
public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum retry attempts for failed uploads
|
||||
/// </summary>
|
||||
public int MaxRetryAttempts { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files).
|
||||
/// This is the only cleanup mode — SelfDestruct always removes task + files.
|
||||
/// </summary>
|
||||
public bool SelfDestructOnComplete { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the Scheduled Task to remove during self-destruct
|
||||
/// </summary>
|
||||
public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to reboot the device after enrollment completes
|
||||
/// </summary>
|
||||
public bool RebootOnComplete { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X).
|
||||
/// Default: 10 seconds
|
||||
/// </summary>
|
||||
public int RebootDelaySeconds { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the log directory is preserved after self-destruct/cleanup.
|
||||
/// All other files (binaries, config, spool, scheduled task) are still removed.
|
||||
/// </summary>
|
||||
public bool KeepLogFile { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable geo-location detection (queries external IP services)
|
||||
/// </summary>
|
||||
public bool EnableGeoLocation { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// NTP server address for time check during enrollment.
|
||||
/// Default: "time.windows.com"
|
||||
/// </summary>
|
||||
public string NtpServer { get; set; } = "time.windows.com";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to automatically set the device timezone based on IP geolocation.
|
||||
/// Requires EnableGeoLocation to be true. Uses tzutil /s to apply.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableTimezoneAutoSet { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom path to IME logs directory for testing.
|
||||
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
|
||||
/// </summary>
|
||||
public string ImeLogPathOverride { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional path to write every IME log line that matched a pattern (for debugging).
|
||||
/// If empty, no match log is written. Example: %ProgramData%\AutopilotMonitor\Logs\ime-matches.log
|
||||
/// </summary>
|
||||
public string ImeMatchLogPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to directory with real IME log files for log replay.
|
||||
/// When set, the agent replays these logs with time compression instead of reading live logs.
|
||||
/// </summary>
|
||||
public string ReplayLogDir { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time compression factor for log replay.
|
||||
/// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay).
|
||||
/// </summary>
|
||||
public double ReplaySpeedFactor { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Wait time in seconds after ESP exit before marking Hello as skipped.
|
||||
/// When ESP exits, we wait this duration for Hello wizard to start (event 62404).
|
||||
/// Default: 30 seconds (reasonable for systems under load).
|
||||
/// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301).
|
||||
/// </summary>
|
||||
public int HelloWaitTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum consecutive authentication failures (401/403) before the agent shuts down.
|
||||
/// Prevents endless retry traffic when the device is not authorized.
|
||||
/// 0 = disabled (retry forever). Default: 5.
|
||||
/// </summary>
|
||||
public int MaxAuthFailures { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in minutes the agent keeps retrying after the first auth failure.
|
||||
/// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0.
|
||||
/// </summary>
|
||||
public int AuthFailureTimeoutMinutes { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum agent lifetime in minutes. Safety net to prevent zombie agents.
|
||||
/// The agent emits enrollment_failed with failureType "agent_timeout" when this expires.
|
||||
/// 0 = disabled (no lifetime limit). Default: 360 (6 hours).
|
||||
/// </summary>
|
||||
public int AgentMaxLifetimeMinutes { get; set; } = 360;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute maximum session age in hours across all agent restarts.
|
||||
/// Emergency break: if the session has been alive longer than this, the agent
|
||||
/// forces cleanup and self-destructs regardless of enrollment state.
|
||||
/// Prevents zombie agents caused by unrecoverable logic errors.
|
||||
/// Respects WhiteGlove scenarios (timer resets on Part 2 resume).
|
||||
/// Default: 48 hours.
|
||||
/// </summary>
|
||||
public int AbsoluteMaxSessionHours { get; set; } = 48;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap token for pre-MDM auth during OOBE.
|
||||
/// Embedded by the OOBE bootstrapper script via --bootstrap-token CLI arg.
|
||||
/// When set, sent as X-Bootstrap-Token header instead of cert auth.
|
||||
/// </summary>
|
||||
public string BootstrapToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use bootstrap token auth (true until MDM cert becomes available).
|
||||
/// </summary>
|
||||
public bool UseBootstrapTokenAuth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show a visual enrollment summary dialog to the end user.
|
||||
/// </summary>
|
||||
public bool ShowEnrollmentSummary { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-close timeout in seconds for the enrollment summary dialog.
|
||||
/// 0 = no auto-close. Default: 60
|
||||
/// </summary>
|
||||
public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional URL to a branding image for the enrollment summary dialog banner.
|
||||
/// </summary>
|
||||
public string EnrollmentSummaryBrandingImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in seconds the agent retries launching the enrollment summary dialog
|
||||
/// when the user's desktop is locked by a credential UI (e.g. Windows Hello).
|
||||
/// 0 = no retry (single attempt). Default: 120.
|
||||
/// </summary>
|
||||
public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Whether diagnostics upload is configured for this tenant.
|
||||
/// When true, the agent requests a short-lived upload URL from the backend just before uploading.
|
||||
/// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only.
|
||||
/// </summary>
|
||||
public bool DiagnosticsUploadEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When to upload diagnostics packages: "Off", "Always", "OnFailure".
|
||||
/// Default: "Off"
|
||||
/// </summary>
|
||||
public string DiagnosticsUploadMode { get; set; } = "Off";
|
||||
|
||||
/// <summary>
|
||||
/// Merged list of log paths/wildcards to include in the diagnostics ZIP package.
|
||||
/// Received from the backend config (global + tenant-specific).
|
||||
/// Each entry is validated against DiagnosticsPathGuards before use.
|
||||
/// </summary>
|
||||
public List<DiagnosticsLogPath> DiagnosticsLogPaths { get; set; } = new List<DiagnosticsLogPath>();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the agent should send Trace-severity events to the backend.
|
||||
/// Controlled per tenant via remote config. Default: true (on in preview).
|
||||
/// </summary>
|
||||
public bool SendTraceEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed.
|
||||
/// File/diagnostics paths allow everything except C:\Users.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool UnrestrictedMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Sanitized command-line arguments for the agent_started event.
|
||||
/// Secrets (e.g. bootstrap token) are redacted before assignment.
|
||||
/// </summary>
|
||||
public string CommandLineArgs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the agent waits for an MDM certificate to appear before starting.
|
||||
/// Used when deploying the agent before Intune enrollment completes.
|
||||
/// The agent polls the certificate store every 5 seconds until a valid cert is found.
|
||||
/// </summary>
|
||||
public bool AwaitEnrollment { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode.
|
||||
/// 0 = wait indefinitely. Default: 480 (8 hours).
|
||||
/// </summary>
|
||||
public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration
|
||||
/// </summary>
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrEmpty(ApiBaseUrl) &&
|
||||
!string.IsNullOrEmpty(SessionId) &&
|
||||
!string.IsNullOrEmpty(TenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches and caches remote configuration from the backend API
|
||||
/// Provides collector toggles and gather rules to the agent
|
||||
/// </summary>
|
||||
public class RemoteConfigService : IDisposable
|
||||
{
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly string _tenantId;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _cacheFilePath;
|
||||
private readonly EmergencyReporter _emergencyReporter;
|
||||
private readonly DistressReporter _distressReporter;
|
||||
|
||||
private AgentConfigResponse _currentConfig;
|
||||
private readonly object _configLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current configuration (thread-safe)
|
||||
/// </summary>
|
||||
public AgentConfigResponse CurrentConfig
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_configLock)
|
||||
{
|
||||
return _currentConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null)
|
||||
{
|
||||
_apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_emergencyReporter = emergencyReporter;
|
||||
_distressReporter = distressReporter;
|
||||
|
||||
var cacheDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config");
|
||||
_cacheFilePath = Path.Combine(cacheDir, "remote-config.json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the initial configuration from the backend
|
||||
/// Falls back to cached config if the API is unreachable
|
||||
/// </summary>
|
||||
public async Task<AgentConfigResponse> FetchConfigAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Fetching remote configuration from backend...");
|
||||
var config = await _apiClient.GetAgentConfigAsync(_tenantId);
|
||||
|
||||
if (config != null)
|
||||
{
|
||||
_logger.Info($"Remote config fetched: ConfigVersion={config.ConfigVersion}, " +
|
||||
$"GatherRules={config.GatherRules?.Count ?? 0}");
|
||||
|
||||
LogCollectorSettings(config.Collectors);
|
||||
SetConfig(config);
|
||||
CacheConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
_logger.Warning("Remote config fetch returned empty response");
|
||||
}
|
||||
catch (BackendAuthException ex)
|
||||
{
|
||||
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
|
||||
|
||||
// Auth failure → use distress channel (pre-auth, works without cert)
|
||||
if (_distressReporter != null)
|
||||
{
|
||||
_ = _distressReporter.TrySendAsync(
|
||||
DistressErrorType.ConfigFetchDenied,
|
||||
ex.Message,
|
||||
httpStatusCode: ex.StatusCode);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to fetch remote config: {ex.Message}");
|
||||
|
||||
// Non-auth failure → use authenticated emergency channel
|
||||
if (_emergencyReporter != null)
|
||||
{
|
||||
_ = _emergencyReporter.TrySendAsync(
|
||||
AgentErrorType.ConfigFetchFailed,
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to cached config
|
||||
var cached = LoadCachedConfig();
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.Info("Using cached remote configuration");
|
||||
SetConfig(cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fall back to defaults
|
||||
_logger.Info("No cached config available, using defaults (all optional collectors disabled)");
|
||||
var defaults = CreateDefaultConfig();
|
||||
SetConfig(defaults);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
private void SetConfig(AgentConfigResponse config)
|
||||
{
|
||||
lock (_configLock)
|
||||
{
|
||||
_currentConfig = config;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogCollectorSettings(CollectorConfiguration collectors)
|
||||
{
|
||||
if (collectors == null) return;
|
||||
|
||||
_logger.Info(" Collector settings:");
|
||||
_logger.Info($" Performance: {(collectors.EnablePerformanceCollector ? "ON" : "OFF")} (interval: {collectors.PerformanceIntervalSeconds}s)");
|
||||
}
|
||||
|
||||
private void CacheConfig(AgentConfigResponse config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_cacheFilePath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
// SECURITY: Never persist UnrestrictedMode to disk — require live backend auth
|
||||
var liveValue = config.UnrestrictedMode;
|
||||
config.UnrestrictedMode = false;
|
||||
var json = JsonConvert.SerializeObject(config, Formatting.Indented);
|
||||
config.UnrestrictedMode = liveValue;
|
||||
|
||||
File.WriteAllText(_cacheFilePath, json);
|
||||
_logger.Debug("Remote config cached to disk");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"Failed to cache remote config: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private AgentConfigResponse LoadCachedConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_cacheFilePath))
|
||||
{
|
||||
var json = File.ReadAllText(_cacheFilePath);
|
||||
var config = JsonConvert.DeserializeObject<AgentConfigResponse>(json);
|
||||
if (config != null)
|
||||
{
|
||||
// SECURITY: Never trust cached UnrestrictedMode — always require live backend auth
|
||||
config.UnrestrictedMode = false;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"Failed to load cached config: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private AgentConfigResponse CreateDefaultConfig()
|
||||
{
|
||||
return new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 0,
|
||||
UploadIntervalSeconds = 30,
|
||||
SelfDestructOnComplete = true,
|
||||
KeepLogFile = false,
|
||||
EnableGeoLocation = true,
|
||||
EnableImeMatchLog = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Log verbosity levels for the agent.
|
||||
/// Info = normal operational messages (default)
|
||||
/// Debug = includes DEBUG-tagged lines (component state, config, decisions)
|
||||
/// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing)
|
||||
/// </summary>
|
||||
public enum AgentLogLevel
|
||||
{
|
||||
Info = 0,
|
||||
Debug = 1,
|
||||
Verbose = 2,
|
||||
Trace = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple file-based logger for the agent.
|
||||
/// Optionally mirrors output to the console when EnableConsoleOutput is set.
|
||||
/// </summary>
|
||||
public class AgentLogger
|
||||
{
|
||||
private readonly string _logFilePath;
|
||||
private AgentLogLevel _logLevel;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
/// <summary>
|
||||
/// When true, log entries are also written to Console.Out (same format as the log file).
|
||||
/// Set via --console flag or programmatically.
|
||||
/// </summary>
|
||||
public bool EnableConsoleOutput { get; set; }
|
||||
|
||||
public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info)
|
||||
{
|
||||
_logLevel = logLevel;
|
||||
|
||||
if (!Directory.Exists(logDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
}
|
||||
|
||||
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
|
||||
_logFilePath = Path.Combine(logDirectory, logFileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active log level at runtime (e.g. after remote config is applied).
|
||||
/// </summary>
|
||||
public void SetLogLevel(AgentLogLevel level)
|
||||
{
|
||||
_logLevel = level;
|
||||
}
|
||||
|
||||
public AgentLogLevel LogLevel => _logLevel;
|
||||
|
||||
public void Trace(string message)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Trace)
|
||||
Log("TRACE", message);
|
||||
}
|
||||
|
||||
public void Verbose(string message)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Verbose)
|
||||
Log("VERBOSE", message);
|
||||
}
|
||||
|
||||
public void Debug(string message)
|
||||
{
|
||||
if (_logLevel >= AgentLogLevel.Debug)
|
||||
Log("DEBUG", message);
|
||||
}
|
||||
|
||||
public void Info(string message)
|
||||
{
|
||||
Log("INFO", message);
|
||||
}
|
||||
|
||||
public void Warning(string message)
|
||||
{
|
||||
Log("WARN", message);
|
||||
}
|
||||
|
||||
public void Error(string message, Exception ex = null)
|
||||
{
|
||||
var fullMessage = ex != null ? $"{message} - {ex}" : message;
|
||||
Log("ERROR", fullMessage);
|
||||
}
|
||||
|
||||
private void Log(string level, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var logEntry = $"[{timestamp}] [{level}] {SanitizeLogMessage(message)}";
|
||||
|
||||
File.AppendAllText(_logFilePath, logEntry + Environment.NewLine);
|
||||
|
||||
if (EnableConsoleOutput)
|
||||
{
|
||||
try { Console.WriteLine(logEntry); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow logging errors to prevent crashes
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes log messages to prevent log injection attacks.
|
||||
/// Escapes control characters that could forge log entries.
|
||||
/// Redaction patterns can be added here later as needed.
|
||||
/// </summary>
|
||||
private static string SanitizeLogMessage(string message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message)) return message;
|
||||
|
||||
// Escape newlines and carriage returns to prevent log injection
|
||||
message = message.Replace("\r", "\\r").Replace("\n", "\\n");
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure-function guards for enrollment completion decisions.
|
||||
/// Extracted 1:1 from the lock block in TryEmitEnrollmentComplete() for testability.
|
||||
/// Each method mirrors the exact condition evaluated in CompletionLogic.cs.
|
||||
/// </summary>
|
||||
internal static class CompletionGuards
|
||||
{
|
||||
/// <summary>
|
||||
/// Guard 1: Hello resolution check.
|
||||
/// Hello is considered resolved when:
|
||||
/// - No EspAndHelloTracker exists (no Hello tracking)
|
||||
/// - Hello has already completed (success, skip, or timeout)
|
||||
/// - Hello policy is not configured (Hello disabled)
|
||||
/// - Deployment is device-only (no interactive user session)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
|
||||
/// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly
|
||||
/// </remarks>
|
||||
public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted,
|
||||
bool isPolicyConfigured, bool isDeviceOnly)
|
||||
{
|
||||
return !hasTracker
|
||||
|| isHelloCompleted
|
||||
|| !isPolicyConfigured
|
||||
|| isDeviceOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guard 2: ESP gate for desktop-based completion sources.
|
||||
/// Blocks completion when:
|
||||
/// - Source is desktop_arrival or desktop_hello
|
||||
/// - Enrollment type is v1 (not WDP)
|
||||
/// - ESP has been seen but has NOT exited yet
|
||||
/// Rationale: In v1 enrollments with active ESP, desktop presence alone is
|
||||
/// NOT sufficient to complete (AccountSetup may still be running).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
|
||||
/// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen
|
||||
/// </remarks>
|
||||
public static bool IsEspGateBlocking(string source, string enrollmentType,
|
||||
bool espEverSeen, bool espFinalExitSeen)
|
||||
{
|
||||
return (source == "desktop_arrival" || source == "desktop_hello")
|
||||
&& enrollmentType != "v2"
|
||||
&& espEverSeen
|
||||
&& !espFinalExitSeen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guard 3: Hybrid join reboot gate.
|
||||
/// In hybrid join, ESP may exit for a mid-enrollment reboot (domain user login required).
|
||||
/// Blocks esp_hello_composite unless we have stronger confirmation:
|
||||
/// - IME user session completed (imePatternSeenUtc has value)
|
||||
/// - Agent restarted after ESP exit (agentStartTimeUtc > espFinalExitUtc)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
|
||||
/// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; }
|
||||
/// </remarks>
|
||||
public static bool IsHybridRebootGateBlocking(bool isHybridJoin, string source,
|
||||
DateTime? imePatternSeenUtc, DateTime? espFinalExitUtc, DateTime agentStartTimeUtc)
|
||||
{
|
||||
if (!isHybridJoin || source != "esp_hello_composite")
|
||||
return false;
|
||||
|
||||
bool hasImeCompletion = imePatternSeenUtc.HasValue;
|
||||
bool agentRestartedAfterEspExit = espFinalExitUtc.HasValue
|
||||
&& agentStartTimeUtc > espFinalExitUtc.Value;
|
||||
|
||||
return !hasImeCompletion && !agentRestartedAfterEspExit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deployment classification: device-only check.
|
||||
/// A deployment is device-only when:
|
||||
/// - Self-Deploying mode (autopilotMode == 1), OR
|
||||
/// - SkipUserStatusPage == true AND no user has AAD-joined
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment
|
||||
/// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser)
|
||||
/// </remarks>
|
||||
public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
|
||||
bool aadJoinedWithUser)
|
||||
{
|
||||
bool isSelfDeploying = autopilotMode == 1;
|
||||
return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
+820
@@ -0,0 +1,820 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only snapshot of all state needed for guard evaluation and transition decisions.
|
||||
/// Populated under lock in the caller, consumed outside lock by the state machine.
|
||||
/// Preserves the existing thread-safety pattern from EnrollmentTracker.
|
||||
/// </summary>
|
||||
internal class CompletionContext
|
||||
{
|
||||
// Deployment classification
|
||||
public string EnrollmentType { get; set; }
|
||||
public int? AutopilotMode { get; set; }
|
||||
public bool? SkipUserStatusPage { get; set; }
|
||||
public bool AadJoinedWithUser { get; set; }
|
||||
public bool IsHybridJoin { get; set; }
|
||||
|
||||
// Computed deployment classification (mirrors IsDeviceOnlyDeployment property)
|
||||
public bool IsDeviceOnly => CompletionGuards.IsDeviceOnlyDeployment(
|
||||
AutopilotMode, SkipUserStatusPage, AadJoinedWithUser);
|
||||
|
||||
// Signal state
|
||||
public bool EspEverSeen { get; set; }
|
||||
public bool EspFinalExitSeen { get; set; }
|
||||
public bool DesktopArrived { get; set; }
|
||||
public string LastEspPhase { get; set; }
|
||||
public bool DeviceInfoCollected { get; set; }
|
||||
|
||||
// Hello state (from EspAndHelloTracker)
|
||||
public bool HasHelloTracker { get; set; }
|
||||
public bool IsHelloCompleted { get; set; }
|
||||
public bool IsHelloPolicyConfigured { get; set; }
|
||||
public bool HasUnresolvedEspCategories { get; set; }
|
||||
public bool HasAccountSetupActivity { get; set; }
|
||||
|
||||
// WhiteGlove detection signals
|
||||
public bool WhiteGloveStartDetected { get; set; }
|
||||
public bool HasSaveWhiteGloveSuccessResult { get; set; }
|
||||
public bool ShellCoreWhiteGloveSuccess { get; set; }
|
||||
public bool IsFooUserDetected { get; set; }
|
||||
|
||||
// Legacy helper — kept for backwards-compat with places that only need a binary
|
||||
// "there is some WG evidence" read. Actual classification now goes through
|
||||
// <see cref="WhiteGloveClassifier"/>.
|
||||
public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult;
|
||||
|
||||
// Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1
|
||||
// boundary marker (the restart between DeviceSetup-complete and user sign-in).
|
||||
public bool AgentRestartedAfterEspExit
|
||||
=> EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value;
|
||||
|
||||
// Timing
|
||||
public DateTime AgentStartTimeUtc { get; set; }
|
||||
public DateTime? EspFinalExitUtc { get; set; }
|
||||
public DateTime? ImePatternSeenUtc { get; set; }
|
||||
|
||||
// Trigger metadata
|
||||
public string Source { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a state machine transition. Contains the new state and metadata
|
||||
/// about what actions the caller should take (emit events, start timers, etc.).
|
||||
/// The state machine itself is side-effect-free — all actions are communicated
|
||||
/// via this result object to preserve the lock-free emission pattern.
|
||||
/// </summary>
|
||||
internal class TransitionResult
|
||||
{
|
||||
public static readonly TransitionResult NoTransition = new TransitionResult
|
||||
{
|
||||
Transitioned = false,
|
||||
NewState = EnrollmentCompletionState.Idle
|
||||
};
|
||||
|
||||
public bool Transitioned { get; set; }
|
||||
public EnrollmentCompletionState NewState { get; set; }
|
||||
public EnrollmentCompletionState PreviousState { get; set; }
|
||||
public string CompletionSource { get; set; }
|
||||
|
||||
// Action flags — caller executes these OUTSIDE lock
|
||||
public bool ShouldEmitEnrollmentComplete { get; set; }
|
||||
public bool ShouldEmitEnrollmentFailed { get; set; }
|
||||
public bool ShouldEmitWhiteGloveComplete { get; set; }
|
||||
public bool ShouldStartHelloWaitTimer { get; set; }
|
||||
public bool ShouldStartHelloSafetyTimer { get; set; }
|
||||
public bool ShouldStartEspSettleTimer { get; set; }
|
||||
public bool ShouldStartDeviceOnlyEspTimer { get; set; }
|
||||
public bool ShouldStartDeviceOnlySafetyTimer { get; set; }
|
||||
public bool ShouldForceMarkHelloCompleted { get; set; }
|
||||
public bool ShouldResetEspForResumption { get; set; }
|
||||
public bool ShouldPersistState { get; set; }
|
||||
public string FailureType { get; set; }
|
||||
public string DeferredSource { get; set; }
|
||||
|
||||
// Signals to record
|
||||
public List<string> SignalsToRecord { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explicit state machine for enrollment completion logic.
|
||||
/// Replaces the implicit boolean-flag-based state tracking in CompletionLogic.cs.
|
||||
///
|
||||
/// Thread safety: The ProcessTrigger method is thread-safe. State reads and transitions
|
||||
/// happen under a single lock (microsecond-level critical sections). All side effects
|
||||
/// (event emission, timer management) are communicated via TransitionResult and executed
|
||||
/// by the caller OUTSIDE the lock — identical pattern to the original code.
|
||||
///
|
||||
/// This class is intentionally side-effect-free: it computes state transitions and
|
||||
/// returns action flags. The caller (EnrollmentTracker) owns the actual side effects.
|
||||
/// </summary>
|
||||
internal class CompletionStateMachine
|
||||
{
|
||||
private readonly object _lock = new object();
|
||||
private EnrollmentCompletionState _currentState = EnrollmentCompletionState.Idle;
|
||||
private string _deferredSource;
|
||||
|
||||
// Signal state (mirrored from EnrollmentTracker fields)
|
||||
private bool _espEverSeen;
|
||||
private bool _espFinalExitSeen;
|
||||
private bool _desktopArrived;
|
||||
private bool _isWaitingForHello;
|
||||
private bool _isWaitingForEspSettle;
|
||||
|
||||
public EnrollmentCompletionState CurrentState
|
||||
{
|
||||
get { lock (_lock) { return _currentState; } }
|
||||
}
|
||||
|
||||
// Expose internal state for shadow-mode comparison and persistence
|
||||
internal bool EspEverSeen { get { lock (_lock) { return _espEverSeen; } } }
|
||||
internal bool EspFinalExitSeen { get { lock (_lock) { return _espFinalExitSeen; } } }
|
||||
internal bool DesktopArrived { get { lock (_lock) { return _desktopArrived; } } }
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs state from persisted EnrollmentStateData (crash recovery).
|
||||
/// Must be called before Start() if recovering from a crash.
|
||||
/// </summary>
|
||||
public void RestoreState(EnrollmentCompletionState state, bool espEverSeen,
|
||||
bool espFinalExitSeen, bool desktopArrived, bool isWaitingForHello,
|
||||
bool isWaitingForEspSettle, string deferredSource)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentState = state;
|
||||
_espEverSeen = espEverSeen;
|
||||
_espFinalExitSeen = espFinalExitSeen;
|
||||
_desktopArrived = desktopArrived;
|
||||
_isWaitingForHello = isWaitingForHello;
|
||||
_isWaitingForEspSettle = isWaitingForEspSettle;
|
||||
_deferredSource = deferredSource;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the explicit state from boolean flags (backwards compatibility).
|
||||
/// Used when loading state from an old-format persistence file that doesn't
|
||||
/// contain the CompletionState field.
|
||||
/// </summary>
|
||||
public static EnrollmentCompletionState ReconstructStateFromFlags(
|
||||
bool enrollmentCompleteEmitted, bool isWaitingForHello, bool isWaitingForEspSettle,
|
||||
bool espFinalExitSeen, bool desktopArrived, bool espEverSeen,
|
||||
bool isDeviceOnly)
|
||||
{
|
||||
if (enrollmentCompleteEmitted)
|
||||
return EnrollmentCompletionState.Completed;
|
||||
if (isWaitingForHello)
|
||||
return EnrollmentCompletionState.WaitingForHello;
|
||||
if (isWaitingForEspSettle)
|
||||
return EnrollmentCompletionState.WaitingForEspSettle;
|
||||
if (isDeviceOnly && espFinalExitSeen)
|
||||
return EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
|
||||
if (espFinalExitSeen && desktopArrived)
|
||||
return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
|
||||
if (espFinalExitSeen)
|
||||
return EnrollmentCompletionState.EspExitedAwaitingCompletion;
|
||||
if (espEverSeen && desktopArrived)
|
||||
return EnrollmentCompletionState.DesktopArrivedEspBlocking;
|
||||
if (espEverSeen)
|
||||
return EnrollmentCompletionState.EspActive;
|
||||
if (desktopArrived)
|
||||
return EnrollmentCompletionState.DesktopArrivedAwaitingHello;
|
||||
return EnrollmentCompletionState.Idle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core state machine: processes a trigger against the current state and context.
|
||||
/// Returns a TransitionResult describing what happened and what actions to take.
|
||||
///
|
||||
/// The state transition happens atomically under lock.
|
||||
/// All side-effect instructions are in the returned result — caller executes them outside lock.
|
||||
/// </summary>
|
||||
public TransitionResult ProcessTrigger(string trigger, CompletionContext ctx)
|
||||
{
|
||||
if (string.IsNullOrEmpty(trigger))
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Sync internal signal flags from the authoritative tracker snapshot.
|
||||
// Monoton false→true: these signals are stable-once-seen. The single
|
||||
// intentional reset (HandleEspResumed clearing _espFinalExitSeen) runs
|
||||
// AFTER this sync inside its own switch case, so it remains the last writer.
|
||||
// Without this, the SM can drift from the tracker (e.g. when
|
||||
// _stateData.EspEverSeen=true was set in memory but Save() hadn't run
|
||||
// before a reboot — restore loads false and downstream guards misfire).
|
||||
if (ctx != null)
|
||||
{
|
||||
if (ctx.EspEverSeen) _espEverSeen = true;
|
||||
if (ctx.EspFinalExitSeen) _espFinalExitSeen = true;
|
||||
if (ctx.DesktopArrived) _desktopArrived = true;
|
||||
}
|
||||
|
||||
// Terminal states: no transitions allowed
|
||||
if (_currentState.IsTerminal())
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
switch (trigger)
|
||||
{
|
||||
case "esp_phase_changed":
|
||||
return HandleEspPhaseChanged(ctx);
|
||||
|
||||
case "esp_exiting":
|
||||
return HandleEspExiting(ctx);
|
||||
|
||||
case "desktop_arrived":
|
||||
return HandleDesktopArrived(ctx);
|
||||
|
||||
case "hello_completed":
|
||||
return HandleHelloCompleted(ctx);
|
||||
|
||||
case "ime_user_session_completed":
|
||||
return HandleImeUserSessionCompleted(ctx);
|
||||
|
||||
case "device_setup_provisioning_complete":
|
||||
return HandleDeviceSetupProvisioningComplete(ctx);
|
||||
|
||||
case "hello_safety_timeout":
|
||||
return HandleHelloSafetyTimeout(ctx);
|
||||
|
||||
case "esp_settle_timeout":
|
||||
return HandleEspSettleTimeout(ctx);
|
||||
|
||||
case "device_only_esp_timer_expired":
|
||||
return HandleDeviceOnlyEspTimerExpired(ctx);
|
||||
|
||||
case "device_only_safety_timeout":
|
||||
return HandleDeviceOnlySafetyTimeout(ctx);
|
||||
|
||||
case "esp_failure_terminal":
|
||||
return HandleEspFailureTerminal(ctx);
|
||||
|
||||
case "esp_failure_grace_expired":
|
||||
return HandleEspFailureTerminal(ctx);
|
||||
|
||||
case "whiteglove_complete":
|
||||
return HandleWhiteGloveComplete(ctx);
|
||||
|
||||
case "esp_resumed":
|
||||
return HandleEspResumed(ctx);
|
||||
|
||||
case "device_info_collected":
|
||||
return HandleDeviceInfoCollected(ctx);
|
||||
|
||||
default:
|
||||
return TransitionResult.NoTransition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Trigger Handlers (all called under _lock) =====
|
||||
|
||||
private TransitionResult HandleEspPhaseChanged(CompletionContext ctx)
|
||||
{
|
||||
if (_currentState != EnrollmentCompletionState.Idle
|
||||
&& _currentState != EnrollmentCompletionState.EspActive)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
_espEverSeen = true;
|
||||
_currentState = EnrollmentCompletionState.EspActive;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = prev != _currentState,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
SignalsToRecord = new List<string> { "esp_phase_changed" }
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleEspExiting(CompletionContext ctx)
|
||||
{
|
||||
// ESP exiting is only relevant when ESP has been seen
|
||||
if (!_espEverSeen)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
var result = new TransitionResult { PreviousState = prev, SignalsToRecord = new List<string>() };
|
||||
|
||||
// Path A: AccountSetup completed, desktop already arrived, or provisioning registry confirms AccountSetup
|
||||
bool accountSetupFromProvisioning = ctx.LastEspPhase == null && ctx.HasAccountSetupActivity;
|
||||
if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)
|
||||
|| _desktopArrived
|
||||
|| accountSetupFromProvisioning)
|
||||
{
|
||||
_espFinalExitSeen = true;
|
||||
_currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
|
||||
result.SignalsToRecord.Add("esp_final_exit");
|
||||
result.ShouldStartHelloWaitTimer = true;
|
||||
result.ShouldPersistState = true;
|
||||
|
||||
// Check if we can complete immediately
|
||||
if (CanComplete(ctx, "esp_hello_composite"))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "esp_hello_composite";
|
||||
}
|
||||
else if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
|
||||
ctx.IsHybridJoin, "esp_hello_composite",
|
||||
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
|
||||
}
|
||||
}
|
||||
// Path B: Registry definitively says device-only
|
||||
else if (ctx.SkipUserStatusPage == true)
|
||||
{
|
||||
// WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
|
||||
// HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths).
|
||||
// Weak confidence falls through to the regular device-only path by design.
|
||||
var wgSignals = WhiteGloveSignals.FromContext(ctx);
|
||||
var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
|
||||
if (wgClassification.ShouldRouteToWhiteGlovePart1)
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
|
||||
result.ShouldEmitWhiteGloveComplete = true;
|
||||
result.SignalsToRecord.Add("whiteglove_guard_esp_exiting");
|
||||
result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
|
||||
result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
_espFinalExitSeen = true;
|
||||
_currentState = EnrollmentCompletionState.DeviceOnlyAwaitingCompletion;
|
||||
result.SignalsToRecord.Add("device_only_esp_registry");
|
||||
result.ShouldStartHelloWaitTimer = true;
|
||||
result.ShouldPersistState = true;
|
||||
|
||||
if (CanComplete(ctx, "device_only_esp_registry"))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "device_only_esp_registry";
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.DeviceOnlySafetyWait;
|
||||
result.ShouldStartDeviceOnlySafetyTimer = true;
|
||||
}
|
||||
}
|
||||
// Path C: Unknown — start device-only detection timer
|
||||
else
|
||||
{
|
||||
result.ShouldStartDeviceOnlyEspTimer = true;
|
||||
// State stays as-is (EspActive or DesktopArrivedEspBlocking)
|
||||
}
|
||||
|
||||
result.Transitioned = prev != _currentState;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
private TransitionResult HandleDesktopArrived(CompletionContext ctx)
|
||||
{
|
||||
if (_desktopArrived)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
_desktopArrived = true;
|
||||
|
||||
var result = new TransitionResult
|
||||
{
|
||||
PreviousState = prev,
|
||||
ShouldPersistState = true,
|
||||
SignalsToRecord = new List<string> { "desktop_arrived" }
|
||||
};
|
||||
|
||||
// SkipUserStatusPage handling: if ESP seen and SkipUser=true but no final exit yet
|
||||
if (_espEverSeen && ctx.SkipUserStatusPage == true && !_espFinalExitSeen)
|
||||
{
|
||||
_espFinalExitSeen = true;
|
||||
result.SignalsToRecord.Add("desktop_arrived_skip_user");
|
||||
}
|
||||
|
||||
// Determine target state based on ESP status
|
||||
if (_espEverSeen && !_espFinalExitSeen)
|
||||
{
|
||||
// ESP still active — gate blocks
|
||||
_currentState = EnrollmentCompletionState.DesktopArrivedEspBlocking;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No ESP blocking — can proceed toward completion
|
||||
_currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
|
||||
|
||||
// Start Hello wait timer if needed (no active ESP, not device-only)
|
||||
if (!ctx.IsDeviceOnly && ctx.HasHelloTracker && !ctx.IsHelloCompleted)
|
||||
{
|
||||
result.ShouldStartHelloWaitTimer = true;
|
||||
}
|
||||
|
||||
// Try immediate completion
|
||||
if (CanComplete(ctx, "desktop_arrival"))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "desktop_arrival";
|
||||
}
|
||||
}
|
||||
|
||||
result.Transitioned = prev != _currentState;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
private TransitionResult HandleHelloCompleted(CompletionContext ctx)
|
||||
{
|
||||
var prev = _currentState;
|
||||
var result = new TransitionResult
|
||||
{
|
||||
PreviousState = prev,
|
||||
SignalsToRecord = new List<string> { "hello_resolved" }
|
||||
};
|
||||
|
||||
if (_isWaitingForHello)
|
||||
{
|
||||
// IME path: was waiting for Hello
|
||||
_isWaitingForHello = false;
|
||||
if (CanCompleteWithHelloResolved(ctx, "ime_hello"))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "ime_hello";
|
||||
}
|
||||
}
|
||||
else if (_espFinalExitSeen)
|
||||
{
|
||||
// ESP composite path
|
||||
string source = "esp_hello_composite";
|
||||
if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking(
|
||||
ctx.IsHybridJoin, source,
|
||||
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.HybridRebootGateBlocked;
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = source;
|
||||
}
|
||||
}
|
||||
else if (_desktopArrived)
|
||||
{
|
||||
// Desktop + Hello path
|
||||
string source = "desktop_hello";
|
||||
if (!CompletionGuards.IsEspGateBlocking(source, ctx.EnrollmentType,
|
||||
_espEverSeen, _espFinalExitSeen))
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = source;
|
||||
}
|
||||
}
|
||||
|
||||
result.Transitioned = prev != _currentState;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
private TransitionResult HandleImeUserSessionCompleted(CompletionContext ctx)
|
||||
{
|
||||
var prev = _currentState;
|
||||
var result = new TransitionResult
|
||||
{
|
||||
PreviousState = prev,
|
||||
SignalsToRecord = new List<string> { "ime_pattern" }
|
||||
};
|
||||
|
||||
// Device-only: skip Hello entirely
|
||||
if (ctx.IsDeviceOnly)
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "ime_pattern";
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Hello configured but not yet completed — wait
|
||||
if (ctx.HasHelloTracker && ctx.IsHelloPolicyConfigured && !ctx.IsHelloCompleted)
|
||||
{
|
||||
_isWaitingForHello = true;
|
||||
_currentState = EnrollmentCompletionState.WaitingForHello;
|
||||
result.ShouldStartHelloWaitTimer = true;
|
||||
result.ShouldStartHelloSafetyTimer = true;
|
||||
result.ShouldPersistState = true;
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ESP categories unresolved — wait for settle
|
||||
if (ctx.HasHelloTracker && ctx.HasUnresolvedEspCategories)
|
||||
{
|
||||
_isWaitingForEspSettle = true;
|
||||
_currentState = EnrollmentCompletionState.WaitingForEspSettle;
|
||||
result.ShouldStartEspSettleTimer = true;
|
||||
result.ShouldPersistState = true;
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
// All clear — complete
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "ime_pattern";
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
private TransitionResult HandleDeviceSetupProvisioningComplete(CompletionContext ctx)
|
||||
{
|
||||
var prev = _currentState;
|
||||
var result = new TransitionResult
|
||||
{
|
||||
PreviousState = prev,
|
||||
SignalsToRecord = new List<string> { "device_setup_provisioning_complete" }
|
||||
};
|
||||
|
||||
// WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as
|
||||
// EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and
|
||||
// TryEmitEnrollmentComplete). Weak confidence falls through to the regular
|
||||
// self-deploying path by design (asymmetric-conservative routing).
|
||||
var wgSignals = WhiteGloveSignals.FromContext(ctx);
|
||||
var wgClassification = WhiteGloveClassifier.Classify(wgSignals);
|
||||
if (wgClassification.ShouldRouteToWhiteGlovePart1)
|
||||
{
|
||||
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
|
||||
result.ShouldEmitWhiteGloveComplete = true;
|
||||
result.SignalsToRecord.Add("whiteglove_guard_activated");
|
||||
result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}");
|
||||
result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}");
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!ctx.IsDeviceOnly)
|
||||
{
|
||||
// Not device-only: check if device info collected yet
|
||||
if (!ctx.DeviceInfoCollected)
|
||||
{
|
||||
_deferredSource = "device_setup_provisioning_complete";
|
||||
_currentState = EnrollmentCompletionState.DeferredForDeviceInfo;
|
||||
result.DeferredSource = _deferredSource;
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
// Not device-only and device info collected: normal paths handle it
|
||||
return TransitionResult.NoTransition;
|
||||
}
|
||||
|
||||
// Device-only: mark ESP as seen and exited
|
||||
if (!_espEverSeen) _espEverSeen = true;
|
||||
if (!_espFinalExitSeen)
|
||||
{
|
||||
_espFinalExitSeen = true;
|
||||
result.SignalsToRecord.Add("self_deploying_esp_final_exit");
|
||||
}
|
||||
result.ShouldPersistState = true;
|
||||
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
result.ShouldEmitEnrollmentComplete = true;
|
||||
result.CompletionSource = "self_deploying_provisioning_complete";
|
||||
result.Transitioned = true;
|
||||
result.NewState = _currentState;
|
||||
return result;
|
||||
}
|
||||
|
||||
private TransitionResult HandleHelloSafetyTimeout(CompletionContext ctx)
|
||||
{
|
||||
if (!_isWaitingForHello)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
_isWaitingForHello = false;
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldForceMarkHelloCompleted = true,
|
||||
ShouldEmitEnrollmentComplete = true,
|
||||
CompletionSource = "ime_hello_safety_timeout",
|
||||
SignalsToRecord = new List<string> { "ime_hello_safety_timeout" }
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleEspSettleTimeout(CompletionContext ctx)
|
||||
{
|
||||
if (!_isWaitingForEspSettle)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
_isWaitingForEspSettle = false;
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldEmitEnrollmentComplete = true,
|
||||
CompletionSource = "ime_pattern",
|
||||
SignalsToRecord = new List<string> { "esp_provisioning_settled" }
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleDeviceOnlyEspTimerExpired(CompletionContext ctx)
|
||||
{
|
||||
// AccountSetup started meanwhile? Timer is obsolete
|
||||
if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
if (!_desktopArrived)
|
||||
return TransitionResult.NoTransition; // Wait for desktop
|
||||
|
||||
var prev = _currentState;
|
||||
_espFinalExitSeen = true;
|
||||
_currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldStartHelloWaitTimer = true,
|
||||
ShouldPersistState = true,
|
||||
SignalsToRecord = new List<string> { "device_only_esp_final_exit" }
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleDeviceOnlySafetyTimeout(CompletionContext ctx)
|
||||
{
|
||||
var prev = _currentState;
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldForceMarkHelloCompleted = true,
|
||||
ShouldEmitEnrollmentComplete = true,
|
||||
CompletionSource = "device_only_esp_safety_timeout",
|
||||
SignalsToRecord = new List<string> { "device_only_esp_safety_timeout" }
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleEspFailureTerminal(CompletionContext ctx)
|
||||
{
|
||||
var prev = _currentState;
|
||||
_currentState = EnrollmentCompletionState.Failed;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldEmitEnrollmentFailed = true,
|
||||
FailureType = ctx.Source
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleWhiteGloveComplete(CompletionContext ctx)
|
||||
{
|
||||
var prev = _currentState;
|
||||
_currentState = EnrollmentCompletionState.WhiteGloveCompleted;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldEmitWhiteGloveComplete = true
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleEspResumed(CompletionContext ctx)
|
||||
{
|
||||
if (!ctx.IsHybridJoin || !_espFinalExitSeen)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
_espFinalExitSeen = false;
|
||||
_currentState = EnrollmentCompletionState.EspActive;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldResetEspForResumption = true,
|
||||
ShouldPersistState = true,
|
||||
SignalsToRecord = new List<string> { "esp_resumed" }
|
||||
};
|
||||
}
|
||||
|
||||
private TransitionResult HandleDeviceInfoCollected(CompletionContext ctx)
|
||||
{
|
||||
if (_currentState != EnrollmentCompletionState.DeferredForDeviceInfo)
|
||||
return TransitionResult.NoTransition;
|
||||
|
||||
var prev = _currentState;
|
||||
var deferredSource = _deferredSource;
|
||||
_deferredSource = null;
|
||||
|
||||
// Re-evaluate: now that device info is available, check if device-only
|
||||
if (ctx.IsDeviceOnly)
|
||||
{
|
||||
if (!_espEverSeen) _espEverSeen = true;
|
||||
if (!_espFinalExitSeen)
|
||||
{
|
||||
_espFinalExitSeen = true;
|
||||
}
|
||||
|
||||
_currentState = EnrollmentCompletionState.Completed;
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
ShouldEmitEnrollmentComplete = true,
|
||||
CompletionSource = deferredSource ?? "device_setup_provisioning_complete",
|
||||
ShouldPersistState = true,
|
||||
SignalsToRecord = new List<string> { "self_deploying_esp_final_exit" }
|
||||
};
|
||||
}
|
||||
|
||||
// Not device-only: revert to appropriate non-terminal state
|
||||
if (_espFinalExitSeen)
|
||||
_currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion;
|
||||
else if (_espEverSeen)
|
||||
_currentState = _desktopArrived
|
||||
? EnrollmentCompletionState.DesktopArrivedEspBlocking
|
||||
: EnrollmentCompletionState.EspActive;
|
||||
else
|
||||
_currentState = _desktopArrived
|
||||
? EnrollmentCompletionState.DesktopArrivedAwaitingHello
|
||||
: EnrollmentCompletionState.Idle;
|
||||
|
||||
return new TransitionResult
|
||||
{
|
||||
Transitioned = true,
|
||||
PreviousState = prev,
|
||||
NewState = _currentState,
|
||||
DeferredSource = deferredSource
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Guard Helpers (called under lock) =====
|
||||
|
||||
/// <summary>
|
||||
/// Checks all three completion guards for a given source.
|
||||
/// </summary>
|
||||
private bool CanComplete(CompletionContext ctx, string source)
|
||||
{
|
||||
bool helloResolved = CompletionGuards.IsHelloResolved(
|
||||
ctx.HasHelloTracker, ctx.IsHelloCompleted,
|
||||
ctx.IsHelloPolicyConfigured, ctx.IsDeviceOnly);
|
||||
|
||||
bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
|
||||
source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
|
||||
|
||||
bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
|
||||
ctx.IsHybridJoin, source,
|
||||
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
|
||||
|
||||
return helloResolved && !espGateBlocking && !hybridRebootGateBlocking;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completion check with Hello always resolved (caller has just confirmed Hello completed).
|
||||
/// Only checks ESP gate and hybrid reboot gate.
|
||||
/// </summary>
|
||||
private bool CanCompleteWithHelloResolved(CompletionContext ctx, string source)
|
||||
{
|
||||
bool espGateBlocking = CompletionGuards.IsEspGateBlocking(
|
||||
source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen);
|
||||
|
||||
bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking(
|
||||
ctx.IsHybridJoin, source,
|
||||
ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc);
|
||||
|
||||
return !espGateBlocking && !hybridRebootGateBlocking;
|
||||
}
|
||||
}
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and
|
||||
/// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog,
|
||||
/// self-destruct, and reboot.
|
||||
/// </summary>
|
||||
public class EnrollmentCompletionHandler
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _agentVersion;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly Action<string, string, Dictionary<string, object>> _emitShutdownEvent;
|
||||
private readonly Func<Task> _uploadEventsAsync;
|
||||
private readonly Action _stopUploadTimers;
|
||||
private readonly CleanupService _cleanupService;
|
||||
private readonly DiagnosticsPackageService _diagnosticsService;
|
||||
private readonly SessionPersistence _sessionPersistence;
|
||||
private readonly EventSpool _spool;
|
||||
|
||||
public EnrollmentCompletionHandler(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
string agentVersion,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
Action<string, string, Dictionary<string, object>> emitShutdownEvent,
|
||||
Func<Task> uploadEventsAsync,
|
||||
Action stopUploadTimers,
|
||||
CleanupService cleanupService,
|
||||
DiagnosticsPackageService diagnosticsService,
|
||||
SessionPersistence sessionPersistence,
|
||||
EventSpool spool)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_agentVersion = agentVersion;
|
||||
_emitEvent = emitEvent;
|
||||
_emitShutdownEvent = emitShutdownEvent;
|
||||
_uploadEventsAsync = uploadEventsAsync;
|
||||
_stopUploadTimers = stopUploadTimers;
|
||||
_cleanupService = cleanupService;
|
||||
_diagnosticsService = diagnosticsService;
|
||||
_sessionPersistence = sessionPersistence;
|
||||
_spool = spool;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles normal enrollment completion (success or failure).
|
||||
/// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs.
|
||||
/// </summary>
|
||||
public async Task HandleEnrollmentComplete(
|
||||
bool enrollmentSucceeded,
|
||||
bool isWhiteGlovePart2,
|
||||
Action stopCollectors,
|
||||
Action<int?> runShutdownAnalyzers)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("===== ENROLLMENT COMPLETE - Starting shutdown sequence =====");
|
||||
|
||||
_logger.Info("Stopping event collectors...");
|
||||
stopCollectors();
|
||||
_spool.StopWatching();
|
||||
|
||||
runShutdownAnalyzers(isWhiteGlovePart2 ? 2 : (int?)null);
|
||||
|
||||
_logger.Info("Uploading final events...");
|
||||
await _uploadEventsAsync();
|
||||
|
||||
await Task.Delay(2000);
|
||||
|
||||
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded);
|
||||
|
||||
if (_configuration.ShowEnrollmentSummary)
|
||||
LaunchEnrollmentSummaryDialog();
|
||||
|
||||
if (_configuration.SelfDestructOnComplete)
|
||||
{
|
||||
_cleanupService.ExecuteSelfDestruct();
|
||||
}
|
||||
else if (_configuration.RebootOnComplete)
|
||||
{
|
||||
_logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s");
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "reboot_triggered",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Complete,
|
||||
Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "rebootDelaySeconds", _configuration.RebootDelaySeconds }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
await _uploadEventsAsync();
|
||||
|
||||
StartReboot(_configuration.RebootDelaySeconds);
|
||||
}
|
||||
|
||||
_emitShutdownEvent(
|
||||
enrollmentSucceeded ? "enrollment_complete" : "enrollment_failed",
|
||||
$"Agent shutting down after enrollment {(enrollmentSucceeded ? "completion" : "failure")}",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "enrollmentSucceeded", enrollmentSucceeded },
|
||||
{ "selfDestruct", _configuration.SelfDestructOnComplete },
|
||||
{ "reboot", _configuration.RebootOnComplete }
|
||||
});
|
||||
await _uploadEventsAsync();
|
||||
|
||||
_logger.Info("Shutdown sequence complete. Agent will now exit.");
|
||||
ExitProcess(0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error during self-destruct sequence", ex);
|
||||
ExitProcess(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes.
|
||||
/// Does NOT delete the session ID — the session must survive for Part 2.
|
||||
/// </summary>
|
||||
public async Task HandleWhiteGloveComplete(
|
||||
Action stopCollectors,
|
||||
Action<int?> runShutdownAnalyzers,
|
||||
Func<long> readEventSequence)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence =====");
|
||||
|
||||
runShutdownAnalyzers(1);
|
||||
|
||||
_logger.Info("Uploading final events (draining spool)...");
|
||||
int maxIterations = 20;
|
||||
while (_spool.GetCount() > 0 && maxIterations-- > 0)
|
||||
{
|
||||
await _uploadEventsAsync();
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
|
||||
await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov");
|
||||
|
||||
_sessionPersistence.SaveWhiteGloveComplete();
|
||||
_logger.Info("WhiteGlove marker persisted for Part 2 detection");
|
||||
|
||||
_emitShutdownEvent(
|
||||
"whiteglove_complete",
|
||||
"Agent shutting down after WhiteGlove Part 1 — no cleanup, session preserved for Part 2 on next boot",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "sessionPreserved", true },
|
||||
{ "selfDestruct", false },
|
||||
{ "reboot", false },
|
||||
{ "cleanup", false },
|
||||
{ "willResumeOnNextBoot", true }
|
||||
});
|
||||
await _uploadEventsAsync();
|
||||
|
||||
var finalSequence = readEventSequence();
|
||||
_sessionPersistence.SaveSequence(finalSequence);
|
||||
_logger.Info($"Sequence counter persisted at {finalSequence} for next boot");
|
||||
|
||||
_logger.Info("WhiteGlove graceful shutdown complete. Agent exiting.");
|
||||
ExitProcess(0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error during WhiteGlove shutdown sequence", ex);
|
||||
ExitProcess(0);
|
||||
}
|
||||
}
|
||||
|
||||
internal virtual void ExitProcess(int code) => Environment.Exit(code);
|
||||
|
||||
internal virtual void StartReboot(int delaySeconds)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Shutdown,
|
||||
Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the persisted session ID and sequence files.
|
||||
/// </summary>
|
||||
public void DeleteSessionId()
|
||||
{
|
||||
_logger.Info("Deleting persisted session ID and sequence...");
|
||||
try
|
||||
{
|
||||
_sessionPersistence.DeleteSession();
|
||||
_logger.Info("Session ID and sequence deleted successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to delete session ID: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null)
|
||||
{
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
if (!_configuration.DiagnosticsUploadEnabled)
|
||||
return;
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
|
||||
return;
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "diagnostics_collecting",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Complete,
|
||||
Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "uploadMode", mode }
|
||||
}
|
||||
});
|
||||
await _uploadEventsAsync();
|
||||
|
||||
var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix);
|
||||
var success = uploadResult?.Success == true;
|
||||
|
||||
var resultData = new Dictionary<string, object>
|
||||
{
|
||||
{ "uploadMode", mode }
|
||||
};
|
||||
if (uploadResult?.BlobName != null)
|
||||
resultData["blobName"] = uploadResult.BlobName;
|
||||
if (uploadResult?.SasUrlPrefix != null)
|
||||
resultData["sasUrl"] = uploadResult.SasUrlPrefix;
|
||||
if (uploadResult?.ErrorCode != null)
|
||||
resultData["errorCode"] = uploadResult.ErrorCode;
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed",
|
||||
Severity = success ? EventSeverity.Info : EventSeverity.Warning,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Complete,
|
||||
Message = success
|
||||
? $"Diagnostics package uploaded: {uploadResult.BlobName}"
|
||||
: $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}",
|
||||
Data = resultData
|
||||
});
|
||||
await _uploadEventsAsync();
|
||||
}
|
||||
|
||||
private void LaunchEnrollmentSummaryDialog()
|
||||
{
|
||||
try
|
||||
{
|
||||
var agentDir = Path.GetDirectoryName(
|
||||
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
|
||||
var dialogExe = Path.Combine(agentDir, "AutopilotMonitor.SummaryDialog.exe");
|
||||
|
||||
if (!File.Exists(dialogExe))
|
||||
{
|
||||
_logger.Warning($"Summary dialog EXE not found at {dialogExe} — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
var statusFile = Path.Combine(
|
||||
Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"),
|
||||
"final-status.json");
|
||||
|
||||
if (!File.Exists(statusFile))
|
||||
{
|
||||
_logger.Warning($"final-status.json not found at {statusFile} — skipping summary dialog");
|
||||
return;
|
||||
}
|
||||
|
||||
var tempDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"AutopilotMonitor-Summary");
|
||||
|
||||
try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { }
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
GrantUserDeletePermission(tempDir);
|
||||
|
||||
var tempDialogExe = Path.Combine(tempDir, "AutopilotMonitor.SummaryDialog.exe");
|
||||
File.Copy(dialogExe, tempDialogExe);
|
||||
|
||||
var jsonDll = Path.Combine(agentDir, "Newtonsoft.Json.dll");
|
||||
if (File.Exists(jsonDll))
|
||||
File.Copy(jsonDll, Path.Combine(tempDir, "Newtonsoft.Json.dll"));
|
||||
|
||||
var exeConfig = dialogExe + ".config";
|
||||
if (File.Exists(exeConfig))
|
||||
File.Copy(exeConfig, tempDialogExe + ".config");
|
||||
|
||||
var tempStatusFile = Path.Combine(tempDir, "final-status.json");
|
||||
File.Copy(statusFile, tempStatusFile);
|
||||
|
||||
var args = $"--status-file \"{tempStatusFile}\" --timeout {_configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
|
||||
if (!string.IsNullOrEmpty(_configuration.EnrollmentSummaryBrandingImageUrl))
|
||||
args += $" --branding-url \"{_configuration.EnrollmentSummaryBrandingImageUrl}\"";
|
||||
|
||||
_logger.Info($"Launching enrollment summary dialog: {tempDialogExe} {args}");
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "enrollment_summary_shown",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Complete,
|
||||
Message = "Launching enrollment summary dialog to user"
|
||||
});
|
||||
|
||||
try { _uploadEventsAsync().Wait(TimeSpan.FromSeconds(5)); } catch { }
|
||||
|
||||
var launched = UserSessionProcessLauncher.LaunchInUserSession(
|
||||
tempDialogExe, args, _logger,
|
||||
_configuration.EnrollmentSummaryLaunchRetrySeconds);
|
||||
if (!launched)
|
||||
{
|
||||
_logger.Warning("Could not launch summary dialog in user session (no interactive session found)");
|
||||
try { Directory.Delete(tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to launch enrollment summary dialog: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void GrantUserDeletePermission(string directoryPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dirInfo = new DirectoryInfo(directoryPath);
|
||||
var security = dirInfo.GetAccessControl();
|
||||
var usersIdentity = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
usersIdentity,
|
||||
FileSystemRights.FullControl,
|
||||
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit,
|
||||
PropagationFlags.None,
|
||||
AccessControlType.Allow));
|
||||
dirInfo.SetAccessControl(security);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"Could not set ACL on summary folder: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Explicit states for the enrollment completion state machine.
|
||||
/// Each state represents a distinguishable configuration of the implicit boolean flags
|
||||
/// that previously existed only as field combinations in EnrollmentTracker.CompletionLogic.cs.
|
||||
/// </summary>
|
||||
internal enum EnrollmentCompletionState
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial state. No completion-relevant signals received.
|
||||
/// Waiting for: ESP phase event, desktop arrival, or device setup provisioning.
|
||||
/// </summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>
|
||||
/// ESP has been seen (at least one phase detected) but no final exit yet.
|
||||
/// Waiting for: ESP exit (FinalizingSetup trigger), desktop arrival, or device-only timer.
|
||||
/// </summary>
|
||||
EspActive,
|
||||
|
||||
/// <summary>
|
||||
/// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true.
|
||||
/// Waiting for: Hello resolution, IME user session completion, or safety timeout.
|
||||
/// </summary>
|
||||
EspExitedAwaitingCompletion,
|
||||
|
||||
/// <summary>
|
||||
/// IME user session completed but Hello is configured and not yet resolved.
|
||||
/// isWaitingForHello=true. Safety timer running (420s / 7 min).
|
||||
/// </summary>
|
||||
WaitingForHello,
|
||||
|
||||
/// <summary>
|
||||
/// IME user session completed but ESP provisioning categories not yet resolved in registry.
|
||||
/// isWaitingForEspSettle=true. Settle timer running (30s).
|
||||
/// </summary>
|
||||
WaitingForEspSettle,
|
||||
|
||||
/// <summary>
|
||||
/// Desktop arrived but ESP is still active (ESP gate blocking completion).
|
||||
/// Waiting for: ESP final exit.
|
||||
/// </summary>
|
||||
DesktopArrivedEspBlocking,
|
||||
|
||||
/// <summary>
|
||||
/// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending.
|
||||
/// Waiting for: Hello resolution or direct completion.
|
||||
/// </summary>
|
||||
DesktopArrivedAwaitingHello,
|
||||
|
||||
/// <summary>
|
||||
/// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user).
|
||||
/// Waiting for provisioning complete or IME completion. Hello guard bypassed.
|
||||
/// </summary>
|
||||
DeviceOnlyAwaitingCompletion,
|
||||
|
||||
/// <summary>
|
||||
/// Device-only ESP exit detected but completion didn't fire immediately (Hello pending).
|
||||
/// Safety timer running (420s / 7 min).
|
||||
/// </summary>
|
||||
DeviceOnlySafetyWait,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid join: esp_hello_composite blocked by reboot gate.
|
||||
/// Waiting for: IME user session completion or agent restart confirmation after ESP exit.
|
||||
/// </summary>
|
||||
HybridRebootGateBlocked,
|
||||
|
||||
/// <summary>
|
||||
/// Device info not yet collected when a completion signal arrived.
|
||||
/// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set).
|
||||
/// </summary>
|
||||
DeferredForDeviceInfo,
|
||||
|
||||
/// <summary>
|
||||
/// Terminal: enrollment_complete event emitted. No further transitions possible.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Terminal: enrollment_failed event emitted. No further transitions possible.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Terminal: whiteglove_complete event emitted. No further transitions possible.
|
||||
/// </summary>
|
||||
WhiteGloveCompleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for EnrollmentCompletionState.
|
||||
/// </summary>
|
||||
internal static class EnrollmentCompletionStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted).
|
||||
/// No further transitions are allowed from terminal states.
|
||||
/// </summary>
|
||||
public static bool IsTerminal(this EnrollmentCompletionState state)
|
||||
{
|
||||
return state == EnrollmentCompletionState.Completed
|
||||
|| state == EnrollmentCompletionState.Failed
|
||||
|| state == EnrollmentCompletionState.WhiteGloveCompleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the state is a waiting state (actively waiting for a signal or timeout).
|
||||
/// </summary>
|
||||
public static bool IsWaiting(this EnrollmentCompletionState state)
|
||||
{
|
||||
return state == EnrollmentCompletionState.WaitingForHello
|
||||
|| state == EnrollmentCompletionState.WaitingForEspSettle
|
||||
|| state == EnrollmentCompletionState.DeviceOnlySafetyWait
|
||||
|| state == EnrollmentCompletionState.HybridRebootGateBlocked
|
||||
|| state == EnrollmentCompletionState.DeferredForDeviceInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Confidence level for a WhiteGlove Part 1 classification.
|
||||
/// </summary>
|
||||
internal enum WhiteGloveConfidence
|
||||
{
|
||||
/// <summary>Score below <see cref="WhiteGloveClassifier.LowThreshold"/> — definitely not WhiteGlove.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).</summary>
|
||||
Weak,
|
||||
|
||||
/// <summary>Score at or above <see cref="WhiteGloveClassifier.HighThreshold"/> — classify as WhiteGlove Part 1.</summary>
|
||||
Strong,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a <see cref="WhiteGloveClassifier.Classify"/> call. Contains the
|
||||
/// final routing decision, the numeric score and the contributing factors for
|
||||
/// observability / tuning.
|
||||
/// </summary>
|
||||
internal sealed class WhiteGloveClassification
|
||||
{
|
||||
public WhiteGloveConfidence Confidence { get; set; }
|
||||
public int Score { get; set; }
|
||||
public List<string> ContributingFactors { get; set; } = new List<string>();
|
||||
public string Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Single Boolean the caller should read to decide whether to route to
|
||||
/// <c>whiteglove_complete</c> (Pending) instead of the normal completion path.
|
||||
/// Only true when <see cref="Confidence"/> is <see cref="WhiteGloveConfidence.Strong"/>.
|
||||
/// </summary>
|
||||
public bool ShouldRouteToWhiteGlovePart1 { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
|
||||
/// <c>AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold</c> pattern.
|
||||
///
|
||||
/// Asymmetric-conservative decision rule: only a <see cref="WhiteGloveConfidence.Strong"/>
|
||||
/// score routes to WhiteGlove. A <see cref="WhiteGloveConfidence.Weak"/> score is emitted
|
||||
/// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG
|
||||
/// classification keeps the agent pending when it should exit; a false-negative emits
|
||||
/// <c>enrollment_complete</c> early and the later user sign-in triggers a new session —
|
||||
/// the user-visible cost of false-negative is much lower than false-positive.
|
||||
/// </summary>
|
||||
internal static class WhiteGloveClassifier
|
||||
{
|
||||
// Thresholds (tunable after production data — see project memory).
|
||||
internal const int HighThreshold = 70; // >= 70 → Strong → route to WG
|
||||
internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only)
|
||||
internal const int BaseConfidence = 0;
|
||||
|
||||
// Positive-signal weights
|
||||
internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407
|
||||
internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices
|
||||
internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid
|
||||
internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker
|
||||
internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary
|
||||
internal const int WeightDeviceOnlyDeployment = 15; // context boost
|
||||
|
||||
// Negative-signal weights (hard excluders)
|
||||
internal const int WeightAadJoinedWithUser = -100; // hard exclude
|
||||
internal const int WeightDesktopArrived = -100; // hard exclude
|
||||
internal const int WeightAccountSetupActive = -40; // soft exclude
|
||||
|
||||
public static WhiteGloveClassification Classify(WhiteGloveSignals s)
|
||||
{
|
||||
if (s == null) throw new ArgumentNullException(nameof(s));
|
||||
|
||||
var factors = new List<string>();
|
||||
int score = BaseConfidence;
|
||||
|
||||
// Hard excluders
|
||||
if (s.AadJoinedWithUser)
|
||||
{
|
||||
score += WeightAadJoinedWithUser;
|
||||
factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}");
|
||||
}
|
||||
if (s.DesktopArrived)
|
||||
{
|
||||
score += WeightDesktopArrived;
|
||||
factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}");
|
||||
}
|
||||
if (s.HasAccountSetupActivity)
|
||||
{
|
||||
score += WeightAccountSetupActive;
|
||||
factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}");
|
||||
}
|
||||
|
||||
// Positive signals — weighted by empirical reliability
|
||||
if (s.ShellCoreWhiteGloveSuccess)
|
||||
{
|
||||
score += WeightShellCoreWhiteGloveSuccess;
|
||||
factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}");
|
||||
}
|
||||
if (s.HasSaveWhiteGloveSuccessResult)
|
||||
{
|
||||
score += WeightHasSaveWhiteGloveSuccess;
|
||||
factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}");
|
||||
}
|
||||
if (s.IsWhiteGloveStartDetected)
|
||||
{
|
||||
score += WeightWhiteGloveStartDetected;
|
||||
factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}");
|
||||
}
|
||||
if (s.IsFooUserDetected)
|
||||
{
|
||||
score += WeightFooUserDetected;
|
||||
factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}");
|
||||
}
|
||||
if (s.AgentRestartedAfterEspExit)
|
||||
{
|
||||
score += WeightAgentRestartedAfterEspExit;
|
||||
factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}");
|
||||
}
|
||||
if (s.IsDeviceOnlyDeployment)
|
||||
{
|
||||
score += WeightDeviceOnlyDeployment;
|
||||
factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}");
|
||||
}
|
||||
|
||||
// NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid.
|
||||
// NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons.
|
||||
|
||||
// Cap to [0, 100]
|
||||
if (score < 0) score = 0;
|
||||
if (score > 100) score = 100;
|
||||
|
||||
WhiteGloveConfidence confidence;
|
||||
if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong;
|
||||
else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak;
|
||||
else confidence = WhiteGloveConfidence.None;
|
||||
|
||||
return new WhiteGloveClassification
|
||||
{
|
||||
Confidence = confidence,
|
||||
Score = score,
|
||||
ContributingFactors = factors,
|
||||
Reason = $"confidence={confidence} score={score}",
|
||||
ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
|
||||
/// Populated under _stateLock in the caller and consumed outside the lock by
|
||||
/// <see cref="WhiteGloveClassifier.Classify"/> — mirrors the CompletionContext
|
||||
/// pattern to preserve the existing thread-safety guarantees.
|
||||
///
|
||||
/// Source-of-truth is the EnrollmentTracker state. When the tracker uses
|
||||
/// CompletionStateMachine (shadow), use <see cref="FromContext"/> to keep both
|
||||
/// decision paths in sync.
|
||||
/// </summary>
|
||||
internal sealed class WhiteGloveSignals
|
||||
{
|
||||
// "Hard" positive signals (registry / Shell-Core)
|
||||
public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal
|
||||
public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG
|
||||
public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too
|
||||
public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator
|
||||
public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary
|
||||
|
||||
// Hard excluders (drive score negative)
|
||||
public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore
|
||||
public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore
|
||||
public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active
|
||||
|
||||
// Deployment context
|
||||
public bool IsDeviceOnlyDeployment { get; set; }
|
||||
|
||||
// Optional metadata (populated for observability, not scored)
|
||||
public DateTime? EspFinalExitUtc { get; set; }
|
||||
public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a signal snapshot from a <see cref="CompletionContext"/>. Used by the
|
||||
/// CompletionStateMachine so that state-machine decisions use the exact same
|
||||
/// inputs as the EnrollmentTracker's direct decisions.
|
||||
/// </summary>
|
||||
public static WhiteGloveSignals FromContext(CompletionContext ctx)
|
||||
{
|
||||
if (ctx == null) throw new ArgumentNullException(nameof(ctx));
|
||||
|
||||
return new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess,
|
||||
HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult,
|
||||
IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected,
|
||||
IsFooUserDetected = ctx.IsFooUserDetected,
|
||||
AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit,
|
||||
|
||||
AadJoinedWithUser = ctx.AadJoinedWithUser,
|
||||
DesktopArrived = ctx.DesktopArrived,
|
||||
HasAccountSetupActivity = ctx.HasAccountSetupActivity,
|
||||
|
||||
IsDeviceOnlyDeployment = ctx.IsDeviceOnly,
|
||||
|
||||
EspFinalExitUtc = ctx.EspFinalExitUtc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles persistence of EnrollmentTracker state to disk (JSON serialization).
|
||||
/// Enables crash-recovery: after agent restart, completion signals already seen
|
||||
/// (ESP exit, desktop arrival, etc.) are restored so the enrollment can complete.
|
||||
/// Pattern follows ImeTrackerStatePersistence.
|
||||
/// </summary>
|
||||
public class EnrollmentStatePersistence
|
||||
{
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public EnrollmentStatePersistence(string stateDirectory, AgentLogger logger)
|
||||
{
|
||||
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
|
||||
_stateFilePath = Path.Combine(_stateDirectory, "enrollment-state.json");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string StateFilePath => _stateFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Loads persisted state from disk. Returns null if no state file exists or on error.
|
||||
/// </summary>
|
||||
public EnrollmentStateData Load()
|
||||
{
|
||||
if (!File.Exists(_stateFilePath))
|
||||
{
|
||||
_logger.Info("EnrollmentStatePersistence: no persisted state found (fresh enrollment)");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_stateFilePath);
|
||||
var state = JsonConvert.DeserializeObject<EnrollmentStateData>(json);
|
||||
if (state == null)
|
||||
{
|
||||
_logger.Warning("EnrollmentStatePersistence: persisted state file was empty or invalid");
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentStatePersistence: failed to load persisted state, starting fresh: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists tracker state to disk as JSON (atomic write via temp file).
|
||||
/// </summary>
|
||||
public void Save(EnrollmentStateData state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
|
||||
File.Copy(tempPath, _stateFilePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentStatePersistence: failed to save state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes persisted state file.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_stateFilePath))
|
||||
{
|
||||
File.Delete(_stateFilePath);
|
||||
_logger.Info("EnrollmentStatePersistence: persisted state deleted");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentStatePersistence: failed to delete state file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DTO for state serialization ----
|
||||
|
||||
public class EnrollmentStateData
|
||||
{
|
||||
public bool EspEverSeen { get; set; }
|
||||
public bool EspFinalExitSeen { get; set; }
|
||||
public bool DesktopArrived { get; set; }
|
||||
public string LastEspPhase { get; set; }
|
||||
public bool IsWaitingForHello { get; set; }
|
||||
public DateTime? WaitingForHelloStartedUtc { get; set; }
|
||||
public bool EnrollmentCompleteEmitted { get; set; }
|
||||
public string EnrollmentType { get; set; }
|
||||
|
||||
// ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
|
||||
public bool? SkipUserStatusPage { get; set; }
|
||||
public bool? SkipDeviceStatusPage { get; set; }
|
||||
|
||||
// Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown
|
||||
public int? AutopilotMode { get; set; }
|
||||
|
||||
// True when AAD join status shows a joined device with a real user email
|
||||
// (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo)
|
||||
public bool AadJoinedWithUser { get; set; }
|
||||
|
||||
// True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been
|
||||
// observed at any point in the session — survives crash/restart so the classifier
|
||||
// keeps its positive WG signal after agent recovery.
|
||||
public bool FooUserDetected { get; set; }
|
||||
|
||||
// True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1)
|
||||
public bool IsHybridJoin { get; set; }
|
||||
|
||||
// Signal timestamps for audit trail
|
||||
public DateTime? EspFirstSeenUtc { get; set; }
|
||||
public DateTime? EspFinalExitUtc { get; set; }
|
||||
public DateTime? DesktopArrivedUtc { get; set; }
|
||||
public DateTime? HelloResolvedUtc { get; set; }
|
||||
public DateTime? ImePatternSeenUtc { get; set; }
|
||||
public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; }
|
||||
public List<string> SignalsSeen { get; set; } = new List<string>();
|
||||
|
||||
// ESP provisioning settle wait state (crash recovery)
|
||||
public bool IsWaitingForEspSettle { get; set; }
|
||||
public DateTime? WaitingForEspSettleStartedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Explicit completion state machine state (dual-write).
|
||||
/// When present, used to restore the state machine directly.
|
||||
/// When absent (old format), the state is reconstructed from boolean flags.
|
||||
/// Serialized as string (enum name) for forward compatibility.
|
||||
/// </summary>
|
||||
public string CompletionState { get; set; }
|
||||
}
|
||||
}
|
||||
+1813
File diff suppressed because it is too large
Load Diff
+381
@@ -0,0 +1,381 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Device info collection at key lifecycle points, app tracking summaries,
|
||||
/// debug state timer, and final status file writing.
|
||||
/// </summary>
|
||||
public partial class EnrollmentTracker
|
||||
{
|
||||
private void CollectDeviceInfoAtEnrollmentStart()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_enrollmentStartDeviceInfoCollected)
|
||||
return;
|
||||
_enrollmentStartDeviceInfoCollected = true;
|
||||
}
|
||||
|
||||
if (!_isBootstrapMode)
|
||||
{
|
||||
_logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)");
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_deviceInfoCollector.CollectAtEnrollmentStart();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void CollectDeviceInfoAtFinalizingSetup(string triggerReason)
|
||||
{
|
||||
bool alreadyCollected;
|
||||
lock (_stateLock)
|
||||
{
|
||||
alreadyCollected = _finalDeviceInfoCollected;
|
||||
if (!alreadyCollected)
|
||||
_finalDeviceInfoCollected = true;
|
||||
}
|
||||
|
||||
if (alreadyCollected)
|
||||
{
|
||||
_logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})");
|
||||
return;
|
||||
}
|
||||
_logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})");
|
||||
CollectDeviceInfoAtEnd();
|
||||
}
|
||||
|
||||
private void SummaryTimerCallback(object state)
|
||||
{
|
||||
// Only emit if state-breakdown counters changed since last emission (backstop for missed events)
|
||||
EmitAppTrackingSummaryIfChanged();
|
||||
|
||||
// Signal-correlated WhiteGlove detection (Ebene 2.6) — event-driven in the sense
|
||||
// that it only triggers when the observed signal set matches; zero-cost when the
|
||||
// session does not look like a sealing candidate.
|
||||
try { CheckSignalCorrelatedWhiteGlove(); }
|
||||
catch (Exception ex) { _logger.Error("CheckSignalCorrelatedWhiteGlove failed", ex); }
|
||||
|
||||
// Periodic state save (piggybacks on the existing 30s timer)
|
||||
bool dirty;
|
||||
EnrollmentStateData stateSnapshot;
|
||||
lock (_stateLock)
|
||||
{
|
||||
dirty = _stateDirty;
|
||||
if (dirty)
|
||||
{
|
||||
_stateDirty = false;
|
||||
stateSnapshot = _stateData;
|
||||
}
|
||||
else
|
||||
{
|
||||
stateSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (dirty)
|
||||
_statePersistence.Save(stateSnapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid
|
||||
/// post-mortem debugging of stuck enrollments without consuming Azure Table Storage.
|
||||
/// </summary>
|
||||
private void DebugStateTimerCallback(object state)
|
||||
{
|
||||
try
|
||||
{
|
||||
string lastPhaseDbg;
|
||||
bool espFinalExitDbg, desktopDbg;
|
||||
lock (_stateLock)
|
||||
{
|
||||
lastPhaseDbg = _lastEspPhase;
|
||||
espFinalExitDbg = _espFinalExitSeen;
|
||||
desktopDbg = _desktopArrived;
|
||||
}
|
||||
|
||||
var states = _imeLogTracker?.PackageStates;
|
||||
if (states == null || states.CountAll == 0) return;
|
||||
|
||||
var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})");
|
||||
var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}");
|
||||
|
||||
_logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " +
|
||||
$"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " +
|
||||
$"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " +
|
||||
$"active: [{string.Join(", ", active)}], " +
|
||||
$"failed: [{string.Join(", ", errors)}], " +
|
||||
$"phase: {lastPhaseDbg ?? "none"}, espExit: {espFinalExitDbg}, desktop: {desktopDbg}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"[StateSnapshot] Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits app_tracking_summary only if state-breakdown counters changed since last emission.
|
||||
/// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop).
|
||||
/// </summary>
|
||||
private void EmitAppTrackingSummaryIfChanged()
|
||||
{
|
||||
var states = _imeLogTracker?.PackageStates;
|
||||
if (states == null || states.CountAll == 0) return;
|
||||
|
||||
var hash = GetSummaryHash(states);
|
||||
|
||||
EventSeverity severity;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (hash == _lastEmittedSummaryHash) return;
|
||||
_lastEmittedSummaryHash = hash;
|
||||
|
||||
var compactHash = $"{states.CountCompleted}_{states.ErrorCount}";
|
||||
severity = (compactHash != _lastEmittedSummaryCompactHash)
|
||||
? (states.HasError ? EventSeverity.Warning : EventSeverity.Info)
|
||||
: EventSeverity.Debug;
|
||||
_lastEmittedSummaryCompactHash = compactHash;
|
||||
}
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "app_tracking_summary",
|
||||
Severity = severity,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
|
||||
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
|
||||
Data = states.GetSummaryData(),
|
||||
ImmediateUpload = (severity != EventSeverity.Debug)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits app_tracking_summary unconditionally (for final summary on completion).
|
||||
/// </summary>
|
||||
private void EmitAppTrackingSummary()
|
||||
{
|
||||
var states = _imeLogTracker?.PackageStates;
|
||||
if (states == null || states.CountAll == 0) return;
|
||||
|
||||
lock (_stateLock) { _lastEmittedSummaryHash = GetSummaryHash(states); }
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "app_tracking_summary",
|
||||
Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" +
|
||||
(states.HasError ? $" ({states.ErrorCount} errors)" : ""),
|
||||
Data = states.GetSummaryData(),
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetSummaryHash(AppPackageStateList states)
|
||||
{
|
||||
return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" +
|
||||
$"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" +
|
||||
$"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" +
|
||||
$"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
|
||||
// Unsubscribe from EspAndHelloTracker events
|
||||
if (_espAndHelloTracker != null)
|
||||
{
|
||||
_espAndHelloTracker.HelloCompleted -= OnHelloCompleted;
|
||||
_espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
|
||||
_espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
|
||||
_espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected;
|
||||
_espAndHelloTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
|
||||
}
|
||||
|
||||
_espFailureTimer?.Dispose();
|
||||
_deviceOnlyEspTimer?.Dispose();
|
||||
_waitingForHelloSafetyTimer?.Dispose();
|
||||
_waitingForEspSettleTimer?.Dispose();
|
||||
_deviceOnlyCompletionSafetyTimer?.Dispose();
|
||||
_summaryTimer?.Dispose();
|
||||
_debugStateTimer?.Dispose();
|
||||
_imeLogTracker?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an enrollment complete marker to the state directory.
|
||||
/// This marker is checked on agent restart to handle cleanup retry if scheduled task fails.
|
||||
/// </summary>
|
||||
private void WriteEnrollmentCompleteMarker()
|
||||
{
|
||||
try
|
||||
{
|
||||
var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
|
||||
Directory.CreateDirectory(stateDirectory);
|
||||
|
||||
var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker");
|
||||
File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}");
|
||||
|
||||
_logger.Info($"Enrollment complete marker written: {markerPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to write enrollment complete marker: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a comprehensive final-status.json with all app states, signals, and error summary.
|
||||
/// Automatically included in diagnostics uploads for post-mortem analysis.
|
||||
/// </summary>
|
||||
private void WriteFinalStatus(string outcome, string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State");
|
||||
Directory.CreateDirectory(stateDirectory);
|
||||
|
||||
var appStates = _imeLogTracker?.PackageStates;
|
||||
|
||||
var signalTimestamps = new Dictionary<string, string>();
|
||||
if (_stateData.EspFirstSeenUtc.HasValue)
|
||||
signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o");
|
||||
if (_stateData.EspFinalExitUtc.HasValue)
|
||||
signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o");
|
||||
if (_stateData.DesktopArrivedUtc.HasValue)
|
||||
signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o");
|
||||
if (_stateData.HelloResolvedUtc.HasValue)
|
||||
signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o");
|
||||
if (_stateData.ImePatternSeenUtc.HasValue)
|
||||
signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o");
|
||||
if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue)
|
||||
signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o");
|
||||
|
||||
// Build per-phase package states: snapshots from completed phases + current phase
|
||||
var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots;
|
||||
var packageStatesByPhase = new Dictionary<string, object>();
|
||||
|
||||
// Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition)
|
||||
if (phaseSnapshots != null)
|
||||
{
|
||||
foreach (var kvp in phaseSnapshots)
|
||||
packageStatesByPhase[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Add current phase (the phase active at completion, e.g. AccountSetup)
|
||||
var currentPhaseName = _lastEspPhase ?? "Unknown";
|
||||
var currentPhaseList = appStates?.ToFinalStatusList() ?? new List<Dictionary<string, object>>();
|
||||
packageStatesByPhase[currentPhaseName] = currentPhaseList;
|
||||
|
||||
// Aggregate app summary across ALL phases (snapshots + current)
|
||||
var currentPhaseTotal = appStates?.CountAll ?? 0;
|
||||
var currentPhaseCompleted = appStates?.CountCompleted ?? 0;
|
||||
var currentPhaseErrors = appStates?.ErrorCount ?? 0;
|
||||
var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0;
|
||||
var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0;
|
||||
|
||||
var allPhaseTotals = new Dictionary<string, int>();
|
||||
var totalAppsAllPhases = currentPhaseTotal;
|
||||
var completedAppsAllPhases = currentPhaseCompleted;
|
||||
var errorCountAllPhases = currentPhaseErrors;
|
||||
var deviceErrorsAllPhases = currentPhaseDeviceErrors;
|
||||
var userErrorsAllPhases = currentPhaseUserErrors;
|
||||
|
||||
allPhaseTotals[currentPhaseName] = currentPhaseTotal;
|
||||
|
||||
if (phaseSnapshots != null)
|
||||
{
|
||||
foreach (var kvp in phaseSnapshots)
|
||||
{
|
||||
var phaseApps = kvp.Value;
|
||||
allPhaseTotals[kvp.Key] = phaseApps.Count;
|
||||
totalAppsAllPhases += phaseApps.Count;
|
||||
foreach (var app in phaseApps)
|
||||
{
|
||||
object stateVal;
|
||||
var isError = app.TryGetValue("state", out stateVal) &&
|
||||
string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase);
|
||||
var isCompleted = app.TryGetValue("state", out stateVal) &&
|
||||
(stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" ||
|
||||
stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error");
|
||||
if (isCompleted) completedAppsAllPhases++;
|
||||
if (isError)
|
||||
{
|
||||
errorCountAllPhases++;
|
||||
object targetVal;
|
||||
if (app.TryGetValue("targeted", out targetVal))
|
||||
{
|
||||
if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase))
|
||||
deviceErrorsAllPhases++;
|
||||
else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase))
|
||||
userErrorsAllPhases++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var status = new Dictionary<string, object>
|
||||
{
|
||||
{ "timestamp", DateTime.UtcNow.ToString("o") },
|
||||
{ "outcome", outcome },
|
||||
{ "completionSource", source },
|
||||
{ "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" },
|
||||
{ "enrollmentType", _enrollmentType ?? "unknown" },
|
||||
{ "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds },
|
||||
{ "signalsSeen", SnapshotSignalsSeen() },
|
||||
{ "signalTimestamps", signalTimestamps },
|
||||
{ "appSummary", new Dictionary<string, object>
|
||||
{
|
||||
{ "totalApps", totalAppsAllPhases },
|
||||
{ "completedApps", completedAppsAllPhases },
|
||||
{ "errorCount", errorCountAllPhases },
|
||||
{ "deviceErrors", deviceErrorsAllPhases },
|
||||
{ "userErrors", userErrorsAllPhases },
|
||||
{ "appsByPhase", allPhaseTotals }
|
||||
}
|
||||
},
|
||||
{ "packageStatesByPhase", packageStatesByPhase }
|
||||
};
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(status,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
var path = Path.Combine(stateDirectory, "final-status.json");
|
||||
File.WriteAllText(path, json);
|
||||
_logger.Info($"Final status written: {path}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to write final status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: IME log event handlers (ESP phase, app state, script completion, etc.).
|
||||
/// </summary>
|
||||
public partial class EnrollmentTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data.
|
||||
/// Use this for all events emitted from ImeLogTracker callback handlers.
|
||||
/// </summary>
|
||||
private void EmitImeTrackerEvent(EnrollmentEvent evt)
|
||||
{
|
||||
var logTimestamp = _imeLogTracker?.LastMatchedLogTimestamp;
|
||||
if (logTimestamp.HasValue)
|
||||
evt.Timestamp = logTimestamp.Value;
|
||||
|
||||
var patternId = _imeLogTracker?.LastMatchedPatternId;
|
||||
if (!string.IsNullOrEmpty(patternId))
|
||||
{
|
||||
if (evt.Data == null)
|
||||
evt.Data = new Dictionary<string, object>();
|
||||
evt.Data["patternId"] = patternId;
|
||||
}
|
||||
_emitEvent(evt);
|
||||
}
|
||||
|
||||
private void HandleEspPhaseChanged(string phase)
|
||||
{
|
||||
string lastPhase;
|
||||
bool tracksEspPhases;
|
||||
lock (_stateLock)
|
||||
{
|
||||
tracksEspPhases = _flowHandler.TracksEspPhases;
|
||||
lastPhase = _lastEspPhase;
|
||||
}
|
||||
|
||||
// Flows that do not track ESP phases (DevPrep/WDP) ignore these events entirely.
|
||||
if (!tracksEspPhases)
|
||||
{
|
||||
_logger.Debug($"EnrollmentTracker: skipping ESP phase event — flow does not track ESP (phase: {phase})");
|
||||
return;
|
||||
}
|
||||
|
||||
// ESP resumed after final exit — only possible during hybrid join reboot recovery.
|
||||
// In hybrid join, ESP exits for a mid-enrollment reboot (domain user login required),
|
||||
// then the same phase re-appears in IME after the agent restarts. Reset completion state
|
||||
// so the agent waits for the real final exit instead of self-destructing prematurely.
|
||||
// Non-hybrid scenarios can see the same phase re-reported by IME (e.g., during user
|
||||
// session completion) which is NOT a real resumption — skip the reset entirely.
|
||||
bool espFinalExitSeen;
|
||||
lock (_stateLock) { espFinalExitSeen = _espFinalExitSeen; }
|
||||
|
||||
if (_isHybridJoin && espFinalExitSeen && string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: ESP phase '{phase}' re-detected after final exit — " +
|
||||
"ESP has resumed (hybrid join reboot recovery), resetting completion state");
|
||||
lock (_stateLock)
|
||||
{
|
||||
_espFinalExitSeen = false;
|
||||
_stateData.EspFinalExitSeen = false;
|
||||
_stateData.EspFinalExitUtc = null;
|
||||
}
|
||||
_statePersistence.Save(_stateData); // Immediate persist — ESP resumed resets final exit, must survive crash
|
||||
RecordSignal("esp_resumed");
|
||||
_espAndHelloTracker?.ResetForEspResumption();
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "esp_resumed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.AccountSetup,
|
||||
Message = $"ESP phase '{phase}' re-detected after final exit — enrollment resumed after reboot",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "espPhase", phase },
|
||||
{ "isHybridJoin", _isHybridJoin }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
// Shadow state machine: track ESP resumed
|
||||
ShadowProcessTrigger("esp_resumed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Only emit event if the phase has actually changed
|
||||
if (string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event");
|
||||
return;
|
||||
}
|
||||
|
||||
// AccountSetup verification: during WhiteGlove pre-provisioning the ESP briefly enters
|
||||
// AccountSetup before WhiteGlove success, but no real user has signed in (only defaultuser0
|
||||
// exists). Suppress the phase event to avoid a fake AccountSetup on the timeline.
|
||||
// The ImeLogTracker has already updated its internal phase tracking (ignore lists, phase order)
|
||||
// which is correct — we only suppress the event emission to the backend.
|
||||
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var hasReal = HasRealUserProfile();
|
||||
_logger.Debug($"EnrollmentTracker: AccountSetup verification — HasRealUserProfile={hasReal}");
|
||||
if (!hasReal)
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: AccountSetup suppressed — no real user profile (likely WhiteGlove)");
|
||||
EmitTraceEvent("AccountSetup_suppressed",
|
||||
"ESP reported AccountSetup but no real user profile found — likely WhiteGlove pre-provisioning",
|
||||
new Dictionary<string, object> { { "espPhase", phase }, { "previousPhase", lastPhase ?? "null" } });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"EnrollmentTracker: ESP phase changed from '{lastPhase ?? "null"}' to '{phase}'");
|
||||
lock (_stateLock)
|
||||
{
|
||||
_lastEspPhase = phase;
|
||||
_hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes
|
||||
_espEverSeen = true;
|
||||
_stateData.EspEverSeen = true;
|
||||
_stateData.LastEspPhase = phase;
|
||||
if (_stateData.EspFirstSeenUtc == null)
|
||||
_stateData.EspFirstSeenUtc = DateTime.UtcNow;
|
||||
}
|
||||
_statePersistence.Save(_stateData); // Immediate persist — ESP phase + espEverSeen must survive reboot
|
||||
RecordSignal($"esp_phase_{phase}");
|
||||
|
||||
// ESP phase change means ESP is progressing — cancel any pending failure grace period
|
||||
CancelPendingEspFailure();
|
||||
|
||||
// Cancel device-only ESP timer if AccountSetup phase detected
|
||||
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_deviceOnlyEspTimer != null)
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer");
|
||||
_deviceOnlyEspTimer.Dispose();
|
||||
_deviceOnlyEspTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Map ESP phase to EnrollmentPhase (phase change events)
|
||||
var enrollmentPhase = EnrollmentPhase.DeviceSetup;
|
||||
if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
|
||||
enrollmentPhase = EnrollmentPhase.AccountSetup;
|
||||
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "esp_phase_changed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = enrollmentPhase,
|
||||
Message = $"ESP phase: {phase}",
|
||||
Data = new Dictionary<string, object> { { "espPhase", phase } },
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
// Re-collect enrollment-dependent device info (AAD join, profile, ESP config)
|
||||
// that may have been empty at startup (bootstrap / pre-enrollment scenario).
|
||||
CollectDeviceInfoAtEnrollmentStart();
|
||||
|
||||
// Check for ConfigMgr client at phase transitions (may arrive after agent startup)
|
||||
Task.Run(() => CheckConfigMgrClient(
|
||||
enrollmentPhase == EnrollmentPhase.AccountSetup ? "account_setup" : "device_setup"));
|
||||
|
||||
// Start summary timer when we detect ESP phase
|
||||
if (!_summaryTimerActive)
|
||||
{
|
||||
_summaryTimerActive = true;
|
||||
_summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
|
||||
}
|
||||
if (!_debugStateTimerActive)
|
||||
{
|
||||
_debugStateTimerActive = true;
|
||||
_debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
// Shadow state machine: track ESP phase change
|
||||
ShadowProcessTrigger("esp_phase_changed");
|
||||
}
|
||||
|
||||
private void HandleImeAgentVersion(string version)
|
||||
{
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "ime_agent_version",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"IME Agent version: {version}",
|
||||
Data = new Dictionary<string, object> { { "agentVersion", version } }
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleImeStarted()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: IME started event");
|
||||
}
|
||||
|
||||
private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
|
||||
{
|
||||
_logger.Verbose($"EnrollmentTracker: app state change '{app.Name ?? app.Id}': {oldState} -> {newState}");
|
||||
|
||||
// Auto-switch to app installation phase when first app activity detected
|
||||
// If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice
|
||||
// If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser
|
||||
bool hasAutoSwitched;
|
||||
string currentPhase;
|
||||
lock (_stateLock)
|
||||
{
|
||||
hasAutoSwitched = _hasAutoSwitchedToAppsPhase;
|
||||
currentPhase = _lastEspPhase;
|
||||
}
|
||||
|
||||
if (!hasAutoSwitched &&
|
||||
(newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) &&
|
||||
oldState < AppInstallationState.Downloading)
|
||||
{
|
||||
if (currentPhase != null)
|
||||
{
|
||||
if (string.Equals(currentPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Switch from DeviceSetup to AppsDevice
|
||||
_logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase");
|
||||
lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "esp_phase_changed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.AppsDevice,
|
||||
Message = "ESP phase: AppsDevice (auto-detected from app activity)",
|
||||
Data = new Dictionary<string, object> { { "espPhase", "AppsDevice" }, { "autoDetected", true } },
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
else if (string.Equals(currentPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Switch from AccountSetup to AppsUser
|
||||
_logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase");
|
||||
lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "esp_phase_changed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.AppsUser,
|
||||
Message = "ESP phase: AppsUser (auto-detected from app activity)",
|
||||
Data = new Dictionary<string, object> { { "espPhase", "AppsUser" }, { "autoDetected", true } },
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
else if (string.Equals(currentPhase, "FinalizingSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// SkipUserStatusPage flow: apps starting after FinalizingSetup are background user apps
|
||||
_logger.Info($"EnrollmentTracker: First app activity detected during FinalizingSetup (SkipUserStatusPage), switching to AppsUser phase");
|
||||
lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; }
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "esp_phase_changed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.AppsUser,
|
||||
Message = "ESP phase: AppsUser (auto-detected from app activity)",
|
||||
Data = new Dictionary<string, object> { { "espPhase", "AppsUser" }, { "autoDetected", true } },
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only emit strategic events for significant state transitions
|
||||
string eventType;
|
||||
var severity = EventSeverity.Info;
|
||||
var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase
|
||||
|
||||
switch (newState)
|
||||
{
|
||||
case AppInstallationState.Downloading:
|
||||
// Emit strategic event once when download starts
|
||||
if (oldState < AppInstallationState.Downloading)
|
||||
{
|
||||
eventType = "app_download_started";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Emit debug event for download progress updates
|
||||
// Skip if no real download data (bytesTotal too small or zero)
|
||||
if (app.BytesTotal > 1024) // At least 1 KB to be a real download
|
||||
{
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "download_progress",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = phase,
|
||||
Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%",
|
||||
Data = app.ToEventData()
|
||||
});
|
||||
}
|
||||
return; // Skip main event emission below
|
||||
}
|
||||
break;
|
||||
|
||||
case AppInstallationState.Installing:
|
||||
// Only emit the strategic event once when install actually starts.
|
||||
// Progress-only updates (oldState == Installing, just progress/bytes changed)
|
||||
// are skipped — same pattern as Downloading above.
|
||||
if (oldState == AppInstallationState.Installing)
|
||||
return;
|
||||
eventType = "app_install_started";
|
||||
break;
|
||||
|
||||
case AppInstallationState.Installed:
|
||||
eventType = "app_install_completed";
|
||||
// Emit download_progress event for download manager (shows as completed)
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "download_progress",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = phase,
|
||||
Message = $"{app.Name ?? app.Id}: completed",
|
||||
Data = new Dictionary<string, object>(app.ToEventData())
|
||||
{
|
||||
["status"] = "completed"
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case AppInstallationState.Skipped:
|
||||
eventType = "app_install_skipped";
|
||||
break;
|
||||
|
||||
case AppInstallationState.Error:
|
||||
eventType = "app_install_failed";
|
||||
severity = EventSeverity.Error;
|
||||
// Emit download_progress event for download manager (shows as failed)
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "download_progress",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = phase,
|
||||
Message = $"{app.Name ?? app.Id}: failed",
|
||||
Data = new Dictionary<string, object>(app.ToEventData())
|
||||
{
|
||||
["status"] = "failed"
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case AppInstallationState.Postponed:
|
||||
eventType = "app_install_postponed";
|
||||
severity = EventSeverity.Warning;
|
||||
break;
|
||||
|
||||
default:
|
||||
return; // Don't emit for Unknown, NotInstalled, InProgress
|
||||
}
|
||||
|
||||
// Build a descriptive message: include error detail if available
|
||||
var message = $"{app.Name ?? app.Id}: {newState}";
|
||||
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail))
|
||||
message = $"{app.Name ?? app.Id}: {app.ErrorDetail}";
|
||||
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = phase,
|
||||
Message = message,
|
||||
Data = app.ToEventData(),
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
// Emit summary immediately if state-breakdown counters changed (instant UI updates)
|
||||
EmitAppTrackingSummaryIfChanged();
|
||||
}
|
||||
|
||||
private void HandlePoliciesDiscovered(string policiesJson)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps");
|
||||
}
|
||||
|
||||
private void HandleAllAppsCompleted()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: all apps completed");
|
||||
|
||||
// Stop timers
|
||||
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_summaryTimerActive = false;
|
||||
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_debugStateTimerActive = false;
|
||||
|
||||
// Emit final summary
|
||||
EmitAppTrackingSummary();
|
||||
|
||||
// Note: Phase transition to FinalizingSetup is now handled by Shell-Core events
|
||||
// (ESP exit or Hello wizard start) for more robust detection.
|
||||
// We no longer automatically transition here when apps complete.
|
||||
string phaseSnap;
|
||||
lock (_stateLock) { phaseSnap = _lastEspPhase; }
|
||||
if (phaseSnap != null)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: All apps completed while in phase '{phaseSnap}'");
|
||||
_logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleDoTelemetryReceived(AppPackageState app)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
|
||||
|
||||
var phase = EnrollmentPhase.Unknown;
|
||||
|
||||
// Dedicated do_telemetry event for backend aggregation
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "do_telemetry",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = phase,
|
||||
Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
|
||||
Data = app.ToEventData(),
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
// Also emit download_progress so the UI picks up DO stats
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "download_progress",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = phase,
|
||||
Message = $"{app.Name ?? app.Id}: DO telemetry received",
|
||||
Data = app.ToEventData(),
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleScriptCompleted(ScriptExecutionState script)
|
||||
{
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId))
|
||||
{
|
||||
_logger.Debug($"EnrollmentTracker: HandleScriptCompleted — skipped (script={script != null}, policyId='{script?.PolicyId}')");
|
||||
return;
|
||||
}
|
||||
|
||||
var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase)
|
||||
|| (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result));
|
||||
|
||||
var eventType = isSuccess
|
||||
? Constants.EventTypes.ScriptCompleted
|
||||
: Constants.EventTypes.ScriptFailed;
|
||||
|
||||
var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning;
|
||||
|
||||
// Build human-readable message
|
||||
string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId;
|
||||
string message;
|
||||
if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase)
|
||||
? "Compliant" : "Non-compliant";
|
||||
message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})";
|
||||
}
|
||||
else
|
||||
{
|
||||
message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})";
|
||||
}
|
||||
|
||||
// Add stderr hint to message if present
|
||||
if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
|
||||
message += " - stderr present";
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["policyId"] = script.PolicyId,
|
||||
["scriptType"] = script.ScriptType ?? "platform",
|
||||
["exitCode"] = script.ExitCode ?? -1
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(script.RunContext))
|
||||
data["runContext"] = script.RunContext;
|
||||
if (!string.IsNullOrEmpty(script.Result))
|
||||
data["result"] = script.Result;
|
||||
if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0)
|
||||
data["stdout"] = script.Stdout;
|
||||
if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0)
|
||||
data["stderr"] = script.Stderr;
|
||||
if (!string.IsNullOrEmpty(script.ComplianceResult))
|
||||
data["complianceResult"] = script.ComplianceResult;
|
||||
if (!string.IsNullOrEmpty(script.ScriptPart))
|
||||
data["scriptPart"] = script.ScriptPart;
|
||||
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = Constants.EventSources.IME,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
|
||||
_logger.Info($"EnrollmentTracker: {message}");
|
||||
}
|
||||
|
||||
private void HandleImeSessionChange(string changeType)
|
||||
{
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "ime_session_change",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"IME session change: {changeType}",
|
||||
Data = new Dictionary<string, object> { { "changeType", changeType ?? "" } }
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleUserSessionCompleted()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
|
||||
_stateData.ImePatternSeenUtc = DateTime.UtcNow;
|
||||
RecordSignal("ime_pattern");
|
||||
|
||||
// Emit timeline event so admins can see exactly when IME finished its user part
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "ime_user_session_completed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ImeLogTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "IME user session completed",
|
||||
Data = new Dictionary<string, object> { { "detectedAt", DateTime.UtcNow.ToString("o") } }
|
||||
});
|
||||
|
||||
// User session completed successfully — cancel any pending ESP failure
|
||||
CancelPendingEspFailure();
|
||||
|
||||
// Stop timers if running
|
||||
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_summaryTimerActive = false;
|
||||
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_debugStateTimerActive = false;
|
||||
|
||||
// Device-only deployment (Self-Deploying or SkipUserStatusPage=true): skip Hello wait
|
||||
// entirely — no interactive user session means Hello provisioning cannot complete.
|
||||
bool isDeviceOnlySnap;
|
||||
int? autopilotModeSnap;
|
||||
bool? skipUserSnap;
|
||||
lock (_stateLock)
|
||||
{
|
||||
isDeviceOnlySnap = IsDeviceOnlyDeployment;
|
||||
autopilotModeSnap = _autopilotMode;
|
||||
skipUserSnap = _skipUserStatusPage;
|
||||
}
|
||||
|
||||
if (isDeviceOnlySnap)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={autopilotModeSnap}, skipUserStatusPage={skipUserSnap}) — skipping Hello wait, proceeding to completion");
|
||||
TryEmitEnrollmentComplete("ime_pattern");
|
||||
ShadowProcessTrigger("ime_user_session_completed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Windows Hello is configured but not yet completed
|
||||
if (_espAndHelloTracker != null)
|
||||
{
|
||||
bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured;
|
||||
bool helloCompleted = _espAndHelloTracker.IsHelloCompleted;
|
||||
|
||||
if (helloPolicyConfigured && !helloCompleted)
|
||||
{
|
||||
// Hello is configured but not finished yet - DO NOT mark enrollment as complete
|
||||
_logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet.");
|
||||
_logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete.");
|
||||
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "waiting_for_hello",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.FinalizingSetup,
|
||||
Message = "User apps completed - waiting for Windows Hello provisioning to finish",
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
// Set flag so we know we're waiting
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isWaitingForHello = true;
|
||||
_stateData.IsWaitingForHello = true;
|
||||
_stateData.WaitingForHelloStartedUtc = DateTime.UtcNow;
|
||||
_stateDirty = true;
|
||||
}
|
||||
_statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped
|
||||
|
||||
// Defense-in-depth: Ensure Hello wait timer is running.
|
||||
// StartHelloWaitTimer() has internal guards (returns if timer already running,
|
||||
// if Hello already completed, or if wizard already started). Safe to call multiple times.
|
||||
_espAndHelloTracker?.StartHelloWaitTimer();
|
||||
|
||||
// Safety net: if Hello never resolves (timer chain fails, agent crash without restart),
|
||||
// force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer.
|
||||
_waitingForHelloSafetyTimer?.Dispose();
|
||||
_waitingForHelloSafetyTimer = new Timer(
|
||||
OnWaitingForHelloSafetyTimeout,
|
||||
null,
|
||||
TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds),
|
||||
TimeSpan.FromMilliseconds(-1));
|
||||
|
||||
ShadowProcessTrigger("ime_user_session_completed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ESP provisioning settle check: if ESP categories are still resolving in the registry,
|
||||
// wait up to 30s for Windows to finalize the outcomes before completing.
|
||||
// This catches cases where the agent started late and the ESP status hasn't settled yet.
|
||||
if (_espAndHelloTracker != null)
|
||||
{
|
||||
var snapshot = _espAndHelloTracker.GetProvisioningSnapshot();
|
||||
if (snapshot != null && snapshot.CategoriesSeen > 0 && !snapshot.AllResolved)
|
||||
{
|
||||
var unresolvedCategories = snapshot.CategoryOutcomes
|
||||
.Where(kvp => kvp.Value == "in_progress")
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
_logger.Info($"EnrollmentTracker: ESP provisioning categories not yet resolved " +
|
||||
$"({string.Join(", ", unresolvedCategories)}) — waiting up to {EspSettleTimeoutSeconds}s for settlement");
|
||||
|
||||
EmitImeTrackerEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "esp_provisioning_settle_started",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ESP provisioning settle wait started — {unresolvedCategories.Count} category(ies) unresolved: {string.Join(", ", unresolvedCategories)}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "unresolvedCategories", unresolvedCategories },
|
||||
{ "settleTimeoutSeconds", EspSettleTimeoutSeconds },
|
||||
{ "categoriesSeen", snapshot.CategoriesSeen },
|
||||
{ "categoriesResolved", snapshot.CategoriesResolved }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isWaitingForEspSettle = true;
|
||||
_stateData.IsWaitingForEspSettle = true;
|
||||
_stateData.WaitingForEspSettleStartedUtc = DateTime.UtcNow;
|
||||
_stateDirty = true;
|
||||
}
|
||||
_statePersistence.Save(_stateData);
|
||||
|
||||
_waitingForEspSettleTimer?.Dispose();
|
||||
_waitingForEspSettleTimer = new Timer(
|
||||
OnEspSettleTimerExpired,
|
||||
null,
|
||||
TimeSpan.FromSeconds(EspSettleTimeoutSeconds),
|
||||
TimeSpan.FromMilliseconds(-1));
|
||||
|
||||
ShadowProcessTrigger("ime_user_session_completed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
TryEmitEnrollmentComplete("ime_pattern");
|
||||
|
||||
// Shadow state machine: track IME user session completed
|
||||
ShadowProcessTrigger("ime_user_session_completed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a real user profile exists in C:\Users\ (beyond system accounts).
|
||||
/// Used to verify AccountSetup phase: during WhiteGlove pre-provisioning only defaultuser0
|
||||
/// exists; in user-driven enrollment the signed-in user's profile is already created.
|
||||
/// </summary>
|
||||
private bool HasRealUserProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var usersDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
// Navigate to the Users root (parent of the current profile)
|
||||
usersDir = Path.GetDirectoryName(usersDir); // e.g. C:\Users
|
||||
if (string.IsNullOrEmpty(usersDir) || !Directory.Exists(usersDir))
|
||||
usersDir = @"C:\Users";
|
||||
|
||||
var excludedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"defaultuser0", "defaultuser1", "Default", "Default User",
|
||||
"Public", "All Users", "Administrator", "Guest"
|
||||
};
|
||||
|
||||
foreach (var dir in Directory.GetDirectories(usersDir))
|
||||
{
|
||||
var name = Path.GetFileName(dir);
|
||||
if (excludedNames.Contains(name))
|
||||
continue;
|
||||
if (name.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
_logger.Debug($"EnrollmentTracker: HasRealUserProfile found '{name}'");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: HasRealUserProfile check failed: {ex.Message}");
|
||||
return false; // Fail safe: treat as no real user (suppress AccountSetup)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,814 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment
|
||||
{
|
||||
/// <summary>
|
||||
/// Central enrollment tracking orchestrator.
|
||||
/// Collects consolidated device info events at startup and manages ImeLogTracker
|
||||
/// for smart app installation tracking with strategic event emission.
|
||||
/// </summary>
|
||||
public partial class EnrollmentTracker : IDisposable
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _imeLogFolder;
|
||||
private readonly List<ImeLogPattern> _imeLogPatterns;
|
||||
private readonly SystemSignals.EspAndHelloTracker _espAndHelloTracker;
|
||||
|
||||
private ImeLogTracker _imeLogTracker;
|
||||
private Timer _summaryTimer;
|
||||
private bool _summaryTimerActive;
|
||||
private Timer _debugStateTimer;
|
||||
private bool _debugStateTimerActive;
|
||||
|
||||
// ===== Thread-safety =====
|
||||
// All mutable state fields below are accessed from multiple threads:
|
||||
// - Timer callbacks (SummaryTimer, DebugStateTimer, safety timers) → ThreadPool
|
||||
// - ImeLogTracker event handlers (HandleEspPhaseChanged, HandleAppStateChanged, etc.) → FileSystemWatcher thread
|
||||
// - EspAndHelloTracker event handlers (OnHelloCompleted, OnFinalizingSetupPhaseTriggered, etc.) → COM thread
|
||||
// - MonitoringService (NotifyDesktopArrived) → desktop detection thread
|
||||
// - Task.Run (CollectDeviceInfo) → ThreadPool
|
||||
//
|
||||
// A single lock protects all shared fields. Lock sections contain ONLY field reads/writes —
|
||||
// never _emitEvent, _logger, timer operations, or external callbacks. This ensures:
|
||||
// - No deadlocks (no nested locking, no I/O under lock)
|
||||
// - Minimal contention (microsecond-level critical sections)
|
||||
// - Consistent multi-field reads (e.g., TryEmitEnrollmentComplete guard checks)
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
private string _lastEspPhase; // Track last ESP phase to prevent duplicate events
|
||||
private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase
|
||||
private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation
|
||||
private IEnrollmentFlowHandler _flowHandler = EnrollmentFlowFactory.FromWireFormat("v1"); // Kept in sync with _enrollmentType via SetEnrollmentType()
|
||||
private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete
|
||||
private readonly bool _isBootstrapMode; // Agent started via bootstrap token (pre-MDM)
|
||||
private bool _sendTraceEvents = true; // Send Trace-severity events to backend for decision auditing
|
||||
|
||||
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
|
||||
private string _lastShadowDiscrepancySignature;
|
||||
private bool _enrollmentStartDeviceInfoCollected = false; // Re-collect enrollment-dependent info once at first ESP phase
|
||||
private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once
|
||||
private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events
|
||||
private string _lastEmittedSummaryCompactHash; // Track completed+error counts only — changes here elevate severity to Info
|
||||
|
||||
// ESP failure handling (Phase 1)
|
||||
private Timer _espFailureTimer;
|
||||
private string _pendingEspFailureType;
|
||||
private const int EspFailureGracePeriodSeconds = 60;
|
||||
private static readonly HashSet<string> RecoverableEspFailureTypes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ESPProgress_Timeout"
|
||||
};
|
||||
|
||||
// Unified completion logic (Phase 3A)
|
||||
private bool _espEverSeen;
|
||||
private bool _espFinalExitSeen;
|
||||
private bool _enrollmentCompleteEmitted;
|
||||
private bool _desktopArrived;
|
||||
private bool _isHybridJoin;
|
||||
private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow;
|
||||
|
||||
// Device info readiness — guards against race where completion signals arrive before CollectDeviceInfo finishes
|
||||
private volatile bool _deviceInfoCollected;
|
||||
private string _pendingCompletionSource;
|
||||
|
||||
// Device-Only ESP detection (Phase 3C)
|
||||
private Timer _deviceOnlyEspTimer;
|
||||
private const int DeviceOnlyEspTimerMinutes = 5;
|
||||
|
||||
// ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage)
|
||||
// null = unknown (registry keys not present), true = skip, false = show
|
||||
private bool? _skipUserStatusPage;
|
||||
private bool? _skipDeviceStatusPage;
|
||||
|
||||
// Autopilot deployment mode: 0=UserDriven, 1/2/3+=not yet confirmed mapping, null=unknown
|
||||
private int? _autopilotMode;
|
||||
private bool IsSelfDeploying => _autopilotMode == 1;
|
||||
|
||||
// True when AAD join status shows a joined device with a real user email.
|
||||
// Detected via CollectAadJoinStatus() in DeviceInfoCollector.
|
||||
private bool _aadJoinedWithUser;
|
||||
// Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern
|
||||
// (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning
|
||||
// (WhiteGlove) and MUST NOT be treated as a real AAD-joined user.
|
||||
private bool _fooUserDetected;
|
||||
|
||||
// Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires.
|
||||
// Distinct from <see cref="_signalCorrelatedWhiteGloveTriggered"/> (which is the
|
||||
// fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds
|
||||
// the WhiteGloveClassifier as the strongest positive signal.
|
||||
private bool _shellCoreWhiteGloveFired;
|
||||
|
||||
// Signal-correlated WhiteGlove detection (Ebene 2.6):
|
||||
// Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector
|
||||
// does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done,
|
||||
// DeviceSetup category succeeded, system reboot observed, AAD Not Joined, no AccountSetup
|
||||
// progress, no desktop arrival). Requires ≥10 min stability to avoid false-positives.
|
||||
private DateTime? _signalCorrelatedWhiteGloveStableSince;
|
||||
private bool _signalCorrelatedWhiteGloveTriggered;
|
||||
private bool _systemRebootObserved;
|
||||
private const int SignalCorrelatedWhiteGloveStabilityMinutes = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Called by MonitoringService when a system_reboot_detected event is emitted
|
||||
/// (agent restart after an unclean exit / machine reboot). Used as an input signal
|
||||
/// for the signal-correlated WhiteGlove detection heuristic.
|
||||
/// </summary>
|
||||
public void NotifySystemRebootDetected()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_systemRebootObserved = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after a successful RegisterSession. Compares the backend's authoritative
|
||||
/// validator verdict against the agent's registry-based enrollment-type detection
|
||||
/// and, on mismatch, emits an <c>enrollment_type_mismatch</c> warning event and
|
||||
/// switches the active flow handler to the backend's verdict.
|
||||
///
|
||||
/// <see cref="ValidatorType.CorporateIdentifier"/> and <see cref="ValidatorType.Bootstrap"/>
|
||||
/// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch.
|
||||
/// </summary>
|
||||
public void ReconcileWithBackendValidator(ValidatorType validatedBy)
|
||||
{
|
||||
EnrollmentType backendType = validatedBy switch
|
||||
{
|
||||
ValidatorType.AutopilotV1 => EnrollmentType.Classic,
|
||||
ValidatorType.DeviceAssociation => EnrollmentType.DevicePreparation,
|
||||
_ => EnrollmentType.Unknown
|
||||
};
|
||||
|
||||
if (backendType == EnrollmentType.Unknown)
|
||||
return;
|
||||
|
||||
string currentWire;
|
||||
lock (_stateLock) { currentWire = _enrollmentType; }
|
||||
var currentType = EnrollmentTypeExtensions.FromWireFormat(currentWire);
|
||||
|
||||
if (currentType == backendType)
|
||||
{
|
||||
_logger.Verbose($"EnrollmentTracker: backend validator '{validatedBy}' matches registry-detected enrollment type '{currentWire}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var newWire = backendType.ToWireFormat();
|
||||
_logger.Warning($"EnrollmentTracker: enrollment type mismatch — registry='{currentWire}', backend='{newWire}' (validator={validatedBy}). Switching to backend verdict.");
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_enrollmentType = newWire;
|
||||
_flowHandler = EnrollmentFlowFactory.Create(backendType);
|
||||
_stateData.EnrollmentType = newWire;
|
||||
_stateDirty = true;
|
||||
}
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "enrollment_type_mismatch",
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Registry-detected enrollment type '{currentWire}' differs from backend validator verdict '{newWire}' ({validatedBy}). Using backend verdict.",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "registryEnrollmentType", currentWire },
|
||||
{ "backendEnrollmentType", newWire },
|
||||
{ "backendValidator", validatedBy.ToString() }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// True when no interactive user session is expected.
|
||||
// Self-Deploying (AutopilotMode=1) is always device-only.
|
||||
// SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip
|
||||
// the user ESP page in user-driven enrollments. Only classify as device-only when
|
||||
// SkipUserStatusPage=true AND no user has AAD-joined (no aad_join_status with userEmail).
|
||||
private bool IsDeviceOnlyDeployment => IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only snapshot of current state for the shadow state machine.
|
||||
/// Must be called OUTSIDE _stateLock (takes its own lock internally).
|
||||
/// </summary>
|
||||
private CompletionContext SnapshotCompletionContext()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
return new CompletionContext
|
||||
{
|
||||
EnrollmentType = _enrollmentType,
|
||||
IsHybridJoin = _isHybridJoin,
|
||||
AutopilotMode = _autopilotMode,
|
||||
SkipUserStatusPage = _skipUserStatusPage,
|
||||
AadJoinedWithUser = _aadJoinedWithUser,
|
||||
EspEverSeen = _espEverSeen,
|
||||
EspFinalExitSeen = _espFinalExitSeen,
|
||||
DesktopArrived = _desktopArrived,
|
||||
LastEspPhase = _lastEspPhase,
|
||||
DeviceInfoCollected = _deviceInfoCollected,
|
||||
HasHelloTracker = _espAndHelloTracker != null,
|
||||
IsHelloCompleted = _espAndHelloTracker?.IsHelloCompleted ?? false,
|
||||
IsHelloPolicyConfigured = _espAndHelloTracker?.IsPolicyConfigured ?? false,
|
||||
HasUnresolvedEspCategories = false, // Will be set by caller when available
|
||||
HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false,
|
||||
WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false,
|
||||
HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false,
|
||||
ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired,
|
||||
IsFooUserDetected = _fooUserDetected,
|
||||
AgentStartTimeUtc = _agentStartTimeUtc,
|
||||
EspFinalExitUtc = _stateData.EspFinalExitUtc,
|
||||
ImePatternSeenUtc = _stateData.ImePatternSeenUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires the shadow state machine trigger and compares the result with the actual state.
|
||||
/// Logs discrepancies as warnings. Does NOT change actual behavior.
|
||||
/// Also dual-writes the CompletionState to _stateData for persistence.
|
||||
/// </summary>
|
||||
private void ShadowProcessTrigger(string trigger, CompletionContext ctx = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ctx = ctx ?? SnapshotCompletionContext();
|
||||
var result = _completionSm.ProcessTrigger(trigger, ctx);
|
||||
|
||||
// Dual-write: persist the explicit state alongside the boolean flags
|
||||
lock (_stateLock)
|
||||
{
|
||||
_stateData.CompletionState = _completionSm.CurrentState.ToString();
|
||||
}
|
||||
|
||||
// Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted.
|
||||
// Both sides must use the same definition of "terminal": the actual flag is set for
|
||||
// enrollment_complete, enrollment_failed AND the WG-redirect path — so shadow must match
|
||||
// that by accepting Completed, Failed and WhiteGloveCompleted as terminal states.
|
||||
bool actualTerminal;
|
||||
lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; }
|
||||
bool shadowTerminal = _completionSm.CurrentState.IsTerminal();
|
||||
|
||||
if (actualTerminal != shadowTerminal)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker [SHADOW DISCREPANCY]: trigger='{trigger}', " +
|
||||
$"shadowState={_completionSm.CurrentState}, actualEmitted={actualTerminal}, " +
|
||||
$"shadowCompleted={shadowTerminal}");
|
||||
|
||||
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
|
||||
// Emit a backend event (throttled to 1x per unique signature per session) so we can query
|
||||
// mismatches without relying on client-side logs. Severity=Warning so it ships independent of SendTraceEvents.
|
||||
EmitShadowDiscrepancyEvent(trigger, ctx, actualTerminal, shadowTerminal);
|
||||
}
|
||||
else if (result.Transitioned)
|
||||
{
|
||||
_logger.Verbose($"EnrollmentTracker [SHADOW]: trigger='{trigger}', " +
|
||||
$"{result.PreviousState} -> {result.NewState}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Shadow mode must never crash the agent
|
||||
_logger.Debug($"EnrollmentTracker [SHADOW ERROR]: trigger='{trigger}', error={ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Safety-net timer for waiting_for_hello state
|
||||
private Timer _waitingForHelloSafetyTimer;
|
||||
private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s)
|
||||
|
||||
// ESP provisioning settle wait: when IME pattern fires but ESP categories are still unresolved,
|
||||
// wait up to 30s for Windows to finalize the ESP provisioning status in the registry.
|
||||
private bool _isWaitingForEspSettle = false;
|
||||
private Timer _waitingForEspSettleTimer;
|
||||
private const int EspSettleTimeoutSeconds = 30;
|
||||
|
||||
// Safety-net timer for device-only ESP completion (SkipUserStatusPage=true)
|
||||
private Timer _deviceOnlyCompletionSafetyTimer;
|
||||
private const int DeviceOnlyCompletionSafetyTimeoutSeconds = 420; // 7 min — same as Hello safety
|
||||
|
||||
// State persistence for crash recovery
|
||||
private readonly EnrollmentStatePersistence _statePersistence;
|
||||
private EnrollmentStateData _stateData = new EnrollmentStateData();
|
||||
private bool _stateDirty;
|
||||
|
||||
// Completion check throttling (max 1x/min per source)
|
||||
private readonly Dictionary<string, DateTime> _lastCompletionCheckBySource = new Dictionary<string, DateTime>();
|
||||
|
||||
// Shadow state machine: runs in parallel with existing logic for behavioral verification.
|
||||
// Logs discrepancies but does NOT affect actual enrollment completion.
|
||||
private readonly CompletionStateMachine _completionSm = new CompletionStateMachine();
|
||||
|
||||
// Default IME log folder
|
||||
private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs";
|
||||
|
||||
private Telemetry.DeviceInfo.DeviceInfoCollector _deviceInfoCollector;
|
||||
|
||||
// ConfigMgr (SCCM) co-management detection — fire-once guard
|
||||
private bool _configMgrDetected;
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether the Autopilot profile indicates Hybrid Azure AD Join
|
||||
/// by reading CloudAssignedDomainJoinMethod from the AutopilotPolicyCache registry key.
|
||||
/// Safe to call before EnrollmentTracker is instantiated.
|
||||
/// Returns true if CloudAssignedDomainJoinMethod == 1.
|
||||
/// </summary>
|
||||
public static bool DetectHybridJoinStatic()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString();
|
||||
return domainJoinMethod == "1";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether this is an Autopilot v1 (Classic ESP) or v2 (Windows Device Preparation) enrollment
|
||||
/// by reading the Autopilot registry keys. Safe to call before EnrollmentTracker is instantiated.
|
||||
/// Returns "v2" if WDP indicators are present, "v1" otherwise.
|
||||
/// </summary>
|
||||
public static string DetectEnrollmentTypeStatic()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
// CloudAssignedDeviceRegistration=2 signals WDP provisioning flow
|
||||
var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
|
||||
if (deviceReg == "2")
|
||||
return "v2";
|
||||
|
||||
// CloudAssignedEspEnabled=0 means no ESP, characteristic of WDP
|
||||
var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
|
||||
if (espEnabled == "0")
|
||||
return "v2";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return "v1";
|
||||
}
|
||||
|
||||
public EnrollmentTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger,
|
||||
List<ImeLogPattern> imeLogPatterns,
|
||||
string imeLogFolderOverride = null,
|
||||
bool simulationMode = false,
|
||||
double speedFactor = 50,
|
||||
string imeMatchLogPath = null,
|
||||
SystemSignals.EspAndHelloTracker espAndHelloTracker = null,
|
||||
bool isBootstrapMode = false,
|
||||
bool sendTraceEvents = true)
|
||||
{
|
||||
_isBootstrapMode = isBootstrapMode;
|
||||
_sendTraceEvents = sendTraceEvents;
|
||||
_isHybridJoin = DetectHybridJoinStatic();
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_emitEvent = emitEvent;
|
||||
_logger = logger;
|
||||
_imeLogPatterns = imeLogPatterns ?? new List<ImeLogPattern>();
|
||||
_imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder;
|
||||
_espAndHelloTracker = espAndHelloTracker;
|
||||
_deviceInfoCollector = new Telemetry.DeviceInfo.DeviceInfoCollector(sessionId, tenantId, emitEvent, logger);
|
||||
|
||||
// State persistence for crash recovery
|
||||
var stateDirectory = @"%ProgramData%\AutopilotMonitor\State";
|
||||
_statePersistence = new EnrollmentStatePersistence(stateDirectory, _logger);
|
||||
|
||||
// Create ImeLogTracker with state persistence directory
|
||||
_imeLogTracker = new ImeLogTracker(_imeLogFolder, _imeLogPatterns, _logger, matchLogPath: imeMatchLogPath, stateDirectory: stateDirectory);
|
||||
_imeLogTracker.SimulationMode = simulationMode;
|
||||
_imeLogTracker.SpeedFactor = speedFactor;
|
||||
|
||||
// Wire up callbacks
|
||||
_imeLogTracker.OnEspPhaseChanged = HandleEspPhaseChanged;
|
||||
_imeLogTracker.OnImeAgentVersion = HandleImeAgentVersion;
|
||||
_imeLogTracker.OnImeStarted = HandleImeStarted;
|
||||
_imeLogTracker.OnAppStateChanged = HandleAppStateChanged;
|
||||
_imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered;
|
||||
_imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted;
|
||||
_imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted;
|
||||
_imeLogTracker.OnImeSessionChange = HandleImeSessionChange;
|
||||
_imeLogTracker.OnDoTelemetryReceived = HandleDoTelemetryReceived;
|
||||
_imeLogTracker.OnScriptCompleted = HandleScriptCompleted;
|
||||
|
||||
// Subscribe to EspAndHelloTracker completion event if available
|
||||
if (_espAndHelloTracker != null)
|
||||
{
|
||||
_espAndHelloTracker.HelloCompleted += OnHelloCompleted;
|
||||
_espAndHelloTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
|
||||
_espAndHelloTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
|
||||
_espAndHelloTracker.EspFailureDetected += OnEspFailureDetected;
|
||||
_espAndHelloTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
|
||||
// Backfill recent ESP exit events that may have fired before we subscribed.
|
||||
// The EspAndHelloTracker is started by CollectorCoordinator before the EnrollmentTracker
|
||||
// is created, so esp_exiting events detected during that window are lost. Backfill recovers them.
|
||||
_espAndHelloTracker.BackfillRecentEspExitEvents();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the SendTraceEvents setting from remote config at runtime.
|
||||
/// </summary>
|
||||
public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value;
|
||||
|
||||
/// <summary>
|
||||
/// Emits a Trace-severity event that captures an agent decision for backend-side troubleshooting.
|
||||
/// Always logged locally; only sent to the backend when SendTraceEvents is enabled.
|
||||
/// </summary>
|
||||
private void EmitTraceEvent(string decision, string reason, Dictionary<string, object> context = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "decision", decision },
|
||||
{ "reason", reason }
|
||||
};
|
||||
if (context != null)
|
||||
{
|
||||
foreach (var kvp in context)
|
||||
data[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
_logger.Trace($"EnrollmentTracker: {decision} — {reason}");
|
||||
|
||||
if (_sendTraceEvents)
|
||||
{
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "agent_trace",
|
||||
Severity = EventSeverity.Trace,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"{decision}: {reason}",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
|
||||
/// <summary>
|
||||
/// Emits a <c>shadow_discrepancy</c> event when the shadow CompletionStateMachine disagrees
|
||||
/// with the actual <c>_enrollmentCompleteEmitted</c> flag. Throttled to one event per unique
|
||||
/// {trigger|shadowState|actualEmitted} signature per session to avoid flooding the backend
|
||||
/// while still surfacing every distinct divergence constellation.
|
||||
/// </summary>
|
||||
private void EmitShadowDiscrepancyEvent(string trigger, CompletionContext ctx, bool actualTerminal, bool shadowTerminal)
|
||||
{
|
||||
var shadowState = _completionSm.CurrentState.ToString();
|
||||
var signature = $"{trigger}|{shadowState}|{actualTerminal}";
|
||||
if (signature == _lastShadowDiscrepancySignature)
|
||||
return;
|
||||
_lastShadowDiscrepancySignature = signature;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "trigger", trigger ?? "(null)" },
|
||||
{ "shadowState", shadowState },
|
||||
{ "actualEmitted", actualTerminal },
|
||||
{ "shadowCompleted", shadowTerminal },
|
||||
{ "enrollmentType", ctx?.EnrollmentType },
|
||||
{ "autopilotMode", ctx?.AutopilotMode },
|
||||
{ "skipUserStatusPage", ctx?.SkipUserStatusPage },
|
||||
{ "aadJoinedWithUser", ctx?.AadJoinedWithUser ?? false },
|
||||
{ "isHybridJoin", ctx?.IsHybridJoin ?? false },
|
||||
{ "isDeviceOnly", ctx?.IsDeviceOnly ?? false },
|
||||
{ "espEverSeen", ctx?.EspEverSeen ?? false },
|
||||
{ "espFinalExitSeen", ctx?.EspFinalExitSeen ?? false },
|
||||
{ "desktopArrived", ctx?.DesktopArrived ?? false },
|
||||
{ "lastEspPhase", ctx?.LastEspPhase },
|
||||
{ "hasHelloTracker", ctx?.HasHelloTracker ?? false },
|
||||
{ "isHelloCompleted", ctx?.IsHelloCompleted ?? false },
|
||||
{ "isHelloPolicyConfigured", ctx?.IsHelloPolicyConfigured ?? false },
|
||||
{ "hasUnresolvedEspCategories", ctx?.HasUnresolvedEspCategories ?? false }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.ShadowDiscrepancy,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Shadow SM mismatch: trigger={trigger}, shadow={shadowState}, emitted={actualTerminal}",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Diagnostic event must never crash the agent
|
||||
_logger.Debug($"EnrollmentTracker [SHADOW EVENT ERROR]: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the enrollment tracker: collects device info and starts IME log tracking.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: starting");
|
||||
|
||||
// Load persisted state for crash recovery
|
||||
LoadState();
|
||||
|
||||
// Start IME log tracking immediately — this is the critical enrollment watcher.
|
||||
// Device info collection (WMI + registry) runs in the background so it doesn't
|
||||
// delay the ImeLogTracker from catching early enrollment events.
|
||||
_imeLogTracker.Start();
|
||||
|
||||
// Collect and emit device info asynchronously (3 WMI queries + 5+ registry reads)
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
CollectDeviceInfo();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: background device info collection failed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
// Check for ConfigMgr co-management client (separate task, must not delay device info)
|
||||
Task.Run(() =>
|
||||
{
|
||||
try { CheckConfigMgrClient("startup"); }
|
||||
catch (Exception ex) { _logger.Debug($"EnrollmentTracker: startup ConfigMgr check failed: {ex.Message}"); }
|
||||
});
|
||||
|
||||
// TODO: Überdenken ob ein 30s timer hier wirklich immer gut ist, hab einen Fall gesehen mit
|
||||
// laaanger Wartephase in WhiteGlove weil ein 24H2 feature update reinkam und installiert wurde
|
||||
|
||||
// Start periodic summary timer (30s, starts when app tracking begins)
|
||||
_summaryTimer = new Timer(SummaryTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
|
||||
_debugStateTimer = new Timer(DebugStateTimerCallback, null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
_logger.Info("EnrollmentTracker: started");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the enrollment tracker
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: stopping");
|
||||
|
||||
_summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_imeLogTracker?.Stop();
|
||||
|
||||
_logger.Info("EnrollmentTracker: stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates IME log patterns (hot-reload from config change)
|
||||
/// </summary>
|
||||
public void UpdatePatterns(List<ImeLogPattern> newPatterns)
|
||||
{
|
||||
if (newPatterns != null)
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)");
|
||||
_imeLogTracker?.CompilePatterns(newPatterns);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access to the ImeLogTracker (for simulator to reference package states)
|
||||
/// </summary>
|
||||
public ImeLogTracker ImeTracker => _imeLogTracker;
|
||||
|
||||
// ===== Device Info Collection =====
|
||||
|
||||
private void CollectDeviceInfo()
|
||||
{
|
||||
var result = _deviceInfoCollector.CollectAll();
|
||||
|
||||
bool wasDeviceOnly;
|
||||
lock (_stateLock)
|
||||
{
|
||||
wasDeviceOnly = IsDeviceOnlyDeployment;
|
||||
_enrollmentType = result.enrollmentType;
|
||||
_flowHandler = EnrollmentFlowFactory.FromWireFormat(result.enrollmentType);
|
||||
_skipUserStatusPage = result.skipUserStatusPage;
|
||||
_skipDeviceStatusPage = result.skipDeviceStatusPage;
|
||||
_autopilotMode = result.autopilotMode;
|
||||
_aadJoinedWithUser = result.hasAadJoinedUser;
|
||||
_fooUserDetected = result.isFooUserDetected;
|
||||
_stateData.EnrollmentType = _enrollmentType;
|
||||
_stateData.SkipUserStatusPage = _skipUserStatusPage;
|
||||
_stateData.SkipDeviceStatusPage = _skipDeviceStatusPage;
|
||||
_stateData.AutopilotMode = _autopilotMode;
|
||||
_stateData.AadJoinedWithUser = _aadJoinedWithUser;
|
||||
_stateData.FooUserDetected = _fooUserDetected;
|
||||
_stateData.IsHybridJoin = _isHybridJoin;
|
||||
_stateDirty = true;
|
||||
}
|
||||
|
||||
// Snapshot values under lock for trace events (emitted outside lock)
|
||||
bool isDeviceOnly, isSelfDeploying, aadJoinedWithUser;
|
||||
int? autopilotMode;
|
||||
bool? skipUserStatusPage;
|
||||
string enrollmentType;
|
||||
lock (_stateLock)
|
||||
{
|
||||
isDeviceOnly = IsDeviceOnlyDeployment;
|
||||
isSelfDeploying = IsSelfDeploying;
|
||||
aadJoinedWithUser = _aadJoinedWithUser;
|
||||
autopilotMode = _autopilotMode;
|
||||
skipUserStatusPage = _skipUserStatusPage;
|
||||
enrollmentType = _enrollmentType;
|
||||
}
|
||||
|
||||
_logger.Info($"EnrollmentTracker: enrollment type detected: {enrollmentType ?? "unknown"} " +
|
||||
$"(autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage}, skipDeviceStatusPage={result.skipDeviceStatusPage}, aadJoinedWithUser={aadJoinedWithUser})");
|
||||
|
||||
// Emit trace when AAD join with user is detected and reclassifies away from device-only
|
||||
if (aadJoinedWithUser && wasDeviceOnly && !isDeviceOnly)
|
||||
{
|
||||
EmitTraceEvent("user_session_detected_via_aad_join",
|
||||
"AAD join with user detected — user session active, using standard completion paths",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "autopilotMode", autopilotMode },
|
||||
{ "skipUserStatusPage", skipUserStatusPage },
|
||||
{ "aadJoinedWithUser", true },
|
||||
{ "isDeviceOnlyDeployment", false }
|
||||
});
|
||||
}
|
||||
|
||||
if (isDeviceOnly)
|
||||
{
|
||||
var reason = isSelfDeploying
|
||||
? "AutopilotMode=1 (Self-Deploying)"
|
||||
: $"SkipUserStatusPage=true (autopilotMode={autopilotMode})";
|
||||
EmitTraceEvent("device_only_deployment_detected",
|
||||
$"{reason} — Hello guard will be bypassed, provisioning status used as completion signal",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "autopilotMode", autopilotMode },
|
||||
{ "skipUserStatusPage", skipUserStatusPage },
|
||||
{ "enrollmentType", enrollmentType },
|
||||
{ "aadJoinedWithUser", aadJoinedWithUser },
|
||||
{ "isSelfDeploying", isSelfDeploying },
|
||||
{ "isDeviceOnlyDeployment", true }
|
||||
});
|
||||
}
|
||||
|
||||
// Mark device info as collected and re-evaluate any deferred completion signal
|
||||
_deviceInfoCollected = true;
|
||||
|
||||
string pendingSource;
|
||||
lock (_stateLock) { pendingSource = _pendingCompletionSource; _pendingCompletionSource = null; }
|
||||
if (pendingSource != null)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: re-evaluating deferred completion signal '{pendingSource}' now that device info is available " +
|
||||
$"(isDeviceOnly={isDeviceOnly}, autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage})");
|
||||
if (pendingSource == "device_setup_provisioning_complete")
|
||||
OnDeviceSetupProvisioningComplete(this, EventArgs.Empty);
|
||||
else
|
||||
TryEmitEnrollmentComplete(pendingSource);
|
||||
}
|
||||
|
||||
// Shadow state machine: track device info collected
|
||||
ShadowProcessTrigger("device_info_collected");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy).
|
||||
/// Called at enrollment complete to capture final state.
|
||||
/// </summary>
|
||||
private void CollectDeviceInfoAtEnd()
|
||||
=> _deviceInfoCollector.CollectAtEnd();
|
||||
|
||||
/// <summary>
|
||||
/// Checks for ConfigMgr (SCCM) co-management client presence on the device.
|
||||
/// Fire-once: emits <c>configmgr_client_detected</c> event on first detection only.
|
||||
/// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking.
|
||||
/// </summary>
|
||||
private void CheckConfigMgrClient(string trigger)
|
||||
{
|
||||
if (_configMgrDetected) return;
|
||||
|
||||
try
|
||||
{
|
||||
bool registryFound = false;
|
||||
bool directoryFound = false;
|
||||
string ccmVersion = null;
|
||||
string siteCode = null;
|
||||
string serviceState = "not_found";
|
||||
|
||||
// 1. Registry: HKLM\SOFTWARE\Microsoft\CCM
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCM"))
|
||||
{
|
||||
if (key != null)
|
||||
registryFound = true;
|
||||
}
|
||||
|
||||
// Version from CCMSetup
|
||||
using (var setupKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCMSetup"))
|
||||
ccmVersion = setupKey?.GetValue("LastValidVersion")?.ToString();
|
||||
|
||||
// SiteCode from SMS Mobile Client
|
||||
using (var smsKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SMS\Mobile Client"))
|
||||
siteCode = smsKey?.GetValue("AssignedSiteCode")?.ToString();
|
||||
|
||||
// 2. Directory: C:\Windows\CCM
|
||||
directoryFound = Directory.Exists(@"C:\Windows\CCM");
|
||||
|
||||
// 3. Service: CcmExec (via ServiceController, native .NET Framework 4.8)
|
||||
try
|
||||
{
|
||||
using (var sc = new System.ServiceProcess.ServiceController("CcmExec"))
|
||||
serviceState = sc.Status.ToString();
|
||||
}
|
||||
catch (InvalidOperationException) { } // service not installed
|
||||
|
||||
// Compute confidence score — directory alone is too weak (could be remnant)
|
||||
int confidence = 0;
|
||||
if (directoryFound) confidence += 10;
|
||||
if (registryFound) confidence += 20;
|
||||
if (ccmVersion != null) confidence += 15;
|
||||
if (siteCode != null) confidence += 15;
|
||||
if (serviceState != "not_found") confidence += 20;
|
||||
if (serviceState == "Running") confidence += 20;
|
||||
|
||||
// Nothing found — no event
|
||||
if (confidence == 0)
|
||||
return;
|
||||
|
||||
_configMgrDetected = true;
|
||||
|
||||
var versionSuffix = ccmVersion != null ? $" (v{ccmVersion})" : "";
|
||||
var siteSuffix = siteCode != null ? $", site {siteCode}" : "";
|
||||
var confidenceLabel = confidence >= 50 ? "high" : "low";
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "configmgr_client_detected",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "Agent",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ConfigMgr (SCCM) client detected ({confidenceLabel} confidence: {confidence}/100) — co-managed device{versionSuffix}{siteSuffix}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "ccmRegistryFound", registryFound },
|
||||
{ "ccmDirectoryFound", directoryFound },
|
||||
{ "ccmServiceState", serviceState },
|
||||
{ "ccmVersion", ccmVersion ?? "unknown" },
|
||||
{ "siteCode", siteCode ?? "unknown" },
|
||||
{ "confidenceScore", confidence },
|
||||
{ "detectedAt", trigger }
|
||||
}
|
||||
});
|
||||
|
||||
_logger.Info($"ConfigMgr client detected (trigger={trigger}, confidence={confidence}, service={serviceState}, version={ccmVersion ?? "?"})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EnrollmentTracker: ConfigMgr check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
|
||||
{
|
||||
public static class EnrollmentFlowFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the flow handler for the given enrollment type.
|
||||
/// <see cref="EnrollmentType.Unknown"/> falls back to the Classic flow
|
||||
/// (safe default — Classic is the long-standing behavior).
|
||||
/// </summary>
|
||||
public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch
|
||||
{
|
||||
EnrollmentType.DevicePreparation => new DevicePreparationFlow(),
|
||||
_ => new ClassicAutopilotFlow()
|
||||
};
|
||||
|
||||
public static IEnrollmentFlowHandler FromWireFormat(string wireFormat)
|
||||
=> Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat));
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows
|
||||
{
|
||||
/// <summary>
|
||||
/// Encapsulates the policy decisions that differ between the Classic Autopilot (v1)
|
||||
/// flow and the Windows Autopilot Device Preparation (v2) flow.
|
||||
///
|
||||
/// Today the flow handler only owns policy bits; tracker ownership remains in
|
||||
/// <c>CollectorCoordinator</c>. When DevPrep-specific trackers land, they will
|
||||
/// move into the corresponding flow handler.
|
||||
/// </summary>
|
||||
public interface IEnrollmentFlowHandler
|
||||
{
|
||||
EnrollmentType SupportedType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the flow relies on ESP-phase signals for progress and completion.
|
||||
/// Classic: true. DevPrep (WDP): false (no ESP exists).
|
||||
/// </summary>
|
||||
bool TracksEspPhases { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when completion triggered by desktop-arrival / desktop-hello must be
|
||||
/// blocked while ESP has been observed but has not reached its final exit.
|
||||
/// Classic: true (AccountSetup may still be running). DevPrep: false.
|
||||
/// </summary>
|
||||
bool AppliesEspGateOnDesktopArrival { get; }
|
||||
}
|
||||
}
|
||||
+548
@@ -0,0 +1,548 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Installation state of an app package during enrollment.
|
||||
/// Order matters: states are compared numerically for upgrade-only transitions.
|
||||
/// </summary>
|
||||
public enum AppInstallationState
|
||||
{
|
||||
Unknown = 0,
|
||||
NotInstalled = 1,
|
||||
InProgress = 2,
|
||||
Downloading = 3,
|
||||
Installing = 4,
|
||||
Installed = 5,
|
||||
Skipped = 6,
|
||||
Postponed = 7,
|
||||
Error = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the app should run
|
||||
/// </summary>
|
||||
public enum AppRunAs
|
||||
{
|
||||
Unknown = -1,
|
||||
User = 0,
|
||||
System = 1
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App install intent from Intune policy
|
||||
/// </summary>
|
||||
public enum AppIntent
|
||||
{
|
||||
Unknown = -1,
|
||||
NotTargeted = 0,
|
||||
Available = 1,
|
||||
Install = 3,
|
||||
Uninstall = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How the app is targeted
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum AppTargeted
|
||||
{
|
||||
Unknown = 128,
|
||||
Dependency = 0,
|
||||
User = 1,
|
||||
Device = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Win32 app state values from IME
|
||||
/// </summary>
|
||||
public enum Win32AppState
|
||||
{
|
||||
Unknown = 0,
|
||||
NotInstalled = 1,
|
||||
InProgress = 2,
|
||||
Completed = 3,
|
||||
Error = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a single app package during enrollment.
|
||||
/// Adapted from EspOverlay PackageState with state transition protection.
|
||||
/// </summary>
|
||||
public class AppPackageState : IComparable<AppPackageState>
|
||||
{
|
||||
public static bool SortErrorsToTop = false;
|
||||
|
||||
public string Id { get; }
|
||||
public int ListPos { get; }
|
||||
public string Name { get; private set; }
|
||||
public AppRunAs RunAs { get; private set; } = AppRunAs.Unknown;
|
||||
public AppIntent Intent { get; private set; } = AppIntent.Unknown;
|
||||
public AppTargeted Targeted { get; private set; } = AppTargeted.Unknown;
|
||||
public HashSet<string> DependsOn { get; private set; } = new HashSet<string>();
|
||||
public AppInstallationState InstallationState { get; private set; } = AppInstallationState.Unknown;
|
||||
public long InstallationStateLastChangedTicks { get; private set; } = 0;
|
||||
public bool DownloadingOrInstallingSeen { get; private set; } = false;
|
||||
public int? ProgressPercent { get; private set; } = null;
|
||||
public long BytesDownloaded { get; private set; } = 0;
|
||||
public long BytesTotal { get; private set; } = 0;
|
||||
public string ErrorPatternId { get; private set; }
|
||||
public string ErrorDetail { get; private set; }
|
||||
public string ErrorCode { get; private set; }
|
||||
|
||||
// Installer result codes (captured from lpExitCode / hResultFromWin32 log lines)
|
||||
public string ExitCode { get; private set; }
|
||||
public string HResultFromWin32 { get; private set; }
|
||||
|
||||
// App metadata (captured from IME log patterns — used by App Dashboard / reports)
|
||||
/// <summary>Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion.</summary>
|
||||
public string AppVersion { get; private set; }
|
||||
/// <summary>App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers.</summary>
|
||||
public string AppType { get; private set; }
|
||||
/// <summary>Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle.</summary>
|
||||
public int AttemptNumber { get; private set; }
|
||||
/// <summary>Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines.</summary>
|
||||
public string DetectionResult { get; private set; }
|
||||
|
||||
// Delivery Optimization telemetry (populated from [DO TEL] log entries)
|
||||
public long DoFileSize { get; private set; }
|
||||
public long DoTotalBytesDownloaded { get; private set; }
|
||||
public long DoBytesFromPeers { get; private set; }
|
||||
public int DoPercentPeerCaching { get; private set; }
|
||||
public long DoBytesFromLanPeers { get; private set; }
|
||||
public long DoBytesFromGroupPeers { get; private set; }
|
||||
public long DoBytesFromInternetPeers { get; private set; }
|
||||
public int DoDownloadMode { get; private set; } = -1;
|
||||
public string DoDownloadDuration { get; private set; }
|
||||
public long DoBytesFromHttp { get; private set; }
|
||||
public bool HasDoTelemetry { get; private set; }
|
||||
|
||||
private static readonly Dictionary<Win32AppState, AppInstallationState> Win32StateMap =
|
||||
new Dictionary<Win32AppState, AppInstallationState>
|
||||
{
|
||||
{ Win32AppState.Unknown, AppInstallationState.Unknown },
|
||||
{ Win32AppState.InProgress, AppInstallationState.InProgress },
|
||||
{ Win32AppState.Completed, AppInstallationState.Installed },
|
||||
{ Win32AppState.Error, AppInstallationState.Error }
|
||||
};
|
||||
|
||||
private static readonly Dictionary<AppInstallationState, int> SortOrderOverrides =
|
||||
new Dictionary<AppInstallationState, int>
|
||||
{
|
||||
{ AppInstallationState.Skipped, (int)AppInstallationState.Installed },
|
||||
{ AppInstallationState.Postponed, (int)AppInstallationState.Installed },
|
||||
{ AppInstallationState.Error, (int)AppInstallationState.Installed }
|
||||
};
|
||||
|
||||
public AppPackageState(string id, int listPos)
|
||||
{
|
||||
Id = id;
|
||||
ListPos = listPos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores an AppPackageState from persisted data (used on agent restart).
|
||||
/// </summary>
|
||||
internal static AppPackageState Restore(
|
||||
string id, int listPos, string name,
|
||||
AppRunAs runAs, AppIntent intent, AppTargeted targeted,
|
||||
HashSet<string> dependsOn,
|
||||
AppInstallationState installationState, bool downloadingOrInstallingSeen,
|
||||
int? progressPercent, long bytesDownloaded, long bytesTotal,
|
||||
string errorPatternId = null, string errorDetail = null, string errorCode = null,
|
||||
string exitCode = null, string hresultFromWin32 = null,
|
||||
long doFileSize = 0, long doTotalBytesDownloaded = 0,
|
||||
long doBytesFromPeers = 0, int doPercentPeerCaching = 0,
|
||||
long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0,
|
||||
long doBytesFromInternetPeers = 0, int doDownloadMode = -1,
|
||||
string doDownloadDuration = null, long doBytesFromHttp = 0,
|
||||
bool hasDoTelemetry = false,
|
||||
string appVersion = null, string appType = null,
|
||||
int attemptNumber = 0, string detectionResult = null)
|
||||
{
|
||||
var pkg = new AppPackageState(id, listPos)
|
||||
{
|
||||
Name = name,
|
||||
RunAs = runAs,
|
||||
Intent = intent,
|
||||
Targeted = targeted,
|
||||
DependsOn = dependsOn ?? new HashSet<string>(),
|
||||
InstallationState = installationState,
|
||||
DownloadingOrInstallingSeen = downloadingOrInstallingSeen,
|
||||
ProgressPercent = progressPercent,
|
||||
BytesDownloaded = bytesDownloaded,
|
||||
BytesTotal = bytesTotal,
|
||||
ErrorPatternId = errorPatternId,
|
||||
ErrorDetail = errorDetail,
|
||||
ErrorCode = errorCode,
|
||||
ExitCode = exitCode,
|
||||
HResultFromWin32 = hresultFromWin32,
|
||||
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(),
|
||||
DoFileSize = doFileSize,
|
||||
DoTotalBytesDownloaded = doTotalBytesDownloaded,
|
||||
DoBytesFromPeers = doBytesFromPeers,
|
||||
DoPercentPeerCaching = doPercentPeerCaching,
|
||||
DoBytesFromLanPeers = doBytesFromLanPeers,
|
||||
DoBytesFromGroupPeers = doBytesFromGroupPeers,
|
||||
DoBytesFromInternetPeers = doBytesFromInternetPeers,
|
||||
DoDownloadMode = doDownloadMode,
|
||||
DoDownloadDuration = doDownloadDuration,
|
||||
DoBytesFromHttp = doBytesFromHttp,
|
||||
HasDoTelemetry = hasDoTelemetry,
|
||||
AppVersion = appVersion,
|
||||
AppType = appType,
|
||||
AttemptNumber = attemptNumber,
|
||||
DetectionResult = detectionResult
|
||||
};
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the app name. Only updates if the new name is not a truncated version of the current name.
|
||||
/// </summary>
|
||||
public bool UpdateName(string newName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(newName) || string.Equals(Name, newName))
|
||||
return false;
|
||||
|
||||
// Avoid overwriting with a truncated version
|
||||
if (Name != null && newName.Length > 0 && Name.StartsWith(newName))
|
||||
return false;
|
||||
|
||||
Name = newName;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates RunAs property
|
||||
/// </summary>
|
||||
public bool UpdateRunAs(AppRunAs newRunAs)
|
||||
{
|
||||
if (RunAs == newRunAs) return false;
|
||||
RunAs = newRunAs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Intent property
|
||||
/// </summary>
|
||||
public bool UpdateIntent(AppIntent newIntent)
|
||||
{
|
||||
if (Intent == newIntent) return false;
|
||||
Intent = newIntent;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Targeted property
|
||||
/// </summary>
|
||||
public bool UpdateTargeted(AppTargeted newTargeted)
|
||||
{
|
||||
if (Targeted == newTargeted) return false;
|
||||
Targeted = newTargeted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates DependsOn set
|
||||
/// </summary>
|
||||
public bool UpdateDependsOn(HashSet<string> newDependsOn)
|
||||
{
|
||||
if (newDependsOn == null) newDependsOn = new HashSet<string>();
|
||||
if (DependsOn.SetEquals(newDependsOn)) return false;
|
||||
DependsOn = newDependsOn;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the error context for the app (called when an error pattern is matched).
|
||||
/// </summary>
|
||||
public void SetErrorContext(string patternId, string detail, string errorCode = null)
|
||||
{
|
||||
ErrorPatternId = patternId;
|
||||
ErrorDetail = detail;
|
||||
if (!string.IsNullOrEmpty(errorCode))
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the installer exit code (lpExitCode) for the app.
|
||||
/// </summary>
|
||||
public void UpdateExitCode(string code)
|
||||
{
|
||||
ExitCode = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the Win32 HRESULT (hResultFromWin32) for the app.
|
||||
/// </summary>
|
||||
public void UpdateHResult(string code)
|
||||
{
|
||||
HResultFromWin32 = code;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the product version detected for this app (e.g. "11.2.1787.0").
|
||||
/// Only applies non-empty values so a later detection without version cannot wipe it.
|
||||
/// </summary>
|
||||
public bool UpdateAppVersion(string version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version)) return false;
|
||||
if (AppVersion == version) return false;
|
||||
AppVersion = version.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the app installer type. Only sets when current is empty OR
|
||||
/// when the incoming value is more specific than the existing one
|
||||
/// (e.g. "WinGet"/"MSI" override the "Win32" default).
|
||||
/// </summary>
|
||||
public bool UpdateAppType(string type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type)) return false;
|
||||
if (AppType == type) return false;
|
||||
// Do not let a generic "Win32" downgrade a previously determined "WinGet"/"MSI".
|
||||
if (!string.IsNullOrEmpty(AppType) && type == "Win32"
|
||||
&& (AppType == "WinGet" || AppType == "MSI" || AppType == "Store" || AppType == "LOB"))
|
||||
return false;
|
||||
AppType = type;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the install attempt number. Keeps the highest observed value
|
||||
/// so it is monotonic across log batches.
|
||||
/// </summary>
|
||||
public bool UpdateAttemptNumber(int attempt)
|
||||
{
|
||||
if (attempt <= AttemptNumber) return false;
|
||||
AttemptNumber = attempt;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the detection rule result ("Detected" / "NotDetected").
|
||||
/// Applies any non-empty value so the latest detection wins.
|
||||
/// </summary>
|
||||
public bool UpdateDetectionResult(string result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result)) return false;
|
||||
if (DetectionResult == result) return false;
|
||||
DetectionResult = result.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates Delivery Optimization telemetry data for this app package.
|
||||
/// Called when a [DO TEL] log entry is matched and linked to this app.
|
||||
/// </summary>
|
||||
public void UpdateDoTelemetry(long fileSize, long totalBytesDownloaded,
|
||||
long bytesFromPeers, int percentPeerCaching,
|
||||
long bytesFromLanPeers, long bytesFromGroupPeers, long bytesFromInternetPeers,
|
||||
int downloadMode, string downloadDuration, long bytesFromHttp)
|
||||
{
|
||||
DoFileSize = fileSize;
|
||||
DoTotalBytesDownloaded = totalBytesDownloaded;
|
||||
DoBytesFromPeers = bytesFromPeers;
|
||||
DoPercentPeerCaching = percentPeerCaching;
|
||||
DoBytesFromLanPeers = bytesFromLanPeers;
|
||||
DoBytesFromGroupPeers = bytesFromGroupPeers;
|
||||
DoBytesFromInternetPeers = bytesFromInternetPeers;
|
||||
DoDownloadMode = downloadMode;
|
||||
DoDownloadDuration = downloadDuration;
|
||||
DoBytesFromHttp = bytesFromHttp;
|
||||
HasDoTelemetry = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the installation state with transition protection.
|
||||
/// Returns true if the state actually changed.
|
||||
/// </summary>
|
||||
public bool UpdateState(AppInstallationState newState, int? newProgressPercent = null, bool upgradeOnly = false, long bytesDownloaded = 0, long bytesTotal = 0)
|
||||
{
|
||||
// upgradeOnly: only allow "higher" states (used by Win32AppState mapping)
|
||||
if (upgradeOnly && newState < InstallationState)
|
||||
return false;
|
||||
|
||||
// Cannot downgrade from Installed to Skipped or Postponed
|
||||
if (InstallationState == AppInstallationState.Installed &&
|
||||
(newState == AppInstallationState.Skipped || newState == AppInstallationState.Postponed))
|
||||
return false;
|
||||
|
||||
// Postponed cannot go back to Downloading or lower active states
|
||||
if (InstallationState == AppInstallationState.Postponed && newState <= AppInstallationState.Downloading)
|
||||
return false;
|
||||
|
||||
// If "Installed" without ever seeing download/install -> auto-downgrade to Skipped
|
||||
// This handles apps with "inverse" detection rules (e.g., uninstall packages marked as
|
||||
// installed when old software is NOT detected)
|
||||
if (newState == AppInstallationState.Installed && !DownloadingOrInstallingSeen)
|
||||
newState = AppInstallationState.Skipped;
|
||||
|
||||
// Compute the effective progress percent that will be applied
|
||||
var effectiveProgressPercent = (newState == AppInstallationState.Installed && newProgressPercent == null) ? 100 : newProgressPercent;
|
||||
// Compute effective bytes: only consider new values if non-zero, otherwise keep current
|
||||
var effectiveBytesDownloaded = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesDownloaded : BytesDownloaded;
|
||||
var effectiveBytesTotal = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesTotal : BytesTotal;
|
||||
// After WinGet completion fix: if Installed and total > 0 and downloaded < total, downloaded becomes total
|
||||
if (newState == AppInstallationState.Installed && effectiveBytesTotal > 0 && effectiveBytesDownloaded < effectiveBytesTotal)
|
||||
effectiveBytesDownloaded = effectiveBytesTotal;
|
||||
|
||||
// Skip if no actual change
|
||||
if (newState == InstallationState && effectiveProgressPercent == ProgressPercent && effectiveBytesDownloaded == BytesDownloaded && effectiveBytesTotal == BytesTotal)
|
||||
return false;
|
||||
|
||||
var oldState = InstallationState;
|
||||
InstallationState = newState;
|
||||
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp();
|
||||
DownloadingOrInstallingSeen |= (InstallationState >= AppInstallationState.Downloading &&
|
||||
InstallationState <= AppInstallationState.Installing);
|
||||
ProgressPercent = effectiveProgressPercent;
|
||||
BytesDownloaded = effectiveBytesDownloaded;
|
||||
BytesTotal = effectiveBytesTotal;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state from a Win32AppState string or integer string (e.g., "2" or "InProgress").
|
||||
/// Uses upgrade-only mode to prevent "InProgress" from destroying more detailed states.
|
||||
/// </summary>
|
||||
public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString)
|
||||
{
|
||||
Win32AppState win32State;
|
||||
if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State))
|
||||
return UpdateStateFromWin32AppState(win32State);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state from Win32AppState enum value.
|
||||
/// </summary>
|
||||
public bool UpdateStateFromWin32AppState(Win32AppState win32State)
|
||||
{
|
||||
AppInstallationState newState;
|
||||
if (Win32StateMap.TryGetValue(win32State, out newState))
|
||||
return UpdateState(newState, upgradeOnly: true);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this app requires installation (Install or Uninstall intent)
|
||||
/// </summary>
|
||||
public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the app is currently being processed (InProgress, Downloading, or Installing)
|
||||
/// </summary>
|
||||
public bool IsActive => InstallationState >= AppInstallationState.InProgress &&
|
||||
InstallationState <= AppInstallationState.Installing;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error)
|
||||
/// </summary>
|
||||
public bool IsCompleted => InstallationState >= AppInstallationState.Installed;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the app is in an error state
|
||||
/// </summary>
|
||||
public bool IsError => InstallationState == AppInstallationState.Error;
|
||||
|
||||
/// <summary>
|
||||
/// Comparison for sorting: active apps first, then by state change time, then by list position.
|
||||
/// </summary>
|
||||
public int CompareTo(AppPackageState other)
|
||||
{
|
||||
// First: descending on sort order (active apps at top)
|
||||
var result = -(SortOrder.CompareTo(other.SortOrder));
|
||||
if (result == 0)
|
||||
{
|
||||
// Then: by installation state last changed
|
||||
result = InstallationStateLastChangedTicks.CompareTo(other.InstallationStateLastChangedTicks);
|
||||
}
|
||||
if (result == 0)
|
||||
{
|
||||
// Then: by original position in policy list
|
||||
result = ListPos.CompareTo(other.ListPos);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private int SortOrder
|
||||
{
|
||||
get
|
||||
{
|
||||
if (SortErrorsToTop && InstallationState == AppInstallationState.Error)
|
||||
return -1;
|
||||
|
||||
int sortOrder;
|
||||
if (SortOrderOverrides.TryGetValue(InstallationState, out sortOrder))
|
||||
return sortOrder;
|
||||
|
||||
return (int)InstallationState;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a summary dictionary for event data
|
||||
/// </summary>
|
||||
public Dictionary<string, object> ToEventData()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "appId", Id },
|
||||
{ "appName", Name ?? Id },
|
||||
{ "state", InstallationState.ToString() },
|
||||
{ "intent", Intent.ToString() },
|
||||
{ "targeted", Targeted.ToString() },
|
||||
{ "runAs", RunAs.ToString() },
|
||||
{ "progressPercent", ProgressPercent ?? 0 },
|
||||
{ "bytesDownloaded", BytesDownloaded },
|
||||
{ "bytesTotal", BytesTotal },
|
||||
{ "isError", IsError },
|
||||
{ "isCompleted", IsCompleted }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(ErrorPatternId))
|
||||
data["errorPatternId"] = ErrorPatternId;
|
||||
if (!string.IsNullOrEmpty(ErrorDetail))
|
||||
data["errorDetail"] = ErrorDetail;
|
||||
if (!string.IsNullOrEmpty(ErrorCode))
|
||||
data["errorCode"] = ErrorCode;
|
||||
|
||||
if (!string.IsNullOrEmpty(ExitCode))
|
||||
data["exitCode"] = ExitCode;
|
||||
if (!string.IsNullOrEmpty(HResultFromWin32))
|
||||
data["hresultFromWin32"] = HResultFromWin32;
|
||||
|
||||
// App metadata fields (emitted whenever available so ingest can merge them).
|
||||
if (!string.IsNullOrEmpty(AppVersion))
|
||||
data["appVersion"] = AppVersion;
|
||||
if (!string.IsNullOrEmpty(AppType))
|
||||
data["appType"] = AppType;
|
||||
if (AttemptNumber > 0)
|
||||
data["attemptNumber"] = AttemptNumber;
|
||||
if (!string.IsNullOrEmpty(DetectionResult))
|
||||
data["detectionResult"] = DetectionResult;
|
||||
|
||||
if (HasDoTelemetry)
|
||||
{
|
||||
data["doFileSize"] = DoFileSize;
|
||||
data["doTotalBytesDownloaded"] = DoTotalBytesDownloaded;
|
||||
data["doBytesFromPeers"] = DoBytesFromPeers;
|
||||
data["doPercentPeerCaching"] = DoPercentPeerCaching;
|
||||
data["doBytesFromLanPeers"] = DoBytesFromLanPeers;
|
||||
data["doBytesFromGroupPeers"] = DoBytesFromGroupPeers;
|
||||
data["doBytesFromInternetPeers"] = DoBytesFromInternetPeers;
|
||||
data["doDownloadMode"] = DoDownloadMode;
|
||||
data["doDownloadDuration"] = DoDownloadDuration ?? "";
|
||||
data["doBytesFromHttp"] = DoBytesFromHttp;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages a collection of app package states during enrollment.
|
||||
/// Tracks current app, ignore list, and dependency cascading.
|
||||
/// Adapted from EspOverlay PackageStatesList.
|
||||
/// </summary>
|
||||
public class AppPackageStateList : List<AppPackageState>
|
||||
{
|
||||
/// <summary>
|
||||
/// The ID of the app currently being processed by IME
|
||||
/// </summary>
|
||||
public string CurrentPackageId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Apps to ignore (e.g., user-targeted apps during device phase).
|
||||
/// Case-insensitive because GUIDs may appear in different casing across IME log entries and policy JSON.
|
||||
/// </summary>
|
||||
public HashSet<string> IgnoreList { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
// States that should also cascade to dependents
|
||||
private static readonly HashSet<AppInstallationState> StatesToCascade =
|
||||
new HashSet<AppInstallationState>
|
||||
{
|
||||
AppInstallationState.Postponed,
|
||||
AppInstallationState.Error
|
||||
};
|
||||
|
||||
public AppPackageStateList(AgentLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a package by ID, optionally creating it if not found.
|
||||
/// Returns null if the ID is in the ignore list.
|
||||
/// </summary>
|
||||
public AppPackageState GetPackage(string id, bool createIfNotFound = false)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id))
|
||||
return null;
|
||||
|
||||
var result = this.SingleOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
if (result == null && createIfNotFound)
|
||||
{
|
||||
result = new AppPackageState(id, Count);
|
||||
Add(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds/updates packages from a JSON policy list (from IME "Get policies" log line).
|
||||
/// Returns true if any package was updated.
|
||||
/// </summary>
|
||||
public bool AddUpdateFromJsonPolicies(List<Dictionary<string, object>> jsonPolicies, bool ignoreUserTargeted)
|
||||
{
|
||||
if (jsonPolicies == null) return false;
|
||||
|
||||
var updated = false;
|
||||
foreach (var policy in jsonPolicies)
|
||||
{
|
||||
string id;
|
||||
object idObj;
|
||||
if (!policy.TryGetValue("Id", out idObj) || (id = idObj?.ToString()) == null)
|
||||
continue;
|
||||
|
||||
// Check RunAs - ignore user-targeted during device phase
|
||||
if (ignoreUserTargeted)
|
||||
{
|
||||
var runAs = GetRunAsFromPolicy(policy);
|
||||
if (runAs == AppRunAs.User)
|
||||
{
|
||||
updated |= AddToIgnoreList(id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var pkg = GetPackage(id, createIfNotFound: true);
|
||||
if (pkg == null) continue;
|
||||
|
||||
// Update name
|
||||
object nameObj;
|
||||
if (policy.TryGetValue("Name", out nameObj))
|
||||
updated |= pkg.UpdateName(nameObj?.ToString());
|
||||
|
||||
// Update intent
|
||||
object intentObj;
|
||||
if (policy.TryGetValue("Intent", out intentObj))
|
||||
{
|
||||
AppIntent intent;
|
||||
if (Enum.TryParse(intentObj.ToString(), true, out intent))
|
||||
updated |= pkg.UpdateIntent(intent);
|
||||
}
|
||||
|
||||
// Update targeted
|
||||
object targetObj;
|
||||
if (policy.TryGetValue("TargetType", out targetObj))
|
||||
{
|
||||
AppTargeted targeted;
|
||||
if (Enum.TryParse(targetObj.ToString(), true, out targeted))
|
||||
updated |= pkg.UpdateTargeted(targeted);
|
||||
}
|
||||
|
||||
// Update RunAs from InstallEx
|
||||
object installExObj;
|
||||
if (policy.TryGetValue("InstallEx", out installExObj))
|
||||
{
|
||||
var runAs = ExtractRunAsFromInstallEx(installExObj);
|
||||
if (runAs != AppRunAs.Unknown)
|
||||
updated |= pkg.UpdateRunAs(runAs);
|
||||
}
|
||||
|
||||
// Update dependencies from FlatDependencies
|
||||
object flatDepsObj;
|
||||
if (policy.TryGetValue("FlatDependencies", out flatDepsObj))
|
||||
{
|
||||
var deps = ExtractDependencies(flatDepsObj, id);
|
||||
if (deps != null)
|
||||
updated |= pkg.UpdateDependsOn(deps);
|
||||
}
|
||||
}
|
||||
|
||||
if (updated)
|
||||
{
|
||||
Sort();
|
||||
LogStates();
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the name of a specific package
|
||||
/// </summary>
|
||||
public bool UpdateName(string id, string newName)
|
||||
{
|
||||
var pkg = GetPackage(id);
|
||||
if (pkg == null) return false;
|
||||
var updated = pkg.UpdateName(newName);
|
||||
if (updated) LogStates();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the installation state of a specific package.
|
||||
/// Returns true if state actually changed.
|
||||
/// Optionally cascades to dependent packages for error/postpone states.
|
||||
/// </summary>
|
||||
public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null)
|
||||
{
|
||||
var packageIds = new HashSet<string> { id };
|
||||
|
||||
// Cascade error/postpone to dependents
|
||||
if (StatesToCascade.Contains(newState))
|
||||
packageIds.UnionWith(GetDependentIdsDeep(id));
|
||||
|
||||
var packages = packageIds.Select(x => GetPackage(x)).Where(x => x != null).ToList();
|
||||
|
||||
var result = false;
|
||||
foreach (var pkg in packages)
|
||||
result |= pkg.UpdateState(newState, progressPercent);
|
||||
|
||||
if (result)
|
||||
{
|
||||
AppPackageState.SortErrorsToTop = IsAllCompleted();
|
||||
Sort();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state to Downloading with optional progress tracking
|
||||
/// </summary>
|
||||
public bool UpdateStateToDownloading(string id, string bytesDownloadedStr, string totalBytesStr)
|
||||
{
|
||||
int? progressPercent = null;
|
||||
long bytesDownloaded = 0;
|
||||
long bytesTotal = 0;
|
||||
|
||||
try
|
||||
{
|
||||
bytesDownloaded = Convert.ToInt64(bytesDownloadedStr);
|
||||
bytesTotal = Convert.ToInt64(totalBytesStr);
|
||||
if (bytesTotal != 0)
|
||||
progressPercent = (int)Math.Round((double)bytesDownloaded / bytesTotal * 100);
|
||||
}
|
||||
catch { }
|
||||
|
||||
var pkg = GetPackage(id);
|
||||
if (pkg != null &&
|
||||
pkg.InstallationState != AppInstallationState.Skipped &&
|
||||
pkg.InstallationState != AppInstallationState.Installed)
|
||||
{
|
||||
var result = pkg.UpdateState(AppInstallationState.Downloading, progressPercent, false, bytesDownloaded, bytesTotal);
|
||||
if (result)
|
||||
{
|
||||
AppPackageState.SortErrorsToTop = IsAllCompleted();
|
||||
Sort();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates state from a Win32AppState string
|
||||
/// </summary>
|
||||
public bool UpdateStateFromWin32AppState(string id, string win32AppStateString)
|
||||
{
|
||||
var pkg = GetPackage(id);
|
||||
if (pkg == null) return false;
|
||||
var updated = pkg.UpdateStateFromWin32AppState(win32AppStateString);
|
||||
if (updated)
|
||||
{
|
||||
AppPackageState.SortErrorsToTop = IsAllCompleted();
|
||||
Sort();
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the current package being processed
|
||||
/// </summary>
|
||||
public void SetCurrent(string id)
|
||||
{
|
||||
CurrentPackageId = id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an ID to the ignore list
|
||||
/// </summary>
|
||||
public bool AddToIgnoreList(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
return false;
|
||||
|
||||
return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if all required apps are completed
|
||||
/// </summary>
|
||||
public bool IsAllCompleted()
|
||||
{
|
||||
if (!this.Any()) return false;
|
||||
|
||||
var allRequiredComplete = this.Where(x => x.IsRequired).All(x => x.IsCompleted);
|
||||
if (allRequiredComplete)
|
||||
{
|
||||
// Mark dependency-only packages that IME never touched as Skipped
|
||||
foreach (var pkg in this.Where(x => !x.IsRequired && x.InstallationState == AppInstallationState.Unknown))
|
||||
pkg.UpdateState(AppInstallationState.Skipped);
|
||||
}
|
||||
return allRequiredComplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first non-completed package (for display)
|
||||
/// </summary>
|
||||
public AppPackageState ActivePackage => this.FirstOrDefault(x => !x.IsCompleted);
|
||||
|
||||
public int CountAll => Count;
|
||||
public int CountCompleted => this.Count(x => x.IsCompleted);
|
||||
public bool HasError => this.Any(x => x.IsError);
|
||||
public int ErrorCount => this.Count(x => x.IsError);
|
||||
|
||||
/// <summary>
|
||||
/// Gets summary data for event emission
|
||||
/// </summary>
|
||||
public Dictionary<string, object> GetSummaryData()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "totalApps", CountAll },
|
||||
{ "completedApps", CountCompleted },
|
||||
{ "errorCount", ErrorCount },
|
||||
{ "deviceErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.Device) },
|
||||
{ "userErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.User) },
|
||||
{ "hasErrors", HasError },
|
||||
{ "isAllCompleted", IsAllCompleted() },
|
||||
{ "ignoredCount", IgnoreList.Count },
|
||||
// State-breakdown for UI progress displays (replaces the full "apps" array
|
||||
// which could exceed Azure Table Storage's 64KB property limit with 150+ apps)
|
||||
{ "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) },
|
||||
{ "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) },
|
||||
{ "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) },
|
||||
{ "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) },
|
||||
{ "failed", this.Count(x => x.InstallationState == AppInstallationState.Error) },
|
||||
{ "postponed", this.Count(x => x.InstallationState == AppInstallationState.Postponed) },
|
||||
{ "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) },
|
||||
// App names per status category for diagnostics (typically few entries per category,
|
||||
// well within Azure Table Storage's 64KB/30K-char limit even with 150+ apps)
|
||||
{ "pendingNames", this.Where(x => x.InstallationState < AppInstallationState.Downloading).Select(x => x.Name).ToList() },
|
||||
{ "failedNames", this.Where(x => x.InstallationState == AppInstallationState.Error).Select(x => x.Name).ToList() },
|
||||
{ "postponedNames", this.Where(x => x.InstallationState == AppInstallationState.Postponed).Select(x => x.Name).ToList() },
|
||||
{ "installedNames", this.Where(x => x.InstallationState == AppInstallationState.Installed).Select(x => x.Name).ToList() },
|
||||
{ "skippedNames", this.Where(x => x.InstallationState == AppInstallationState.Skipped).Select(x => x.Name).ToList() }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all package states as a list of dictionaries for final status dump.
|
||||
/// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry.
|
||||
/// </summary>
|
||||
public List<Dictionary<string, object>> ToFinalStatusList()
|
||||
{
|
||||
return this.Select(pkg => pkg.ToEventData()).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively gets all dependent package IDs (packages that depend on the given parent)
|
||||
/// </summary>
|
||||
private HashSet<string> GetDependentIdsDeep(string parentId, int level = 0)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
try
|
||||
{
|
||||
level++;
|
||||
if (level > 10)
|
||||
{
|
||||
_logger?.Warning("Dependency tree deeper than 10 levels - possible circular dependency");
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var dependent in this.Where(x => x.DependsOn.Contains(parentId)).Select(x => x.Id))
|
||||
{
|
||||
result.Add(dependent);
|
||||
result.UnionWith(GetDependentIdsDeep(dependent, level));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.Warning($"Error resolving dependencies for {parentId}: {ex.Message}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AppRunAs GetRunAsFromPolicy(Dictionary<string, object> policy)
|
||||
{
|
||||
try
|
||||
{
|
||||
object installExObj;
|
||||
if (policy.TryGetValue("InstallEx", out installExObj))
|
||||
return ExtractRunAsFromInstallEx(installExObj);
|
||||
}
|
||||
catch { }
|
||||
return AppRunAs.Unknown;
|
||||
}
|
||||
|
||||
private static AppRunAs ExtractRunAsFromInstallEx(object installExObj)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (installExObj is Dictionary<string, object> installEx)
|
||||
{
|
||||
object runAsObj;
|
||||
if (installEx.TryGetValue("RunAs", out runAsObj))
|
||||
return (AppRunAs)Convert.ToInt32(runAsObj);
|
||||
}
|
||||
// Handle as JSON string
|
||||
var str = installExObj?.ToString();
|
||||
if (!string.IsNullOrEmpty(str))
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(str);
|
||||
if (json != null)
|
||||
{
|
||||
object runAsObj;
|
||||
if (json.TryGetValue("RunAs", out runAsObj))
|
||||
return (AppRunAs)Convert.ToInt32(runAsObj.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return AppRunAs.Unknown;
|
||||
}
|
||||
|
||||
private static HashSet<string> ExtractDependencies(object flatDepsObj, string appId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (flatDepsObj == null) return new HashSet<string>();
|
||||
|
||||
var deps = flatDepsObj as List<object>;
|
||||
if (deps == null) return new HashSet<string>();
|
||||
|
||||
var result = new HashSet<string>();
|
||||
foreach (var dep in deps)
|
||||
{
|
||||
var depDict = dep as Dictionary<string, object>;
|
||||
if (depDict == null) continue;
|
||||
|
||||
object actionObj, appIdObj, childIdObj, typeObj, levelObj;
|
||||
if (depDict.TryGetValue("Action", out actionObj) &&
|
||||
depDict.TryGetValue("AppId", out appIdObj) &&
|
||||
depDict.TryGetValue("ChildId", out childIdObj) &&
|
||||
depDict.TryGetValue("Type", out typeObj) &&
|
||||
depDict.TryGetValue("Level", out levelObj))
|
||||
{
|
||||
if (Convert.ToInt32(actionObj) == 10 &&
|
||||
string.Equals(appIdObj?.ToString(), appId, StringComparison.OrdinalIgnoreCase) &&
|
||||
Convert.ToInt32(typeObj) == 0 &&
|
||||
Convert.ToInt32(levelObj) == 0)
|
||||
{
|
||||
result.Add(childIdObj.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch { }
|
||||
return new HashSet<string>();
|
||||
}
|
||||
|
||||
private void LogStates()
|
||||
{
|
||||
if (_logger == null) return;
|
||||
var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}"));
|
||||
_logger.Debug($"App states [{Count}]: {stateList}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single parsed line from a CMTrace-format log file
|
||||
/// </summary>
|
||||
public class CmTraceLogEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string Component { get; set; }
|
||||
public int Type { get; set; } // 1=Info, 2=Warning, 3=Error
|
||||
public string Thread { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses CMTrace/SCCM log format used by IME and other Microsoft components.
|
||||
/// Format: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file="">
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
// Pre-compiled regex for the CMTrace format
|
||||
private static readonly Regex CmTraceRegex = new Regex(
|
||||
@"<!\[LOG\[(?<message>.*)\]LOG\]!><time=""(?<time>[\d:.]+)""\s+date=""(?<date>[\d-]+)""\s+component=""(?<component>[^""]*)""\s+context=""[^""]*""\s+type=""(?<type>\d+)""\s+thread=""(?<thread>\d+)""\s+file=""[^""]*"">",
|
||||
RegexOptions.Compiled | RegexOptions.Singleline
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse a single line of CMTrace-formatted log.
|
||||
/// Returns true if parsing succeeded, false if the line doesn't match the format.
|
||||
/// </summary>
|
||||
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
|
||||
{
|
||||
entry = null;
|
||||
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
|
||||
return false;
|
||||
|
||||
var match = CmTraceRegex.Match(line);
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
var message = match.Groups["message"].Value;
|
||||
var timeStr = match.Groups["time"].Value;
|
||||
var dateStr = match.Groups["date"].Value;
|
||||
var component = match.Groups["component"].Value;
|
||||
var typeStr = match.Groups["type"].Value;
|
||||
var thread = match.Groups["thread"].Value;
|
||||
|
||||
// Parse timestamp: date is "M-d-yyyy", time is "HH:mm:ss.ticks"
|
||||
DateTime timestamp;
|
||||
if (!TryParseTimestamp(dateStr, timeStr, out timestamp))
|
||||
{
|
||||
timestamp = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
int type;
|
||||
int.TryParse(typeStr, out type);
|
||||
|
||||
entry = new CmTraceLogEntry
|
||||
{
|
||||
Timestamp = timestamp,
|
||||
Message = message,
|
||||
Component = component,
|
||||
Type = type,
|
||||
Thread = thread
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseTimestamp(string dateStr, string timeStr, out DateTime result)
|
||||
{
|
||||
result = DateTime.MinValue;
|
||||
|
||||
// Date format: "M-d-yyyy" (e.g., "2-8-2026")
|
||||
// Time format: "HH:mm:ss.ticks" (e.g., "06:08:04.8834397")
|
||||
// Truncate time to 7 fractional digits max for DateTime parsing
|
||||
var timeParts = timeStr.Split('.');
|
||||
string normalizedTime;
|
||||
if (timeParts.Length == 2)
|
||||
{
|
||||
var fraction = timeParts[1];
|
||||
if (fraction.Length > 7)
|
||||
fraction = fraction.Substring(0, 7);
|
||||
normalizedTime = timeParts[0] + "." + fraction;
|
||||
}
|
||||
else
|
||||
{
|
||||
normalizedTime = timeStr;
|
||||
}
|
||||
|
||||
var combined = dateStr + " " + normalizedTime;
|
||||
|
||||
// Try multiple date formats to handle varying date styles
|
||||
string[] formats = new[]
|
||||
{
|
||||
"M-d-yyyy H:mm:ss.fffffff",
|
||||
"M-d-yyyy H:mm:ss.ffffff",
|
||||
"M-d-yyyy H:mm:ss.fffff",
|
||||
"M-d-yyyy H:mm:ss.ffff",
|
||||
"M-d-yyyy H:mm:ss.fff",
|
||||
"M-d-yyyy H:mm:ss.ff",
|
||||
"M-d-yyyy H:mm:ss.f",
|
||||
"M-d-yyyy H:mm:ss",
|
||||
"M-d-yyyy HH:mm:ss.fffffff",
|
||||
"M-d-yyyy HH:mm:ss.ffffff",
|
||||
"M-d-yyyy HH:mm:ss.fffff",
|
||||
"M-d-yyyy HH:mm:ss.ffff",
|
||||
"M-d-yyyy HH:mm:ss.fff",
|
||||
"M-d-yyyy HH:mm:ss.ff",
|
||||
"M-d-yyyy HH:mm:ss.f",
|
||||
"M-d-yyyy HH:mm:ss"
|
||||
};
|
||||
|
||||
// CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC.
|
||||
if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeLocal, out result))
|
||||
{
|
||||
result = result.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Event handlers for pattern matches (app state, scripts, ESP phases),
|
||||
/// telemetry processing, and utility methods.
|
||||
/// </summary>
|
||||
public partial class ImeLogTracker
|
||||
{
|
||||
private void HandleDoTelemetry(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Extract app GUID from FileId
|
||||
string fileId = root.TryGetProperty("FileId", out var fileIdProp)
|
||||
? fileIdProp.GetString() : null;
|
||||
|
||||
if (string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
_logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
string appId = ExtractAppIdFromDoFileId(fileId);
|
||||
if (string.IsNullOrEmpty(appId))
|
||||
{
|
||||
appId = _packageStates.CurrentPackageId;
|
||||
_logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(appId)) return;
|
||||
|
||||
var pkg = _packageStates.GetPackage(appId);
|
||||
if (pkg == null)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract all DO fields
|
||||
long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
|
||||
long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
|
||||
string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
|
||||
|
||||
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
|
||||
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
|
||||
downloadMode, downloadDuration, bytesFromHttp);
|
||||
|
||||
_logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
|
||||
|
||||
OnDoTelemetryReceived?.Invoke(pkg);
|
||||
_stateDirty = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts app GUID from DO FileId string.
|
||||
/// Format: ...intunewin-bin_{appGuid}_{number}
|
||||
/// Falls back to trying the second-to-last GUID-like segment.
|
||||
/// </summary>
|
||||
internal static string ExtractAppIdFromDoFileId(string fileId)
|
||||
{
|
||||
// Primary: look for "intunewin-bin_" marker
|
||||
const string marker = "intunewin-bin_";
|
||||
var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0)
|
||||
{
|
||||
var afterMarker = fileId.Substring(idx + marker.Length);
|
||||
if (afterMarker.Length >= 36)
|
||||
{
|
||||
var candidate = afterMarker.Substring(0, 36);
|
||||
if (Guid.TryParse(candidate, out var guid))
|
||||
return guid.ToString().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: split by underscore and find GUIDs, take the second-to-last one
|
||||
var segments = fileId.Split('_');
|
||||
for (int i = segments.Length - 2; i >= 0; i--)
|
||||
{
|
||||
if (Guid.TryParse(segments[i], out var guid))
|
||||
return guid.ToString().ToLowerInvariant();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PowerShell script tracking handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private ScriptExecutionState GetCurrentScript(Dictionary<string, string> parameters)
|
||||
{
|
||||
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null;
|
||||
return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)
|
||||
? _currentRemediationScript
|
||||
: GetCurrentPlatformScript();
|
||||
}
|
||||
|
||||
private ScriptExecutionState GetCurrentPlatformScript()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) &&
|
||||
_pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state))
|
||||
return state;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void HandleScriptStarted(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
|
||||
var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null;
|
||||
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
_logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Flush any pending remediation script (shouldn't happen normally, but defensive)
|
||||
if (_currentRemediationScript != null)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}");
|
||||
EmitScriptEvent(_currentRemediationScript);
|
||||
}
|
||||
|
||||
_currentRemediationScript = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "remediation",
|
||||
ScriptPart = "detection" // detection runs first; updated if remediation runs
|
||||
};
|
||||
_logger.Info($"ImeLogTracker: remediation script started: {id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Platform script: AgentExecutor.log entries create/enrich, IME log entries also create
|
||||
if (!_pendingPlatformScripts.ContainsKey(id))
|
||||
{
|
||||
_pendingPlatformScripts[id] = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "platform"
|
||||
};
|
||||
}
|
||||
_lastPlatformScriptPolicyId = id;
|
||||
_logger.Info($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleScriptContext(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var context = match.Groups["context"]?.Value;
|
||||
if (string.IsNullOrEmpty(context)) return;
|
||||
|
||||
var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User";
|
||||
var script = GetCurrentScript(parameters);
|
||||
if (script != null)
|
||||
{
|
||||
script.RunContext = runContext;
|
||||
_logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleScriptExitCode(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var exitCodeStr = match.Groups["exitCode"]?.Value;
|
||||
if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return;
|
||||
|
||||
var script = GetCurrentScript(parameters);
|
||||
if (script != null)
|
||||
{
|
||||
script.ExitCode = exitCode;
|
||||
_logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleScriptOutput(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null;
|
||||
var script = GetCurrentScript(parameters);
|
||||
if (script == null) return;
|
||||
|
||||
// PS-AGENT-OUTPUT captures both stdout and stderr in one pattern
|
||||
var output = match.Groups["output"]?.Value;
|
||||
var error = match.Groups["error"]?.Value;
|
||||
|
||||
if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error))
|
||||
{
|
||||
// Combined pattern (write output done. output = ..., error = ...)
|
||||
script.Stdout = TruncateOutput(output.Trim());
|
||||
script.Stderr = TruncateOutput(error.Trim());
|
||||
}
|
||||
else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
script.Stderr = TruncateOutput(output?.Trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
script.Stdout = TruncateOutput(output?.Trim());
|
||||
}
|
||||
|
||||
_logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})");
|
||||
}
|
||||
|
||||
private void HandleScriptCompleted(Match match, Dictionary<string, string> parameters)
|
||||
{
|
||||
var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform";
|
||||
var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null;
|
||||
|
||||
if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_currentRemediationScript == null) return;
|
||||
|
||||
var compliance = match.Groups["compliance"]?.Value;
|
||||
var id = match.Groups["id"]?.Value;
|
||||
|
||||
_currentRemediationScript.ComplianceResult = compliance;
|
||||
if (!string.IsNullOrEmpty(part))
|
||||
_currentRemediationScript.ScriptPart = part;
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_currentRemediationScript.PolicyId = id; // confirm policyId from compliance line
|
||||
|
||||
_logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " +
|
||||
$"compliance={compliance}, exit={_currentRemediationScript.ExitCode}");
|
||||
|
||||
EmitScriptEvent(_currentRemediationScript);
|
||||
_currentRemediationScript = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Platform script: final result from IntuneManagementExtension.log
|
||||
var id = match.Groups["id"]?.Value;
|
||||
var result = match.Groups["result"]?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
|
||||
// Merge with pending AgentExecutor data if available
|
||||
if (!_pendingPlatformScripts.TryGetValue(id, out var script))
|
||||
{
|
||||
script = new ScriptExecutionState
|
||||
{
|
||||
PolicyId = id,
|
||||
ScriptType = "platform"
|
||||
};
|
||||
}
|
||||
|
||||
script.Result = result;
|
||||
|
||||
_logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}");
|
||||
|
||||
EmitScriptEvent(script);
|
||||
_pendingPlatformScripts.Remove(id);
|
||||
if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase))
|
||||
_lastPlatformScriptPolicyId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitScriptEvent(ScriptExecutionState script)
|
||||
{
|
||||
if (script == null || string.IsNullOrEmpty(script.PolicyId)) return;
|
||||
OnScriptCompleted?.Invoke(script);
|
||||
}
|
||||
|
||||
private static string TruncateOutput(string output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength)
|
||||
return output;
|
||||
return output.Substring(0, MaxScriptOutputLength) + "...[truncated]";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// IME lifecycle handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void HandleImeStarted()
|
||||
{
|
||||
_logger.Info("ImeLogTracker: IME Agent Started detected");
|
||||
|
||||
// Log any currently active app — it will be re-evaluated by new log entries after IME restart.
|
||||
// We intentionally do NOT mark it as Installed here because IME may retry the app.
|
||||
if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId))
|
||||
{
|
||||
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
|
||||
if (currentPkg?.IsActive == true)
|
||||
_logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart");
|
||||
}
|
||||
|
||||
OnImeStarted?.Invoke();
|
||||
}
|
||||
|
||||
private void HandleImeShutdown()
|
||||
{
|
||||
_logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed");
|
||||
|
||||
foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList())
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})");
|
||||
UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed);
|
||||
}
|
||||
}
|
||||
|
||||
private string _lastEspPhaseDetected;
|
||||
|
||||
private void HandleEspPhaseDetected(string espPhaseString)
|
||||
{
|
||||
// Validate phase and get its ordinal for forward-only enforcement
|
||||
int phaseOrd;
|
||||
if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd))
|
||||
return; // Not a recognized ESP phase
|
||||
|
||||
bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current"
|
||||
|
||||
// Forward-only phase progression: reject backward transitions.
|
||||
// During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup"
|
||||
// which would corrupt tracking if we allowed it to trigger a phase change.
|
||||
if (phaseOrd < _currentPhaseOrder)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})");
|
||||
return;
|
||||
}
|
||||
|
||||
// If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup),
|
||||
// move ALL known app IDs into the ignore list so they won't emit events in the new phase.
|
||||
// We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from
|
||||
// any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp,
|
||||
// esptrackstatus etc. that never entered _packageStates are also silenced.
|
||||
if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (_lastEspPhaseDetected != null) // Not the first phase detection
|
||||
{
|
||||
var ignoredFromStates = 0;
|
||||
var ignoredFromSeen = 0;
|
||||
|
||||
foreach (var pkg in _packageStates)
|
||||
{
|
||||
if (_packageStates.AddToIgnoreList(pkg.Id))
|
||||
ignoredFromStates++;
|
||||
}
|
||||
foreach (var appId in _seenAppIds)
|
||||
{
|
||||
if (_packageStates.AddToIgnoreList(appId))
|
||||
ignoredFromSeen++;
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " +
|
||||
$"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " +
|
||||
$"(total ignore list: {_packageStates.IgnoreList.Count})");
|
||||
|
||||
// Snapshot package states from the completed phase before clearing
|
||||
if (_packageStates.CountAll > 0)
|
||||
{
|
||||
_phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList();
|
||||
_logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase");
|
||||
}
|
||||
|
||||
_packageStates.Clear();
|
||||
_packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference
|
||||
_seenAppIds.Clear();
|
||||
_allAppsCompletedFired = false;
|
||||
}
|
||||
_lastEspPhaseDetected = espPhaseString;
|
||||
_currentPhaseOrder = phaseOrd;
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}");
|
||||
ActivatePatterns(logPhaseIsCurrentPhase);
|
||||
|
||||
OnEspPhaseChanged?.Invoke(espPhaseString);
|
||||
}
|
||||
|
||||
private void HandleEspTrackStatus(Match match)
|
||||
{
|
||||
var from = match.Groups["from"]?.Value;
|
||||
var to = match.Groups["to"]?.Value;
|
||||
var id = match.Groups["id"]?.Value;
|
||||
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return;
|
||||
|
||||
var pkg = _packageStates.GetPackage(id);
|
||||
var label = pkg?.Name ?? id;
|
||||
|
||||
if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress");
|
||||
_packageStates.SetCurrent(id);
|
||||
UpdateStateWithCallback(id, AppInstallationState.Installing);
|
||||
}
|
||||
else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed");
|
||||
UpdateStateWithCallback(id, AppInstallationState.Installed);
|
||||
}
|
||||
else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error");
|
||||
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePoliciesDiscovered(string policiesJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var policies = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(policiesJson);
|
||||
if (policies != null)
|
||||
{
|
||||
// Ignore user-targeted packages during device setup (we only track device phase in general)
|
||||
_packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false);
|
||||
_logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages");
|
||||
OnPoliciesDiscovered?.Invoke(policiesJson);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleCancelStuckAndSetCurrent(string newId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(newId)) return;
|
||||
|
||||
// If current app is stuck in an active state, mark it as skipped
|
||||
var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId);
|
||||
if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing ||
|
||||
currentPkg.InstallationState == AppInstallationState.InProgress ||
|
||||
currentPkg.InstallationState == AppInstallationState.Downloading))
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}");
|
||||
UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped);
|
||||
}
|
||||
|
||||
_packageStates.SetCurrent(newId);
|
||||
}
|
||||
|
||||
private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null, string errorCode = null)
|
||||
{
|
||||
var pkg = _packageStates.GetPackage(id);
|
||||
if (pkg == null) return;
|
||||
|
||||
// Set error context before state change so it's available in ToEventData()
|
||||
if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId))
|
||||
pkg.SetErrorContext(errorPatternId, errorDetail, errorCode);
|
||||
|
||||
var oldState = pkg.InstallationState;
|
||||
var changed = _packageStates.UpdateState(id, newState, progressPercent);
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}");
|
||||
OnAppStateChanged?.Invoke(pkg, oldState, newState);
|
||||
|
||||
// Check if all apps are now completed - only fire once
|
||||
if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted())
|
||||
{
|
||||
_allAppsCompletedFired = true;
|
||||
_logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed");
|
||||
OnAllAppsCompleted?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes)
|
||||
{
|
||||
var pkg = _packageStates.GetPackage(id);
|
||||
if (pkg == null) return;
|
||||
|
||||
var oldState = pkg.InstallationState;
|
||||
var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes);
|
||||
|
||||
if (changed)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}");
|
||||
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading);
|
||||
}
|
||||
}
|
||||
|
||||
private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false)
|
||||
{
|
||||
if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase)
|
||||
return;
|
||||
|
||||
var patterns = new List<CompiledPattern>(_patternsAlways);
|
||||
if (logPhaseIsCurrentPhase)
|
||||
{
|
||||
patterns.AddRange(_patternsCurrentPhase);
|
||||
_lastLogTimestamp = DateTime.MinValue; // Reset simulation delay
|
||||
}
|
||||
else
|
||||
{
|
||||
patterns.AddRange(_patternsOtherPhases);
|
||||
}
|
||||
|
||||
_activePatterns = patterns;
|
||||
_packageStates.SetCurrent(""); // Reset current package
|
||||
_logPhaseIsCurrentPhase = logPhaseIsCurrentPhase;
|
||||
}
|
||||
|
||||
private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token)
|
||||
{
|
||||
if (!SimulationMode || logTimestamp == DateTime.MinValue)
|
||||
return;
|
||||
|
||||
if (_lastLogTimestamp != DateTime.MinValue)
|
||||
{
|
||||
var timeSpan = logTimestamp - _lastLogTimestamp;
|
||||
if (timeSpan.TotalMilliseconds > 0)
|
||||
{
|
||||
var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor);
|
||||
delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds
|
||||
if (delayMs > 0)
|
||||
await Task.Delay(delayMs, token);
|
||||
}
|
||||
}
|
||||
_lastLogTimestamp = logTimestamp;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
private void WriteMatchLog(string sourceFile, string rawLine, string patternId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_matchLogPath)) return;
|
||||
try
|
||||
{
|
||||
var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}";
|
||||
lock (_matchLogLock)
|
||||
{
|
||||
File.AppendAllText(_matchLogPath, entry + Environment.NewLine);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A compiled regex pattern with its action and parameters
|
||||
/// </summary>
|
||||
private class CompiledPattern
|
||||
{
|
||||
public string PatternId { get; set; }
|
||||
public Regex Regex { get; set; }
|
||||
public string Action { get; set; }
|
||||
public Dictionary<string, string> Parameters { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Log file polling and pattern matching logic.
|
||||
/// </summary>
|
||||
public partial class ImeLogTracker
|
||||
{
|
||||
private async Task CheckLogFilesAsync(CancellationToken token)
|
||||
{
|
||||
if (!Directory.Exists(_logFolder))
|
||||
return;
|
||||
|
||||
// Get all matching log files, sorted by name (archived files come before current)
|
||||
var files = new List<string>();
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
files.AddRange(Directory.GetFiles(_logFolder, pattern));
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
|
||||
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var filePath in files)
|
||||
{
|
||||
if (token.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
if (!fileInfo.Exists) continue;
|
||||
|
||||
var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length);
|
||||
if (startPos >= fileInfo.Length)
|
||||
{
|
||||
_logger.Trace($"ImeLogTracker: {Path.GetFileName(filePath)} — no new data (pos={startPos}, size={fileInfo.Length})");
|
||||
continue;
|
||||
}
|
||||
_logger.Trace($"ImeLogTracker: reading {Path.GetFileName(filePath)} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})");
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
stream.Seek(startPos, SeekOrigin.Begin);
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true))
|
||||
{
|
||||
// Buffer for multiline CMTrace entries (e.g. AgentExecutor.log
|
||||
// "write output done. output = ..." spans many lines)
|
||||
StringBuilder multiLineBuffer = null;
|
||||
int multiLineCount = 0;
|
||||
|
||||
string line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
if (token.IsCancellationRequested) break;
|
||||
|
||||
// --- Multiline CMTrace buffering ---
|
||||
// CMTrace entries: <![LOG[message]LOG]!><time=...>
|
||||
// When message contains newlines, the entry spans multiple lines.
|
||||
// We buffer until we find the closing ]LOG]!> tag.
|
||||
if (multiLineBuffer != null)
|
||||
{
|
||||
// Continuing a multiline entry
|
||||
multiLineBuffer.Append('\n').Append(line);
|
||||
multiLineCount++;
|
||||
|
||||
if (line.Contains("]LOG]!>"))
|
||||
{
|
||||
// Entry complete — use the assembled line
|
||||
line = multiLineBuffer.ToString();
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
}
|
||||
else if (multiLineCount >= MaxMultiLineBufferLines)
|
||||
{
|
||||
// Safety limit — discard to prevent unbounded memory usage
|
||||
_logger.Debug($"ImeLogTracker: discarding multiline CMTrace buffer after {multiLineCount} lines (corrupt entry?)");
|
||||
multiLineBuffer = null;
|
||||
multiLineCount = 0;
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Still accumulating — read next line
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (line.StartsWith("<![LOG[") && !line.Contains("]LOG]!>"))
|
||||
{
|
||||
// Start of a multiline CMTrace entry
|
||||
multiLineBuffer = new StringBuilder(line);
|
||||
multiLineCount = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Normal processing (single-line or completed multiline) ---
|
||||
CmTraceLogEntry entry;
|
||||
string messageToMatch;
|
||||
if (CmTraceLogParser.TryParseLine(line, out entry))
|
||||
{
|
||||
messageToMatch = entry.Message;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-CMTrace line - match raw
|
||||
messageToMatch = line;
|
||||
entry = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(messageToMatch)) continue;
|
||||
|
||||
// Simulation mode delay
|
||||
if (SimulationMode && entry != null)
|
||||
{
|
||||
await ApplySimulationDelay(entry.Timestamp, token);
|
||||
}
|
||||
|
||||
// Match against active patterns
|
||||
foreach (var pattern in _activePatterns)
|
||||
{
|
||||
try
|
||||
{
|
||||
var match = pattern.Regex.Match(messageToMatch);
|
||||
if (match.Success)
|
||||
{
|
||||
WriteMatchLog(filePath, line, pattern.PatternId);
|
||||
HandlePatternMatch(pattern, match, messageToMatch, entry);
|
||||
}
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' — skipped to prevent ReDoS");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_positionTracker.SetPosition(filePath, stream.Position);
|
||||
_stateDirty = true;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
|
||||
{
|
||||
LastMatchedPatternId = pattern.PatternId;
|
||||
LastMatchedLogTimestamp = entry?.Timestamp;
|
||||
try
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") &&
|
||||
pattern.Parameters["useCurrentApp"] == "true";
|
||||
|
||||
if (useCurrentApp && string.IsNullOrEmpty(id))
|
||||
id = _packageStates.CurrentPackageId;
|
||||
|
||||
// Track every app ID seen during the current phase for comprehensive ignore on phase change
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_seenAppIds.Add(id);
|
||||
|
||||
switch (pattern.Action?.ToLower())
|
||||
{
|
||||
case "imestarted":
|
||||
HandleImeStarted();
|
||||
break;
|
||||
|
||||
case "imeshutdown":
|
||||
HandleImeShutdown();
|
||||
break;
|
||||
|
||||
case "imesessionchange":
|
||||
var sessionChange = match.Groups["change"]?.Value;
|
||||
_logger.Debug($"IME session change: {sessionChange}");
|
||||
OnImeSessionChange?.Invoke(sessionChange);
|
||||
break;
|
||||
|
||||
case "espphasedetected":
|
||||
var phase = match.Groups["espPhase"]?.Value;
|
||||
if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase"))
|
||||
phase = pattern.Parameters["phase"];
|
||||
if (!string.IsNullOrEmpty(phase))
|
||||
HandleEspPhaseDetected(phase);
|
||||
break;
|
||||
|
||||
case "setcurrentapp":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_packageStates.SetCurrent(id);
|
||||
break;
|
||||
|
||||
case "imeagentversion":
|
||||
var version = match.Groups["agentVersion"]?.Value;
|
||||
if (!string.IsNullOrEmpty(version))
|
||||
OnImeAgentVersion?.Invoke(version);
|
||||
break;
|
||||
|
||||
case "imeimpersonation":
|
||||
_logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}");
|
||||
break;
|
||||
|
||||
case "enrollmentcompleted":
|
||||
_logger.Info("ImeLogTracker: User session completed detected");
|
||||
OnUserSessionCompleted?.Invoke();
|
||||
break;
|
||||
|
||||
case "updatestateinstalled":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
UpdateStateWithCallback(id, AppInstallationState.Installed);
|
||||
break;
|
||||
|
||||
case "updatestatedownloading":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
var bytes = match.Groups["bytes"]?.Value;
|
||||
var ofbytes = match.Groups["ofbytes"]?.Value;
|
||||
if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes))
|
||||
UpdateDownloadingWithCallback(id, bytes, ofbytes);
|
||||
else
|
||||
UpdateStateWithCallback(id, AppInstallationState.Downloading);
|
||||
}
|
||||
break;
|
||||
|
||||
case "updatestateinstalling":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
UpdateStateWithCallback(id, AppInstallationState.Installing);
|
||||
break;
|
||||
|
||||
case "updatestateskipped":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
UpdateStateWithCallback(id, AppInstallationState.Skipped);
|
||||
break;
|
||||
|
||||
case "updatestateerror":
|
||||
// Extract structured error code from named capture groups (exitCode, hresult, errorCode)
|
||||
var extractedErrorCode = match.Groups["exitCode"]?.Value;
|
||||
if (string.IsNullOrEmpty(extractedErrorCode))
|
||||
extractedErrorCode = match.Groups["hresult"]?.Value;
|
||||
if (string.IsNullOrEmpty(extractedErrorCode))
|
||||
extractedErrorCode = match.Groups["errorCode"]?.Value;
|
||||
|
||||
if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true")
|
||||
{
|
||||
// Only apply error if the "to" value is "Error"
|
||||
var toValue = match.Groups["to"]?.Value;
|
||||
if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id))
|
||||
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode);
|
||||
}
|
||||
break;
|
||||
|
||||
case "captureexitcode":
|
||||
var exitCodeVal = match.Groups["exitCode"]?.Value;
|
||||
if (!string.IsNullOrEmpty(exitCodeVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateExitCode(exitCodeVal);
|
||||
break;
|
||||
|
||||
case "capturehresult":
|
||||
var hresultVal = match.Groups["hresult"]?.Value;
|
||||
if (!string.IsNullOrEmpty(hresultVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateHResult(hresultVal);
|
||||
break;
|
||||
|
||||
case "captureappversion":
|
||||
var appVersionVal = match.Groups["appVersion"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(appVersionVal))
|
||||
_packageStates.GetPackage(id)?.UpdateAppVersion(appVersionVal);
|
||||
break;
|
||||
|
||||
case "captureapptypewinget":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_packageStates.GetPackage(id)?.UpdateAppType("WinGet");
|
||||
break;
|
||||
|
||||
case "captureapptypemsi":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
_packageStates.GetPackage(id)?.UpdateAppType("MSI");
|
||||
break;
|
||||
|
||||
case "captureattemptnumber":
|
||||
// IME logs "Execute retry 0" for the first attempt. We report attempt+1 so
|
||||
// the human-friendly value starts at 1 (first attempt).
|
||||
var attemptVal = match.Groups["attempt"]?.Value;
|
||||
if (!string.IsNullOrEmpty(attemptVal) && int.TryParse(attemptVal, out var attemptIdx)
|
||||
&& !string.IsNullOrEmpty(_packageStates.CurrentPackageId))
|
||||
{
|
||||
_packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateAttemptNumber(attemptIdx + 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case "capturedetectionresult":
|
||||
var detectionVal = match.Groups["detection"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal))
|
||||
_packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal);
|
||||
break;
|
||||
|
||||
case "updatestatepostponed":
|
||||
if (!string.IsNullOrEmpty(id))
|
||||
{
|
||||
// Only postpone if not already in a terminal state
|
||||
var pkg = _packageStates.GetPackage(id);
|
||||
if (pkg != null && pkg.InstallationState != AppInstallationState.Installed &&
|
||||
pkg.InstallationState != AppInstallationState.Error)
|
||||
{
|
||||
UpdateStateWithCallback(id, AppInstallationState.Postponed);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "esptrackstatus":
|
||||
HandleEspTrackStatus(match);
|
||||
break;
|
||||
|
||||
case "policiesdiscovered":
|
||||
var policiesJson = match.Groups["policies"]?.Value;
|
||||
if (!string.IsNullOrEmpty(policiesJson))
|
||||
HandlePoliciesDiscovered(policiesJson);
|
||||
break;
|
||||
|
||||
case "ignorecompletedapp":
|
||||
_packageStates.AddToIgnoreList(_packageStates.CurrentPackageId);
|
||||
break;
|
||||
|
||||
case "updatename":
|
||||
var name = match.Groups["name"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name))
|
||||
_packageStates.UpdateName(id, name);
|
||||
break;
|
||||
|
||||
case "updatewin32appstate":
|
||||
var state = match.Groups["state"]?.Value;
|
||||
if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state))
|
||||
_packageStates.UpdateStateFromWin32AppState(id, state);
|
||||
break;
|
||||
|
||||
case "cancelstuckandsetcurrent":
|
||||
HandleCancelStuckAndSetCurrent(id);
|
||||
break;
|
||||
|
||||
case "updatedotelemetry":
|
||||
var doTelJson = match.Groups["doTelJson"]?.Value;
|
||||
if (!string.IsNullOrEmpty(doTelJson))
|
||||
HandleDoTelemetry(doTelJson);
|
||||
break;
|
||||
|
||||
// PowerShell script tracking actions
|
||||
case "scriptstarted":
|
||||
HandleScriptStarted(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
case "scriptcontext":
|
||||
HandleScriptContext(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
case "scriptexitcode":
|
||||
HandleScriptExitCode(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
case "scriptoutput":
|
||||
HandleScriptOutput(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
case "scriptcompleted":
|
||||
HandleScriptCompleted(match, pattern.Parameters);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses [DO TEL] JSON and links it to the correct app via FileId.
|
||||
/// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
|
||||
/// </summary>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses IME (Intune Management Extension) log files using regex patterns from the backend.
|
||||
/// Tracks app installation state transitions and emits strategic events.
|
||||
/// Split into partial classes: Core, LogProcessing, Handlers.
|
||||
///
|
||||
/// Key design: Regex patterns are NOT hardcoded - they come from backend config via ImeLogPattern list.
|
||||
/// This allows updating patterns without agent rebuild when Microsoft changes IME log output.
|
||||
/// </summary>
|
||||
public partial class ImeLogTracker : IDisposable
|
||||
{
|
||||
private readonly string _logFolder;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly LogFilePositionTracker _positionTracker = new LogFilePositionTracker();
|
||||
private readonly AppPackageStateList _packageStates;
|
||||
private readonly int _pollingIntervalMs;
|
||||
private readonly string _matchLogPath; // Optional: path to write every matched raw line
|
||||
private readonly object _matchLogLock = new object();
|
||||
|
||||
// Compiled pattern matchers grouped by category
|
||||
private List<CompiledPattern> _patternsAlways = new List<CompiledPattern>();
|
||||
private List<CompiledPattern> _patternsCurrentPhase = new List<CompiledPattern>();
|
||||
private List<CompiledPattern> _patternsOtherPhases = new List<CompiledPattern>();
|
||||
|
||||
// Active matchers (changes based on ESP phase)
|
||||
private List<CompiledPattern> _activePatterns = new List<CompiledPattern>();
|
||||
private bool _logPhaseIsCurrentPhase = false;
|
||||
|
||||
// Phase isolation: track ALL app IDs seen during pattern matching in the current phase.
|
||||
// On phase change, these are added to the ignore list to prevent device-phase apps from
|
||||
// bleeding into AccountSetup. This is more comprehensive than only ignoring packageStates
|
||||
// because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates.
|
||||
private readonly HashSet<string> _seenAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup
|
||||
// re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase).
|
||||
private static readonly Dictionary<string, int> EspPhaseOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "DeviceSetup", 1 },
|
||||
{ "AccountSetup", 2 }
|
||||
};
|
||||
private int _currentPhaseOrder = 0;
|
||||
|
||||
// Snapshots of package states from completed ESP phases (e.g. DeviceSetup apps before AccountSetup starts)
|
||||
private readonly Dictionary<string, List<Dictionary<string, object>>> _phasePackageSnapshots =
|
||||
new Dictionary<string, List<Dictionary<string, object>>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Simulation mode
|
||||
public bool SimulationMode { get; set; }
|
||||
public double SpeedFactor { get; set; } = 50;
|
||||
private DateTime _lastLogTimestamp = DateTime.MinValue;
|
||||
|
||||
// Background task
|
||||
private Task _pollingTask;
|
||||
private CancellationTokenSource _cts;
|
||||
private bool _allAppsCompletedFired;
|
||||
|
||||
// State persistence: saves tracker state to disk so agent restart continues
|
||||
// from the exact log position without re-parsing or re-building ignore lists.
|
||||
private readonly ImeTrackerStatePersistence _statePersistence;
|
||||
private bool _stateDirty;
|
||||
|
||||
// Standard GUID capture pattern used as {GUID} placeholder in patterns
|
||||
private const string GuidPattern = @"(?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})";
|
||||
|
||||
// Log files to monitor
|
||||
private static readonly string[] LogFilePatterns = new[]
|
||||
{
|
||||
"IntuneManagementExtension.log",
|
||||
"_IntuneManagementExtension.log",
|
||||
"IntuneManagementExtension-????????-??????.log",
|
||||
"AppWorkload.log",
|
||||
"AppWorkload-????????-??????.log",
|
||||
// PowerShell script tracking
|
||||
"AgentExecutor.log",
|
||||
"AgentExecutor-????????-??????.log",
|
||||
"HealthScripts.log",
|
||||
"HealthScripts-????????-??????.log"
|
||||
};
|
||||
|
||||
// Script execution state tracking (accumulates multi-line data before emitting events)
|
||||
private ScriptExecutionState _currentRemediationScript;
|
||||
private readonly Dictionary<string, ScriptExecutionState> _pendingPlatformScripts =
|
||||
new Dictionary<string, ScriptExecutionState>(StringComparer.OrdinalIgnoreCase);
|
||||
private string _lastPlatformScriptPolicyId;
|
||||
private const int MaxScriptOutputLength = 2048;
|
||||
private const int MaxMultiLineBufferLines = 100;
|
||||
|
||||
// Set synchronously during HandlePatternMatch so callbacks can read it
|
||||
public string LastMatchedPatternId { get; private set; }
|
||||
public DateTime? LastMatchedLogTimestamp { get; private set; }
|
||||
|
||||
// Callbacks to EnrollmentTracker
|
||||
public Action<string> OnEspPhaseChanged { get; set; }
|
||||
public Action<string> OnImeAgentVersion { get; set; }
|
||||
public Action OnImeStarted { get; set; }
|
||||
public Action<AppPackageState, AppInstallationState, AppInstallationState> OnAppStateChanged { get; set; }
|
||||
public Action<string> OnPoliciesDiscovered { get; set; }
|
||||
public Action OnAllAppsCompleted { get; set; }
|
||||
public Action OnUserSessionCompleted { get; set; }
|
||||
public Action<string> OnImeSessionChange { get; set; }
|
||||
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
|
||||
public Action<ScriptExecutionState> OnScriptCompleted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Access to the tracked package states
|
||||
/// </summary>
|
||||
public AppPackageStateList PackageStates => _packageStates;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots of package states from completed ESP phases (keyed by phase name, e.g. "DeviceSetup").
|
||||
/// Captured before package states are cleared on phase transition.
|
||||
/// </summary>
|
||||
public Dictionary<string, List<Dictionary<string, object>>> PhasePackageSnapshots => _phasePackageSnapshots;
|
||||
|
||||
public ImeLogTracker(string logFolder, List<ImeLogPattern> patterns, AgentLogger logger, int pollingIntervalMs = 100, string matchLogPath = null, string stateDirectory = null)
|
||||
{
|
||||
_logFolder = Environment.ExpandEnvironmentVariables(logFolder);
|
||||
_logger = logger;
|
||||
_pollingIntervalMs = pollingIntervalMs;
|
||||
_matchLogPath = matchLogPath;
|
||||
_packageStates = new AppPackageStateList(logger);
|
||||
|
||||
// State persistence setup
|
||||
if (!string.IsNullOrEmpty(stateDirectory))
|
||||
{
|
||||
_statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_matchLogPath))
|
||||
{
|
||||
try { Directory.CreateDirectory(Path.GetDirectoryName(_matchLogPath)); } catch { }
|
||||
_logger.Info($"ImeLogTracker: match log enabled -> {_matchLogPath}");
|
||||
}
|
||||
|
||||
CompilePatterns(patterns);
|
||||
ActivatePatterns(logPhaseIsCurrentPhase: false, force: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles ImeLogPattern list from backend into Regex objects grouped by category.
|
||||
/// </summary>
|
||||
public void CompilePatterns(List<ImeLogPattern> patterns)
|
||||
{
|
||||
_patternsAlways = new List<CompiledPattern>();
|
||||
_patternsCurrentPhase = new List<CompiledPattern>();
|
||||
_patternsOtherPhases = new List<CompiledPattern>();
|
||||
|
||||
if (patterns == null) return;
|
||||
|
||||
foreach (var pattern in patterns.Where(p => p.Enabled))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Replace {GUID} placeholder with actual GUID capture regex
|
||||
var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern);
|
||||
// Singleline: make '.' match newlines too — required because multiline CMTrace
|
||||
// entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101)
|
||||
var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1));
|
||||
|
||||
var compiled = new CompiledPattern
|
||||
{
|
||||
PatternId = pattern.PatternId,
|
||||
Regex = regex,
|
||||
Action = pattern.Action,
|
||||
Parameters = pattern.Parameters ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
switch (pattern.Category?.ToLower())
|
||||
{
|
||||
case "always":
|
||||
_patternsAlways.Add(compiled);
|
||||
break;
|
||||
case "currentphase":
|
||||
_patternsCurrentPhase.Add(compiled);
|
||||
break;
|
||||
case "otherphases":
|
||||
_patternsOtherPhases.Add(compiled);
|
||||
break;
|
||||
default:
|
||||
_patternsAlways.Add(compiled); // Default to always
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to compile IME pattern {pattern.PatternId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"ImeLogTracker: compiled {_patternsAlways.Count} always, {_patternsCurrentPhase.Count} currentPhase, {_patternsOtherPhases.Count} otherPhases patterns");
|
||||
|
||||
// Re-activate with current phase state
|
||||
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the background polling task
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_pollingTask != null) return;
|
||||
|
||||
// Restore persisted state from previous agent lifetime (handles agent restart mid-enrollment).
|
||||
// This recovers phase order, ignore list, app states, and log file positions so we continue
|
||||
// exactly where we left off — no re-parsing, no device-phase bleeding.
|
||||
LoadState();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
var token = _cts.Token;
|
||||
|
||||
_pollingTask = Task.Run(async () =>
|
||||
{
|
||||
_logger.Info("ImeLogTracker: polling started");
|
||||
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckLogFilesAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: error during log check: {ex.Message}");
|
||||
try { await Task.Delay(1000, token); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
// Persist state after each polling cycle so agent restart continues from here
|
||||
if (_stateDirty)
|
||||
{
|
||||
SaveState();
|
||||
_stateDirty = false;
|
||||
}
|
||||
|
||||
try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
// Final state save on shutdown
|
||||
if (_stateDirty)
|
||||
{
|
||||
SaveState();
|
||||
_stateDirty = false;
|
||||
}
|
||||
|
||||
_logger.Info("ImeLogTracker: polling stopped");
|
||||
}, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the background polling task
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { }
|
||||
_pollingTask = null;
|
||||
}
|
||||
|
||||
#region State Persistence
|
||||
|
||||
/// <summary>
|
||||
/// Loads persisted state from disk. Called on Start() to restore tracker state
|
||||
/// after an agent restart, so parsing continues exactly where it left off.
|
||||
/// </summary>
|
||||
private void LoadState()
|
||||
{
|
||||
if (_statePersistence == null) return;
|
||||
|
||||
var state = _statePersistence.Load();
|
||||
if (state == null) return;
|
||||
|
||||
// Restore phase tracking
|
||||
_currentPhaseOrder = state.CurrentPhaseOrder;
|
||||
_lastEspPhaseDetected = state.LastEspPhaseDetected;
|
||||
_allAppsCompletedFired = state.AllAppsCompletedFired;
|
||||
_logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
|
||||
|
||||
// Restore seen app IDs
|
||||
_seenAppIds.Clear();
|
||||
if (state.SeenAppIds != null)
|
||||
{
|
||||
foreach (var id in state.SeenAppIds)
|
||||
_seenAppIds.Add(id);
|
||||
}
|
||||
|
||||
// Restore ignore list
|
||||
if (state.IgnoreList != null)
|
||||
{
|
||||
foreach (var id in state.IgnoreList)
|
||||
_packageStates.AddToIgnoreList(id);
|
||||
}
|
||||
|
||||
// Restore current package ID
|
||||
_packageStates.CurrentPackageId = state.CurrentPackageId;
|
||||
|
||||
// Restore package states
|
||||
if (state.Packages != null)
|
||||
{
|
||||
_packageStates.Clear();
|
||||
foreach (var p in state.Packages)
|
||||
{
|
||||
var pkg = AppPackageState.Restore(
|
||||
p.Id, p.ListPos, p.Name,
|
||||
(AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted,
|
||||
p.DependsOn != null ? new HashSet<string>(p.DependsOn) : new HashSet<string>(),
|
||||
(AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen,
|
||||
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
|
||||
errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode,
|
||||
exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32,
|
||||
doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
|
||||
doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
|
||||
doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
|
||||
doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode,
|
||||
doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp,
|
||||
hasDoTelemetry: p.HasDoTelemetry,
|
||||
appVersion: p.AppVersion, appType: p.AppType,
|
||||
attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult);
|
||||
_packageStates.Add(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore file positions
|
||||
if (state.FilePositions != null)
|
||||
{
|
||||
foreach (var kvp in state.FilePositions)
|
||||
{
|
||||
var fullPath = Path.Combine(_logFolder, kvp.Key);
|
||||
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-activate patterns based on restored phase state
|
||||
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
|
||||
|
||||
_logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " +
|
||||
$"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " +
|
||||
$"file positions: {state.FilePositions?.Count ?? 0}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists current tracker state to disk as JSON.
|
||||
/// Called after each polling cycle when state has changed.
|
||||
/// </summary>
|
||||
private void SaveState()
|
||||
{
|
||||
if (_statePersistence == null) return;
|
||||
|
||||
// Build state DTO
|
||||
var state = new ImeTrackerStateData
|
||||
{
|
||||
CurrentPhaseOrder = _currentPhaseOrder,
|
||||
LastEspPhaseDetected = _lastEspPhaseDetected,
|
||||
AllAppsCompletedFired = _allAppsCompletedFired,
|
||||
LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
|
||||
SeenAppIds = _seenAppIds.ToList(),
|
||||
IgnoreList = _packageStates.IgnoreList.ToList(),
|
||||
CurrentPackageId = _packageStates.CurrentPackageId,
|
||||
Packages = _packageStates.Select(p => new PackageStateData
|
||||
{
|
||||
Id = p.Id,
|
||||
ListPos = p.ListPos,
|
||||
Name = p.Name,
|
||||
RunAs = (int)p.RunAs,
|
||||
Intent = (int)p.Intent,
|
||||
Targeted = (int)p.Targeted,
|
||||
DependsOn = p.DependsOn?.ToList() ?? new List<string>(),
|
||||
InstallationState = (int)p.InstallationState,
|
||||
DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
|
||||
ProgressPercent = p.ProgressPercent,
|
||||
BytesDownloaded = p.BytesDownloaded,
|
||||
BytesTotal = p.BytesTotal,
|
||||
ErrorPatternId = p.ErrorPatternId,
|
||||
ErrorDetail = p.ErrorDetail,
|
||||
ErrorCode = p.ErrorCode,
|
||||
ExitCode = p.ExitCode,
|
||||
HResultFromWin32 = p.HResultFromWin32,
|
||||
DoFileSize = p.DoFileSize,
|
||||
DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
|
||||
DoBytesFromPeers = p.DoBytesFromPeers,
|
||||
DoPercentPeerCaching = p.DoPercentPeerCaching,
|
||||
DoBytesFromLanPeers = p.DoBytesFromLanPeers,
|
||||
DoBytesFromGroupPeers = p.DoBytesFromGroupPeers,
|
||||
DoBytesFromInternetPeers = p.DoBytesFromInternetPeers,
|
||||
DoDownloadMode = p.DoDownloadMode,
|
||||
DoDownloadDuration = p.DoDownloadDuration,
|
||||
DoBytesFromHttp = p.DoBytesFromHttp,
|
||||
HasDoTelemetry = p.HasDoTelemetry,
|
||||
AppVersion = p.AppVersion,
|
||||
AppType = p.AppType,
|
||||
AttemptNumber = p.AttemptNumber,
|
||||
DetectionResult = p.DetectionResult
|
||||
}).ToList(),
|
||||
FilePositions = new Dictionary<string, FilePositionData>()
|
||||
};
|
||||
|
||||
// Store file positions by filename only (log folder is known)
|
||||
foreach (var kvp in _positionTracker.GetAllPositions())
|
||||
{
|
||||
var fileName = Path.GetFileName(kvp.Key);
|
||||
state.FilePositions[fileName] = new FilePositionData
|
||||
{
|
||||
Position = kvp.Value.Position,
|
||||
LastKnownSize = kvp.Value.LastKnownSize
|
||||
};
|
||||
}
|
||||
|
||||
_statePersistence.Save(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes persisted state file. Called on enrollment complete to ensure
|
||||
/// a fresh state on the next enrollment cycle.
|
||||
/// </summary>
|
||||
public void DeleteState()
|
||||
{
|
||||
_statePersistence?.Delete();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches for IntuneManagementExtension.exe process exit.
|
||||
/// Discovers the process once (with retry every 5s), then attaches Process.Exited —
|
||||
/// no continuous polling after the process is found.
|
||||
/// Emits ime_process_exited when the process terminates.
|
||||
/// </summary>
|
||||
public class ImeProcessWatcher : IDisposable
|
||||
{
|
||||
private const string ImeProcessName = "IntuneManagementExtension";
|
||||
private const int DiscoveryIntervalSeconds = 5;
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
private Timer _discoveryTimer;
|
||||
private Process _imeProcess;
|
||||
private bool _disposed;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
public ImeProcessWatcher(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found)");
|
||||
_discoveryTimer = new Timer(TryAttach, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(DiscoveryIntervalSeconds));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to find and attach to IntuneManagementExtension.exe.
|
||||
/// Stops the discovery timer once attached — switches to pure event-driven from there.
|
||||
/// </summary>
|
||||
private void TryAttach(object state)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed || _imeProcess != null)
|
||||
return;
|
||||
|
||||
Process[] candidates = null;
|
||||
try
|
||||
{
|
||||
candidates = Process.GetProcessesByName(ImeProcessName);
|
||||
if (candidates.Length == 0)
|
||||
return;
|
||||
|
||||
var proc = candidates[0];
|
||||
for (int i = 1; i < candidates.Length; i++)
|
||||
candidates[i].Dispose();
|
||||
|
||||
proc.EnableRaisingEvents = true;
|
||||
proc.Exited += OnImeExited;
|
||||
_imeProcess = proc;
|
||||
|
||||
// Suspend discovery timer — now event-driven
|
||||
_discoveryTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
_logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={proc.Id})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ImeProcessWatcher: could not attach to {ImeProcessName}.exe: {ex.Message}");
|
||||
|
||||
// Dispose any leftover handles from the failed attempt
|
||||
if (candidates != null)
|
||||
foreach (var p in candidates)
|
||||
try { p.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnImeExited(object sender, EventArgs e)
|
||||
{
|
||||
if (!(sender is Process proc))
|
||||
return;
|
||||
|
||||
int pid = -1;
|
||||
int exitCode = -1;
|
||||
double uptimeSeconds = 0;
|
||||
|
||||
try { pid = proc.Id; } catch { }
|
||||
try { exitCode = proc.ExitCode; } catch { }
|
||||
try { uptimeSeconds = (proc.ExitTime - proc.StartTime).TotalSeconds; } catch { }
|
||||
|
||||
_logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s)");
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "ime_process_exited",
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "ImeProcessWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"{ImeProcessName}.exe exited (PID={pid}, exit code={exitCode})",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "processName", ImeProcessName },
|
||||
{ "pid", pid },
|
||||
{ "exitCode", exitCode },
|
||||
{ "imeUptimeSeconds", Math.Round(uptimeSeconds, 0) }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_discoveryTimer?.Dispose();
|
||||
_discoveryTimer = null;
|
||||
|
||||
if (_imeProcess != null)
|
||||
{
|
||||
try { _imeProcess.Exited -= OnImeExited; } catch { }
|
||||
_imeProcess.Dispose();
|
||||
_imeProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles persistence of ImeLogTracker state to disk (JSON serialization).
|
||||
/// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
|
||||
/// </summary>
|
||||
public class ImeTrackerStatePersistence
|
||||
{
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _stateFilePath;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger)
|
||||
{
|
||||
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
|
||||
_stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string StateFilePath => _stateFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Loads persisted state from disk. Returns null if no state file exists or on error.
|
||||
/// </summary>
|
||||
public ImeTrackerStateData Load()
|
||||
{
|
||||
if (!File.Exists(_stateFilePath))
|
||||
{
|
||||
_logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_stateFilePath);
|
||||
var state = JsonConvert.DeserializeObject<ImeTrackerStateData>(json);
|
||||
if (state == null)
|
||||
{
|
||||
_logger.Warning("ImeLogTracker: persisted state file was empty or invalid");
|
||||
return null;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists tracker state to disk as JSON (atomic write via temp file).
|
||||
/// </summary>
|
||||
public void Save(ImeTrackerStateData state)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var tempPath = _stateFilePath + ".tmp";
|
||||
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
|
||||
File.Copy(tempPath, _stateFilePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes persisted state file.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_stateFilePath))
|
||||
{
|
||||
File.Delete(_stateFilePath);
|
||||
_logger.Info("ImeLogTracker: persisted state deleted");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DTOs for state serialization ----
|
||||
|
||||
public class ImeTrackerStateData
|
||||
{
|
||||
public int CurrentPhaseOrder { get; set; }
|
||||
public string LastEspPhaseDetected { get; set; }
|
||||
public bool AllAppsCompletedFired { get; set; }
|
||||
public bool LogPhaseIsCurrentPhase { get; set; }
|
||||
public List<string> SeenAppIds { get; set; }
|
||||
public List<string> IgnoreList { get; set; }
|
||||
public string CurrentPackageId { get; set; }
|
||||
public List<PackageStateData> Packages { get; set; }
|
||||
public Dictionary<string, FilePositionData> FilePositions { get; set; }
|
||||
}
|
||||
|
||||
public class PackageStateData
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public int ListPos { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int RunAs { get; set; }
|
||||
public int Intent { get; set; }
|
||||
public int Targeted { get; set; }
|
||||
public List<string> DependsOn { get; set; }
|
||||
public int InstallationState { get; set; }
|
||||
public bool DownloadingOrInstallingSeen { get; set; }
|
||||
public int? ProgressPercent { get; set; }
|
||||
public long BytesDownloaded { get; set; }
|
||||
public long BytesTotal { get; set; }
|
||||
|
||||
// Error context
|
||||
public string ErrorPatternId { get; set; }
|
||||
public string ErrorDetail { get; set; }
|
||||
public string ErrorCode { get; set; }
|
||||
|
||||
// Installer result codes
|
||||
public string ExitCode { get; set; }
|
||||
public string HResultFromWin32 { get; set; }
|
||||
|
||||
// Delivery Optimization telemetry
|
||||
public long DoFileSize { get; set; }
|
||||
public long DoTotalBytesDownloaded { get; set; }
|
||||
public long DoBytesFromPeers { get; set; }
|
||||
public int DoPercentPeerCaching { get; set; }
|
||||
public long DoBytesFromLanPeers { get; set; }
|
||||
public long DoBytesFromGroupPeers { get; set; }
|
||||
public long DoBytesFromInternetPeers { get; set; }
|
||||
public int DoDownloadMode { get; set; } = -1;
|
||||
public string DoDownloadDuration { get; set; }
|
||||
public long DoBytesFromHttp { get; set; }
|
||||
public bool HasDoTelemetry { get; set; }
|
||||
|
||||
// App metadata (captured from IME logs for App Dashboard reporting)
|
||||
public string AppVersion { get; set; }
|
||||
public string AppType { get; set; }
|
||||
public int AttemptNumber { get; set; }
|
||||
public string DetectionResult { get; set; }
|
||||
}
|
||||
|
||||
public class FilePositionData
|
||||
{
|
||||
public long Position { get; set; }
|
||||
public long LastKnownSize { get; set; }
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks file read positions for incremental log file reading.
|
||||
/// In-memory only - resets on agent restart, which is acceptable since
|
||||
/// we start fresh per enrollment session.
|
||||
/// </summary>
|
||||
public class LogFilePositionTracker
|
||||
{
|
||||
private readonly Dictionary<string, FilePositionState> _positions
|
||||
= new Dictionary<string, FilePositionState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the safe read position for a file, detecting rollover/truncation.
|
||||
/// If the current file size is smaller than the stored position, the file
|
||||
/// has been rotated - resets to 0 to read from beginning.
|
||||
/// Returns 0 if no position has been recorded yet.
|
||||
/// </summary>
|
||||
public long GetSafePosition(string filePath, long currentFileSize)
|
||||
{
|
||||
FilePositionState state;
|
||||
if (!_positions.TryGetValue(filePath, out state))
|
||||
return 0;
|
||||
|
||||
// Detect rollover: file is smaller than our stored position
|
||||
if (currentFileSize < state.Position)
|
||||
{
|
||||
state.Position = 0;
|
||||
state.LastKnownSize = currentFileSize;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return state.Position;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the current read position for a file after successful reading.
|
||||
/// </summary>
|
||||
public void SetPosition(string filePath, long position)
|
||||
{
|
||||
FilePositionState state;
|
||||
if (!_positions.TryGetValue(filePath, out state))
|
||||
{
|
||||
state = new FilePositionState();
|
||||
_positions[filePath] = state;
|
||||
}
|
||||
|
||||
state.Position = position;
|
||||
state.LastKnownSize = position; // Position is always <= file size
|
||||
state.LastReadTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stored position for a file, or 0 if not tracked.
|
||||
/// Does not perform rollover detection.
|
||||
/// </summary>
|
||||
public long GetPosition(string filePath)
|
||||
{
|
||||
FilePositionState state;
|
||||
if (_positions.TryGetValue(filePath, out state))
|
||||
return state.Position;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all tracked positions for state persistence.
|
||||
/// Keys are full file paths.
|
||||
/// </summary>
|
||||
public Dictionary<string, FilePositionState> GetAllPositions()
|
||||
{
|
||||
return new Dictionary<string, FilePositionState>(_positions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a previously persisted position for a file.
|
||||
/// Used on agent restart to continue reading from the last known position.
|
||||
/// </summary>
|
||||
public void RestorePosition(string filePath, long position, long lastKnownSize)
|
||||
{
|
||||
_positions[filePath] = new FilePositionState
|
||||
{
|
||||
Position = position,
|
||||
LastKnownSize = lastKnownSize,
|
||||
LastReadTime = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class FilePositionState
|
||||
{
|
||||
public long Position { get; set; }
|
||||
public long LastKnownSize { get; set; }
|
||||
public DateTime LastReadTime { get; set; }
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
{
|
||||
/// <summary>
|
||||
/// Replays a real Autopilot enrollment using IME log files with time compression.
|
||||
///
|
||||
/// Key principle: Real data, no mocks.
|
||||
/// - Device info: collected from actual WMI/Registry on the machine running the replay
|
||||
/// - App tracking: replayed from real IME log files with configurable speed factor
|
||||
/// - Performance data: handled by PerformanceCollector (always on, real machine data)
|
||||
/// </summary>
|
||||
public class LogReplayService : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly string _replayLogDir;
|
||||
private readonly double _speedFactor;
|
||||
private readonly List<ImeLogPattern> _imeLogPatterns;
|
||||
|
||||
private CancellationTokenSource _cancellationTokenSource;
|
||||
private Task _replayTask;
|
||||
private EnrollmentTracker _enrollmentTracker;
|
||||
private bool _disposed = false;
|
||||
|
||||
public LogReplayService(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
string replayLogDir = null,
|
||||
double speedFactor = 50,
|
||||
List<ImeLogPattern> imeLogPatterns = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_replayLogDir = replayLogDir;
|
||||
_speedFactor = speedFactor;
|
||||
_imeLogPatterns = imeLogPatterns ?? new List<ImeLogPattern>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts log replay
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info("Starting log replay service");
|
||||
|
||||
if (string.IsNullOrEmpty(_replayLogDir) || !Directory.Exists(_replayLogDir))
|
||||
{
|
||||
_logger.Warning($"Replay log directory not found or not specified: {_replayLogDir}");
|
||||
_logger.Info("Log replay will only emit device info events (no log replay)");
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_replayTask = Task.Run(() => RunReplay(_cancellationTokenSource.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the log replay
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
_logger.Info("Stopping log replay service");
|
||||
|
||||
try
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_replayTask?.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (AggregateException) { }
|
||||
}
|
||||
|
||||
private async Task RunReplay(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Starting log replay (real device data + IME log replay, speed factor: {_speedFactor}x)");
|
||||
|
||||
_enrollmentTracker = new EnrollmentTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_onEventCollected,
|
||||
_logger,
|
||||
_imeLogPatterns,
|
||||
_replayLogDir,
|
||||
simulationMode: true,
|
||||
speedFactor: _speedFactor
|
||||
);
|
||||
|
||||
_enrollmentTracker.Start();
|
||||
_logger.Info($"EnrollmentTracker started in replay mode (speed factor: {_speedFactor}x)");
|
||||
|
||||
// Wait for log replay to complete or be cancelled
|
||||
var checkInterval = TimeSpan.FromSeconds(5);
|
||||
var maxWaitTime = TimeSpan.FromMinutes(10); // Safety timeout
|
||||
var elapsed = TimeSpan.Zero;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested && elapsed < maxWaitTime)
|
||||
{
|
||||
await Task.Delay(checkInterval, cancellationToken);
|
||||
elapsed += checkInterval;
|
||||
|
||||
var packageStates = _enrollmentTracker.ImeTracker?.PackageStates;
|
||||
if (packageStates != null && packageStates.CountAll > 0 && packageStates.IsAllCompleted())
|
||||
{
|
||||
_logger.Info($"Log replay: all {packageStates.CountAll} apps completed after {elapsed.TotalSeconds:F0}s");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("Log replay completed (enrollment_complete will be emitted when IME log shows user session completion)");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.Info("Log replay cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error in log replay", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
Stop();
|
||||
_enrollmentTracker?.Dispose();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates multi-line script execution data from IME logs before emitting
|
||||
/// a single consolidated event. Used for both platform scripts and remediation scripts.
|
||||
/// </summary>
|
||||
public class ScriptExecutionState
|
||||
{
|
||||
/// <summary>Intune policy GUID identifying the script.</summary>
|
||||
public string PolicyId { get; set; }
|
||||
|
||||
/// <summary>"platform" or "remediation".</summary>
|
||||
public string ScriptType { get; set; }
|
||||
|
||||
/// <summary>"detection" or "remediation" (remediation scripts only).</summary>
|
||||
public string ScriptPart { get; set; }
|
||||
|
||||
/// <summary>"System" or "User" execution context.</summary>
|
||||
public string RunContext { get; set; }
|
||||
|
||||
/// <summary>PowerShell exit code.</summary>
|
||||
public int? ExitCode { get; set; }
|
||||
|
||||
/// <summary>Standard output (truncated).</summary>
|
||||
public string Stdout { get; set; }
|
||||
|
||||
/// <summary>Standard error output (truncated).</summary>
|
||||
public string Stderr { get; set; }
|
||||
|
||||
/// <summary>"Success" or "Failed" (platform scripts).</summary>
|
||||
public string Result { get; set; }
|
||||
|
||||
/// <summary>"True" or "False" compliance result (remediation detection only).</summary>
|
||||
public string ComplianceResult { get; set; }
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads AAD join information from the Windows registry and classifies the
|
||||
/// signed-in user e-mail into "real user" vs. "provisioning placeholder".
|
||||
///
|
||||
/// Registry path: HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\<thumbprint>\UserEmail
|
||||
///
|
||||
/// Placeholder users (<see cref="IsPlaceholderUserEmail"/>) appear transiently during
|
||||
/// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join.
|
||||
/// </summary>
|
||||
internal static class AadJoinInfo
|
||||
{
|
||||
internal const string JoinInfoRegistryPath =
|
||||
@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the first JoinInfo sub-key and returns the UserEmail + thumbprint.
|
||||
/// Returns true when ANY sub-key was found (even without UserEmail); the caller
|
||||
/// inspects the out-parameters to decide semantics.
|
||||
/// </summary>
|
||||
/// <param name="userEmail">Raw UserEmail value (may be null/empty).</param>
|
||||
/// <param name="thumbprint">The certificate thumbprint sub-key name (may be null).</param>
|
||||
/// <param name="isPlaceholderUser">
|
||||
/// True when <paramref name="userEmail"/> matches a known provisioning-placeholder
|
||||
/// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join.
|
||||
/// </param>
|
||||
internal static bool TryReadAadJoinedUser(
|
||||
out string userEmail,
|
||||
out string thumbprint,
|
||||
out bool isPlaceholderUser)
|
||||
{
|
||||
userEmail = null;
|
||||
thumbprint = null;
|
||||
isPlaceholderUser = false;
|
||||
|
||||
try
|
||||
{
|
||||
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(JoinInfoRegistryPath))
|
||||
{
|
||||
if (joinInfoKey == null)
|
||||
return false;
|
||||
|
||||
var subKeyNames = joinInfoKey.GetSubKeyNames();
|
||||
if (subKeyNames == null || subKeyNames.Length == 0)
|
||||
return false;
|
||||
|
||||
thumbprint = subKeyNames[0];
|
||||
using (var subKey = joinInfoKey.OpenSubKey(thumbprint))
|
||||
{
|
||||
if (subKey == null)
|
||||
return true;
|
||||
|
||||
userEmail = subKey.GetValue("UserEmail")?.ToString();
|
||||
isPlaceholderUser = IsPlaceholderUserEmail(userEmail);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the given email matches a known transient provisioning-account
|
||||
/// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot device
|
||||
/// preparation and must not be treated as a real signed-in user.
|
||||
/// </summary>
|
||||
internal static bool IsPlaceholderUserEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|
||||
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
public sealed class AadPlaceholderUserDetectedEventArgs : EventArgs
|
||||
{
|
||||
public string UserEmail { get; }
|
||||
public string Thumbprint { get; }
|
||||
public AadPlaceholderUserDetectedEventArgs(string userEmail, string thumbprint)
|
||||
{
|
||||
UserEmail = userEmail;
|
||||
Thumbprint = thumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AadUserJoinedEventArgs : EventArgs
|
||||
{
|
||||
public string UserEmail { get; }
|
||||
public string Thumbprint { get; }
|
||||
public AadUserJoinedEventArgs(string userEmail, string thumbprint)
|
||||
{
|
||||
UserEmail = userEmail;
|
||||
Thumbprint = thumbprint;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo</c> for a
|
||||
/// late AAD user join. Uses <see cref="RegistryWatcher"/> (RegNotifyChangeKeyValue) for
|
||||
/// instant, low-overhead change detection — no polling.
|
||||
///
|
||||
/// Two distinct events are raised:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="PlaceholderUserDetected"/> — first time a transient
|
||||
/// provisioning account (<c>foouser@*</c>, <c>autopilot@*</c>) appears. Fire-once.
|
||||
/// The watcher keeps running afterwards, as the placeholder is expected to be
|
||||
/// replaced by a real user.</description></item>
|
||||
/// <item><description><see cref="AadUserJoined"/> — first time a REAL user e-mail
|
||||
/// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Retry semantics: the <c>JoinInfo</c> root key may not exist yet when the agent
|
||||
/// starts — it is created during AAD provisioning. A retry timer polls for its
|
||||
/// appearance every 2 seconds until it exists, then attaches the registry watcher.
|
||||
/// </summary>
|
||||
internal sealed class AadJoinWatcher : IDisposable
|
||||
{
|
||||
internal const int RetryIntervalSeconds = 2;
|
||||
internal const int DebounceMilliseconds = 500;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
private RegistryWatcher _watcher;
|
||||
private Timer _retryTimer;
|
||||
private Timer _debounceTimer;
|
||||
|
||||
private bool _placeholderFired;
|
||||
private bool _realUserFired;
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
|
||||
public event EventHandler<AadUserJoinedEventArgs> AadUserJoined;
|
||||
|
||||
public AadJoinWatcher(AgentLogger logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(AadJoinWatcher));
|
||||
|
||||
// Initial synchronous read. If a real AAD user is already present (typical after
|
||||
// DeviceInfoCollector ran at agent startup), fire AadUserJoined once and do NOT
|
||||
// attach the registry watcher — there is nothing left to watch for.
|
||||
CheckJoinInfo();
|
||||
bool terminalFromStartup;
|
||||
lock (_stateLock) { terminalFromStartup = _realUserFired; }
|
||||
if (terminalFromStartup)
|
||||
{
|
||||
_logger.Info("AadJoinWatcher: real AAD user already present at startup — skipping RegistryWatcher attach");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryStartWatcher())
|
||||
{
|
||||
_logger.Info("AadJoinWatcher: JoinInfo key not yet present — retrying every 2s");
|
||||
_retryTimer = new Timer(
|
||||
_ =>
|
||||
{
|
||||
if (TryStartWatcher())
|
||||
{
|
||||
_retryTimer?.Dispose();
|
||||
_retryTimer = null;
|
||||
}
|
||||
},
|
||||
null,
|
||||
TimeSpan.FromSeconds(RetryIntervalSeconds),
|
||||
TimeSpan.FromSeconds(RetryIntervalSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop(string reason = "watcher_stopped")
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_retryTimer?.Dispose();
|
||||
_retryTimer = null;
|
||||
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = null;
|
||||
|
||||
if (_watcher != null)
|
||||
{
|
||||
_watcher.Dispose();
|
||||
_watcher = null;
|
||||
_logger.Info($"AadJoinWatcher: stopped ({reason})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("AadJoinWatcher: error during Stop", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Stop("disposed");
|
||||
}
|
||||
|
||||
private bool TryStartWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
|
||||
{
|
||||
if (key == null) return false;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_watcher != null) return true; // Already running
|
||||
|
||||
_debounceTimer = new Timer(
|
||||
_ => CheckJoinInfo(),
|
||||
null,
|
||||
Timeout.Infinite,
|
||||
Timeout.Infinite);
|
||||
|
||||
_watcher = new RegistryWatcher(
|
||||
RegistryHive.LocalMachine,
|
||||
AadJoinInfo.JoinInfoRegistryPath,
|
||||
watchSubtree: true,
|
||||
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet
|
||||
| RegistryNativeMethods.RegChangeNotifyFilter.Name,
|
||||
trace: msg => _logger.Trace($"AadJoinWatcher(reg): {msg}"));
|
||||
|
||||
_watcher.Changed += (s, e) =>
|
||||
{
|
||||
_logger.Trace("AadJoinWatcher: registry changed — debouncing");
|
||||
_debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite);
|
||||
};
|
||||
_watcher.Error += (s, ex) => _logger.Warning($"AadJoinWatcher: watcher error: {ex.Message}");
|
||||
|
||||
_watcher.Start();
|
||||
_logger.Info("AadJoinWatcher: RegistryWatcher attached to JoinInfo");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"AadJoinWatcher: failed to start watcher: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckJoinInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Already reached terminal state? Stop any further processing.
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_realUserFired) return;
|
||||
}
|
||||
|
||||
if (!AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
|
||||
{
|
||||
// Key gone or unreadable — keep watching, may come back
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
// Sub-key exists but no UserEmail value yet — nothing to act on
|
||||
return;
|
||||
}
|
||||
|
||||
bool firePlaceholder = false;
|
||||
bool fireRealUser = false;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (isPlaceholderUser && !_placeholderFired)
|
||||
{
|
||||
_placeholderFired = true;
|
||||
firePlaceholder = true;
|
||||
}
|
||||
else if (!isPlaceholderUser && !_realUserFired)
|
||||
{
|
||||
_realUserFired = true;
|
||||
fireRealUser = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (firePlaceholder)
|
||||
{
|
||||
_logger.Info($"AadJoinWatcher: placeholder user detected ({userEmail}) — pre-provisioning indicator");
|
||||
try { PlaceholderUserDetected?.Invoke(this, new AadPlaceholderUserDetectedEventArgs(userEmail, thumbprint)); }
|
||||
catch (Exception ex) { _logger.Error("AadJoinWatcher: PlaceholderUserDetected handler threw", ex); }
|
||||
}
|
||||
|
||||
if (fireRealUser)
|
||||
{
|
||||
_logger.Info($"AadJoinWatcher: real AAD user joined ({userEmail}) — firing AadUserJoined and stopping watcher");
|
||||
try { AadUserJoined?.Invoke(this, new AadUserJoinedEventArgs(userEmail, thumbprint)); }
|
||||
catch (Exception ex) { _logger.Error("AadJoinWatcher: AadUserJoined handler threw", ex); }
|
||||
|
||||
// Terminal — stop the watcher. Run on a thread-pool thread to avoid
|
||||
// callback-in-callback lock edge cases with the RegistryWatcher worker.
|
||||
ThreadPool.QueueUserWorkItem(_ => Stop("real_user_joined"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"AadJoinWatcher: CheckJoinInfo threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects when a real user desktop becomes available (explorer.exe under a non-system user).
|
||||
/// Fires DesktopArrived event exactly once per agent lifetime.
|
||||
/// Used for enrollment completion in no-ESP scenarios (WDP v2, ESP disabled) and
|
||||
/// as a backup signal for AccountSetup phase correction.
|
||||
/// </summary>
|
||||
public class DesktopArrivalDetector : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private Timer _pollingTimer;
|
||||
private bool _desktopArrived;
|
||||
private bool _excludedUserTraced; // Emit trace event only once for excluded-user skips
|
||||
private const int PollingIntervalSeconds = 30;
|
||||
|
||||
/// <summary>
|
||||
/// System/service account names that should NOT be considered real users.
|
||||
/// </summary>
|
||||
private static readonly string[] ExcludedUserNames =
|
||||
{
|
||||
"SYSTEM",
|
||||
"LOCAL SERVICE",
|
||||
"NETWORK SERVICE",
|
||||
"DefaultUser0",
|
||||
"DefaultUser1",
|
||||
"defaultuser0",
|
||||
"defaultuser1"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Fired exactly once when a real user desktop is detected (explorer.exe under a real user).
|
||||
/// </summary>
|
||||
public event EventHandler DesktopArrived;
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback for trace events (decision, reason, context).
|
||||
/// Wired by MonitoringService to emit agent_trace events to the backend.
|
||||
/// </summary>
|
||||
public Action<string, string, Dictionary<string, object>> OnTraceEvent { get; set; }
|
||||
|
||||
public DesktopArrivalDetector(AgentLogger logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info("DesktopArrivalDetector: starting (polling every 30s)");
|
||||
_pollingTimer = new Timer(
|
||||
PollForDesktop,
|
||||
null,
|
||||
TimeSpan.FromSeconds(5), // Initial check after 5s
|
||||
TimeSpan.FromSeconds(PollingIntervalSeconds));
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_pollingTimer?.Dispose();
|
||||
_pollingTimer = null;
|
||||
}
|
||||
|
||||
private void PollForDesktop(object state)
|
||||
{
|
||||
if (_desktopArrived)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var explorerProcesses = Process.GetProcessesByName("explorer");
|
||||
foreach (var proc in explorerProcesses)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Session 0 = SYSTEM session, skip
|
||||
if (proc.SessionId == 0)
|
||||
continue;
|
||||
|
||||
var owner = GetProcessOwner(proc.Id);
|
||||
if (owner == null)
|
||||
continue;
|
||||
|
||||
// Check against exclusion list
|
||||
if (IsExcludedUser(owner))
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping");
|
||||
|
||||
// Trace this decision once so it's visible in the backend
|
||||
if (!_excludedUserTraced)
|
||||
{
|
||||
_excludedUserTraced = true;
|
||||
try
|
||||
{
|
||||
OnTraceEvent?.Invoke(
|
||||
"desktop_excluded_user",
|
||||
$"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } });
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Real user desktop detected
|
||||
_desktopArrived = true;
|
||||
_logger.Info($"DesktopArrivalDetector: real user desktop detected (explorer.exe PID {proc.Id}, session {proc.SessionId}, user '{owner}')");
|
||||
|
||||
try
|
||||
{
|
||||
OnTraceEvent?.Invoke(
|
||||
"desktop_real_user_detected",
|
||||
$"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')",
|
||||
new Dictionary<string, object> { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } });
|
||||
}
|
||||
catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); }
|
||||
|
||||
// Stop polling
|
||||
_pollingTimer?.Dispose();
|
||||
_pollingTimer = null;
|
||||
|
||||
try { DesktopArrived?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Warning($"DesktopArrivalDetector: DesktopArrived handler failed: {ex.Message}"); }
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: error checking explorer.exe PID {proc.Id}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: polling error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owner of a process using WMI (Win32_Process.GetOwner).
|
||||
/// Returns "DOMAIN\User" or "User" string, or null on failure.
|
||||
/// </summary>
|
||||
private string GetProcessOwner(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var outParams = new object[2];
|
||||
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
|
||||
if (result == 0)
|
||||
{
|
||||
var user = outParams[0]?.ToString();
|
||||
var domain = outParams[1]?.ToString();
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"DesktopArrivalDetector: WMI GetOwner failed for PID {processId}: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user name matches any excluded system/service account.
|
||||
/// Handles both "User" and "DOMAIN\User" formats.
|
||||
/// Also matches the pattern DefaultUser* (case-insensitive).
|
||||
/// </summary>
|
||||
internal static bool IsExcludedUser(string fullUserName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullUserName))
|
||||
return true;
|
||||
|
||||
// Extract just the username part (after backslash if present)
|
||||
var userName = fullUserName;
|
||||
var backslashIndex = fullUserName.LastIndexOf('\\');
|
||||
if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1)
|
||||
userName = fullUserName.Substring(backslashIndex + 1);
|
||||
|
||||
// Check exact matches
|
||||
foreach (var excluded in ExcludedUserNames)
|
||||
{
|
||||
if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check DefaultUser* pattern
|
||||
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello
|
||||
/// monitoring surface expected by <c>CollectorCoordinator</c> and <c>EnrollmentTracker</c>:
|
||||
///
|
||||
/// - <see cref="HelloTracker"/> — WHfB policy, UDR 300/301/358/360/362/376,
|
||||
/// HelloForBusiness 3024/6045, Hello timers
|
||||
/// - <see cref="ShellCoreTracker"/> — Shell-Core 62404/62407 (ESP exit / failure,
|
||||
/// WhiteGlove success, Hello wizard start)
|
||||
/// - <see cref="ProvisioningStatusTracker"/> — Registry-driven ESP provisioning category
|
||||
/// tracking + DeviceSetup fallback
|
||||
/// - <see cref="ModernDeploymentTracker"/> — ModernDeployment-Diagnostics-Provider live
|
||||
/// capture + WhiteGlove Event 509 backfill
|
||||
///
|
||||
/// Public API (events, properties, methods) is preserved byte-for-byte from the pre-split
|
||||
/// monolith so callers don't change.
|
||||
/// </summary>
|
||||
public sealed class EspAndHelloTracker : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
|
||||
private readonly int _helloWaitTimeoutSeconds;
|
||||
private readonly bool _modernDeploymentWatcherEnabled;
|
||||
private readonly int _modernDeploymentLogLevelMax;
|
||||
private readonly bool _modernDeploymentBackfillEnabled;
|
||||
private readonly int _modernDeploymentBackfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly int[] _modernDeploymentHarmlessEventIds;
|
||||
|
||||
private HelloTracker _helloTracker;
|
||||
private ShellCoreTracker _shellCoreTracker;
|
||||
private ProvisioningStatusTracker _provisioningTracker;
|
||||
private ModernDeploymentTracker _modernDeploymentTracker;
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or timeout).
|
||||
/// Based on events 300/301/362 only — NOT on event 360 (which is just a snapshot).
|
||||
/// </summary>
|
||||
public event EventHandler HelloCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when ESP exit or Hello wizard start is detected.
|
||||
/// Triggers transition to FinalizingSetup phase in <c>EnrollmentTracker</c>.
|
||||
/// </summary>
|
||||
public event EventHandler<string> FinalizingSetupPhaseTriggered;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when WhiteGlove (Pre-Provisioning) completes successfully.
|
||||
/// The device will shut down; the agent should terminate gracefully.
|
||||
/// </summary>
|
||||
public event EventHandler WhiteGloveCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort,
|
||||
/// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type.
|
||||
/// </summary>
|
||||
public event EventHandler<string> EspFailureDetected;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when DeviceSetup provisioning status shows categorySucceeded=true (or fallback confirmed).
|
||||
/// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit and
|
||||
/// desktop arrival signals may never arrive.
|
||||
/// </summary>
|
||||
public event EventHandler DeviceSetupProvisioningComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of Hello provisioning. Set when Hello resolves (via events, timeout, or not configured).
|
||||
/// Values: "completed", "skipped", "timeout", "not_configured", "wizard_not_started", null (not yet resolved).
|
||||
/// </summary>
|
||||
public string HelloOutcome => _helloTracker?.HelloOutcome;
|
||||
|
||||
/// <summary>
|
||||
/// True when Windows Hello for Business policy is configured (enabled or disabled).
|
||||
/// </summary>
|
||||
public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when Windows Hello provisioning has completed (successfully, failed, or skipped).
|
||||
/// </summary>
|
||||
public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed.
|
||||
/// </summary>
|
||||
public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
|
||||
/// </summary>
|
||||
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run).
|
||||
/// Forwarded from ModernDeploymentTracker.
|
||||
/// </summary>
|
||||
public bool IsWhiteGloveStartDetected => _modernDeploymentTracker?.IsWhiteGloveStartDetected ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded".
|
||||
/// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false;
|
||||
|
||||
public EspAndHelloTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
int helloWaitTimeoutSeconds = 30,
|
||||
bool modernDeploymentWatcherEnabled = true,
|
||||
int modernDeploymentLogLevelMax = 3,
|
||||
bool modernDeploymentBackfillEnabled = true,
|
||||
int modernDeploymentBackfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] modernDeploymentHarmlessEventIds = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
|
||||
_modernDeploymentWatcherEnabled = modernDeploymentWatcherEnabled;
|
||||
_modernDeploymentLogLevelMax = modernDeploymentLogLevelMax;
|
||||
_modernDeploymentBackfillEnabled = modernDeploymentBackfillEnabled;
|
||||
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Forwarded state/snapshot methods
|
||||
// =====================================================================
|
||||
|
||||
public System.Collections.Generic.Dictionary<string, bool?> GetProvisioningCategorySnapshot()
|
||||
=> _provisioningTracker?.GetProvisioningCategorySnapshot()
|
||||
?? new System.Collections.Generic.Dictionary<string, bool?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot();
|
||||
|
||||
// =====================================================================
|
||||
// Forwarded Hello coordination API
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker).
|
||||
/// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly.
|
||||
/// </summary>
|
||||
public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
|
||||
/// </summary>
|
||||
public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer();
|
||||
|
||||
/// <summary>
|
||||
/// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join).
|
||||
/// </summary>
|
||||
public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption();
|
||||
|
||||
/// <summary>
|
||||
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
|
||||
/// Secondary recovery mechanism when state persistence is unavailable.
|
||||
/// </summary>
|
||||
public void BackfillRecentEspExitEvents() => _shellCoreTracker?.BackfillRecentEspExitEvents();
|
||||
|
||||
// =====================================================================
|
||||
// Lifecycle
|
||||
// =====================================================================
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info("Starting ESP and Hello tracker");
|
||||
|
||||
_helloTracker = new HelloTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_onEventCollected,
|
||||
_logger,
|
||||
_helloWaitTimeoutSeconds);
|
||||
_helloTracker.HelloCompleted += OnHelloCompleted;
|
||||
_helloTracker.Start();
|
||||
|
||||
_shellCoreTracker = new ShellCoreTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_onEventCollected,
|
||||
_logger,
|
||||
_helloTracker);
|
||||
_shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered;
|
||||
_shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted;
|
||||
_shellCoreTracker.EspFailureDetected += OnEspFailureDetected;
|
||||
_shellCoreTracker.Start();
|
||||
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_onEventCollected,
|
||||
_logger);
|
||||
_provisioningTracker.EspFailureDetected += OnEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
|
||||
if (_modernDeploymentWatcherEnabled)
|
||||
{
|
||||
_modernDeploymentTracker = new ModernDeploymentTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_onEventCollected,
|
||||
_logger,
|
||||
_modernDeploymentLogLevelMax,
|
||||
_modernDeploymentBackfillEnabled,
|
||||
_modernDeploymentBackfillLookbackMinutes,
|
||||
_stateDirectory,
|
||||
_modernDeploymentHarmlessEventIds);
|
||||
_modernDeploymentTracker.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_logger.Info("Stopping ESP and Hello tracker");
|
||||
|
||||
DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop());
|
||||
DisposeProvisioningTracker();
|
||||
DisposeShellCoreTracker();
|
||||
DisposeHelloTracker();
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void DisposeHelloTracker()
|
||||
{
|
||||
if (_helloTracker == null) return;
|
||||
try
|
||||
{
|
||||
_helloTracker.HelloCompleted -= OnHelloCompleted;
|
||||
_helloTracker.Stop();
|
||||
}
|
||||
catch (Exception ex) { _logger.Error("Error stopping Hello tracker", ex); }
|
||||
_helloTracker = null;
|
||||
}
|
||||
|
||||
private void DisposeShellCoreTracker()
|
||||
{
|
||||
if (_shellCoreTracker == null) return;
|
||||
try
|
||||
{
|
||||
_shellCoreTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered;
|
||||
_shellCoreTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted;
|
||||
_shellCoreTracker.EspFailureDetected -= OnEspFailureDetected;
|
||||
_shellCoreTracker.Stop();
|
||||
}
|
||||
catch (Exception ex) { _logger.Error("Error stopping Shell-Core tracker", ex); }
|
||||
_shellCoreTracker = null;
|
||||
}
|
||||
|
||||
private void DisposeProvisioningTracker()
|
||||
{
|
||||
if (_provisioningTracker == null) return;
|
||||
try
|
||||
{
|
||||
_provisioningTracker.EspFailureDetected -= OnEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.Stop("tracker_stopped");
|
||||
}
|
||||
catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); }
|
||||
_provisioningTracker = null;
|
||||
}
|
||||
|
||||
private void DisposeTracker<T>(ref T tracker, string name, Action<T> stopper) where T : class
|
||||
{
|
||||
if (tracker == null) return;
|
||||
try { stopper(tracker); }
|
||||
catch (Exception ex) { _logger.Error($"Error stopping {name} tracker", ex); }
|
||||
tracker = null;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Event forwarders
|
||||
// =====================================================================
|
||||
|
||||
private void OnHelloCompleted(object sender, EventArgs e)
|
||||
{
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("Error forwarding HelloCompleted", ex); }
|
||||
}
|
||||
|
||||
private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
|
||||
{
|
||||
try { FinalizingSetupPhaseTriggered?.Invoke(this, reason); }
|
||||
catch (Exception ex) { _logger.Error("Error forwarding FinalizingSetupPhaseTriggered", ex); }
|
||||
}
|
||||
|
||||
private void OnWhiteGloveCompleted(object sender, EventArgs e)
|
||||
{
|
||||
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("Error forwarding WhiteGloveCompleted", ex); }
|
||||
}
|
||||
|
||||
private void OnEspFailureDetected(object sender, string failureType)
|
||||
{
|
||||
try { EspFailureDetected?.Invoke(this, failureType); }
|
||||
catch (Exception ex) { _logger.Error($"Error forwarding EspFailureDetected for '{failureType}'", ex); }
|
||||
}
|
||||
|
||||
private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e)
|
||||
{
|
||||
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+997
@@ -0,0 +1,997 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks Windows Hello for Business (WHfB) provisioning during Autopilot enrollment.
|
||||
///
|
||||
/// Signals:
|
||||
/// - User Device Registration/Admin: 300/301/358/360/362/376 (NGC key lifecycle)
|
||||
/// - HelloForBusiness/Operational: 3024/6045 (processing start/stop + skip HRESULT)
|
||||
/// - Registry (CSP + GPO): PassportForWork policy detection
|
||||
/// - External "wizard started" signal from ESP (Shell-Core 62404)
|
||||
/// - External "ESP exited" signal from ESP (Shell-Core 62407)
|
||||
///
|
||||
/// Completion outcomes: "completed", "skipped", "timeout", "not_configured", "wizard_not_started".
|
||||
/// </summary>
|
||||
internal sealed class HelloTracker : IDisposable
|
||||
{
|
||||
internal const string UdrEventLogChannel = "Microsoft-Windows-User Device Registration/Admin";
|
||||
internal const string HelloForBusinessEventLogChannel = "Microsoft-Windows-HelloForBusiness/Operational";
|
||||
|
||||
internal const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork";
|
||||
internal const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork";
|
||||
|
||||
internal const int EventId_NgcKeyRegistered = 300;
|
||||
internal const int EventId_NgcKeyRegistrationFailed = 301;
|
||||
internal const int EventId_ProvisioningWillLaunch = 358;
|
||||
internal const int EventId_ProvisioningWillNotLaunch = 360;
|
||||
internal const int EventId_ProvisioningBlocked = 362;
|
||||
internal const int EventId_PinStatus = 376;
|
||||
|
||||
internal const int EventId_HelloForBusiness_ProcessingStarted = 3024;
|
||||
internal const int EventId_HelloForBusiness_ProcessingStopped = 6045;
|
||||
|
||||
internal const string HResult_UserSkippedHello = "0x801C044F";
|
||||
|
||||
internal const int HelloCompletionTimeoutSeconds = 300;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
private static readonly HashSet<int> TrackedUdrEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_NgcKeyRegistered,
|
||||
EventId_NgcKeyRegistrationFailed,
|
||||
EventId_ProvisioningWillLaunch,
|
||||
EventId_ProvisioningWillNotLaunch,
|
||||
EventId_ProvisioningBlocked,
|
||||
EventId_PinStatus
|
||||
};
|
||||
|
||||
private static readonly HashSet<int> TrackedHelloForBusinessEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_HelloForBusiness_ProcessingStarted,
|
||||
EventId_HelloForBusiness_ProcessingStopped
|
||||
};
|
||||
|
||||
private static readonly Regex HResultPattern = new Regex(@"0x[0-9A-Fa-f]{8}", RegexOptions.Compiled);
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly int _helloWaitTimeoutSeconds;
|
||||
|
||||
private EventLogWatcher _udrWatcher;
|
||||
private EventLogWatcher _helloForBusinessWatcher;
|
||||
private System.Threading.Timer _policyCheckTimer;
|
||||
private System.Threading.Timer _helloWaitTimer;
|
||||
private System.Threading.Timer _helloCompletionTimer;
|
||||
|
||||
private bool _isPolicyConfigured;
|
||||
private bool _isHelloPolicyEnabled;
|
||||
private bool _isHelloCompleted;
|
||||
private bool _helloWizardStarted;
|
||||
private bool _espExitSeen;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
public event EventHandler HelloCompleted;
|
||||
|
||||
public string HelloOutcome { get; private set; }
|
||||
|
||||
public bool IsPolicyConfigured { get { lock (_stateLock) { return _isPolicyConfigured; } } }
|
||||
|
||||
public bool IsHelloCompleted { get { lock (_stateLock) { return _isHelloCompleted; } } }
|
||||
|
||||
public HelloTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
int helloWaitTimeoutSeconds = 30)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloWaitTimeoutSeconds = helloWaitTimeoutSeconds;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Lifecycle
|
||||
// =====================================================================
|
||||
|
||||
public void Start()
|
||||
{
|
||||
CheckHelloPolicy();
|
||||
|
||||
_policyCheckTimer = new System.Threading.Timer(
|
||||
_ => CheckHelloPolicy(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(10));
|
||||
|
||||
StartUdrEventLogWatcher();
|
||||
StartHelloForBusinessEventLogWatcher();
|
||||
|
||||
BackfillRecentTerminalHelloEvents();
|
||||
BackfillRecentHelloForBusinessEvents();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
DisposeTimer(ref _policyCheckTimer, "policy check");
|
||||
DisposeTimer(ref _helloWaitTimer, "Hello wait");
|
||||
DisposeTimer(ref _helloCompletionTimer, "Hello completion");
|
||||
|
||||
if (_udrWatcher != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_udrWatcher.Enabled = false;
|
||||
_udrWatcher.EventRecordWritten -= OnUdrEventRecordWritten;
|
||||
_udrWatcher.Dispose();
|
||||
_udrWatcher = null;
|
||||
}
|
||||
catch (Exception ex) { _logger.Error("Error stopping UDR watcher", ex); }
|
||||
}
|
||||
|
||||
if (_helloForBusinessWatcher != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_helloForBusinessWatcher.Enabled = false;
|
||||
_helloForBusinessWatcher.EventRecordWritten -= OnHelloForBusinessEventRecordWritten;
|
||||
_helloForBusinessWatcher.Dispose();
|
||||
_helloForBusinessWatcher = null;
|
||||
}
|
||||
catch (Exception ex) { _logger.Error("Error stopping HelloForBusiness watcher", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
private void DisposeTimer(ref System.Threading.Timer timer, string name)
|
||||
{
|
||||
if (timer == null) return;
|
||||
try { timer.Dispose(); }
|
||||
catch (Exception ex) { _logger.Error($"Error stopping {name} timer", ex); }
|
||||
timer = null;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// External coordination API (called by EspTracker / coordinator)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker).
|
||||
/// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly.
|
||||
/// </summary>
|
||||
public void ForceMarkHelloCompleted(string reason)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_isHelloCompleted) return;
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = reason;
|
||||
StopHelloCompletionTimerLocked();
|
||||
_logger.Warning($"Hello force-completed by external caller: {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join).
|
||||
/// Stops all running timers and clears Hello state so the timer chain restarts fresh when
|
||||
/// ESP exits again for real.
|
||||
/// </summary>
|
||||
public void ResetForEspResumption()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_helloWaitTimer?.Dispose();
|
||||
_helloWaitTimer = null;
|
||||
_helloCompletionTimer?.Dispose();
|
||||
_helloCompletionTimer = null;
|
||||
|
||||
_isHelloCompleted = false;
|
||||
_helloWizardStarted = false;
|
||||
_espExitSeen = false;
|
||||
HelloOutcome = null;
|
||||
}
|
||||
_logger.Info("HelloTracker: Reset for ESP resumption — Hello tracking restarted");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits.
|
||||
/// Waits for Hello wizard to start (Shell-Core event 62404, reported via
|
||||
/// <see cref="NotifyHelloWizardStarted"/>) within the configured timeout.
|
||||
/// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed.
|
||||
/// </summary>
|
||||
public void StartHelloWaitTimer()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_helloWizardStarted)
|
||||
{
|
||||
_logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isHelloCompleted)
|
||||
{
|
||||
_logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_helloWaitTimer != null)
|
||||
{
|
||||
_logger.Debug("StartHelloWaitTimer called but timer already running - skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start");
|
||||
_helloWaitTimer = new System.Threading.Timer(
|
||||
OnHelloWaitTimeout,
|
||||
null,
|
||||
TimeSpan.FromSeconds(_helloWaitTimeoutSeconds),
|
||||
TimeSpan.FromMilliseconds(-1));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the ESP tracker when Shell-Core event 62404 fires with AADHello/NGC context.
|
||||
/// Stops the wait timer (if running) and arms the long completion timer to catch cases
|
||||
/// where the wizard appeared but no terminal Hello event arrives.
|
||||
/// </summary>
|
||||
public void NotifyHelloWizardStarted()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_helloWizardStarted) return;
|
||||
_helloWizardStarted = true;
|
||||
|
||||
if (_helloWaitTimer != null)
|
||||
{
|
||||
_helloWaitTimer.Dispose();
|
||||
_helloWaitTimer = null;
|
||||
_logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer");
|
||||
}
|
||||
|
||||
StartHelloCompletionTimerLocked();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the ESP tracker when the ESP exits (Shell-Core 62407 with OOBE_ESP*Exiting).
|
||||
/// Used only for informational data fields in wait/completion timeout events.
|
||||
/// </summary>
|
||||
public void NotifyEspExited()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_espExitSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Policy detection (CSP + GPO)
|
||||
// =====================================================================
|
||||
|
||||
private void CheckHelloPolicy()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (isEnabled, source) = DetectHelloPolicy();
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_isPolicyConfigured && isEnabled.HasValue)
|
||||
{
|
||||
_isPolicyConfigured = true;
|
||||
_isHelloPolicyEnabled = isEnabled.Value;
|
||||
var status = isEnabled.Value ? "enabled" : "disabled";
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "hello_policy_detected",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Windows Hello for Business policy detected: {status} (via {source})",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "helloEnabled", isEnabled.Value },
|
||||
{ "policySource", source }
|
||||
}
|
||||
});
|
||||
|
||||
_logger.Info($"WHfB policy detected: {status} (source: {source})");
|
||||
|
||||
if (_policyCheckTimer != null)
|
||||
{
|
||||
_policyCheckTimer.Dispose();
|
||||
_policyCheckTimer = null;
|
||||
_logger.Debug("Stopped periodic Hello policy check - policy has been detected");
|
||||
}
|
||||
}
|
||||
else if (!_isPolicyConfigured && !isEnabled.HasValue)
|
||||
{
|
||||
_logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error checking WHfB policy: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private (bool? isEnabled, string source) DetectHelloPolicy()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
|
||||
{
|
||||
if (baseCspKey != null)
|
||||
{
|
||||
foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
|
||||
{
|
||||
using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false))
|
||||
{
|
||||
if (tenantKey != null)
|
||||
{
|
||||
foreach (var scopeSubKey in tenantKey.GetSubKeyNames())
|
||||
{
|
||||
using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false))
|
||||
{
|
||||
if (policiesKey != null)
|
||||
{
|
||||
var value = policiesKey.GetValue("UsePassportForWork");
|
||||
if (value != null)
|
||||
{
|
||||
var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase)
|
||||
? "device"
|
||||
: "user";
|
||||
return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
|
||||
{
|
||||
if (gpoKey != null)
|
||||
{
|
||||
var value = gpoKey.GetValue("Enabled");
|
||||
if (value != null)
|
||||
{
|
||||
return (Convert.ToInt32(value) == 1, "GPO");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// UDR event log watcher (events 300/301/358/360/362/376)
|
||||
// =====================================================================
|
||||
|
||||
private void StartUdrEventLogWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(
|
||||
UdrEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]");
|
||||
|
||||
_udrWatcher = new EventLogWatcher(query);
|
||||
_udrWatcher.EventRecordWritten += OnUdrEventRecordWritten;
|
||||
_udrWatcher.Enabled = true;
|
||||
|
||||
_logger.Info($"Started watching: {UdrEventLogChannel}");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Event log not found: {UdrEventLogChannel} (normal if not on a real device)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Failed to start Hello event log watcher", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUdrEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var record = e.EventRecord;
|
||||
var eventId = record.Id;
|
||||
if (!TrackedUdrEventIds.Contains(eventId)) return;
|
||||
|
||||
ProcessHelloEvent(
|
||||
eventId,
|
||||
(record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(),
|
||||
record.ProviderName ?? "",
|
||||
isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing Hello event record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill)
|
||||
{
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
string message;
|
||||
bool shouldTriggerHelloCompleted = false;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ProvisioningWillLaunch: // 358
|
||||
eventType = "hello_provisioning_willlaunch";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)";
|
||||
_logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)");
|
||||
break;
|
||||
|
||||
case EventId_NgcKeyRegistered: // 300
|
||||
eventType = "hello_provisioning_completed";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business provisioned successfully - NGC key registered";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED successfully");
|
||||
break;
|
||||
|
||||
case EventId_NgcKeyRegistrationFailed: // 301
|
||||
eventType = "hello_provisioning_failed";
|
||||
severity = EventSeverity.Error;
|
||||
message = "Windows Hello for Business provisioning failed - NGC key registration error";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED with failure");
|
||||
break;
|
||||
|
||||
case EventId_ProvisioningWillNotLaunch: // 360
|
||||
eventType = "hello_provisioning_willnotlaunch";
|
||||
severity = EventSeverity.Warning;
|
||||
message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)";
|
||||
// DO NOT mark Hello as completed - event 360 is just a snapshot and can change
|
||||
_logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)");
|
||||
break;
|
||||
|
||||
case EventId_ProvisioningBlocked: // 362
|
||||
eventType = "hello_provisioning_blocked";
|
||||
severity = EventSeverity.Warning;
|
||||
message = "Windows Hello for Business provisioning blocked";
|
||||
shouldTriggerHelloCompleted = MarkHelloCompleted();
|
||||
_logger.Info("Windows Hello provisioning COMPLETED (blocked)");
|
||||
break;
|
||||
|
||||
case EventId_PinStatus: // 376
|
||||
eventType = "hello_pin_status";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello PIN status update";
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep payload minimal — full descriptions may contain sensitive data
|
||||
// (server responses with key IDs, UPNs, tokens).
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", eventId },
|
||||
{ "providerName", providerName ?? "" },
|
||||
{ "description", "truncated" },
|
||||
{ "eventTime", timestamp.ToString("o") }
|
||||
};
|
||||
|
||||
if (isBackfill)
|
||||
{
|
||||
data["backfill"] = true;
|
||||
}
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = timestamp,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = shouldTriggerHelloCompleted || eventType == "hello_provisioning_failed"
|
||||
});
|
||||
|
||||
_logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
if (shouldTriggerHelloCompleted)
|
||||
{
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private bool MarkHelloCompleted()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_isHelloCompleted)
|
||||
{
|
||||
_logger.Debug("Hello terminal event received but Hello is already marked completed");
|
||||
return false;
|
||||
}
|
||||
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = "completed";
|
||||
StopHelloCompletionTimerLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void BackfillRecentTerminalHelloEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
|
||||
var query = new EventLogQuery(
|
||||
UdrEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
DateTime latestTimestamp = DateTime.MinValue;
|
||||
int? latestEventId = null;
|
||||
string latestProvider = string.Empty;
|
||||
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
var eventId = record.Id;
|
||||
if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked)
|
||||
continue;
|
||||
|
||||
var timestamp = record.TimeCreated ?? DateTime.MinValue;
|
||||
if (timestamp < latestTimestamp) continue;
|
||||
|
||||
latestTimestamp = timestamp;
|
||||
latestEventId = eventId;
|
||||
latestProvider = record.ProviderName ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (latestEventId.HasValue)
|
||||
{
|
||||
_logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}");
|
||||
ProcessHelloEvent(
|
||||
latestEventId.Value,
|
||||
(latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(),
|
||||
latestProvider,
|
||||
isBackfill: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Hello terminal event backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HelloForBusiness/Operational watcher (events 3024/6045)
|
||||
// =====================================================================
|
||||
|
||||
private void StartHelloForBusinessEventLogWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(
|
||||
HelloForBusinessEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=3024 or EventID=6045)]]");
|
||||
|
||||
_helloForBusinessWatcher = new EventLogWatcher(query);
|
||||
_helloForBusinessWatcher.EventRecordWritten += OnHelloForBusinessEventRecordWritten;
|
||||
_helloForBusinessWatcher.Enabled = true;
|
||||
|
||||
_logger.Info($"Started watching: {HelloForBusinessEventLogChannel}");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Event log not found: {HelloForBusinessEventLogChannel} (normal if not on a real device)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Failed to start HelloForBusiness event log watcher", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHelloForBusinessEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var record = e.EventRecord;
|
||||
var eventId = record.Id;
|
||||
if (!TrackedHelloForBusinessEventIds.Contains(eventId)) return;
|
||||
|
||||
var description = record.FormatDescription() ?? $"Event ID {eventId}";
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
|
||||
ProcessHelloForBusinessEvent(eventId, timestamp, description, record.ProviderName ?? "", isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing HelloForBusiness event record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal void ProcessHelloForBusinessEvent(int eventId, DateTime timestamp, string description, string providerName, bool isBackfill)
|
||||
{
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
string message;
|
||||
bool shouldTriggerHelloCompleted = false;
|
||||
string hresult = null;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_HelloForBusiness_ProcessingStarted: // 3024
|
||||
eventType = "hello_processing_started";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business processing started";
|
||||
_logger.Info("Hello for Business processing started (event 3024)");
|
||||
break;
|
||||
|
||||
case EventId_HelloForBusiness_ProcessingStopped: // 6045
|
||||
hresult = ExtractHResultFromDescription(description);
|
||||
|
||||
if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
eventType = "hello_skipped";
|
||||
severity = EventSeverity.Warning;
|
||||
message = $"Windows Hello for Business skipped by user ({HResult_UserSkippedHello})";
|
||||
shouldTriggerHelloCompleted = MarkHelloSkipped();
|
||||
_logger.Info($"Hello for Business SKIPPED by user (event 6045, HRESULT {hresult})");
|
||||
}
|
||||
else
|
||||
{
|
||||
eventType = "hello_processing_stopped";
|
||||
severity = EventSeverity.Info;
|
||||
message = $"Windows Hello for Business processing stopped (HRESULT: {hresult ?? "unknown"})";
|
||||
_logger.Info($"Hello for Business processing stopped (event 6045, HRESULT {hresult ?? "unknown"}) - not treated as terminal");
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", eventId },
|
||||
{ "providerName", providerName ?? "" },
|
||||
{ "description", description },
|
||||
{ "eventLogChannel", HelloForBusinessEventLogChannel },
|
||||
{ "eventTime", timestamp.ToString("o") }
|
||||
};
|
||||
|
||||
if (hresult != null) data["hresult"] = hresult;
|
||||
if (isBackfill) data["backfill"] = true;
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = timestamp,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = shouldTriggerHelloCompleted
|
||||
});
|
||||
|
||||
_logger.Info($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})");
|
||||
|
||||
if (shouldTriggerHelloCompleted)
|
||||
{
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private bool MarkHelloSkipped()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_isHelloCompleted)
|
||||
{
|
||||
_logger.Debug("Hello skip event received but Hello is already marked completed");
|
||||
return false;
|
||||
}
|
||||
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = "skipped";
|
||||
StopHelloCompletionTimerLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ExtractHResultFromDescription(string description)
|
||||
{
|
||||
if (string.IsNullOrEmpty(description)) return null;
|
||||
var match = HResultPattern.Match(description);
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
|
||||
private void BackfillRecentHelloForBusinessEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
|
||||
var query = new EventLogQuery(
|
||||
HelloForBusinessEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=6045) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
DateTime latestTimestamp = DateTime.MinValue;
|
||||
string latestDescription = null;
|
||||
string latestProvider = string.Empty;
|
||||
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
var timestamp = record.TimeCreated ?? DateTime.MinValue;
|
||||
if (timestamp < latestTimestamp) continue;
|
||||
|
||||
latestTimestamp = timestamp;
|
||||
latestDescription = record.FormatDescription() ?? $"Event ID {record.Id}";
|
||||
latestProvider = record.ProviderName ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (latestDescription != null)
|
||||
{
|
||||
var hresult = ExtractHResultFromDescription(latestDescription);
|
||||
if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"Backfill found recent Hello skip event: HRESULT {hresult} at {latestTimestamp:O}");
|
||||
ProcessHelloForBusinessEvent(
|
||||
EventId_HelloForBusiness_ProcessingStopped,
|
||||
(latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(),
|
||||
latestDescription,
|
||||
latestProvider,
|
||||
isBackfill: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug($"Backfill found HelloForBusiness event 6045 but HRESULT {hresult ?? "unknown"} is not a known terminal code - skipping");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug($"Backfill found no HelloForBusiness 6045 events in last {BackfillLookbackMinutes} minutes");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"HelloForBusiness event backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Timers: wait → completion
|
||||
// =====================================================================
|
||||
|
||||
private void OnHelloWaitTimeout(object state)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_helloWizardStarted)
|
||||
{
|
||||
_logger.Debug("Hello wait timeout fired but wizard already started - ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isHelloCompleted)
|
||||
{
|
||||
_logger.Debug("Hello wait timeout fired but Hello already completed - ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
// Timeout expired without Hello wizard starting.
|
||||
// If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet
|
||||
// (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment
|
||||
// complete — start the long HelloCompletion timer and keep waiting.
|
||||
if (_isHelloPolicyEnabled)
|
||||
{
|
||||
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " +
|
||||
$"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)");
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "hello_wait_timeout",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " +
|
||||
$"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
|
||||
{ "espExitDetected", _espExitSeen },
|
||||
{ "helloPolicyEnabled", true },
|
||||
{ "action", "extended_wait" }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
StartHelloCompletionTimerLocked();
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting");
|
||||
_logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped");
|
||||
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = "not_configured";
|
||||
StopHelloCompletionTimerLocked();
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "hello_wait_timeout",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", _helloWaitTimeoutSeconds },
|
||||
{ "espExitDetected", _espExitSeen },
|
||||
{ "helloPolicyEnabled", false },
|
||||
{ "action", "enrollment_complete" }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from timeout", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
private void StartHelloCompletionTimerLocked()
|
||||
{
|
||||
if (_isHelloCompleted) return;
|
||||
if (_helloCompletionTimer != null) return;
|
||||
|
||||
_logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)");
|
||||
_helloCompletionTimer = new System.Threading.Timer(
|
||||
OnHelloCompletionTimeout,
|
||||
null,
|
||||
TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds),
|
||||
TimeSpan.FromMilliseconds(-1));
|
||||
}
|
||||
|
||||
private void StopHelloCompletionTimerLocked()
|
||||
{
|
||||
if (_helloCompletionTimer == null) return;
|
||||
try { _helloCompletionTimer.Dispose(); } catch { }
|
||||
finally { _helloCompletionTimer = null; }
|
||||
}
|
||||
|
||||
private void OnHelloCompletionTimeout(object state)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_isHelloCompleted)
|
||||
{
|
||||
_logger.Debug("Hello completion timeout fired but Hello already completed - ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event");
|
||||
_isHelloCompleted = true;
|
||||
HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started";
|
||||
StopHelloCompletionTimerLocked();
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "hello_completion_timeout",
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "timeoutSeconds", HelloCompletionTimeoutSeconds },
|
||||
{ "helloWizardStarted", _helloWizardStarted }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
try { HelloCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from completion timeout", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Test seams — allow tests to drive timer logic deterministically
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Test-only: simulate policy detection without a real registry read.</summary>
|
||||
internal void SetPolicyForTest(bool helloEnabled, string source)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_isPolicyConfigured = true;
|
||||
_isHelloPolicyEnabled = helloEnabled;
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "hello_policy_detected",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Windows Hello for Business policy detected: {(helloEnabled ? "enabled" : "disabled")} (via {source})",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "helloEnabled", helloEnabled },
|
||||
{ "policySource", source }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null);
|
||||
internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null);
|
||||
|
||||
internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } }
|
||||
internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } }
|
||||
internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } }
|
||||
}
|
||||
}
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches Microsoft-Windows-ModernDeployment-Diagnostics-Provider channels
|
||||
/// (Autopilot + ManagementService) for Autopilot / ESP events and forwards them
|
||||
/// to the event sink as modern_deployment_log/warning/error events.
|
||||
///
|
||||
/// Additionally detects WhiteGlove (Pre-Provisioning) initiation via
|
||||
/// ManagementService Event 509 with cross-restart dedup via disk persistence.
|
||||
/// </summary>
|
||||
internal sealed class ModernDeploymentTracker : IDisposable
|
||||
{
|
||||
internal const string AutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot";
|
||||
internal const string ManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService";
|
||||
|
||||
/// <summary>
|
||||
/// ManagementService Event ID 509: fires when a technician actually initiates
|
||||
/// pre-provisioning (Win 5x → Provision). Level 4 (Informational) — bypasses
|
||||
/// the default Level≤3 filter via a targeted OR clause in the XPath.
|
||||
/// </summary>
|
||||
internal const int EventId_WhiteGloveStart = 509;
|
||||
|
||||
internal static readonly HashSet<int> TargetedManagementServiceEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_WhiteGloveStart
|
||||
};
|
||||
|
||||
internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json";
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly int _logLevelMax;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly HashSet<int> _harmlessEventIds;
|
||||
|
||||
private EventLogWatcher _autopilotWatcher;
|
||||
private EventLogWatcher _managementWatcher;
|
||||
private bool _whiteGloveStartDetected;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
public ModernDeploymentTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
int logLevelMax = 3,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] harmlessEventIds = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_logLevelMax = logLevelMax;
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
|
||||
? new HashSet<int>(harmlessEventIds)
|
||||
: new HashSet<int> { 100, 1005 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run).
|
||||
/// Exposed primarily for diagnostics/tests.
|
||||
/// </summary>
|
||||
internal bool IsWhiteGloveStartDetected
|
||||
{
|
||||
get { lock (_stateLock) { return _whiteGloveStartDetected; } }
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
StartWatchers();
|
||||
|
||||
if (_backfillEnabled)
|
||||
{
|
||||
BackfillTargetedEvents();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("ModernDeployment backfill disabled by config");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
StopWatcher(ref _autopilotWatcher, "Autopilot");
|
||||
StopWatcher(ref _managementWatcher, "ManagementService");
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watcher lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void StartWatchers()
|
||||
{
|
||||
_autopilotWatcher = TryStartWatcher(AutopilotChannel, "Autopilot", null);
|
||||
_managementWatcher = TryStartWatcher(ManagementChannel, "ManagementService", TargetedManagementServiceEventIds);
|
||||
}
|
||||
|
||||
private EventLogWatcher TryStartWatcher(string channelName, string shortName, HashSet<int> targetedEventIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var xpath = BuildXPath(_logLevelMax, targetedEventIds);
|
||||
var query = new EventLogQuery(channelName, PathType.LogName, xpath);
|
||||
var watcher = new EventLogWatcher(query);
|
||||
watcher.EventRecordWritten += (sender, args) => OnEventRecordWritten(args, shortName, channelName);
|
||||
watcher.Enabled = true;
|
||||
_logger.Info($"ModernDeployment watcher started: {channelName} (levelMax={Math.Max(1, Math.Min(5, _logLevelMax))}, targetedIds={targetedEventIds?.Count ?? 0})");
|
||||
return watcher;
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"ModernDeployment event log not found: {channelName} (normal on non-Windows 10/11 test environments)");
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"ModernDeployment watcher access denied for {channelName}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start ModernDeployment watcher for {channelName}", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the XPath filter for the watcher. Exposed as internal for tests.
|
||||
/// </summary>
|
||||
internal static string BuildXPath(int logLevelMax, HashSet<int> targetedEventIds)
|
||||
{
|
||||
var levelMax = Math.Max(1, Math.Min(5, logLevelMax));
|
||||
var levelFilter = $"Level=0 or (Level >= 1 and Level <= {levelMax})";
|
||||
if (targetedEventIds != null && targetedEventIds.Count > 0)
|
||||
{
|
||||
var idClauses = string.Join(" or ", targetedEventIds.Select(id => $"EventID={id}"));
|
||||
levelFilter += $" or ({idClauses})";
|
||||
}
|
||||
return $"*[System[{levelFilter}]]";
|
||||
}
|
||||
|
||||
private void StopWatcher(ref EventLogWatcher watcher, string shortName)
|
||||
{
|
||||
if (watcher == null) return;
|
||||
try
|
||||
{
|
||||
watcher.Enabled = false;
|
||||
watcher.Dispose();
|
||||
_logger.Info($"ModernDeployment watcher stopped: {shortName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error stopping ModernDeployment watcher ({shortName})", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event processing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void OnEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
try
|
||||
{
|
||||
ProcessRecord(e.EventRecord, shortName, channelName, isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error processing ModernDeployment event from {shortName}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessRecord(EventRecord record, string shortName, string channelName, bool isBackfill)
|
||||
{
|
||||
string description = null;
|
||||
try { description = record.FormatDescription(); }
|
||||
catch { /* some events lack formatting resources */ }
|
||||
|
||||
ProcessEvent(
|
||||
eventId: record.Id,
|
||||
level: record.Level,
|
||||
levelDisplayName: record.LevelDisplayName,
|
||||
providerName: record.ProviderName,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
formattedDescription: description,
|
||||
shortName: shortName,
|
||||
channelName: channelName,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core event-processing logic, extracted to primitive inputs so tests can drive it
|
||||
/// without needing to synthesize an EventRecord (which is abstract + Windows-only).
|
||||
/// </summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
int? level,
|
||||
string levelDisplayName,
|
||||
string providerName,
|
||||
DateTime? timeCreatedUtc,
|
||||
string formattedDescription,
|
||||
string shortName,
|
||||
string channelName,
|
||||
bool isBackfill)
|
||||
{
|
||||
if (eventId == EventId_WhiteGloveStart && shortName == "ManagementService")
|
||||
{
|
||||
HandleWhiteGloveStart(eventId, level, timeCreatedUtc, formattedDescription, isBackfill);
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveLevel = level ?? 4;
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
switch (effectiveLevel)
|
||||
{
|
||||
case 1:
|
||||
case 2:
|
||||
eventType = Constants.EventTypes.ModernDeploymentError;
|
||||
severity = EventSeverity.Error;
|
||||
break;
|
||||
case 3:
|
||||
eventType = Constants.EventTypes.ModernDeploymentWarning;
|
||||
severity = EventSeverity.Warning;
|
||||
break;
|
||||
default:
|
||||
eventType = Constants.EventTypes.ModernDeploymentLog;
|
||||
severity = EventSeverity.Info;
|
||||
break;
|
||||
}
|
||||
|
||||
// Downgrade configurable harmless EventIDs to Debug.
|
||||
// Level 1 (Critical) is NEVER downgraded — too risky to silence. Only Level 2
|
||||
// (Error) and Level 3 (Warning) are considered. The list is delivered by the
|
||||
// backend via CollectorConfiguration.ModernDeploymentHarmlessEventIds and
|
||||
// pre-seeded with known-harmless IDs (e.g. 100 "Autopilot policy not found",
|
||||
// 1005 — no real enrollment impact). Events stay visible in the timeline
|
||||
// (Debug severity) for troubleshooting.
|
||||
if ((effectiveLevel == 2 || effectiveLevel == 3) && _harmlessEventIds.Contains(eventId))
|
||||
{
|
||||
eventType = Constants.EventTypes.ModernDeploymentLog;
|
||||
severity = EventSeverity.Debug;
|
||||
}
|
||||
|
||||
var description = string.IsNullOrEmpty(formattedDescription)
|
||||
? $"Event ID {eventId} (no formatted description)"
|
||||
: formattedDescription;
|
||||
|
||||
var truncated = description.Length > 1000 ? description.Substring(0, 1000) + "…" : description;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "channel", shortName },
|
||||
{ "channelFullName", channelName },
|
||||
{ "eventId", eventId },
|
||||
{ "level", effectiveLevel },
|
||||
{ "levelName", levelDisplayName ?? string.Empty },
|
||||
{ "providerName", providerName ?? string.Empty },
|
||||
{ "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty },
|
||||
{ "backfilled", isBackfill }
|
||||
};
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "ModernDeploymentWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"[{shortName}] EventID {eventId}: {truncated}",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private void HandleWhiteGloveStart(int eventId, int? level, DateTime? timeCreatedUtc, string formattedDescription, bool isBackfill)
|
||||
{
|
||||
var description = string.IsNullOrEmpty(formattedDescription)
|
||||
? $"Event ID {eventId} (no formatted description)"
|
||||
: formattedDescription;
|
||||
|
||||
if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
_logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_whiteGloveStartDetected) return;
|
||||
_whiteGloveStartDetected = true;
|
||||
}
|
||||
|
||||
_logger.Info($"WhiteGlove start detected via ManagementService Event 509 (backfill={isBackfill}): {description}");
|
||||
|
||||
PersistWhiteGloveBackfillState(timeCreatedUtc);
|
||||
|
||||
var truncated = description.Length > 500 ? description.Substring(0, 500) + "…" : description;
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "whiteglove_started",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ModernDeploymentWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"First WhiteGlove (Pre-Provisioning) hint — ManagementService EventID {eventId} (soft signal, not yet confirmed)",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "channel", "ManagementService" },
|
||||
{ "channelFullName", ManagementChannel },
|
||||
{ "eventId", eventId },
|
||||
{ "level", level ?? 4 },
|
||||
{ "description", truncated },
|
||||
{ "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty },
|
||||
{ "backfilled", isBackfill }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Backfill
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void BackfillTargetedEvents()
|
||||
{
|
||||
if (TargetedManagementServiceEventIds.Count == 0) return;
|
||||
|
||||
var persistedState = LoadWhiteGloveBackfillState();
|
||||
if (persistedState != null && persistedState.WhiteGloveStartSeen)
|
||||
{
|
||||
_logger.Info($"WhiteGlove start already persisted from prior run (seen at {persistedState.SeenUtc:O}) — skipping backfill");
|
||||
lock (_stateLock) { _whiteGloveStartDetected = true; }
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var lookbackMs = _backfillLookbackMinutes * 60 * 1000;
|
||||
var idClauses = string.Join(" or ",
|
||||
TargetedManagementServiceEventIds.Select(id => $"EventID={id}"));
|
||||
var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
|
||||
|
||||
_logger.Info($"ModernDeployment backfill: scanning {ManagementChannel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, targetedIds={TargetedManagementServiceEventIds.Count})");
|
||||
|
||||
var query = new EventLogQuery(ManagementChannel, PathType.LogName, xpath)
|
||||
{
|
||||
ReverseDirection = true
|
||||
};
|
||||
|
||||
int found = 0;
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
found++;
|
||||
_logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}");
|
||||
ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (found == 0)
|
||||
_logger.Debug($"ModernDeployment backfill: no targeted events found in last {_backfillLookbackMinutes} minutes");
|
||||
else
|
||||
_logger.Info($"ModernDeployment backfill: processed {found} event(s)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"ModernDeployment event log not found during backfill: {ManagementChannel} (normal on non-Windows 10/11 test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"ModernDeployment backfill access denied: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ModernDeployment backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Backfill state persistence
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
internal void PersistWhiteGloveBackfillState(DateTime? eventTimeUtc)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName);
|
||||
var state = new WhiteGloveBackfillState
|
||||
{
|
||||
WhiteGloveStartSeen = true,
|
||||
SeenUtc = eventTimeUtc ?? DateTime.UtcNow,
|
||||
PersistedUtc = DateTime.UtcNow
|
||||
};
|
||||
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
|
||||
var tempPath = filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, filePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
_logger.Info($"WhiteGlove backfill state persisted to {filePath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist WhiteGlove backfill state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal WhiteGloveBackfillState LoadWhiteGloveBackfillState()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return null;
|
||||
|
||||
var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName);
|
||||
if (!File.Exists(filePath)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
return JsonConvert.DeserializeObject<WhiteGloveBackfillState>(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup).
|
||||
/// </summary>
|
||||
internal void MarkWhiteGloveStartDetectedForTest()
|
||||
{
|
||||
lock (_stateLock) { _whiteGloveStartDetected = true; }
|
||||
}
|
||||
|
||||
internal class WhiteGloveBackfillState
|
||||
{
|
||||
public bool WhiteGloveStartSeen { get; set; }
|
||||
public DateTime SeenUtc { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+1088
File diff suppressed because it is too large
Load Diff
+382
@@ -0,0 +1,382 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches Microsoft-Windows-Shell-Core/Operational for ESP-related events:
|
||||
/// 62404 — CloudExperienceHost Web App Activity Started (CXID: 'AADHello' / 'NGC' = Hello wizard)
|
||||
/// 62407 — CloudExperienceHost Web App Event 2:
|
||||
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
|
||||
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete
|
||||
/// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure
|
||||
///
|
||||
/// Raises <see cref="FinalizingSetupPhaseTriggered"/>, <see cref="WhiteGloveCompleted"/>,
|
||||
/// and <see cref="EspFailureDetected"/>. Cross-notifies the <see cref="HelloTracker"/>
|
||||
/// on Hello wizard start and ESP exit so Hello timers can react.
|
||||
/// </summary>
|
||||
internal sealed class ShellCoreTracker : IDisposable
|
||||
{
|
||||
internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational";
|
||||
internal const int EventId_ShellCore_WebAppStarted = 62404;
|
||||
internal const int EventId_ShellCore_WebAppEvent = 62407;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_ShellCore_WebAppStarted,
|
||||
EventId_ShellCore_WebAppEvent
|
||||
};
|
||||
|
||||
private static readonly Regex EspExitingPattern = new Regex(
|
||||
@"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly HelloTracker _helloTracker;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
public event EventHandler<string> FinalizingSetupPhaseTriggered;
|
||||
public event EventHandler WhiteGloveCompleted;
|
||||
public event EventHandler<string> EspFailureDetected;
|
||||
|
||||
public ShellCoreTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
HelloTracker helloTracker)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups
|
||||
}
|
||||
|
||||
internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } }
|
||||
internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[(EventID=62404 or EventID=62407)]]");
|
||||
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
|
||||
_logger.Info($"Started watching: {ShellCoreEventLogChannel}");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Failed to start Shell-Core event log watcher", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.EventRecordWritten -= OnEventRecordWritten;
|
||||
_watcher.Dispose();
|
||||
}
|
||||
catch (Exception ex) { _logger.Error("Error stopping Shell-Core event watcher", ex); }
|
||||
finally { _watcher = null; }
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// =====================================================================
|
||||
// Live event handler
|
||||
// =====================================================================
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var record = e.EventRecord;
|
||||
if (!TrackedShellCoreEventIds.Contains(record.Id)) return;
|
||||
|
||||
var description = record.FormatDescription() ?? $"Event ID {record.Id}";
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
|
||||
ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing Shell-Core event record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core event-processing logic. Exposed as internal so tests can drive it without
|
||||
/// needing to synthesize an <see cref="EventRecord"/> (abstract + Windows-only).
|
||||
/// </summary>
|
||||
internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill)
|
||||
{
|
||||
string eventType;
|
||||
EventSeverity severity = EventSeverity.Info;
|
||||
string message;
|
||||
bool triggerFinalizingSetup = false;
|
||||
string finalizingSetupReason = null;
|
||||
string detectedFailureType = null;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ShellCore_WebAppStarted: // 62404
|
||||
if (description.Contains("AADHello") || description.Contains("'NGC'"))
|
||||
{
|
||||
eventType = "hello_wizard_started";
|
||||
message = "Windows Hello wizard started (CloudExperienceHost)";
|
||||
triggerFinalizingSetup = true;
|
||||
finalizingSetupReason = "hello_wizard_started";
|
||||
|
||||
_helloTracker?.NotifyHelloWizardStarted();
|
||||
|
||||
_logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404");
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
case EventId_ShellCore_WebAppEvent: // 62407
|
||||
if (description.IndexOf("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
// Fire-once guard
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_whiteGloveDetected) return;
|
||||
_whiteGloveDetected = true;
|
||||
}
|
||||
|
||||
eventType = "whiteglove_complete";
|
||||
message = "WhiteGlove (Pre-Provisioning) completed successfully";
|
||||
// No FinalizingSetup transition — WhiteGlove terminates pre-provisioning entirely
|
||||
|
||||
_logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407");
|
||||
}
|
||||
else if (HasEspFailurePattern(description))
|
||||
{
|
||||
detectedFailureType = ExtractEspFailureType(description);
|
||||
eventType = "esp_failure";
|
||||
severity = EventSeverity.Error;
|
||||
message = $"ESP (Enrollment Status Page) reported a failure: {detectedFailureType}";
|
||||
_logger.Warning($"ESP failure detected via Shell-Core event 62407: {detectedFailureType}");
|
||||
}
|
||||
else if (EspExitingPattern.IsMatch(description))
|
||||
{
|
||||
eventType = "esp_exiting";
|
||||
message = "ESP (Enrollment Status Page) phase exiting";
|
||||
triggerFinalizingSetup = true;
|
||||
finalizingSetupReason = "esp_exiting";
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_espExitDetected = true;
|
||||
// Note: We do NOT start the Hello wait timer here!
|
||||
// Event 62407 occurs at every ESP phase transition (Device->Account, Account->End)
|
||||
// EnrollmentTracker will decide based on lastEspPhase whether to start the timer
|
||||
}
|
||||
_helloTracker?.NotifyEspExited();
|
||||
|
||||
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
var eventData = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", eventId },
|
||||
{ "providerName", providerName ?? "" },
|
||||
{ "description", description },
|
||||
{ "eventLogChannel", ShellCoreEventLogChannel },
|
||||
{ "eventTime", timestamp.ToString("o") }
|
||||
};
|
||||
|
||||
if (eventType == "esp_failure" && detectedFailureType != null)
|
||||
{
|
||||
eventData["failureType"] = detectedFailureType;
|
||||
}
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = timestamp,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = eventData,
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
_logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})");
|
||||
|
||||
if (triggerFinalizingSetup)
|
||||
{
|
||||
try { FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); }
|
||||
catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); }
|
||||
}
|
||||
|
||||
// Fire WhiteGloveCompleted AFTER event emission so the whiteglove_complete event
|
||||
// is in the spool before the agent exits.
|
||||
if (eventType == "whiteglove_complete")
|
||||
{
|
||||
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
|
||||
catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); }
|
||||
}
|
||||
|
||||
// Fire EspFailureDetected AFTER event emission so the esp_failure event is in the
|
||||
// spool before the agent potentially shuts down.
|
||||
if (eventType == "esp_failure" && detectedFailureType != null)
|
||||
{
|
||||
try { EspFailureDetected?.Invoke(this, detectedFailureType); }
|
||||
catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{detectedFailureType}'", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Backfill (public — called by coordinator)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
|
||||
/// Secondary recovery mechanism when state persistence is unavailable.
|
||||
/// </summary>
|
||||
public void BackfillRecentEspExitEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
var description = record.FormatDescription() ?? "";
|
||||
HandleBackfillRecord(description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal backfill record handler — extracted for testability and to keep the
|
||||
/// backfill loop free of direct event-processing logic.
|
||||
/// </summary>
|
||||
internal void HandleBackfillRecord(string description)
|
||||
{
|
||||
if (EspExitingPattern.IsMatch(description))
|
||||
{
|
||||
bool shouldNotify = false;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_espExitDetected)
|
||||
{
|
||||
_espExitDetected = true;
|
||||
shouldNotify = true;
|
||||
}
|
||||
}
|
||||
if (shouldNotify)
|
||||
{
|
||||
_helloTracker?.NotifyEspExited();
|
||||
_logger.Info("Backfill: ESP exit event found in recent Shell-Core logs");
|
||||
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); }
|
||||
catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
if (HasEspFailurePattern(description))
|
||||
{
|
||||
var failureType = ExtractEspFailureType(description);
|
||||
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}");
|
||||
try { EspFailureDetected?.Invoke(this, failureType); }
|
||||
catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pattern helpers
|
||||
// =====================================================================
|
||||
|
||||
internal static bool HasEspFailurePattern(string description)
|
||||
{
|
||||
return description.IndexOf("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| description.IndexOf("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| description.IndexOf("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| description.IndexOf("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| description.IndexOf("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| description.IndexOf("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a structured failure type from the Shell-Core event description.
|
||||
/// Returns e.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed",
|
||||
/// or "Unknown_ESP_Failure" as a fallback.
|
||||
/// </summary>
|
||||
internal static string ExtractEspFailureType(string description)
|
||||
{
|
||||
string[] knownTypes = {
|
||||
"ESPProgress_Failure",
|
||||
"ESPProgress_Failed",
|
||||
"ESPProgress_Timeout",
|
||||
"ESPProgress_Abort",
|
||||
"WhiteGlove_Failed",
|
||||
"WhiteGlove_Failure"
|
||||
};
|
||||
|
||||
foreach (var type in knownTypes)
|
||||
{
|
||||
if (description.IndexOf(type, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return type;
|
||||
}
|
||||
|
||||
return "Unknown_ESP_Failure";
|
||||
}
|
||||
}
|
||||
}
|
||||
+551
@@ -0,0 +1,551 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Ebene 2 — Defensive deep probes that run only when enrollment has been silent for a while.
|
||||
///
|
||||
/// The collector is NOT a timer of its own. Instead, it is invoked from
|
||||
/// <see cref="Core.MonitoringService.IdleCheckCallback"/> (which runs every 60 s for the
|
||||
/// entire agent lifetime). On each tick the collector inspects the idle duration and fires
|
||||
/// a full scan at the configured thresholds (default: 2, 15, 30, 60, 180 min).
|
||||
///
|
||||
/// Each probe scans up to four read-only sources (all wrapped in try/catch + hard timeout):
|
||||
/// 1. Provisioning registry (HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings)
|
||||
/// 2. Diagnostics registry (HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot)
|
||||
/// 3. Windows event logs (ModernDeployment + Shell-Core + Application)
|
||||
/// 4. AppWorkload.log tail (IME ESP app install state)
|
||||
///
|
||||
/// The probe classifies the outcome as:
|
||||
/// - anomaly → <c>stall_probe_result</c> (Warning) + optional <c>EspFailureDetected</c>
|
||||
/// - activeInstalls → positive signal (healthy-in-progress), no alarm
|
||||
/// - quiet → nothing found in any source
|
||||
///
|
||||
/// Output policy (configurable via StallProbeTraceIndices, default = [2]):
|
||||
/// - Trace-indexed probes: emit a <c>stall_probe_check</c> Trace event on quiet/activeInstalls.
|
||||
/// - Non-trace probes: emit nothing when no anomaly is found (agent log only).
|
||||
/// - Anomaly found: always emit <c>stall_probe_result</c> Warning (plus EspFailureDetected if terminal).
|
||||
/// - Probe index configured as "session-stalled": emit fire-once <c>session_stalled</c> Warning.
|
||||
/// </summary>
|
||||
public class StallProbeCollector
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly int[] _thresholdsMinutes;
|
||||
private readonly HashSet<int> _traceIndices;
|
||||
private readonly HashSet<string> _sources;
|
||||
private readonly int _sessionStalledAfterProbeIndex;
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
private bool _sessionStalledFired;
|
||||
|
||||
public event EventHandler<string> EspFailureDetected;
|
||||
|
||||
// Per-source hard timeouts (ms)
|
||||
private const int RegistryScanTimeoutMs = 500;
|
||||
private const int DiagnosticsRegistryScanTimeoutMs = 500;
|
||||
private const int EventLogScanTimeoutMs = 2000;
|
||||
private const int AppWorkloadTailTimeoutMs = 1000;
|
||||
|
||||
// Terminal ESP failure EventIDs in ModernDeployment-Diagnostics-Provider channels.
|
||||
// Conservative list — we prefer to NOT auto-terminate on unknown IDs and let the user decide.
|
||||
//
|
||||
// TODO(stall-probe-classification): This is stellschraube #1 for promoting stall-probe findings
|
||||
// to terminal failures. Currently empty by design: the ModernDeployment watcher (Ebene 1) runs
|
||||
// in live-capture mode and forwards raw events so we can gather real production EventIDs first.
|
||||
// Once we have 1–2 weeks of production data showing which EventIDs reliably correspond to
|
||||
// terminal ESP failures (e.g. "app install failed terminally", "ESP category Error"), add them
|
||||
// here. From that point on, Probe 1 (2 min) will detect them and fire EspFailureDetected →
|
||||
// the session goes terminal-Failed within ~2 min instead of waiting for the 5h backend sweep.
|
||||
// Cross-reference: the matching terminalReason should go into result.TerminalReason when you
|
||||
// add entries (see ScanEventLogs around the TerminalModernDeploymentEventIds.Contains check).
|
||||
private static readonly HashSet<int> TerminalModernDeploymentEventIds = new HashSet<int>
|
||||
{
|
||||
// Populated as real failure EventIDs are observed in production.
|
||||
// Kept empty intentionally — live-capture mode (Etappe 3) collects the raw events first.
|
||||
};
|
||||
|
||||
// Fallback list of ModernDeployment EventIDs that are known to be harmless and should NOT
|
||||
// count as anomalies in the stall probe scan. The runtime-configurable list from the backend
|
||||
// is injected via the constructor (CollectorConfiguration.ModernDeploymentHarmlessEventIds);
|
||||
// this fallback is used only when no list is provided.
|
||||
private static readonly int[] DefaultHarmlessModernDeploymentEventIds = new[] { 100, 1005 };
|
||||
|
||||
// AppWorkload.log active-install markers (positive signals)
|
||||
private static readonly Regex ActiveInstallRegex = new Regex(
|
||||
@"EnforcementState\s*[:=]\s*(Downloading|Installing|InProgress)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
// AppWorkload.log failure markers (negative signals — but scanner only reports, no terminal decision).
|
||||
//
|
||||
// TODO(stall-probe-classification): This is stellschraube #2 for promoting stall-probe findings
|
||||
// to terminal failures. Today any match of this regex produces a Warning-severity
|
||||
// stall_probe_result event but does NOT set result.IsTerminal or fire EspFailureDetected —
|
||||
// on purpose, because IME writes transient "Failed" lines that recover (retry, re-detect) and
|
||||
// we do not want false-terminal verdicts during the live-capture phase. Once we can
|
||||
// distinguish "retryable IME hiccup" from "terminal app install failure" based on real
|
||||
// production data, tighten the regex (e.g. require a specific error code range or a second
|
||||
// confirming line) and set result.IsTerminal = true + result.TerminalReason in ScanAppWorkloadTail.
|
||||
// Ideas for stronger terminal signals to add here once validated:
|
||||
// - EnforcementState: Failed + specific HRESULT ranges (0x87D1... Win32 app failures)
|
||||
// - DetectionState: Error that persists across multiple tail samples
|
||||
// - Explicit "Required app failed" lines from the ESP required-app enforcer
|
||||
//
|
||||
// TODO(stall-probe-imelog-scan): Also scan IntuneManagementExtension.log (not just
|
||||
// AppWorkload.log). Learning from the Oriflame session b4b5d37e-a993-453e-b16b-9b75098022e4
|
||||
// (MS Intune AAD token service outage 2026-04-08): the ESP blocker was
|
||||
// "Failed to get AAD token. errorCode = 3399548929" (= 0xCAA90001 AADSTS_SERVER_ERROR)
|
||||
// and this string lives in IntuneManagementExtension.log, NOT AppWorkload.log. Add a third
|
||||
// tail-scan source for `C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log`
|
||||
// with a regex that catches repeated "Failed to get AAD token.*errorCode" lines (decimal or
|
||||
// hex) as a strong "IME is blocked by service issue" signal. Watch out: IME retries every
|
||||
// ~1h, so requiring at least 2 occurrences within the 15-min probe window dedup's single
|
||||
// transient blips. The errorCode is DECIMAL in IME logs (3399548929), NOT hex, so the regex
|
||||
// must handle both formats. A non-terminal Warning is probably the right severity — a
|
||||
// service outage is recoverable and Windows ESP will eventually time out on its own if the
|
||||
// outage persists, and at that point the ModernDeployment watcher (Ebene 1) should pick it up.
|
||||
private static readonly Regex FailureRegex = new Regex(
|
||||
@"EnforcementState\s*[:=]\s*Failed|Error\s+0x[0-9A-Fa-f]+|DetectionState\s*[:=]\s*Error",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public StallProbeCollector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
int[] thresholdsMinutes,
|
||||
int[] traceIndices,
|
||||
string[] sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[] harmlessModernDeploymentEventIds = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_thresholdsMinutes = thresholdsMinutes ?? throw new ArgumentNullException(nameof(thresholdsMinutes));
|
||||
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
|
||||
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
|
||||
_harmlessModernDeploymentEventIds = new HashSet<int>(
|
||||
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
|
||||
? harmlessModernDeploymentEventIds
|
||||
: DefaultHarmlessModernDeploymentEventIds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all per-probe fire-once state. Called when a new real (non-periodic) event arrives.
|
||||
/// session_stalled stays fire-once across the whole session — we don't re-warn if the
|
||||
/// session stalls a second time; the backend maintenance sweep handles late stalls.
|
||||
/// </summary>
|
||||
public void ResetProbes()
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_firedProbeIndices.Count > 0)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes");
|
||||
_firedProbeIndices.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from the 60-s idle check tick. Decides whether any probe threshold has been
|
||||
/// newly crossed and runs the corresponding probe.
|
||||
/// </summary>
|
||||
public void CheckAndRunProbes(double idleMinutes)
|
||||
{
|
||||
// Iterate thresholds from lowest to highest so probes fire in order.
|
||||
for (int i = 0; i < _thresholdsMinutes.Length; i++)
|
||||
{
|
||||
var thresholdMinutes = _thresholdsMinutes[i];
|
||||
if (idleMinutes < thresholdMinutes)
|
||||
return;
|
||||
|
||||
int probeIndex = i + 1; // 1-based for user-facing output
|
||||
|
||||
bool alreadyFired;
|
||||
lock (_stateLock)
|
||||
{
|
||||
alreadyFired = _firedProbeIndices.Contains(probeIndex);
|
||||
}
|
||||
if (alreadyFired)
|
||||
continue;
|
||||
|
||||
RunProbe(probeIndex, thresholdMinutes, idleMinutes);
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_firedProbeIndices.Add(probeIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes)
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) };
|
||||
|
||||
// Run each source best-effort; one failure does not skip the others.
|
||||
if (_sources.Contains("provisioning_registry"))
|
||||
RunWithTimeout(() => ScanProvisioningRegistry(result), RegistryScanTimeoutMs, "provisioning_registry", result);
|
||||
|
||||
if (_sources.Contains("diagnostics_registry"))
|
||||
RunWithTimeout(() => ScanDiagnosticsRegistry(result), DiagnosticsRegistryScanTimeoutMs, "diagnostics_registry", result);
|
||||
|
||||
if (_sources.Contains("eventlog"))
|
||||
RunWithTimeout(() => ScanEventLogs(result), EventLogScanTimeoutMs, "eventlog", result);
|
||||
|
||||
if (_sources.Contains("appworkload_log"))
|
||||
RunWithTimeout(() => ScanAppWorkloadTail(result), AppWorkloadTailTimeoutMs, "appworkload_log", result);
|
||||
|
||||
result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds;
|
||||
|
||||
// Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability.
|
||||
var anomaliesStr = result.Anomalies.Count > 0
|
||||
? $"anomalies=[{string.Join(",", result.Anomalies)}]"
|
||||
: "quiet";
|
||||
var activeStr = result.ActiveInstalls.Count > 0
|
||||
? $" activeInstalls=[{string.Join(",", result.ActiveInstalls)}]"
|
||||
: string.Empty;
|
||||
var failedStr = result.SourcesFailed.Count > 0
|
||||
? $" sources_failed=[{string.Join(",", result.SourcesFailed)}]"
|
||||
: string.Empty;
|
||||
_logger.Info($"StallProbeCollector: Probe {probeIndex}/{_thresholdsMinutes.Length} ran at idle={result.IdleMinutes}min, {anomaliesStr}{activeStr}{failedStr} (duration={result.DurationMs}ms)");
|
||||
|
||||
bool hasAnomaly = result.Anomalies.Count > 0;
|
||||
bool hasActiveInstalls = result.ActiveInstalls.Count > 0;
|
||||
|
||||
// Emit stall_probe_result Warning on anomaly — always, regardless of probe index.
|
||||
if (hasAnomaly)
|
||||
{
|
||||
EmitProbeResultEvent(result, severity: EventSeverity.Warning);
|
||||
|
||||
if (result.IsTerminal)
|
||||
{
|
||||
_logger.Warning($"StallProbeCollector: terminal ESP failure detected in Probe {probeIndex} — firing EspFailureDetected");
|
||||
try { EspFailureDetected?.Invoke(this, result.TerminalReason ?? "stall_probe_terminal"); }
|
||||
catch (Exception ex) { _logger.Error("EspFailureDetected handler threw", ex); }
|
||||
}
|
||||
}
|
||||
else if (_traceIndices.Contains(probeIndex))
|
||||
{
|
||||
// Quiet or activeInstalls — emit a Trace heartbeat at trace-indexed probes.
|
||||
EmitProbeCheckTraceEvent(result);
|
||||
}
|
||||
// else: non-trace probe without anomaly → silent except for agent log.
|
||||
|
||||
// session_stalled is fire-once across the whole session. It fires at the configured
|
||||
// probe index UNLESS activeInstalls is observed (activeInstalls counts as progress).
|
||||
if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_sessionStalledFired)
|
||||
return;
|
||||
_sessionStalledFired = true;
|
||||
}
|
||||
EmitSessionStalledEvent(result);
|
||||
}
|
||||
}
|
||||
|
||||
private void RunWithTimeout(Action action, int timeoutMs, string sourceName, ProbeResult result)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Run on the calling thread but guard against exceptions. We intentionally do not
|
||||
// spawn a new thread per source — the IdleCheckCallback already runs on a worker
|
||||
// thread, and spinning extra threads would be heavier than the work itself.
|
||||
// The timeoutMs is enforced via source-internal reads (FileStream, Registry handles
|
||||
// are using-scoped and the reads themselves are bounded).
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
action();
|
||||
sw.Stop();
|
||||
if (sw.ElapsedMilliseconds > timeoutMs)
|
||||
{
|
||||
_logger.Warning($"StallProbeCollector: {sourceName} scan exceeded soft timeout ({sw.ElapsedMilliseconds}ms > {timeoutMs}ms)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"StallProbeCollector: {sourceName} scan failed: {ex.Message}");
|
||||
result.SourcesFailed.Add(sourceName);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanProvisioningRegistry(ProbeResult result)
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
return;
|
||||
|
||||
foreach (var valueName in new[] { "DevicePreparationCategory.Status", "DeviceSetupCategory.Status", "AccountSetupCategory.Status" })
|
||||
{
|
||||
var json = key.GetValue(valueName)?.ToString();
|
||||
if (string.IsNullOrEmpty(json))
|
||||
continue;
|
||||
|
||||
// Rudimentary scan — only flag patterns that are definitively bad.
|
||||
if (json.IndexOf("\"categorySucceeded\":false", StringComparison.OrdinalIgnoreCase) >= 0 ||
|
||||
json.IndexOf("\"subcategoryState\":\"failed\"", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
result.Anomalies.Add($"provisioning_registry:{valueName}");
|
||||
if (result.RawSamples.Count < 5)
|
||||
result.RawSamples.Add($"{valueName}: {Truncate(json, 400)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanDiagnosticsRegistry(ProbeResult result)
|
||||
{
|
||||
using (var diagRoot = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot", writable: false))
|
||||
{
|
||||
if (diagRoot == null)
|
||||
return;
|
||||
|
||||
object deployErrorCode = diagRoot.GetValue("DeploymentErrorCode");
|
||||
object deployErrorReason = diagRoot.GetValue("DeploymentErrorReason");
|
||||
|
||||
if (deployErrorCode != null)
|
||||
{
|
||||
// DeploymentErrorCode is typically a DWORD. 0 = no error.
|
||||
var asString = deployErrorCode.ToString();
|
||||
if (!string.IsNullOrEmpty(asString) && asString != "0" && asString != "0x0")
|
||||
{
|
||||
result.Anomalies.Add("diagnostics_registry:DeploymentErrorCode");
|
||||
result.RawSamples.Add($"DeploymentErrorCode={asString}; DeploymentErrorReason={deployErrorReason ?? "(null)"}");
|
||||
result.IsTerminal = true;
|
||||
result.TerminalReason = $"DeploymentErrorCode={asString}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanEventLogs(ProbeResult result)
|
||||
{
|
||||
// Scan a small window (last 15 min) of Autopilot/ESP-related channels at Level ≤ 3.
|
||||
var since = DateTime.UtcNow.AddMinutes(-15);
|
||||
var sinceStr = since.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||
var xpath = $"*[System[(Level >= 1 and Level <= 3) and TimeCreated[@SystemTime >= '{sinceStr}']]]";
|
||||
|
||||
var channels = new[]
|
||||
{
|
||||
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot",
|
||||
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService",
|
||||
"Microsoft-Windows-Shell-Core/Operational"
|
||||
};
|
||||
|
||||
int foundCount = 0;
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(channel, PathType.LogName, xpath) { ReverseDirection = true };
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
int perChannelCap = 20;
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null && perChannelCap-- > 0)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
// Skip known harmless events — they are normal operational noise
|
||||
// and should not be counted as stall anomalies.
|
||||
if (_harmlessModernDeploymentEventIds.Contains(record.Id))
|
||||
continue;
|
||||
|
||||
foundCount++;
|
||||
if (result.RawSamples.Count < 5)
|
||||
{
|
||||
string desc = null;
|
||||
try { desc = record.FormatDescription(); }
|
||||
catch { }
|
||||
desc = Truncate(desc ?? $"EventID {record.Id}", 400);
|
||||
result.RawSamples.Add($"[{channel.Split('/').Last()}] ID={record.Id} Level={record.Level}: {desc}");
|
||||
}
|
||||
|
||||
// Check for known terminal ModernDeployment failure event IDs.
|
||||
if (TerminalModernDeploymentEventIds.Contains(record.Id))
|
||||
{
|
||||
result.IsTerminal = true;
|
||||
result.TerminalReason = $"ModernDeployment EventID {record.Id}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
// Channel not available on this OS — ignore silently.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (foundCount > 0)
|
||||
{
|
||||
result.Anomalies.Add($"eventlog:{foundCount}_events");
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanAppWorkloadTail(ProbeResult result)
|
||||
{
|
||||
var path = @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log";
|
||||
if (!File.Exists(path))
|
||||
return;
|
||||
|
||||
// Read the last ~200 KB. AppWorkload.log can be huge; we never read the whole file.
|
||||
const int tailBytes = 200 * 1024;
|
||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
|
||||
{
|
||||
long start = Math.Max(0, fs.Length - tailBytes);
|
||||
fs.Seek(start, SeekOrigin.Begin);
|
||||
using (var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true))
|
||||
{
|
||||
string content = reader.ReadToEnd();
|
||||
|
||||
// Cut at the first newline to avoid starting mid-line.
|
||||
var firstNewline = content.IndexOf('\n');
|
||||
if (firstNewline > 0 && firstNewline < content.Length - 1)
|
||||
content = content.Substring(firstNewline + 1);
|
||||
|
||||
// Positive-signal scan: active installations recently logged.
|
||||
foreach (Match m in ActiveInstallRegex.Matches(content))
|
||||
{
|
||||
if (result.ActiveInstalls.Count < 10 && !result.ActiveInstalls.Contains(m.Value))
|
||||
result.ActiveInstalls.Add(m.Value);
|
||||
}
|
||||
|
||||
// Negative-signal scan: failures.
|
||||
var failureMatches = FailureRegex.Matches(content);
|
||||
if (failureMatches.Count > 0)
|
||||
{
|
||||
result.Anomalies.Add($"appworkload_log:{failureMatches.Count}_errors");
|
||||
// Sample up to 3 failure lines for context.
|
||||
int sampled = 0;
|
||||
foreach (Match m in failureMatches)
|
||||
{
|
||||
if (sampled >= 3 || result.RawSamples.Count >= 5)
|
||||
break;
|
||||
// Extract the enclosing line for context.
|
||||
int lineStart = content.LastIndexOf('\n', Math.Max(0, m.Index));
|
||||
int lineEnd = content.IndexOf('\n', m.Index);
|
||||
if (lineStart < 0) lineStart = 0;
|
||||
if (lineEnd < 0) lineEnd = content.Length;
|
||||
var line = content.Substring(lineStart, Math.Min(400, lineEnd - lineStart)).Trim();
|
||||
result.RawSamples.Add($"AppWorkload: {line}");
|
||||
sampled++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitProbeResultEvent(ProbeResult result, EventSeverity severity)
|
||||
{
|
||||
var data = BuildDataJson(result);
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.StallProbeResult,
|
||||
Severity = severity,
|
||||
Source = "StallProbeCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length}: {result.Anomalies.Count} anomaly/ies after {result.IdleMinutes}min idle",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private void EmitProbeCheckTraceEvent(ProbeResult result)
|
||||
{
|
||||
var data = BuildDataJson(result);
|
||||
var summary = result.ActiveInstalls.Count > 0
|
||||
? $"activeInstalls={result.ActiveInstalls.Count}"
|
||||
: "quiet";
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.StallProbeCheck,
|
||||
Severity = EventSeverity.Trace,
|
||||
Source = "StallProbeCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length} heartbeat: {summary} after {result.IdleMinutes}min idle",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private void EmitSessionStalledEvent(ProbeResult result)
|
||||
{
|
||||
var data = BuildDataJson(result);
|
||||
data["probeIndexTriggered"] = result.ProbeIndex;
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.SessionStalled,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "StallProbeCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Session stalled: no enrollment progress for {result.IdleMinutes}min (stall probe {result.ProbeIndex})",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> BuildDataJson(ProbeResult r)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "probeIndex", r.ProbeIndex },
|
||||
{ "idleMinutes", r.IdleMinutes },
|
||||
{ "probeDurationMs", r.DurationMs },
|
||||
{ "anomalyTypes", r.Anomalies.ToArray() },
|
||||
{ "activeInstalls", r.ActiveInstalls.ToArray() },
|
||||
{ "sources_failed", r.SourcesFailed.ToArray() },
|
||||
{ "rawSamples", r.RawSamples.ToArray() },
|
||||
{ "isTerminal", r.IsTerminal },
|
||||
{ "terminalReason", r.TerminalReason ?? string.Empty }
|
||||
};
|
||||
}
|
||||
|
||||
private static string Truncate(string s, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return string.Empty;
|
||||
return s.Length <= max ? s : s.Substring(0, max) + "…";
|
||||
}
|
||||
|
||||
private class ProbeResult
|
||||
{
|
||||
public int ProbeIndex;
|
||||
public double IdleMinutes;
|
||||
public int DurationMs;
|
||||
public readonly List<string> Anomalies = new List<string>();
|
||||
public readonly List<string> ActiveInstalls = new List<string>();
|
||||
public readonly List<string> SourcesFailed = new List<string>();
|
||||
public readonly List<string> RawSamples = new List<string>();
|
||||
public bool IsTerminal;
|
||||
public string TerminalReason;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
internal static class MemoryNativeMethods
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
|
||||
private struct MEMORYSTATUSEX
|
||||
{
|
||||
public uint dwLength;
|
||||
public uint dwMemoryLoad;
|
||||
public ulong ullTotalPhys;
|
||||
public ulong ullAvailPhys;
|
||||
public ulong ullTotalPageFile;
|
||||
public ulong ullAvailPageFile;
|
||||
public ulong ullTotalVirtual;
|
||||
public ulong ullAvailVirtual;
|
||||
public ulong ullAvailExtendedVirtual;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer);
|
||||
|
||||
public static bool TryGetMemoryInfo(out ulong availBytes, out ulong totalBytes, out uint loadPercent)
|
||||
{
|
||||
var status = new MEMORYSTATUSEX();
|
||||
status.dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX));
|
||||
|
||||
if (GlobalMemoryStatusEx(ref status))
|
||||
{
|
||||
availBytes = status.ullAvailPhys;
|
||||
totalBytes = status.ullTotalPhys;
|
||||
loadPercent = status.dwMemoryLoad;
|
||||
return true;
|
||||
}
|
||||
|
||||
availBytes = 0;
|
||||
totalBytes = 0;
|
||||
loadPercent = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
/// <summary>
|
||||
/// Win32 P/Invoke declarations for process creation in user session.
|
||||
/// Used by UserSessionProcessLauncher to launch processes in interactive desktop sessions from SYSTEM context.
|
||||
/// </summary>
|
||||
internal static class ProcessNativeMethods
|
||||
{
|
||||
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
|
||||
public const int STARTF_USESHOWWINDOW = 0x00000001;
|
||||
public const int STARTF_FORCEONFEEDBACK = 0x00000040;
|
||||
public const short SW_SHOW = 5;
|
||||
public const uint WAIT_TIMEOUT = 258;
|
||||
public const uint MAXIMUM_ALLOWED = 0x02000000;
|
||||
public const int SECURITY_IMPERSONATION = 2;
|
||||
public const int TOKEN_PRIMARY = 1;
|
||||
public const int TokenSessionId = 12;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct SECURITY_ATTRIBUTES
|
||||
{
|
||||
public int nLength;
|
||||
public IntPtr lpSecurityDescriptor;
|
||||
public bool bInheritHandle;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct STARTUPINFO
|
||||
{
|
||||
public int cb;
|
||||
public string lpReserved;
|
||||
public string lpDesktop;
|
||||
public string lpTitle;
|
||||
public int dwX;
|
||||
public int dwY;
|
||||
public int dwXSize;
|
||||
public int dwYSize;
|
||||
public int dwXCountChars;
|
||||
public int dwYCountChars;
|
||||
public int dwFillAttribute;
|
||||
public int dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct PROCESS_INFORMATION
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public int dwProcessId;
|
||||
public int dwThreadId;
|
||||
}
|
||||
|
||||
[DllImport("Wtsapi32.dll", SetLastError = true)]
|
||||
public static extern bool WTSQueryUserToken(uint SessionId, out IntPtr phToken);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
public static extern bool DuplicateTokenEx(
|
||||
IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes,
|
||||
int ImpersonationLevel, int TokenType, out IntPtr phNewToken);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
public static extern bool SetTokenInformation(
|
||||
IntPtr TokenHandle, int TokenInformationClass,
|
||||
ref uint TokenInformation, uint TokenInformationLength);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool CreateProcessAsUser(
|
||||
IntPtr hToken, string lpApplicationName, string lpCommandLine,
|
||||
ref SECURITY_ATTRIBUTES lpProcessAttributes,
|
||||
ref SECURITY_ATTRIBUTES lpThreadAttributes,
|
||||
bool bInheritHandles, uint dwCreationFlags,
|
||||
IntPtr lpEnvironment, string lpCurrentDirectory,
|
||||
ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);
|
||||
|
||||
[DllImport("userenv.dll", SetLastError = true)]
|
||||
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
|
||||
{
|
||||
internal static class RegistryNativeMethods
|
||||
{
|
||||
public const int KEY_NOTIFY = 0x0010;
|
||||
public const int KEY_WOW64_64KEY = 0x0100;
|
||||
public const int KEY_WOW64_32KEY = 0x0200;
|
||||
|
||||
[Flags]
|
||||
public enum RegChangeNotifyFilter : uint
|
||||
{
|
||||
Name = 0x00000001,
|
||||
Attributes = 0x00000002,
|
||||
LastSet = 0x00000004,
|
||||
Security = 0x00000008,
|
||||
|
||||
// Supported on Windows 8+
|
||||
ThreadAgnostic = 0x10000000
|
||||
}
|
||||
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern int RegOpenKeyEx(
|
||||
UIntPtr hKey,
|
||||
string lpSubKey,
|
||||
uint ulOptions,
|
||||
int samDesired,
|
||||
out SafeRegistryHandle phkResult);
|
||||
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
public static extern int RegNotifyChangeKeyValue(
|
||||
SafeRegistryHandle hKey,
|
||||
bool bWatchSubtree,
|
||||
RegChangeNotifyFilter dwNotifyFilter,
|
||||
SafeWaitHandle hEvent,
|
||||
bool fAsynchronous);
|
||||
|
||||
public static int GetSamDesired(RegistryView view)
|
||||
{
|
||||
switch (view)
|
||||
{
|
||||
case RegistryView.Registry64:
|
||||
return KEY_NOTIFY | KEY_WOW64_64KEY;
|
||||
|
||||
case RegistryView.Registry32:
|
||||
return KEY_NOTIFY | KEY_WOW64_32KEY;
|
||||
|
||||
case RegistryView.Default:
|
||||
return KEY_NOTIFY;
|
||||
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(view));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages agent analyzers: initialization from remote config, startup execution
|
||||
/// on a background thread, and shutdown execution with optional WhiteGlove part.
|
||||
/// </summary>
|
||||
public class AnalyzerManager
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly RemoteConfigService _remoteConfigService;
|
||||
|
||||
private readonly List<IAgentAnalyzer> _analyzers = new List<IAgentAnalyzer>();
|
||||
|
||||
public AnalyzerManager(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
RemoteConfigService remoteConfigService)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_emitEvent = emitEvent;
|
||||
_remoteConfigService = remoteConfigService;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_analyzers.Clear();
|
||||
|
||||
var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration();
|
||||
|
||||
if (analyzerConfig.EnableLocalAdminAnalyzer)
|
||||
{
|
||||
_analyzers.Add(new LocalAdminAnalyzer(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
analyzerConfig.LocalAdminAllowedAccounts
|
||||
));
|
||||
_logger.Info("LocalAdminAnalyzer registered");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("LocalAdminAnalyzer disabled by remote config");
|
||||
}
|
||||
|
||||
if (analyzerConfig.EnableSoftwareInventoryAnalyzer)
|
||||
{
|
||||
_analyzers.Add(new SoftwareInventoryAnalyzer(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger
|
||||
));
|
||||
_logger.Info("SoftwareInventoryAnalyzer registered");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("SoftwareInventoryAnalyzer disabled by remote config");
|
||||
}
|
||||
|
||||
if (analyzerConfig.EnableIntegrityBypassAnalyzer)
|
||||
{
|
||||
_analyzers.Add(new IntegrityBypassAnalyzer(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger
|
||||
));
|
||||
_logger.Info("IntegrityBypassAnalyzer registered");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("IntegrityBypassAnalyzer disabled by remote config");
|
||||
}
|
||||
|
||||
_logger.Info($"Analyzers initialized: {_analyzers.Count} active");
|
||||
}
|
||||
|
||||
public void RunStartup()
|
||||
{
|
||||
if (_analyzers.Count == 0)
|
||||
return;
|
||||
|
||||
var analyzers = new List<IAgentAnalyzer>(_analyzers);
|
||||
|
||||
_logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread");
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
foreach (var analyzer in analyzers)
|
||||
{
|
||||
try
|
||||
{
|
||||
analyzer.AnalyzeAtStartup();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Analyzer {analyzer.Name} threw during startup", ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void RunShutdown(int? whiteGlovePart = null)
|
||||
{
|
||||
if (_analyzers.Count == 0)
|
||||
return;
|
||||
|
||||
_logger.Info($"Running {_analyzers.Count} shutdown analyzer(s) (whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
|
||||
|
||||
foreach (var analyzer in _analyzers)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (analyzer is SoftwareInventoryAnalyzer softwareAnalyzer)
|
||||
softwareAnalyzer.AnalyzeAtShutdown(whiteGlovePart);
|
||||
else
|
||||
analyzer.AnalyzeAtShutdown();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles agent cleanup on enrollment completion:
|
||||
/// removes the Scheduled Task and deletes all agent files (self-destruct).
|
||||
/// </summary>
|
||||
public class CleanupService
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public CleanupService(AgentConfiguration configuration, AgentLogger logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes full self-destruct: removes scheduled task and deletes all files
|
||||
/// </summary>
|
||||
public virtual void ExecuteSelfDestruct()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})");
|
||||
|
||||
var currentProcessId = Process.GetCurrentProcess().Id;
|
||||
var agentBasePath = Path.GetFullPath(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
|
||||
"AutopilotMonitor"
|
||||
));
|
||||
|
||||
var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs"));
|
||||
var keepLogs = _configuration.KeepLogFile;
|
||||
|
||||
// Create a self-deleting PowerShell script
|
||||
var cleanupScript = $@"
|
||||
$scriptPath = $MyInvocation.MyCommand.Path
|
||||
|
||||
# Wait for agent process to exit
|
||||
Start-Sleep -Seconds 2
|
||||
Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30
|
||||
Start-Sleep -Seconds 1
|
||||
|
||||
# Remove Scheduled Task
|
||||
try {{
|
||||
Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue
|
||||
}} catch {{ }}
|
||||
|
||||
{(keepLogs ? $@"
|
||||
# Delete everything except Logs directory
|
||||
Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{
|
||||
$dest = $_.FullName + '.del'
|
||||
try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }}
|
||||
Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}}
|
||||
" : $@"
|
||||
# Rename folder then delete. Retry a few times to let the OS release handles.
|
||||
$renamedPath = '{agentBasePath}.del'
|
||||
$renamed = $false
|
||||
for ($i = 1; $i -le 10; $i++) {{
|
||||
try {{
|
||||
Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop
|
||||
$renamed = $true
|
||||
break
|
||||
}} catch {{
|
||||
Start-Sleep -Seconds 2
|
||||
}}
|
||||
}}
|
||||
if ($renamed) {{
|
||||
Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}} else {{
|
||||
Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}}
|
||||
")}
|
||||
{(_configuration.RebootOnComplete ? @"
|
||||
Restart-Computer -Force -Delay 10 -Comment 'Autopilot enrollment completed - Autopilot Monitor is rebooting'
|
||||
" : "")}
|
||||
Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue
|
||||
";
|
||||
|
||||
// Write cleanup script to temp location (outside of agent folder)
|
||||
var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1");
|
||||
File.WriteAllText(tempScriptPath, cleanupScript);
|
||||
_logger.Info($"Cleanup script written to {tempScriptPath}");
|
||||
|
||||
// Change CWD to temp so this process no longer holds a reference into
|
||||
// the AutopilotMonitor folder tree - Windows won't allow renaming a
|
||||
// directory that any process has as its current working directory.
|
||||
try { Directory.SetCurrentDirectory(Path.GetTempPath()); } catch { }
|
||||
|
||||
// Launch via cmd /c start so the powershell process is created outside the
|
||||
// current Job Object (Scheduled Task job). cmd's 'start' command always
|
||||
// creates a new process group that breaks job inheritance, even under SYSTEM.
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Cmd,
|
||||
// Absolute path to powershell.exe is passed to `start` to prevent PATH hijacking
|
||||
// (the `start` builtin does its own PATH resolution for the target command).
|
||||
Arguments = $"/c start \"\" /b \"{SystemPaths.PowerShell}\" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetTempPath()
|
||||
};
|
||||
|
||||
Process.Start(psi);
|
||||
_logger.Info("Cleanup script launched. Agent will now exit.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error executing self-destruct", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages collector lifecycle: start/stop, idle detection, restart after activity,
|
||||
/// max lifetime timer, gather rules, and analyzers.
|
||||
/// </summary>
|
||||
public class CollectorCoordinator : IDisposable
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _agentVersion;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly Action<string, string, string, Dictionary<string, object>> _emitTraceEvent;
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly EventSpool _spool;
|
||||
private readonly RemoteConfigService _remoteConfigService;
|
||||
private readonly Func<bool> _isTerminalEventSeen;
|
||||
private readonly string _previousExitType;
|
||||
private readonly DateTime? _lastBootTimeUtc;
|
||||
private readonly DateTime _agentStartTimeUtc;
|
||||
|
||||
// Core event collectors (always on)
|
||||
private EspAndHelloTracker _espAndHelloTracker;
|
||||
private LogReplayService _logReplay;
|
||||
|
||||
// Optional collectors (toggled via remote config)
|
||||
private DeliveryOptimizationCollector _deliveryOptimizationCollector;
|
||||
private StallProbeCollector _stallProbeCollector;
|
||||
|
||||
// Periodic collectors + idle management
|
||||
private PeriodicCollectorManager _periodicManager;
|
||||
|
||||
// Smart enrollment tracking
|
||||
private EnrollmentTracker _enrollmentTracker;
|
||||
|
||||
// Gather rules
|
||||
private GatherRuleExecutor _gatherRuleExecutor;
|
||||
|
||||
// Desktop arrival / IME watcher / Network change
|
||||
private DesktopArrivalDetector _desktopArrivalDetector;
|
||||
private AadJoinWatcher _aadJoinWatcher;
|
||||
private ImeProcessWatcher _imeProcessWatcher;
|
||||
private NetworkChangeDetector _networkChangeDetector;
|
||||
|
||||
// Analyzers
|
||||
private AnalyzerManager _analyzerManager;
|
||||
|
||||
// Max lifetime safety net
|
||||
private Timer _maxLifetimeTimer;
|
||||
|
||||
public CollectorCoordinator(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
string agentVersion,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
Action<string, string, string, Dictionary<string, object>> emitTraceEvent,
|
||||
BackendApiClient apiClient,
|
||||
EventSpool spool,
|
||||
RemoteConfigService remoteConfigService,
|
||||
Func<bool> isTerminalEventSeen,
|
||||
string previousExitType,
|
||||
DateTime? lastBootTimeUtc,
|
||||
DateTime agentStartTimeUtc)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_agentVersion = agentVersion;
|
||||
_emitEvent = emitEvent;
|
||||
_emitTraceEvent = emitTraceEvent;
|
||||
_apiClient = apiClient;
|
||||
_spool = spool;
|
||||
_remoteConfigService = remoteConfigService;
|
||||
_isTerminalEventSeen = isTerminalEventSeen;
|
||||
_previousExitType = previousExitType;
|
||||
_lastBootTimeUtc = lastBootTimeUtc;
|
||||
_agentStartTimeUtc = agentStartTimeUtc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether periodic collectors are currently paused due to idle timeout.
|
||||
/// </summary>
|
||||
public bool CollectorsIdleStopped => _periodicManager?.CollectorsIdleStopped ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Called from EmitEvent when a non-periodic event is received.
|
||||
/// Updates idle tracking, resets stall probes, and restarts paused collectors.
|
||||
/// </summary>
|
||||
public void OnRealEventReceived()
|
||||
{
|
||||
_periodicManager?.OnRealEventReceived();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the max lifetime timer. Called when a terminal event is seen.
|
||||
/// </summary>
|
||||
public void CancelMaxLifetimeTimer()
|
||||
{
|
||||
_maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the gather rule executor of a phase change.
|
||||
/// </summary>
|
||||
public void OnPhaseChanged(EnrollmentPhase phase)
|
||||
{
|
||||
try { _gatherRuleExecutor?.OnPhaseChanged(phase); }
|
||||
catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnPhaseChanged failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the gather rule executor of an event type (for on_event triggers).
|
||||
/// </summary>
|
||||
public void OnEvent(string eventType)
|
||||
{
|
||||
try { _gatherRuleExecutor?.OnEvent(eventType); }
|
||||
catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnEvent('{eventType}') failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops just the performance collector (used during WhiteGlove completion).
|
||||
/// </summary>
|
||||
public void StopPerformanceCollectorOnly()
|
||||
{
|
||||
_periodicManager?.StopPerformanceCollectorOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the enrollment tracker that desktop has arrived.
|
||||
/// </summary>
|
||||
public void NotifyDesktopArrived()
|
||||
{
|
||||
_enrollmentTracker?.NotifyDesktopArrived();
|
||||
}
|
||||
|
||||
/// Backend validator verdict cached when RegisterSession completes before the
|
||||
/// EnrollmentTracker is instantiated. Applied to the tracker in StartEventCollectors().
|
||||
private ValidatorType _pendingBackendValidator = ValidatorType.Unknown;
|
||||
|
||||
/// <summary>
|
||||
/// Forwards the backend's authoritative validator verdict to the enrollment tracker
|
||||
/// so it can reconcile with its registry-based enrollment-type detection.
|
||||
/// If the tracker does not yet exist (RegisterSession runs before tracker start),
|
||||
/// the verdict is cached and applied when the tracker is created.
|
||||
/// </summary>
|
||||
public void ReconcileWithBackendValidator(ValidatorType validatedBy)
|
||||
{
|
||||
if (_enrollmentTracker != null)
|
||||
{
|
||||
_enrollmentTracker.ReconcileWithBackendValidator(validatedBy);
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingBackendValidator = validatedBy;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true for events generated by periodic timers (not real enrollment activity).
|
||||
/// </summary>
|
||||
public static bool IsPeriodicEvent(string eventType)
|
||||
{
|
||||
return PeriodicCollectorManager.IsPeriodicEvent(eventType);
|
||||
}
|
||||
|
||||
public void StartEventCollectors()
|
||||
{
|
||||
_logger.Info("Starting event collectors");
|
||||
|
||||
try
|
||||
{
|
||||
var helloTimeout = _remoteConfigService?.CurrentConfig?.Collectors?.HelloWaitTimeoutSeconds
|
||||
?? _configuration.HelloWaitTimeoutSeconds;
|
||||
var modernDeploymentEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentWatcherEnabled ?? true;
|
||||
var modernDeploymentLevelMax = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentLogLevelMax ?? 3;
|
||||
var modernDeploymentBackfillEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillEnabled ?? true;
|
||||
var modernDeploymentBackfillLookbackMinutes = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillLookbackMinutes ?? 30;
|
||||
var modernDeploymentHarmlessEventIds = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentHarmlessEventIds;
|
||||
_espAndHelloTracker = new EspAndHelloTracker(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
helloTimeout,
|
||||
modernDeploymentWatcherEnabled: modernDeploymentEnabled,
|
||||
modernDeploymentLogLevelMax: modernDeploymentLevelMax,
|
||||
modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
|
||||
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
||||
stateDirectory: @"%ProgramData%\AutopilotMonitor\State",
|
||||
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds
|
||||
);
|
||||
_espAndHelloTracker.Start();
|
||||
|
||||
if (!string.IsNullOrEmpty(_configuration.ReplayLogDir))
|
||||
{
|
||||
_logger.Info($"Log replay mode enabled - starting log replay from: {_configuration.ReplayLogDir}");
|
||||
var imeLogPatterns = _remoteConfigService?.CurrentConfig?.ImeLogPatterns;
|
||||
_logReplay = new LogReplayService(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
_configuration.ReplayLogDir,
|
||||
_configuration.ReplaySpeedFactor,
|
||||
imeLogPatterns
|
||||
);
|
||||
_logReplay.Start();
|
||||
_logger.Info("Log replay started");
|
||||
}
|
||||
|
||||
_logger.Info("Core event collectors started successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error starting event collectors", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void StartOptionalCollectors()
|
||||
{
|
||||
var config = _remoteConfigService?.CurrentConfig;
|
||||
var collectors = config?.Collectors;
|
||||
|
||||
_logger.Info("Starting collectors based on remote config");
|
||||
|
||||
try
|
||||
{
|
||||
_periodicManager = new PeriodicCollectorManager(
|
||||
_configuration, _logger, _agentVersion,
|
||||
_emitEvent, _apiClient, _spool, _remoteConfigService);
|
||||
_periodicManager.Start();
|
||||
|
||||
if (collectors?.StallProbeEnabled != false)
|
||||
{
|
||||
var probeThresholds = collectors?.StallProbeThresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 };
|
||||
var probeTraceIndices = collectors?.StallProbeTraceIndices ?? new[] { 2, 3, 4 };
|
||||
var probeSources = collectors?.StallProbeSources ?? new[]
|
||||
{
|
||||
"provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log"
|
||||
};
|
||||
var stalledAfterIndex = collectors?.SessionStalledAfterProbeIndex ?? 4;
|
||||
var harmlessMdmIds = collectors?.ModernDeploymentHarmlessEventIds ?? new[] { 100, 1005 };
|
||||
_stallProbeCollector = new StallProbeCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
probeThresholds,
|
||||
probeTraceIndices,
|
||||
probeSources,
|
||||
stalledAfterIndex,
|
||||
harmlessMdmIds);
|
||||
_logger.Info($"StallProbeCollector enabled: thresholds=[{string.Join(",", probeThresholds)}]min, traces=[{string.Join(",", probeTraceIndices)}], sources={probeSources.Length}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("StallProbeCollector disabled via config");
|
||||
}
|
||||
|
||||
_periodicManager.StallProbeCollector = _stallProbeCollector;
|
||||
|
||||
if (string.IsNullOrEmpty(_configuration.ReplayLogDir))
|
||||
{
|
||||
var imeLogPatterns = config?.ImeLogPatterns;
|
||||
var imeMatchLogPath = string.IsNullOrEmpty(_configuration.ImeMatchLogPath)
|
||||
? null
|
||||
: Environment.ExpandEnvironmentVariables(_configuration.ImeMatchLogPath);
|
||||
|
||||
_enrollmentTracker = new EnrollmentTracker(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
imeLogPatterns,
|
||||
_configuration.ImeLogPathOverride,
|
||||
imeMatchLogPath: imeMatchLogPath,
|
||||
espAndHelloTracker: _espAndHelloTracker,
|
||||
isBootstrapMode: _configuration.UseBootstrapTokenAuth,
|
||||
sendTraceEvents: _configuration.SendTraceEvents
|
||||
);
|
||||
_enrollmentTracker.Start();
|
||||
_logger.Info("EnrollmentTracker started — listening for IME patterns");
|
||||
|
||||
if (_pendingBackendValidator != ValidatorType.Unknown)
|
||||
{
|
||||
_enrollmentTracker.ReconcileWithBackendValidator(_pendingBackendValidator);
|
||||
_pendingBackendValidator = ValidatorType.Unknown;
|
||||
}
|
||||
|
||||
if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue)
|
||||
{
|
||||
_enrollmentTracker.NotifySystemRebootDetected();
|
||||
}
|
||||
|
||||
if (_stallProbeCollector != null)
|
||||
{
|
||||
_stallProbeCollector.EspFailureDetected += (sender, failureType) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_enrollmentTracker?.ReportEspFailureFromExternalSource(failureType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Failed to forward stall probe failure to EnrollmentTracker", ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (collectors?.EnableDeliveryOptimizationCollector != false && _enrollmentTracker != null)
|
||||
{
|
||||
var doInterval = collectors?.DeliveryOptimizationIntervalSeconds ?? 3;
|
||||
_deliveryOptimizationCollector = new DeliveryOptimizationCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
doInterval,
|
||||
() => _enrollmentTracker?.ImeTracker?.PackageStates,
|
||||
app => _enrollmentTracker?.ImeTracker?.OnDoTelemetryReceived?.Invoke(app),
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory)
|
||||
);
|
||||
_deliveryOptimizationCollector.Start();
|
||||
_logger.Info($"DeliveryOptimizationCollector started dormant (interval={doInterval}s, wakes on download)");
|
||||
|
||||
var existingHandler = _enrollmentTracker.ImeTracker.OnAppStateChanged;
|
||||
var doCollector = _deliveryOptimizationCollector;
|
||||
_enrollmentTracker.ImeTracker.OnAppStateChanged = (pkg, oldState, newState) =>
|
||||
{
|
||||
existingHandler?.Invoke(pkg, oldState, newState);
|
||||
if (newState >= AppInstallationState.Downloading && newState <= AppInstallationState.Installing)
|
||||
doCollector?.WakeUp();
|
||||
};
|
||||
}
|
||||
|
||||
_desktopArrivalDetector = new DesktopArrivalDetector(_logger);
|
||||
_desktopArrivalDetector.DesktopArrived += OnDesktopArrived;
|
||||
_desktopArrivalDetector.OnTraceEvent = (decision, reason, context) =>
|
||||
_emitTraceEvent("DesktopArrivalDetector", decision, reason, context);
|
||||
_desktopArrivalDetector.Start();
|
||||
_logger.Info("DesktopArrivalDetector started — monitoring for real user desktop");
|
||||
|
||||
_aadJoinWatcher = new AadJoinWatcher(_logger);
|
||||
_aadJoinWatcher.PlaceholderUserDetected += OnAadPlaceholderUserDetected;
|
||||
_aadJoinWatcher.AadUserJoined += OnAadUserJoined;
|
||||
_aadJoinWatcher.Start();
|
||||
_logger.Info("AadJoinWatcher started — monitoring JoinInfo registry for late AAD user");
|
||||
|
||||
_imeProcessWatcher = new ImeProcessWatcher(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger
|
||||
);
|
||||
_imeProcessWatcher.Start();
|
||||
_logger.Info("ImeProcessWatcher started — watching for IntuneManagementExtension.exe exit");
|
||||
|
||||
_networkChangeDetector = new NetworkChangeDetector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
_configuration.ApiBaseUrl
|
||||
);
|
||||
_networkChangeDetector.Start();
|
||||
_logger.Info("NetworkChangeDetector started — monitoring for network changes");
|
||||
|
||||
var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes;
|
||||
if (maxLifetimeMinutes > 0)
|
||||
{
|
||||
_maxLifetimeTimer = new Timer(
|
||||
MaxLifetimeTimerCallback,
|
||||
null,
|
||||
TimeSpan.FromMinutes(maxLifetimeMinutes),
|
||||
Timeout.InfiniteTimeSpan);
|
||||
_logger.Info($"Agent max lifetime timer armed: {maxLifetimeMinutes} min");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("Agent max lifetime timer disabled (0)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error starting optional collectors", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void StopOptionalCollectors()
|
||||
{
|
||||
_periodicManager?.Stop();
|
||||
|
||||
_maxLifetimeTimer?.Dispose();
|
||||
_maxLifetimeTimer = null;
|
||||
|
||||
if (_desktopArrivalDetector != null)
|
||||
{
|
||||
_desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
|
||||
_desktopArrivalDetector.Dispose();
|
||||
_desktopArrivalDetector = null;
|
||||
}
|
||||
|
||||
if (_aadJoinWatcher != null)
|
||||
{
|
||||
_aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
|
||||
_aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
|
||||
_aadJoinWatcher.Dispose();
|
||||
_aadJoinWatcher = null;
|
||||
}
|
||||
|
||||
_imeProcessWatcher?.Dispose();
|
||||
_imeProcessWatcher = null;
|
||||
|
||||
_networkChangeDetector?.Dispose();
|
||||
_networkChangeDetector = null;
|
||||
|
||||
_deliveryOptimizationCollector?.Stop();
|
||||
_deliveryOptimizationCollector?.Dispose();
|
||||
_deliveryOptimizationCollector = null;
|
||||
|
||||
_enrollmentTracker?.Stop();
|
||||
_enrollmentTracker?.Dispose();
|
||||
_enrollmentTracker = null;
|
||||
}
|
||||
|
||||
public void StopEventCollectors()
|
||||
{
|
||||
_logger.Info("Stopping event collectors");
|
||||
|
||||
try
|
||||
{
|
||||
_espAndHelloTracker?.Stop();
|
||||
_espAndHelloTracker?.Dispose();
|
||||
|
||||
_logReplay?.Stop();
|
||||
_logReplay?.Dispose();
|
||||
|
||||
StopOptionalCollectors();
|
||||
|
||||
_gatherRuleExecutor?.Dispose();
|
||||
_gatherRuleExecutor = null;
|
||||
|
||||
_logger.Info("Event collectors stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping event collectors", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void StartGatherRuleExecutor()
|
||||
{
|
||||
var config = _remoteConfigService?.CurrentConfig;
|
||||
if (config?.GatherRules == null || config.GatherRules.Count == 0)
|
||||
{
|
||||
_logger.Info("No gather rules to execute");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_gatherRuleExecutor = new GatherRuleExecutor(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
_configuration.ImeLogPathOverride
|
||||
);
|
||||
_gatherRuleExecutor.UnrestrictedMode = _configuration.UnrestrictedMode;
|
||||
_gatherRuleExecutor.UpdateRules(config.GatherRules);
|
||||
_logger.Info($"GatherRuleExecutor started with {config.GatherRules.Count} rule(s)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error starting gather rule executor", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void InitializeAnalyzers()
|
||||
{
|
||||
_analyzerManager = new AnalyzerManager(_configuration, _logger, _emitEvent, _remoteConfigService);
|
||||
_analyzerManager.Initialize();
|
||||
}
|
||||
|
||||
public void RunStartupAnalyzers()
|
||||
{
|
||||
_analyzerManager?.RunStartup();
|
||||
}
|
||||
|
||||
public void RunShutdownAnalyzers(int? whiteGlovePart = null)
|
||||
{
|
||||
_analyzerManager?.RunShutdown(whiteGlovePart);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the EnrollmentTracker's trace event setting.
|
||||
/// </summary>
|
||||
public void UpdateSendTraceEvents(bool sendTraceEvents)
|
||||
{
|
||||
_enrollmentTracker?.UpdateSendTraceEvents(sendTraceEvents);
|
||||
}
|
||||
|
||||
private void MaxLifetimeTimerCallback(object state)
|
||||
{
|
||||
if (_isTerminalEventSeen())
|
||||
{
|
||||
_logger.Info("Max lifetime timer fired but terminal event already seen — ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes;
|
||||
_logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed");
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = "enrollment_failed",
|
||||
Severity = EventSeverity.Error,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Complete,
|
||||
Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "failureType", "agent_timeout" },
|
||||
{ "failureSource", "max_lifetime_timer" },
|
||||
{ "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) }
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
private void OnDesktopArrived(object sender, EventArgs e)
|
||||
{
|
||||
if (_isTerminalEventSeen())
|
||||
{
|
||||
_logger.Debug("Desktop arrived but terminal event already seen — ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = "desktop_arrived",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "DesktopArrivalDetector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "User desktop detected (explorer.exe under real user)",
|
||||
ImmediateUpload = true
|
||||
});
|
||||
|
||||
_enrollmentTracker?.NotifyDesktopArrived();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_periodicManager?.Dispose();
|
||||
_maxLifetimeTimer?.Dispose();
|
||||
_espAndHelloTracker?.Dispose();
|
||||
_logReplay?.Dispose();
|
||||
_deliveryOptimizationCollector?.Dispose();
|
||||
_enrollmentTracker?.Dispose();
|
||||
_networkChangeDetector?.Dispose();
|
||||
_gatherRuleExecutor?.Dispose();
|
||||
if (_desktopArrivalDetector != null)
|
||||
{
|
||||
_desktopArrivalDetector.DesktopArrived -= OnDesktopArrived;
|
||||
_desktopArrivalDetector.Dispose();
|
||||
}
|
||||
if (_aadJoinWatcher != null)
|
||||
{
|
||||
_aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected;
|
||||
_aadJoinWatcher.AadUserJoined -= OnAadUserJoined;
|
||||
_aadJoinWatcher.Dispose();
|
||||
}
|
||||
_imeProcessWatcher?.Dispose();
|
||||
}
|
||||
|
||||
private void OnAadPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_enrollmentTracker?.NotifyAadPlaceholderUserDetected(e.UserEmail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("CollectorCoordinator: OnAadPlaceholderUserDetected failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
_enrollmentTracker?.NotifyAadUserJoinedLate(e.UserEmail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("CollectorCoordinator: OnAadUserJoined failed", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of a diagnostics package upload attempt.
|
||||
/// </summary>
|
||||
public class DiagnosticsUploadResult
|
||||
{
|
||||
/// <summary>Blob name on success, null on failure or skip.</summary>
|
||||
public string BlobName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// First part of the SAS URL (up to and including the first query param) with the token truncated,
|
||||
/// so the event shows the target container without leaking the full signature.
|
||||
/// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)"
|
||||
/// </summary>
|
||||
public string SasUrlPrefix { get; set; }
|
||||
|
||||
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
|
||||
public string ErrorCode { get; set; }
|
||||
|
||||
public bool Success => BlobName != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info)
|
||||
/// to the tenant's Azure Blob Storage container via a short-lived SAS URL.
|
||||
/// The SAS URL is fetched on-demand just before upload and never stored in config or on disk.
|
||||
/// </summary>
|
||||
public class DiagnosticsPackageService
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private static readonly string ImeLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs");
|
||||
|
||||
private static readonly string AgentLogFolder =
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// File extensions collected from built-in log folders (Agent + IME).
|
||||
/// Covers standard logs, structured data, traces, event logs, and diagnostics archives.
|
||||
/// </summary>
|
||||
private static readonly string[] LogFilePatterns = new[]
|
||||
{
|
||||
"*.log", "*.txt", "*.json", "*.jsonl",
|
||||
"*.etl", "*.evtx", "*.xml", "*.csv", "*.cab"
|
||||
};
|
||||
|
||||
public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_apiClient = apiClient;
|
||||
_httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
|
||||
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
|
||||
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
|
||||
/// This method is non-fatal: all exceptions are caught and logged.
|
||||
/// </summary>
|
||||
/// <param name="enrollmentSucceeded">
|
||||
/// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content).
|
||||
/// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point).
|
||||
/// </param>
|
||||
/// <param name="fileNameSuffix">
|
||||
/// Optional suffix inserted before the .zip extension.
|
||||
/// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip
|
||||
/// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip
|
||||
/// </param>
|
||||
public virtual async Task<DiagnosticsUploadResult> CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if upload is needed based on configuration
|
||||
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
|
||||
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Debug("Diagnostics upload disabled (mode=Off)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!_configuration.DiagnosticsUploadEnabled)
|
||||
{
|
||||
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
|
||||
{
|
||||
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
|
||||
|
||||
// Build ZIP in memory
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
|
||||
var suffix = string.IsNullOrEmpty(fileNameSuffix) ? "" : $"-{fileNameSuffix}";
|
||||
var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip";
|
||||
|
||||
byte[] zipBytes;
|
||||
using (var ms = new MemoryStream())
|
||||
{
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
// 1. sessioninfo.txt
|
||||
AddSessionInfo(archive, enrollmentSucceeded);
|
||||
|
||||
// 2. Agent logs
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
AddLogFiles(archive, AgentLogFolder, "AgentLogs", pattern);
|
||||
|
||||
// 3. IME logs (all relevant files in the IME log folder)
|
||||
foreach (var pattern in LogFilePatterns)
|
||||
AddLogFiles(archive, ImeLogFolder, "ImeLogs", pattern);
|
||||
|
||||
// 4. Configured additional log paths (global + tenant, validated by guards)
|
||||
foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List<Shared.Models.DiagnosticsLogPath>())
|
||||
{
|
||||
// Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
|
||||
if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath))
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
if (expandedPath == null)
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
var pattern = Path.GetFileName(expandedPath);
|
||||
if (string.IsNullOrEmpty(folder)) continue;
|
||||
if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*";
|
||||
var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}";
|
||||
AddLogFiles(archive, folder, zipFolder, pattern, entry.IncludeSubfolders);
|
||||
}
|
||||
}
|
||||
|
||||
zipBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
// Fetch a short-lived upload URL just before uploading — never stored in config or on disk
|
||||
_logger.Info("Requesting diagnostics upload URL from backend...");
|
||||
var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync(
|
||||
_configuration.TenantId,
|
||||
_configuration.SessionId,
|
||||
zipFileName);
|
||||
|
||||
if (uploadUrlResponse == null || !uploadUrlResponse.Success || string.IsNullOrEmpty(uploadUrlResponse.UploadUrl))
|
||||
{
|
||||
var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend";
|
||||
_logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}");
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode };
|
||||
}
|
||||
|
||||
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
|
||||
|
||||
// Upload to Blob Storage using the freshly obtained URL
|
||||
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, uploadUrlResponse.UploadUrl);
|
||||
if (uploaded)
|
||||
{
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {zipFileName}");
|
||||
return new DiagnosticsUploadResult { BlobName = zipFileName, SasUrlPrefix = sasUrlPrefix };
|
||||
}
|
||||
|
||||
return new DiagnosticsUploadResult { SasUrlPrefix = sasUrlPrefix, ErrorCode = uploadErrorCode };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Diagnostics package creation/upload failed (non-fatal): {ex.Message}");
|
||||
return new DiagnosticsUploadResult { ErrorCode = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a safe, truncated SAS URL prefix for logging — shows account/container but not the token.
|
||||
/// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)"
|
||||
/// </summary>
|
||||
private static string BuildSasUrlPrefix(string sasUrl)
|
||||
{
|
||||
try
|
||||
{
|
||||
var qIndex = sasUrl.IndexOf('?');
|
||||
if (qIndex < 0) return sasUrl;
|
||||
|
||||
// Keep only the first query param name to show which container/permissions are set
|
||||
var basePath = sasUrl.Substring(0, qIndex);
|
||||
var query = sasUrl.Substring(qIndex + 1);
|
||||
var firstParam = query.Split('&')[0].Split('=')[0];
|
||||
return $"{basePath}?{firstParam}=(truncated)";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "(url unavailable)";
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Session ID: {_configuration.SessionId}");
|
||||
sb.AppendLine($"Tenant ID: {_configuration.TenantId}");
|
||||
sb.AppendLine($"Device Name: {Environment.MachineName}");
|
||||
sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}");
|
||||
sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}");
|
||||
|
||||
// Hardware info via existing DeviceInfoProvider (WMI)
|
||||
sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}");
|
||||
sb.AppendLine($"Model: {DeviceInfoProvider.GetModel()}");
|
||||
sb.AppendLine($"Serial Number: {DeviceInfoProvider.GetSerialNumber()}");
|
||||
|
||||
var entry = archive.CreateEntry("sessioninfo.txt");
|
||||
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
|
||||
writer.Write(sb.ToString());
|
||||
}
|
||||
|
||||
private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern,
|
||||
bool includeSubfolders = false)
|
||||
{
|
||||
if (!Directory.Exists(sourceFolder))
|
||||
{
|
||||
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var searchOption = includeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
var files = Directory.GetFiles(sourceFolder, searchPattern, searchOption);
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
|
||||
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
|
||||
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
|
||||
|
||||
// Read with FileShare.ReadWrite to avoid locking conflicts with active log writers
|
||||
byte[] content;
|
||||
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var reader = new MemoryStream())
|
||||
{
|
||||
fs.CopyTo(reader);
|
||||
content = reader.ToArray();
|
||||
}
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
using var entryStream = entry.Open();
|
||||
entryStream.Write(content, 0, content.Length);
|
||||
|
||||
_logger.Debug($"Added to diagnostics package: {entryName} ({content.Length / 1024} KB)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT with the provided container SAS URL.
|
||||
/// Container SAS URL format: https://account.blob.core.windows.net/container?sv=...&sig=...
|
||||
/// Blob URL is constructed by inserting the blob name before the query string.
|
||||
/// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error.
|
||||
/// </summary>
|
||||
private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string containerSasUrl)
|
||||
{
|
||||
// Build blob URL: split at '?' to insert blob name
|
||||
var questionMarkIndex = containerSasUrl.IndexOf('?');
|
||||
string blobUrl;
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = containerSasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = containerSasUrl.Substring(questionMarkIndex);
|
||||
blobUrl = $"{basePath}/{blobName}{queryString}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// No query string (unlikely for SAS but handle gracefully)
|
||||
blobUrl = $"{containerSasUrl.TrimEnd('/')}/{blobName}";
|
||||
}
|
||||
|
||||
const int maxRetries = 3;
|
||||
string lastErrorCode = null;
|
||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Uploading diagnostics package (attempt {attempt}/{maxRetries}, {data.Length / 1024} KB)...");
|
||||
|
||||
using var content = new ByteArrayContent(data);
|
||||
content.Headers.Add("x-ms-blob-type", "BlockBlob");
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/zip");
|
||||
|
||||
var response = await _httpClient.PutAsync(blobUrl, content);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
lastErrorCode = $"HTTP {statusCode} {response.ReasonPhrase}";
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Auth errors (401/403) are permanent — SAS token invalid or expired, retrying won't help
|
||||
if (statusCode == 401 || statusCode == 403)
|
||||
{
|
||||
_logger.Warning($"Blob upload auth error (not retryable): {lastErrorCode} - {responseBody}");
|
||||
return (false, lastErrorCode);
|
||||
}
|
||||
|
||||
_logger.Warning($"Blob upload attempt {attempt} failed: {lastErrorCode} - {responseBody}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastErrorCode = ex.Message;
|
||||
_logger.Warning($"Blob upload attempt {attempt} failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (attempt < maxRetries)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); // 2s, 4s, 8s
|
||||
_logger.Info($"Retrying blob upload in {delay.TotalSeconds}s...");
|
||||
await Task.Delay(delay);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Warning($"Diagnostics package upload failed after {maxRetries} attempts");
|
||||
return (false, lastErrorCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+311
@@ -0,0 +1,311 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages periodic collectors (PerformanceCollector, AgentSelfMetricsCollector) and their
|
||||
/// idle lifecycle: stops them after inactivity, restarts when real enrollment activity resumes.
|
||||
/// </summary>
|
||||
public class PeriodicCollectorManager : IDisposable
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _agentVersion;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly EventSpool _spool;
|
||||
private readonly RemoteConfigService _remoteConfigService;
|
||||
|
||||
private PerformanceCollector _performanceCollector;
|
||||
private AgentSelfMetricsCollector _agentSelfMetricsCollector;
|
||||
|
||||
private DateTime _lastRealEventTime = DateTime.UtcNow;
|
||||
private bool _collectorsIdleStopped;
|
||||
private int _collectorIdleTimeoutMinutes = 15;
|
||||
private Timer _idleCheckTimer;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference to the StallProbeCollector for resetting/running probes
|
||||
/// during idle checks. Set after creation in StartOptionalCollectors.
|
||||
/// </summary>
|
||||
public StallProbeCollector StallProbeCollector { get; set; }
|
||||
|
||||
public PeriodicCollectorManager(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
string agentVersion,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
BackendApiClient apiClient,
|
||||
EventSpool spool,
|
||||
RemoteConfigService remoteConfigService)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_agentVersion = agentVersion;
|
||||
_emitEvent = emitEvent;
|
||||
_apiClient = apiClient;
|
||||
_spool = spool;
|
||||
_remoteConfigService = remoteConfigService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether periodic collectors are currently paused due to idle timeout.
|
||||
/// </summary>
|
||||
public bool CollectorsIdleStopped => _collectorsIdleStopped;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the performance and metrics collectors and arms the idle check timer.
|
||||
/// Called from CollectorCoordinator.StartOptionalCollectors.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
var config = _remoteConfigService?.CurrentConfig;
|
||||
var collectors = config?.Collectors;
|
||||
|
||||
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
|
||||
_performanceCollector = new PerformanceCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
perfInterval
|
||||
);
|
||||
_performanceCollector.Start();
|
||||
_logger.Info($"PerformanceCollector started (interval={perfInterval}s)");
|
||||
|
||||
if (collectors?.EnableAgentSelfMetrics != false)
|
||||
{
|
||||
var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
|
||||
_agentSelfMetricsCollector = new AgentSelfMetricsCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_apiClient.NetworkMetrics,
|
||||
_spool,
|
||||
_logger,
|
||||
_agentVersion,
|
||||
selfMetricsInterval
|
||||
);
|
||||
_agentSelfMetricsCollector.Start();
|
||||
_logger.Info($"AgentSelfMetricsCollector started (interval={selfMetricsInterval}s)");
|
||||
}
|
||||
|
||||
_collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15;
|
||||
_lastRealEventTime = DateTime.UtcNow;
|
||||
_collectorsIdleStopped = false;
|
||||
|
||||
_idleCheckTimer = new Timer(
|
||||
IdleCheckCallback,
|
||||
null,
|
||||
TimeSpan.FromSeconds(60),
|
||||
TimeSpan.FromSeconds(60)
|
||||
);
|
||||
if (_collectorIdleTimeoutMinutes > 0)
|
||||
_logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min");
|
||||
else
|
||||
_logger.Info("Collector idle timeout disabled (0)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a non-periodic event is received. Updates idle tracking,
|
||||
/// resets stall probes, and restarts paused collectors.
|
||||
/// </summary>
|
||||
public void OnRealEventReceived()
|
||||
{
|
||||
_lastRealEventTime = DateTime.UtcNow;
|
||||
|
||||
try { StallProbeCollector?.ResetProbes(); }
|
||||
catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); }
|
||||
|
||||
if (_collectorsIdleStopped)
|
||||
{
|
||||
RestartPeriodicCollectors();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops just the performance collector (used during WhiteGlove completion).
|
||||
/// </summary>
|
||||
public void StopPerformanceCollectorOnly()
|
||||
{
|
||||
_performanceCollector?.Stop();
|
||||
_performanceCollector?.Dispose();
|
||||
_performanceCollector = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops all periodic collectors and the idle check timer.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_idleCheckTimer?.Dispose();
|
||||
_idleCheckTimer = null;
|
||||
|
||||
_performanceCollector?.Stop();
|
||||
_performanceCollector?.Dispose();
|
||||
_performanceCollector = null;
|
||||
|
||||
_agentSelfMetricsCollector?.Stop();
|
||||
_agentSelfMetricsCollector?.Dispose();
|
||||
_agentSelfMetricsCollector = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true for events generated by periodic timers (not real enrollment activity).
|
||||
/// </summary>
|
||||
public static bool IsPeriodicEvent(string eventType)
|
||||
{
|
||||
return eventType == "performance_snapshot" ||
|
||||
eventType == "agent_metrics_snapshot" ||
|
||||
eventType == "performance_collector_stopped" ||
|
||||
eventType == "agent_metrics_collector_stopped" ||
|
||||
eventType == Constants.EventTypes.StallProbeCheck ||
|
||||
eventType == Constants.EventTypes.StallProbeResult ||
|
||||
eventType == Constants.EventTypes.SessionStalled;
|
||||
}
|
||||
|
||||
private void IdleCheckCallback(object state)
|
||||
{
|
||||
var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes;
|
||||
|
||||
try
|
||||
{
|
||||
StallProbeCollector?.CheckAndRunProbes(idleMinutes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("StallProbeCollector.CheckAndRunProbes threw", ex);
|
||||
}
|
||||
|
||||
if (_collectorsIdleStopped)
|
||||
return;
|
||||
|
||||
if (idleMinutes < _collectorIdleTimeoutMinutes)
|
||||
return;
|
||||
|
||||
_logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors");
|
||||
|
||||
if (_performanceCollector != null)
|
||||
{
|
||||
_performanceCollector.Stop();
|
||||
_performanceCollector.Dispose();
|
||||
_performanceCollector = null;
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "performance_collector_stopped",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", "idle_timeout" },
|
||||
{ "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
|
||||
{ "idleMinutes", Math.Round(idleMinutes, 1) }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (_agentSelfMetricsCollector != null)
|
||||
{
|
||||
_agentSelfMetricsCollector.Stop();
|
||||
_agentSelfMetricsCollector.Dispose();
|
||||
_agentSelfMetricsCollector = null;
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "agent_metrics_collector_stopped",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MonitoringService",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reason", "idle_timeout" },
|
||||
{ "idleTimeoutMinutes", _collectorIdleTimeoutMinutes },
|
||||
{ "idleMinutes", Math.Round(idleMinutes, 1) }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_collectorsIdleStopped = true;
|
||||
}
|
||||
|
||||
private void RestartPeriodicCollectors()
|
||||
{
|
||||
var config = _remoteConfigService?.CurrentConfig;
|
||||
var collectors = config?.Collectors;
|
||||
|
||||
_logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop");
|
||||
|
||||
try
|
||||
{
|
||||
var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60;
|
||||
_performanceCollector = new PerformanceCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_logger,
|
||||
perfInterval
|
||||
);
|
||||
_performanceCollector.Start();
|
||||
|
||||
if (collectors?.EnableAgentSelfMetrics != false)
|
||||
{
|
||||
var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60;
|
||||
_agentSelfMetricsCollector = new AgentSelfMetricsCollector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
_emitEvent,
|
||||
_apiClient.NetworkMetrics,
|
||||
_spool,
|
||||
_logger,
|
||||
_agentVersion,
|
||||
selfMetricsInterval
|
||||
);
|
||||
_agentSelfMetricsCollector.Start();
|
||||
}
|
||||
|
||||
_lastRealEventTime = DateTime.UtcNow;
|
||||
_collectorsIdleStopped = false;
|
||||
if (_collectorIdleTimeoutMinutes > 0)
|
||||
{
|
||||
_idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
_logger.Info("Periodic collectors restarted successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error restarting periodic collectors", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_idleCheckTimer?.Dispose();
|
||||
_performanceCollector?.Dispose();
|
||||
_agentSelfMetricsCollector?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using Microsoft.Win32;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches a registry key for changes using RegNotifyChangeKeyValue.
|
||||
/// Raises Changed whenever the watched key/subtree changes.
|
||||
///
|
||||
/// Notes:
|
||||
/// - Notification is for the key scope, not one individual value.
|
||||
/// - To track a specific value, read/compare that value in the Changed handler.
|
||||
/// - Uses a dedicated background thread for robustness.
|
||||
/// </summary>
|
||||
internal sealed class RegistryWatcher : IDisposable
|
||||
{
|
||||
private readonly UIntPtr _hive;
|
||||
private readonly string _subKey;
|
||||
private readonly bool _watchSubtree;
|
||||
private readonly RegistryView _view;
|
||||
private readonly RegistryNativeMethods.RegChangeNotifyFilter _filter;
|
||||
private readonly Action<string> _trace;
|
||||
|
||||
private readonly object _sync = new object();
|
||||
|
||||
private CancellationTokenSource _cts;
|
||||
private Thread _thread;
|
||||
private Exception _backgroundException;
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler Changed;
|
||||
public event EventHandler<Exception> Error;
|
||||
|
||||
/// <param name="trace">Optional trace callback for diagnostic logging (called on every loop iteration).</param>
|
||||
public RegistryWatcher(
|
||||
RegistryHive hive,
|
||||
string subKey,
|
||||
bool watchSubtree = false,
|
||||
RegistryView view = RegistryView.Default,
|
||||
RegistryNativeMethods.RegChangeNotifyFilter filter =
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.LastSet |
|
||||
RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic,
|
||||
Action<string> trace = null)
|
||||
{
|
||||
_hive = HiveToPointer(hive);
|
||||
_subKey = subKey ?? throw new ArgumentNullException(nameof(subKey));
|
||||
_watchSubtree = watchSubtree;
|
||||
_view = view;
|
||||
_filter = filter;
|
||||
_trace = trace;
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _thread != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_thread != null)
|
||||
throw new InvalidOperationException("Watcher is already running.");
|
||||
|
||||
_backgroundException = null;
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
_thread = new Thread(WatchThreadMain)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"RegistryWatcher: {_subKey}"
|
||||
};
|
||||
|
||||
_thread.Start(_cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests stop without blocking.
|
||||
/// Safe to call from Changed handlers.
|
||||
/// </summary>
|
||||
public void RequestStop()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and waits for the watcher thread to exit.
|
||||
/// Avoid calling from inside Changed; use RequestStop there.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
Thread threadToJoin = null;
|
||||
CancellationTokenSource ctsToDispose = null;
|
||||
Exception backgroundException = null;
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_thread == null)
|
||||
return;
|
||||
|
||||
_cts.Cancel();
|
||||
threadToJoin = _thread;
|
||||
}
|
||||
|
||||
threadToJoin.Join();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
backgroundException = _backgroundException;
|
||||
|
||||
ctsToDispose = _cts;
|
||||
_cts = null;
|
||||
_thread = null;
|
||||
_backgroundException = null;
|
||||
}
|
||||
|
||||
ctsToDispose?.Dispose();
|
||||
|
||||
if (backgroundException != null &&
|
||||
!(backgroundException is OperationCanceledException))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Registry watcher stopped because the background thread failed.",
|
||||
backgroundException);
|
||||
}
|
||||
}
|
||||
|
||||
private void WatchThreadMain(object state)
|
||||
{
|
||||
var cancellationToken = (CancellationToken)state;
|
||||
|
||||
try
|
||||
{
|
||||
WatchLoop(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_backgroundException = ex;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_backgroundException = ex;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Error?.Invoke(this, ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never let handler exceptions escape the background thread.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void WatchLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
int samDesired = RegistryNativeMethods.GetSamDesired(_view);
|
||||
|
||||
_trace?.Invoke($"RegOpenKeyEx '{_subKey}' (view: {_view}, sam: 0x{samDesired:X})");
|
||||
|
||||
int openResult = RegistryNativeMethods.RegOpenKeyEx(
|
||||
_hive,
|
||||
_subKey,
|
||||
0,
|
||||
samDesired,
|
||||
out SafeRegistryHandle keyHandle);
|
||||
|
||||
if (openResult != 0)
|
||||
{
|
||||
throw new Win32Exception(
|
||||
openResult,
|
||||
$"RegOpenKeyEx failed for '{_subKey}' (view: {_view}).");
|
||||
}
|
||||
|
||||
_trace?.Invoke($"RegOpenKeyEx succeeded — handle valid: {!keyHandle.IsInvalid}");
|
||||
|
||||
using (keyHandle)
|
||||
using (var changedEvent = new AutoResetEvent(false))
|
||||
using (var cancelEvent = new ManualResetEvent(false))
|
||||
using (cancellationToken.Register(() => cancelEvent.Set()))
|
||||
{
|
||||
WaitHandle[] waitHandles = { changedEvent, cancelEvent };
|
||||
int iteration = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
iteration++;
|
||||
|
||||
_trace?.Invoke($"Iteration {iteration}: calling RegNotifyChangeKeyValue (subtree={_watchSubtree}, filter=0x{(uint)_filter:X})");
|
||||
|
||||
int notifyResult = RegistryNativeMethods.RegNotifyChangeKeyValue(
|
||||
keyHandle,
|
||||
_watchSubtree,
|
||||
_filter,
|
||||
changedEvent.SafeWaitHandle,
|
||||
true);
|
||||
|
||||
if (notifyResult != 0)
|
||||
{
|
||||
throw new Win32Exception(
|
||||
notifyResult,
|
||||
$"RegNotifyChangeKeyValue failed for '{_subKey}'.");
|
||||
}
|
||||
|
||||
_trace?.Invoke($"Iteration {iteration}: waiting for change notification...");
|
||||
|
||||
int signaled = WaitHandle.WaitAny(waitHandles);
|
||||
|
||||
if (signaled == WaitHandle.WaitTimeout)
|
||||
{
|
||||
_trace?.Invoke($"Iteration {iteration}: WaitAny returned WaitTimeout — continuing");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signaled == 1)
|
||||
{
|
||||
_trace?.Invoke($"Iteration {iteration}: cancel event signaled — exiting loop");
|
||||
break;
|
||||
}
|
||||
|
||||
_trace?.Invoke($"Iteration {iteration}: change notification received — invoking Changed handler");
|
||||
|
||||
try
|
||||
{
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
Error?.Invoke(this, ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore secondary error-handler failures.
|
||||
}
|
||||
}
|
||||
|
||||
_trace?.Invoke($"Iteration {iteration}: Changed handler completed");
|
||||
}
|
||||
|
||||
_trace?.Invoke("WatchLoop exiting (cancellation requested)");
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Dispose must not throw.
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (_disposed)
|
||||
throw new ObjectDisposedException(nameof(RegistryWatcher));
|
||||
}
|
||||
|
||||
private static UIntPtr HiveToPointer(RegistryHive hive)
|
||||
{
|
||||
switch (hive)
|
||||
{
|
||||
case RegistryHive.ClassesRoot:
|
||||
return (UIntPtr)0x80000000u;
|
||||
case RegistryHive.CurrentUser:
|
||||
return (UIntPtr)0x80000001u;
|
||||
case RegistryHive.LocalMachine:
|
||||
return (UIntPtr)0x80000002u;
|
||||
case RegistryHive.Users:
|
||||
return (UIntPtr)0x80000003u;
|
||||
case RegistryHive.CurrentConfig:
|
||||
return (UIntPtr)0x80000005u;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(hive));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Fast self-update at agent startup: checks version.json in blob storage,
|
||||
/// downloads the ZIP if newer, swaps files (rename-trick for locked binaries),
|
||||
/// and restarts via PowerShell Wait-Process.
|
||||
///
|
||||
/// Design priority: speed over update — better to run the old version than delay startup.
|
||||
/// </summary>
|
||||
static class SelfUpdater
|
||||
{
|
||||
private const int VersionCheckTimeoutMs = 2500; // 2.5s — covers cold DNS/TLS on fresh Autopilot boot
|
||||
private const int DownloadTimeoutMs = 10000; // 10s — abort if too slow
|
||||
private const string OldFileSuffix = ".old";
|
||||
|
||||
/// <summary>
|
||||
/// Writes an init banner to the log file to visually separate this agent process
|
||||
/// from install-mode logs that share the same file.
|
||||
/// </summary>
|
||||
public static void LogInit(string agentVersion)
|
||||
{
|
||||
LogToFile($"======================= Agent init (v{agentVersion}) =======================");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a message to the agent log file. Used for pre-update logging
|
||||
/// when the full AgentLogger is not yet initialized.
|
||||
/// </summary>
|
||||
public static void Log(string message)
|
||||
{
|
||||
LogToFile(message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes leftover .old files from a previous self-update.
|
||||
/// Called early in startup before any other logic.
|
||||
/// </summary>
|
||||
public static void CleanupPreviousUpdate(string agentDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(agentDir))
|
||||
return;
|
||||
|
||||
foreach (var oldFile in Directory.GetFiles(agentDir, "*" + OldFileSuffix))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(oldFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: file may still be locked if previous process hasn't fully exited
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean up any leftover staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
if (Directory.Exists(stagingDir))
|
||||
{
|
||||
try { Directory.Delete(stagingDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"Self-update cleanup warning: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Optional: backend-provided SHA-256 hash of the latest agent ZIP.
|
||||
/// Set from the last AgentConfigResponse before calling CheckAndApplyUpdateAsync.
|
||||
/// Takes priority over the hash in version.json (separate trust channel).
|
||||
/// </summary>
|
||||
public static string BackendExpectedSha256 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Checks for a newer agent version and applies the update if available.
|
||||
/// On success, restarts the process and never returns.
|
||||
/// On any failure, returns normally so the current version continues.
|
||||
/// </summary>
|
||||
/// <param name="forceUpdate">
|
||||
/// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger
|
||||
/// to cover hotfixes that reuse the same version number.
|
||||
/// </param>
|
||||
/// <param name="triggerReason">
|
||||
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
|
||||
/// agent_version_check event (outcome=updated) can distinguish the two paths.
|
||||
/// </param>
|
||||
/// <param name="downloadTimeoutMsOverride">
|
||||
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
|
||||
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
|
||||
/// and we know an update is required.
|
||||
/// </param>
|
||||
/// <param name="allowDowngrade">
|
||||
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
|
||||
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
|
||||
/// <c>version.json</c> from silently overwriting a higher-versioned dev/pre-release agent via
|
||||
/// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios.
|
||||
/// </param>
|
||||
public static async Task CheckAndApplyUpdateAsync(
|
||||
string currentVersion,
|
||||
string agentDir,
|
||||
bool consoleMode,
|
||||
bool forceUpdate = false,
|
||||
string triggerReason = "startup",
|
||||
int? downloadTimeoutMsOverride = null,
|
||||
bool allowDowngrade = false)
|
||||
{
|
||||
Action<string> log = msg =>
|
||||
{
|
||||
LogToFile(msg);
|
||||
if (consoleMode) Console.WriteLine(msg);
|
||||
};
|
||||
|
||||
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
|
||||
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
|
||||
long zipSizeBytes = 0;
|
||||
string latestVersion = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Fetch version.json (1s timeout)
|
||||
var versionSw = Stopwatch.StartNew();
|
||||
string manifestSha256;
|
||||
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
|
||||
versionSw.Stop();
|
||||
versionCheckMs = versionSw.ElapsedMilliseconds;
|
||||
|
||||
if (latestVersion == null)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("version_check_failed", currentVersion, null, "version.json fetch failed or timed out", log);
|
||||
return; // Could not determine latest version — continue with current
|
||||
}
|
||||
|
||||
// Step 2: Version comparison. Three-way decision:
|
||||
// cmp < 0 → latest < current = downgrade
|
||||
// cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate)
|
||||
// cmp > 0 → latest > current = upgrade
|
||||
var cmp = CompareVersions(currentVersion, latestVersion);
|
||||
|
||||
if (cmp < 0)
|
||||
{
|
||||
// Downgrade — forward-only policy blocks this by default even when forceUpdate=true.
|
||||
// Protects dev/pre-release builds from being silently replaced by the production
|
||||
// version advertised by the prod backend (runtime_hash_mismatch force path).
|
||||
if (!allowDowngrade)
|
||||
{
|
||||
log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false");
|
||||
WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log);
|
||||
return;
|
||||
}
|
||||
log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}");
|
||||
}
|
||||
else if (cmp == 0)
|
||||
{
|
||||
// Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair).
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
if (isStartupTrigger)
|
||||
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
|
||||
return;
|
||||
}
|
||||
log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}");
|
||||
}
|
||||
else if (cmp > 0)
|
||||
{
|
||||
log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// cmp == int.MinValue sentinel from CompareVersions → unparsable version string on
|
||||
// either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate.
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
|
||||
return;
|
||||
}
|
||||
log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions");
|
||||
}
|
||||
|
||||
// Step 3: Download ZIP (timeout = downloadTimeoutMs)
|
||||
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
|
||||
var downloadTimerSw = Stopwatch.StartNew();
|
||||
var downloadOk = await DownloadZipAsync(zipPath, downloadTimeoutMs, log);
|
||||
downloadTimerSw.Stop();
|
||||
downloadMs = downloadTimerSw.ElapsedMilliseconds;
|
||||
if (!downloadOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
|
||||
return;
|
||||
}
|
||||
try { zipSizeBytes = new FileInfo(zipPath).Length; } catch { /* best-effort */ }
|
||||
|
||||
// Step 3b: Verify SHA-256 integrity (backend hash has priority over version.json hash)
|
||||
string expectedSha256;
|
||||
if (!string.IsNullOrEmpty(BackendExpectedSha256))
|
||||
{
|
||||
expectedSha256 = BackendExpectedSha256;
|
||||
log("Self-update: using backend hash for integrity verification (cached config — trusted channel)");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(manifestSha256))
|
||||
{
|
||||
expectedSha256 = manifestSha256;
|
||||
log("Self-update: using version.json hash for integrity verification (blob storage)");
|
||||
}
|
||||
else
|
||||
{
|
||||
expectedSha256 = null;
|
||||
}
|
||||
|
||||
var verifySw = Stopwatch.StartNew();
|
||||
var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log);
|
||||
verifySw.Stop();
|
||||
verifyMs = verifySw.ElapsedMilliseconds;
|
||||
|
||||
if (!integrityOk)
|
||||
{
|
||||
CleanupStaging(null, zipPath);
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Extract to staging directory
|
||||
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
|
||||
var extractSw = Stopwatch.StartNew();
|
||||
var extractOk = ExtractToStaging(zipPath, stagingDir, log);
|
||||
extractSw.Stop();
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
if (!extractOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Validate staging
|
||||
var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe");
|
||||
if (!File.Exists(stagedExe))
|
||||
{
|
||||
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Swap files (rename locked → .old, copy new)
|
||||
var swapTimerSw = Stopwatch.StartNew();
|
||||
var swapOk = SwapFiles(agentDir, stagingDir, log);
|
||||
swapTimerSw.Stop();
|
||||
swapMs = swapTimerSw.ElapsedMilliseconds;
|
||||
if (!swapOk)
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 7: Clean up staging + temp ZIP
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
|
||||
// Step 8: Write marker file so the new agent can emit an event on next startup
|
||||
totalSw.Stop();
|
||||
WriteSelfUpdateMarker(
|
||||
previousVersion: currentVersion,
|
||||
newVersion: latestVersion,
|
||||
triggerReason: triggerReason,
|
||||
versionCheckMs: versionCheckMs,
|
||||
downloadMs: downloadMs,
|
||||
zipSizeBytes: zipSizeBytes,
|
||||
verifyMs: verifyMs,
|
||||
extractMs: extractMs,
|
||||
swapMs: swapMs,
|
||||
totalUpdateMs: totalSw.ElapsedMilliseconds,
|
||||
log: log);
|
||||
|
||||
// Step 9: Restart via PowerShell Wait-Process
|
||||
log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})...");
|
||||
RestartAgent(agentDir, log);
|
||||
|
||||
// RestartAgent calls Environment.Exit — we should never reach here
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
|
||||
if (isStartupTrigger)
|
||||
{
|
||||
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches version.json from blob storage and returns the version string and optional SHA-256 hash.
|
||||
/// Returns (null, null) if the check fails or times out.
|
||||
/// </summary>
|
||||
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileName}";
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
|
||||
{
|
||||
var json = await client.GetStringAsync(versionUrl);
|
||||
var obj = JObject.Parse(json);
|
||||
var version = obj["version"]?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
log("Self-update: version.json has no 'version' field");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var sha256 = obj["sha256"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(sha256))
|
||||
log($"Self-update: version.json has SHA-256 hash for integrity verification");
|
||||
|
||||
return (version.Trim(), sha256?.Trim());
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update");
|
||||
return (null, null);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
|
||||
{
|
||||
log("Self-update: version.json not found (404) — skipping update");
|
||||
return (null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: version check failed — {ex.Message}");
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the SHA-256 hash of the downloaded ZIP against the expected hash.
|
||||
/// Returns true if the hash matches or if no expected hash is available (backward compat).
|
||||
/// </summary>
|
||||
private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action<string> log)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedSha256))
|
||||
{
|
||||
log("Self-update: no SHA-256 hash available — skipping integrity check (backward compat)");
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var sha256 = SHA256.Create())
|
||||
using (var stream = File.OpenRead(zipPath))
|
||||
{
|
||||
var hashBytes = sha256.ComputeHash(stream);
|
||||
var actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
|
||||
|
||||
if (string.Equals(actualHash, expectedSha256.ToLowerInvariant(), StringComparison.Ordinal))
|
||||
{
|
||||
log("Self-update: SHA-256 integrity check passed");
|
||||
return true;
|
||||
}
|
||||
|
||||
log($"Self-update: SHA-256 MISMATCH — expected={expectedSha256}, actual={actualHash}. Aborting update.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: SHA-256 verification failed — {ex.Message}. Aborting update.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings. Returns true if latest is newer than current.
|
||||
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
|
||||
/// System.Version cannot handle them (e.g. "1.0.386+b7f8d3c..." would fail).
|
||||
/// </summary>
|
||||
private static bool IsNewerVersion(string current, string latest)
|
||||
{
|
||||
return CompareVersions(current, latest) > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings. Returns negative when latest is older than current,
|
||||
/// zero when equal, positive when latest is newer than current. Returns
|
||||
/// <see cref="int.MinValue"/> when either side is not a parseable <see cref="Version"/>
|
||||
/// (callers must treat this as "cannot compare" rather than an ordered result).
|
||||
/// Strips SemVer suffixes (+metadata, -prerelease) before parsing because
|
||||
/// System.Version cannot handle them.
|
||||
/// </summary>
|
||||
internal static int CompareVersions(string current, string latest)
|
||||
{
|
||||
if (!Version.TryParse(StripVersionSuffix(current), out var currentVer))
|
||||
return int.MinValue;
|
||||
if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer))
|
||||
return int.MinValue;
|
||||
|
||||
return latestVer.CompareTo(currentVer);
|
||||
}
|
||||
|
||||
private static string StripVersionSuffix(string version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return version;
|
||||
|
||||
var plusIndex = version.IndexOf('+');
|
||||
if (plusIndex >= 0)
|
||||
version = version.Substring(0, plusIndex);
|
||||
|
||||
var dashIndex = version.IndexOf('-');
|
||||
if (dashIndex >= 0)
|
||||
version = version.Substring(0, dashIndex);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the agent ZIP to a temp path. Returns false on failure.
|
||||
/// </summary>
|
||||
private static async Task<bool> DownloadZipAsync(string zipPath, int timeoutMs, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileName}";
|
||||
|
||||
// Clean up any previous download
|
||||
if (File.Exists(zipPath))
|
||||
File.Delete(zipPath);
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
|
||||
{
|
||||
using (var response = await client.GetAsync(zipUrl))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
using (var fileStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await response.Content.CopyToAsync(fileStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log($"Self-update: ZIP downloaded ({new FileInfo(zipPath).Length / 1024}KB)");
|
||||
return true;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
log($"Self-update: ZIP download timed out ({timeoutMs}ms) — aborting update");
|
||||
try { File.Delete(zipPath); } catch { }
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: ZIP download failed — {ex.Message}");
|
||||
try { File.Delete(zipPath); } catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the ZIP to the staging directory. Returns false on failure.
|
||||
/// </summary>
|
||||
private static bool ExtractToStaging(string zipPath, string stagingDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Clean up any previous staging
|
||||
if (Directory.Exists(stagingDir))
|
||||
Directory.Delete(stagingDir, true);
|
||||
|
||||
ZipFile.ExtractToDirectory(zipPath, stagingDir);
|
||||
log($"Self-update: ZIP extracted to staging ({Directory.GetFiles(stagingDir).Length} files)");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: ZIP extraction failed — {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swaps files from staging into the agent directory.
|
||||
/// For locked files: rename to .old (Windows allows renaming locked files), then copy new.
|
||||
/// Returns false if the swap fails critically.
|
||||
/// </summary>
|
||||
private static bool SwapFiles(string agentDir, string stagingDir, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stagedFiles = Directory.GetFiles(stagingDir);
|
||||
int swapped = 0;
|
||||
|
||||
foreach (var stagedFile in stagedFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(stagedFile);
|
||||
var targetPath = Path.Combine(agentDir, fileName);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
// Try direct overwrite first
|
||||
try
|
||||
{
|
||||
File.Copy(stagedFile, targetPath, overwrite: true);
|
||||
swapped++;
|
||||
continue;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// File is locked — use rename trick
|
||||
}
|
||||
|
||||
// Rename locked file to .old (Windows allows this even for locked files)
|
||||
var oldPath = targetPath + OldFileSuffix;
|
||||
if (File.Exists(oldPath))
|
||||
{
|
||||
try { File.Delete(oldPath); } catch { }
|
||||
}
|
||||
|
||||
File.Move(targetPath, oldPath);
|
||||
}
|
||||
|
||||
File.Copy(stagedFile, targetPath);
|
||||
swapped++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: failed to swap {fileName} — {ex.Message}");
|
||||
// Continue with other files; partial update is better than no update
|
||||
// for non-critical files (configs, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
log($"Self-update: swapped {swapped}/{stagedFiles.Length} files");
|
||||
|
||||
// Critical check: the main exe must have been swapped
|
||||
var mainExe = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
|
||||
if (!File.Exists(mainExe))
|
||||
{
|
||||
log("Self-update: CRITICAL — main exe missing after swap, aborting");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: file swap failed — {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the agent using PowerShell Wait-Process (same pattern as SummaryDialog.SelfCleanup).
|
||||
/// Wait-Process uses OS handles — zero polling, exits within milliseconds of process termination.
|
||||
/// </summary>
|
||||
private static void RestartAgent(string agentDir, Action<string> log)
|
||||
{
|
||||
var pid = Process.GetCurrentProcess().Id;
|
||||
var agentExePath = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe");
|
||||
|
||||
// PowerShell Wait-Process: waits for our process to actually exit (no polling),
|
||||
// then immediately starts the new agent. 30s timeout as safety net.
|
||||
var psScript = $"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; " +
|
||||
$"& '{agentExePath}'";
|
||||
var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript));
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.PowerShell,
|
||||
Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encoded}",
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
CreateNoWindow = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
Process.Start(psi);
|
||||
|
||||
log("Self-update: restart script launched, exiting current process");
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a small JSON marker so the next agent startup can emit an agent_version_check event
|
||||
/// with outcome=updated. Best-effort — failure here must not block the update. Includes phase
|
||||
/// timings, trigger reason, and exit timestamp so the downstream event can carry aggregable
|
||||
/// telemetry (downtime, total update duration, etc.).
|
||||
/// </summary>
|
||||
private static void WriteSelfUpdateMarker(
|
||||
string previousVersion,
|
||||
string newVersion,
|
||||
string triggerReason,
|
||||
long versionCheckMs,
|
||||
long downloadMs,
|
||||
long zipSizeBytes,
|
||||
long verifyMs,
|
||||
long extractMs,
|
||||
long swapMs,
|
||||
long totalUpdateMs,
|
||||
Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile);
|
||||
var dir = Path.GetDirectoryName(markerPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var payload = new JObject
|
||||
{
|
||||
["outcome"] = "updated",
|
||||
["previousVersion"] = previousVersion ?? "unknown",
|
||||
["newVersion"] = newVersion ?? "unknown",
|
||||
["triggerReason"] = triggerReason ?? "startup",
|
||||
["updatedAtUtc"] = now.ToString("O"),
|
||||
// exitAtUtc is written here — actual Environment.Exit happens a few hundred ms later
|
||||
// via PowerShell Wait-Process, which is close enough for downtime telemetry.
|
||||
["exitAtUtc"] = now.ToString("O"),
|
||||
["versionCheckMs"] = versionCheckMs,
|
||||
["downloadMs"] = downloadMs,
|
||||
["zipSizeBytes"] = zipSizeBytes,
|
||||
["verifyMs"] = verifyMs,
|
||||
["extractMs"] = extractMs,
|
||||
["swapMs"] = swapMs,
|
||||
["totalUpdateMs"] = totalUpdateMs
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log("Self-update: marker file written for next-startup event");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: could not write marker file — {ex.Message} (non-critical)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a small JSON marker so the next agent startup can emit an agent_version_check event
|
||||
/// with outcome=skipped (or check_failed if the version.json fetch itself failed). Only called
|
||||
/// on the startup trigger path (runtime-triggered failures are logged normally because the full
|
||||
/// logger is already up). Best-effort — never throws.
|
||||
/// </summary>
|
||||
private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
|
||||
var dir = Path.GetDirectoryName(markerPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var detail = errorDetail ?? string.Empty;
|
||||
if (detail.Length > 200) detail = detail.Substring(0, 200);
|
||||
|
||||
// version_check_failed = we never got a latestVersion; everything else = check succeeded
|
||||
// but the download/verify/extract/swap step failed.
|
||||
var outcome = string.Equals(reason, "version_check_failed", StringComparison.Ordinal)
|
||||
? "check_failed"
|
||||
: "skipped";
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["outcome"] = outcome,
|
||||
["reason"] = reason ?? "unknown",
|
||||
["currentVersion"] = currentVersion ?? "unknown",
|
||||
["latestVersion"] = latestVersion, // may be null if version check failed
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = detail
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log($"Self-update: skip marker written (outcome={outcome}, reason={reason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: could not write skip marker — {ex.Message} (non-critical)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a downgrade-blocked marker so the next agent startup can emit an
|
||||
/// <c>agent_version_check</c> event with <c>outcome=downgrade_blocked</c>. Uses the
|
||||
/// existing skipped-marker channel so <see cref="Core.Monitoring.Core.VersionCheckEventBuilder"/>
|
||||
/// picks it up alongside other skip reasons. Best-effort — never throws.
|
||||
/// </summary>
|
||||
private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
|
||||
var dir = Path.GetDirectoryName(markerPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["outcome"] = "downgrade_blocked",
|
||||
["reason"] = "downgrade_blocked",
|
||||
["currentVersion"] = currentVersion ?? "unknown",
|
||||
["latestVersion"] = latestVersion ?? "unknown",
|
||||
["triggerReason"] = triggerReason ?? "unknown",
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = string.Empty
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a small JSON marker on the happy path (current version is already up to date) so
|
||||
/// the next agent startup can emit an agent_version_check event with outcome=up_to_date.
|
||||
/// Subject to session-scoped dedup by the MonitoringService emitter. Best-effort — never throws.
|
||||
/// </summary>
|
||||
private static void WriteCheckedMarker(string currentVersion, string latestVersion, long versionCheckMs, Action<string> log)
|
||||
{
|
||||
try
|
||||
{
|
||||
var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile);
|
||||
var dir = Path.GetDirectoryName(markerPath);
|
||||
if (!Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["outcome"] = "up_to_date",
|
||||
["currentVersion"] = currentVersion ?? "unknown",
|
||||
["latestVersion"] = latestVersion ?? "unknown",
|
||||
["versionCheckMs"] = versionCheckMs,
|
||||
["checkedAtUtc"] = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
log("Self-update: checked marker written (up_to_date) for next-startup event");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: could not write checked marker — {ex.Message} (non-critical)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up staging directory and temp ZIP file.
|
||||
/// </summary>
|
||||
private static void CleanupStaging(string stagingDir, string zipPath)
|
||||
{
|
||||
try { if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, true); } catch { }
|
||||
try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a log line to the main agent log file (logger isn't initialized yet at update time).
|
||||
/// Uses the same date-based naming and format as AgentLogger: agent_YYYYMMDD.log
|
||||
/// </summary>
|
||||
private static void LogToFile(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
|
||||
var logPath = Path.Combine(logDir, logFileName);
|
||||
File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [INFO] {message}{Environment.NewLine}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort logging — never block on log failure
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes server-issued actions delivered via ingest responses.
|
||||
///
|
||||
/// Design notes:
|
||||
/// - Idempotent per type: each handler tolerates repeated delivery (at-least-once channel).
|
||||
/// - Unknown action types are logged as <c>server_action_unknown</c> and skipped — rolling out a new
|
||||
/// type on the server must not break older agents.
|
||||
/// - Every action emits two telemetry events: <c>server_action_received</c> on entry and either
|
||||
/// <c>server_action_executed</c> or <c>server_action_failed</c> on exit, so operators can audit the
|
||||
/// end-to-end lifecycle from backend App Insights → agent session events.
|
||||
/// - All three concrete handlers are passed in as callbacks so the dispatcher itself does not
|
||||
/// depend on the internal wiring of <see cref="RemoteConfigService"/>, <see cref="DiagnosticsPackageService"/>,
|
||||
/// or the orchestrator's shutdown sequence.
|
||||
/// </summary>
|
||||
public class ServerActionDispatcher
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<Task<bool>> _rotateConfigAsync;
|
||||
private readonly Func<string, Task<DiagnosticsUploadResult>> _uploadDiagnosticsAsync;
|
||||
private readonly Func<ServerAction, Task> _onTerminateRequested;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
|
||||
/// <param name="rotateConfigAsync">
|
||||
/// Refetch remote config from the backend and apply to the agent's live state.
|
||||
/// Return true on success, false on failure (the handler will emit the appropriate telemetry).
|
||||
/// Typically wired to <c>RemoteConfigService.FetchConfigAsync</c>.
|
||||
/// </param>
|
||||
/// <param name="uploadDiagnosticsAsync">
|
||||
/// Trigger a diagnostics package upload with the given file-name suffix. Return the result
|
||||
/// (success/failure, blob name, error code). Typically wired to
|
||||
/// <c>DiagnosticsPackageService.CreateAndUploadAsync</c>.
|
||||
/// </param>
|
||||
/// <param name="onTerminateRequested">
|
||||
/// Called for <c>terminate_session</c> actions. The dispatcher does NOT execute the shutdown
|
||||
/// itself — shutdown requires coordinating timers, spool flush, and cleanup that lives in the
|
||||
/// orchestrator. The callback is responsible for the actual termination sequence.
|
||||
/// </param>
|
||||
public ServerActionDispatcher(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
Func<Task<bool>> rotateConfigAsync,
|
||||
Func<string, Task<DiagnosticsUploadResult>> uploadDiagnosticsAsync,
|
||||
Func<ServerAction, Task> onTerminateRequested,
|
||||
Action<EnrollmentEvent> emitEvent)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_rotateConfigAsync = rotateConfigAsync;
|
||||
_uploadDiagnosticsAsync = uploadDiagnosticsAsync;
|
||||
_onTerminateRequested = onTerminateRequested;
|
||||
_emitEvent = emitEvent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a batch of actions to their handlers. Processes sequentially — ordering is defined
|
||||
/// by the order actions were queued on the server. A failing handler does not abort the batch;
|
||||
/// subsequent actions still run so a bad rotate_config doesn't block a terminate_session.
|
||||
/// </summary>
|
||||
public virtual async Task DispatchAsync(List<ServerAction> actions)
|
||||
{
|
||||
if (actions == null || actions.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
if (action == null) continue;
|
||||
|
||||
EmitReceived(action);
|
||||
|
||||
try
|
||||
{
|
||||
switch (action.Type?.ToLowerInvariant())
|
||||
{
|
||||
case ServerActionTypes.RotateConfig:
|
||||
await HandleRotateConfigAsync(action);
|
||||
break;
|
||||
|
||||
case ServerActionTypes.RequestDiagnostics:
|
||||
await HandleRequestDiagnosticsAsync(action);
|
||||
break;
|
||||
|
||||
case ServerActionTypes.TerminateSession:
|
||||
if (_onTerminateRequested != null)
|
||||
await _onTerminateRequested(action);
|
||||
else
|
||||
EmitFailed(action, "no_terminate_handler_wired");
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.Warning($"ServerActionDispatcher: unknown action type '{action.Type}' — skipping");
|
||||
EmitFailed(action, "unknown_action_type");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"ServerActionDispatcher: handler for '{action.Type}' threw", ex);
|
||||
EmitFailed(action, ex.GetType().Name + ": " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRotateConfigAsync(ServerAction action)
|
||||
{
|
||||
if (_rotateConfigAsync == null)
|
||||
{
|
||||
EmitFailed(action, "no_rotate_config_handler");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"ServerAction rotate_config: refetching remote config (reason: {action.Reason})");
|
||||
var ok = await _rotateConfigAsync();
|
||||
if (ok)
|
||||
EmitExecuted(action);
|
||||
else
|
||||
EmitFailed(action, "config_fetch_failed");
|
||||
}
|
||||
|
||||
private async Task HandleRequestDiagnosticsAsync(ServerAction action)
|
||||
{
|
||||
if (_uploadDiagnosticsAsync == null)
|
||||
{
|
||||
EmitFailed(action, "no_diagnostics_handler");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"ServerAction request_diagnostics: initiating best-effort upload (reason: {action.Reason})");
|
||||
var result = await _uploadDiagnosticsAsync("server-requested");
|
||||
if (result != null && result.Success)
|
||||
{
|
||||
EmitExecuted(action, extraData: new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty }
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
EmitFailed(action, result?.ErrorCode ?? "diagnostics_upload_failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Telemetry helpers ----
|
||||
|
||||
private void EmitReceived(ServerAction action)
|
||||
{
|
||||
_emitEvent?.Invoke(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = "server_action_received",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ServerActionDispatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Received server action '{action.Type}'",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Data = BuildTelemetryData(action)
|
||||
});
|
||||
}
|
||||
|
||||
private void EmitExecuted(ServerAction action, Dictionary<string, object> extraData = null)
|
||||
{
|
||||
var data = BuildTelemetryData(action);
|
||||
if (extraData != null)
|
||||
{
|
||||
foreach (var kvp in extraData)
|
||||
data[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
_emitEvent?.Invoke(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = "server_action_executed",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ServerActionDispatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Executed server action '{action.Type}'",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private void EmitFailed(ServerAction action, string reason)
|
||||
{
|
||||
var data = BuildTelemetryData(action);
|
||||
data["failureReason"] = reason ?? string.Empty;
|
||||
|
||||
_emitEvent?.Invoke(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = "server_action_failed",
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "ServerActionDispatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Failed to execute server action '{action.Type}': {reason}",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> BuildTelemetryData(ServerAction action)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "actionType", action?.Type ?? string.Empty },
|
||||
{ "reason", action?.Reason ?? string.Empty },
|
||||
{ "ruleId", action?.RuleId ?? string.Empty },
|
||||
{ "queuedAt", action?.QueuedAt.ToString("O") ?? string.Empty }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages persistent session ID storage to maintain tracking across agent restarts
|
||||
/// </summary>
|
||||
public class SessionPersistence
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum age (hours) of a session.id file before the orphan guard treats it as
|
||||
/// stale and discards it. Matches <c>AgentConfiguration.AbsoluteMaxSessionHours</c>.
|
||||
/// A session.id younger than this whose session.created is missing is assumed to be
|
||||
/// from the current enrollment (file lost during reboot/crash) and will be recovered.
|
||||
/// </summary>
|
||||
internal const double OrphanGuardMaxAgeHours = 48;
|
||||
|
||||
private readonly string _sessionFilePath;
|
||||
private readonly string _sequenceFilePath;
|
||||
private readonly string _whiteGloveMarkerPath;
|
||||
private readonly string _sessionCreatedPath;
|
||||
private readonly object _lockObject = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Protected constructor for testability (Moq proxy creation).
|
||||
/// </summary>
|
||||
protected SessionPersistence() { }
|
||||
|
||||
public SessionPersistence(string dataDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataDirectory))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(dataDirectory));
|
||||
}
|
||||
|
||||
if (!Directory.Exists(dataDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
}
|
||||
|
||||
_sessionFilePath = Path.Combine(dataDirectory, "session.id");
|
||||
_sequenceFilePath = Path.Combine(dataDirectory, "session.seq");
|
||||
_whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete");
|
||||
_sessionCreatedPath = Path.Combine(dataDirectory, "session.created");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads existing session ID from disk or creates a new one if none exists
|
||||
/// </summary>
|
||||
/// <returns>Session ID (GUID string)</returns>
|
||||
public string LoadOrCreateSessionId()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if session file exists and is valid
|
||||
if (File.Exists(_sessionFilePath))
|
||||
{
|
||||
// Guard: if session.id exists but session.created does not, check
|
||||
// whether this is an orphaned session from a previous enrollment
|
||||
// (ProgramData survived OS reset/reinstall) or a current session
|
||||
// whose session.created was lost during a reboot/crash.
|
||||
// Distinguish by file age: recent session.id (< OrphanGuardMaxAgeHours)
|
||||
// is likely from the current enrollment → recover by initializing
|
||||
// session.created and resuming. Old session.id is a true orphan → discard.
|
||||
if (!File.Exists(_sessionCreatedPath))
|
||||
{
|
||||
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(_sessionFilePath);
|
||||
if (fileAge.TotalHours < OrphanGuardMaxAgeHours)
|
||||
{
|
||||
// Recent session — recover: initialize session.created and resume
|
||||
var sessionId = File.ReadAllText(_sessionFilePath).Trim();
|
||||
if (Guid.TryParse(sessionId, out _))
|
||||
{
|
||||
SaveSessionCreatedAt(DateTime.UtcNow);
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Old session or invalid GUID — true orphan, discard
|
||||
try { File.Delete(_sessionFilePath); } catch { }
|
||||
try { File.Delete(_sequenceFilePath); } catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
var sessionId = File.ReadAllText(_sessionFilePath).Trim();
|
||||
|
||||
// Validate it's a valid GUID format
|
||||
if (Guid.TryParse(sessionId, out _))
|
||||
{
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session ID if file doesn't exist or is invalid
|
||||
var newSessionId = Guid.NewGuid().ToString();
|
||||
File.WriteAllText(_sessionFilePath, newSessionId);
|
||||
SaveSessionCreatedAt(DateTime.UtcNow);
|
||||
return newSessionId;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If we can't read/write the session file, generate a new ID in memory
|
||||
// Log this error if logger is available
|
||||
throw new InvalidOperationException($"Failed to load or create session ID at {_sessionFilePath}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the persisted session ID file
|
||||
/// This should be called when enrollment is complete
|
||||
/// </summary>
|
||||
public virtual void DeleteSession()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_sessionFilePath))
|
||||
File.Delete(_sessionFilePath);
|
||||
if (File.Exists(_sequenceFilePath))
|
||||
File.Delete(_sequenceFilePath);
|
||||
if (File.Exists(_whiteGloveMarkerPath))
|
||||
File.Delete(_whiteGloveMarkerPath);
|
||||
if (File.Exists(_sessionCreatedPath))
|
||||
File.Delete(_sessionCreatedPath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Suppress deletion errors - not critical if cleanup fails
|
||||
// The file will be overwritten on next start anyway
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a persisted session ID exists
|
||||
/// </summary>
|
||||
/// <returns>True if session file exists</returns>
|
||||
public bool SessionExists()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return File.Exists(_sessionFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the persisted sequence counter. Returns 0 if no file exists or content is invalid.
|
||||
/// </summary>
|
||||
public long LoadSequence()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_sequenceFilePath))
|
||||
{
|
||||
var content = File.ReadAllText(_sequenceFilePath).Trim();
|
||||
if (long.TryParse(content, out var sequence) && sequence >= 0)
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the current sequence counter to disk.
|
||||
/// Called periodically and before graceful shutdown (WhiteGlove).
|
||||
/// </summary>
|
||||
public virtual void SaveSequence(long sequence)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try { File.WriteAllText(_sequenceFilePath, sequence.ToString()); }
|
||||
catch { } // Non-fatal: worst case, next boot starts with a small gap
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the persisted sequence file.
|
||||
/// Called alongside DeleteSession when enrollment completes.
|
||||
/// </summary>
|
||||
public void DeleteSequence()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try { if (File.Exists(_sequenceFilePath)) File.Delete(_sequenceFilePath); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a marker indicating WhiteGlove Part 1 completed.
|
||||
/// On the next boot the agent reads this marker, emits a whiteglove_resumed
|
||||
/// event, and clears it — giving the backend an explicit signal to transition
|
||||
/// the session from Pending to InProgress for Part 2.
|
||||
/// </summary>
|
||||
public virtual void SaveWhiteGloveComplete()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try { File.WriteAllText(_whiteGloveMarkerPath, "1"); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a WhiteGlove Part 1 marker exists, indicating this boot is Part 2.
|
||||
/// </summary>
|
||||
public bool IsWhiteGloveResume()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return File.Exists(_whiteGloveMarkerPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the session creation timestamp (UTC). Called once when a new session is created.
|
||||
/// </summary>
|
||||
public void SaveSessionCreatedAt(DateTime utcTimestamp)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try { File.WriteAllText(_sessionCreatedPath, utcTimestamp.ToString("O")); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the persisted session creation timestamp. Returns null if file is missing or invalid.
|
||||
/// </summary>
|
||||
public DateTime? LoadSessionCreatedAt()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_sessionCreatedPath))
|
||||
{
|
||||
var content = File.ReadAllText(_sessionCreatedPath).Trim();
|
||||
if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt))
|
||||
return dt.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the session creation timestamp to now (UTC).
|
||||
/// Used on WhiteGlove Part 2 resume to give the user enrollment phase a fresh timer.
|
||||
/// </summary>
|
||||
public void ResetSessionCreatedAt()
|
||||
{
|
||||
SaveSessionCreatedAt(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the WhiteGlove marker after the whiteglove_resumed event has been emitted.
|
||||
/// </summary>
|
||||
public void ClearWhiteGloveComplete()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
try { if (File.Exists(_whiteGloveMarkerPath)) File.Delete(_whiteGloveMarkerPath); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the custom %LOGGED_ON_USER_PROFILE% token to the logged-on user's profile path.
|
||||
///
|
||||
/// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or
|
||||
/// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class
|
||||
/// detects the real user via explorer.exe ownership (WMI) and caches the result for
|
||||
/// the agent's lifetime.
|
||||
///
|
||||
/// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log
|
||||
/// </summary>
|
||||
public static class UserProfileResolver
|
||||
{
|
||||
public const string Token = "%LOGGED_ON_USER_PROFILE%";
|
||||
|
||||
private static readonly object Lock = new object();
|
||||
private static bool _resolved;
|
||||
private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected
|
||||
|
||||
/// <summary>
|
||||
/// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null
|
||||
/// if no interactive user session has been detected yet.
|
||||
/// Result is cached after first successful detection.
|
||||
/// </summary>
|
||||
public static string GetLoggedOnUserProfilePath()
|
||||
{
|
||||
if (_resolved)
|
||||
return _userProfilePath;
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
if (_resolved)
|
||||
return _userProfilePath;
|
||||
|
||||
_userProfilePath = DetectLoggedOnUserProfile();
|
||||
_resolved = true;
|
||||
return _userProfilePath;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands the custom %LOGGED_ON_USER_PROFILE% token, then delegates to
|
||||
/// Environment.ExpandEnvironmentVariables for standard tokens.
|
||||
/// Returns null if the path contains the token but no user is logged on.
|
||||
/// </summary>
|
||||
public static string ExpandCustomTokens(string rawPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
return rawPath;
|
||||
|
||||
if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
var profilePath = GetLoggedOnUserProfilePath();
|
||||
if (profilePath == null)
|
||||
return null; // No user detected — caller should skip this path
|
||||
|
||||
rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath);
|
||||
}
|
||||
|
||||
return Environment.ExpandEnvironmentVariables(rawPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the raw (unexpanded) path contains the custom user profile token.
|
||||
/// </summary>
|
||||
public static bool ContainsUserProfileToken(string rawPath)
|
||||
{
|
||||
return !string.IsNullOrEmpty(rawPath) &&
|
||||
rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the cached state. Only used for testing.
|
||||
/// </summary>
|
||||
internal static void Reset()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_resolved = false;
|
||||
_userProfilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allows tests to inject a specific profile path without WMI.
|
||||
/// </summary>
|
||||
internal static void SetForTesting(string profilePath)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
_userProfilePath = profilePath;
|
||||
_resolved = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectLoggedOnUserProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
var explorerProcesses = Process.GetProcessesByName("explorer");
|
||||
foreach (var proc in explorerProcesses)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Session 0 = SYSTEM session, skip
|
||||
if (proc.SessionId == 0)
|
||||
continue;
|
||||
|
||||
var userName = GetProcessOwnerUserName(proc.Id);
|
||||
if (userName == null)
|
||||
continue;
|
||||
|
||||
if (DesktopArrivalDetector.IsExcludedUser(userName))
|
||||
continue;
|
||||
|
||||
// Extract just the username part (after backslash if DOMAIN\User)
|
||||
var backslashIndex = userName.LastIndexOf('\\');
|
||||
if (backslashIndex >= 0 && backslashIndex < userName.Length - 1)
|
||||
userName = userName.Substring(backslashIndex + 1);
|
||||
|
||||
var profilePath = Path.Combine(@"C:\Users", userName);
|
||||
if (Directory.Exists(profilePath))
|
||||
return profilePath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Continue checking other explorer instances
|
||||
}
|
||||
finally
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WMI or process enumeration failure — no user detected
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owner of a process using WMI (Win32_Process.GetOwner).
|
||||
/// Returns "DOMAIN\User" or "User" string, or null on failure.
|
||||
/// </summary>
|
||||
private static string GetProcessOwnerUserName(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var outParams = new object[2];
|
||||
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
|
||||
if (result == 0)
|
||||
{
|
||||
var user = outParams[0]?.ToString();
|
||||
var domain = outParams[1]?.ToString();
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WMI failure — return null
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReplaceCaseInsensitive(string source, string oldValue, string newValue)
|
||||
{
|
||||
var index = source.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0) return source;
|
||||
return source.Substring(0, index) + newValue + source.Substring(index + oldValue.Length);
|
||||
}
|
||||
}
|
||||
}
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Launches a process in the logged-in user's desktop session from a SYSTEM context.
|
||||
/// Uses WTSQueryUserToken + DuplicateTokenEx + SetTokenInformation(TokenSessionId)
|
||||
/// to create a session-stamped token, then CreateProcessAsUser to launch in the
|
||||
/// correct interactive session (not Session 0).
|
||||
/// </summary>
|
||||
internal static class UserSessionProcessLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// System/service account names that should NOT be considered real users.
|
||||
/// </summary>
|
||||
private static readonly string[] ExcludedUserNames =
|
||||
{
|
||||
"SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE",
|
||||
"DefaultUser0", "DefaultUser1", "DefaultUser2",
|
||||
"defaultuser0", "defaultuser1", "defaultuser2"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Launches the specified executable in the logged-in user's desktop session.
|
||||
/// Returns true if the process was launched successfully, false otherwise.
|
||||
/// This is fire-and-forget — the caller does not wait for the process to exit.
|
||||
/// If the process fails with 0x8007045A (DLL init failed), retries for up to maxRetrySeconds.
|
||||
/// </summary>
|
||||
public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger, int maxRetrySeconds = 120)
|
||||
{
|
||||
IntPtr userToken = IntPtr.Zero;
|
||||
IntPtr dupToken = IntPtr.Zero;
|
||||
IntPtr environment = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
// Find explorer.exe in a non-zero session owned by a real user.
|
||||
var explorerProcess = FindExplorerProcess(logger);
|
||||
if (explorerProcess == null)
|
||||
{
|
||||
logger.Warning("UserSessionProcessLauncher: No explorer.exe found in user session — cannot launch dialog");
|
||||
return false;
|
||||
}
|
||||
|
||||
uint sessionId;
|
||||
using (explorerProcess)
|
||||
{
|
||||
sessionId = (uint)explorerProcess.SessionId;
|
||||
logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}");
|
||||
}
|
||||
|
||||
// Get the interactive session token via WTS.
|
||||
if (!ProcessNativeMethods.WTSQueryUserToken(sessionId, out userToken))
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
logger.Warning($"UserSessionProcessLauncher: WTSQueryUserToken failed for session {sessionId} (error {error})");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Duplicate token and stamp the target session ID.
|
||||
// WTSQueryUserToken gives us the user's identity, but CreateProcessAsUser
|
||||
// needs TokenSessionId explicitly set to avoid Session 0 placement
|
||||
// (the calling service runs in Session 0; without stamping, the child
|
||||
// process inherits Session 0 even though the token belongs to the user).
|
||||
if (!ProcessNativeMethods.DuplicateTokenEx(userToken, ProcessNativeMethods.MAXIMUM_ALLOWED, IntPtr.Zero,
|
||||
ProcessNativeMethods.SECURITY_IMPERSONATION, ProcessNativeMethods.TOKEN_PRIMARY, out dupToken))
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: DuplicateTokenEx failed (error {Marshal.GetLastWin32Error()})");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ProcessNativeMethods.SetTokenInformation(dupToken, ProcessNativeMethods.TokenSessionId,
|
||||
ref sessionId, (uint)Marshal.SizeOf(typeof(uint))))
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: SetTokenInformation(SessionId={sessionId}) failed (error {Marshal.GetLastWin32Error()})");
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.Info($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}");
|
||||
|
||||
// Create an environment block for the user (using session-stamped token)
|
||||
if (!ProcessNativeMethods.CreateEnvironmentBlock(out environment, dupToken, false))
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: CreateEnvironmentBlock failed (error {Marshal.GetLastWin32Error()})");
|
||||
environment = IntPtr.Zero;
|
||||
}
|
||||
|
||||
// Set up startup info — empty lpDesktop lets Windows select the correct
|
||||
// desktop for the session-stamped token.
|
||||
var si = new ProcessNativeMethods.STARTUPINFO();
|
||||
si.cb = Marshal.SizeOf(si);
|
||||
si.lpDesktop = "";
|
||||
si.dwFlags = ProcessNativeMethods.STARTF_USESHOWWINDOW | ProcessNativeMethods.STARTF_FORCEONFEEDBACK;
|
||||
si.wShowWindow = ProcessNativeMethods.SW_SHOW;
|
||||
|
||||
uint creationFlags = 0;
|
||||
if (environment != IntPtr.Zero)
|
||||
creationFlags |= ProcessNativeMethods.CREATE_UNICODE_ENVIRONMENT;
|
||||
|
||||
var commandLine = $"\"{exePath}\" {arguments}";
|
||||
var workingDirectory = System.IO.Path.GetDirectoryName(exePath);
|
||||
|
||||
// Retry loop with integrated CXH monitoring.
|
||||
// See WaitForCxhOrSleep for the CXH wake-up strategy.
|
||||
const int retryIntervalSeconds = 10;
|
||||
const string defaultDesktop = "";
|
||||
const string explicitDesktop = "WinSta0\\Default";
|
||||
const int defaultDesktopOnlyAttempts = 3;
|
||||
var retryStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
int attempt = 0;
|
||||
|
||||
// One-time CXH lookup — track for the lifetime of the retry loop
|
||||
Process cxhProcess = null;
|
||||
if (maxRetrySeconds > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId);
|
||||
if (cxhProcess != null)
|
||||
logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " +
|
||||
$"in session {sessionId} — will monitor for exit during retries");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
attempt++;
|
||||
|
||||
// First N attempts use default desktop (""), then alternate with explicit desktop.
|
||||
// Default works ~90% of the time; explicit fallback catches post-Hello desktop issues.
|
||||
bool useExplicitDesktop = (attempt > defaultDesktopOnlyAttempts) && (attempt % 2 == 0);
|
||||
si.lpDesktop = useExplicitDesktop ? explicitDesktop : defaultDesktop;
|
||||
|
||||
// CreateProcessAsUser with session-stamped token.
|
||||
// The duplicated token has TokenSessionId set to the user's session,
|
||||
// ensuring the process lands on the interactive desktop (not Session 0).
|
||||
bool created;
|
||||
ProcessNativeMethods.PROCESS_INFORMATION pi;
|
||||
|
||||
var procSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES();
|
||||
procSa.nLength = Marshal.SizeOf(procSa);
|
||||
var threadSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES();
|
||||
threadSa.nLength = Marshal.SizeOf(threadSa);
|
||||
|
||||
created = ProcessNativeMethods.CreateProcessAsUser(
|
||||
dupToken, null, commandLine,
|
||||
ref procSa, ref threadSa, false,
|
||||
creationFlags, environment, workingDirectory,
|
||||
ref si, out pi);
|
||||
|
||||
if (!created)
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})");
|
||||
return false;
|
||||
}
|
||||
|
||||
ProcessNativeMethods.CloseHandle(pi.hThread);
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" +
|
||||
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop], verifying startup...");
|
||||
|
||||
// Wait 3s to verify the process survives DLL init + CLR bootstrap.
|
||||
var waitResult = ProcessNativeMethods.WaitForSingleObject(pi.hProcess, 3000);
|
||||
if (waitResult == ProcessNativeMethods.WAIT_TIMEOUT)
|
||||
{
|
||||
// Process is still running after 3s — success
|
||||
ProcessNativeMethods.CloseHandle(pi.hProcess);
|
||||
logger.Info($"UserSessionProcessLauncher: Successfully launched PID {pi.dwProcessId} in user session" +
|
||||
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" +
|
||||
(attempt > 1 ? $" (after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process exited within 3s — check why
|
||||
ProcessNativeMethods.GetExitCodeProcess(pi.hProcess, out var exitCode);
|
||||
ProcessNativeMethods.CloseHandle(pi.hProcess);
|
||||
|
||||
// 0x8007045A = ERROR_DLL_INIT_FAILED — profile or desktop issue.
|
||||
// Retry until the desktop becomes accessible or we exceed the retry window.
|
||||
if (exitCode == 0x8007045A && retryStopwatch.Elapsed.TotalSeconds < maxRetrySeconds)
|
||||
{
|
||||
// If CXH is tracked and still running, wait for its exit instead of blind sleep.
|
||||
// On 24H2+/≤22H2: CXH exits → we wake up instantly.
|
||||
// On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval.
|
||||
if (cxhProcess != null)
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)...");
|
||||
if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000))
|
||||
{
|
||||
logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available");
|
||||
cxhProcess.Dispose();
|
||||
cxhProcess = null;
|
||||
System.Threading.Thread.Sleep(500); // brief delay for desktop init
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s...");
|
||||
System.Threading.Thread.Sleep(retryIntervalSeconds * 1000);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-retryable error, or retry timeout exceeded
|
||||
logger.Warning($"UserSessionProcessLauncher: Process PID {pi.dwProcessId} exited within 3s — " +
|
||||
$"exit code 0x{exitCode:X8}" +
|
||||
$" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" +
|
||||
(attempt > 1 ? $" (gave up after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : ""));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cxhProcess?.Dispose();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"UserSessionProcessLauncher: Exception: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (environment != IntPtr.Zero)
|
||||
ProcessNativeMethods.DestroyEnvironmentBlock(environment);
|
||||
if (dupToken != IntPtr.Zero)
|
||||
ProcessNativeMethods.CloseHandle(dupToken);
|
||||
if (userToken != IntPtr.Zero)
|
||||
ProcessNativeMethods.CloseHandle(userToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a process by name running in a specific session.
|
||||
/// Returns the first matching Process handle, or null if none found.
|
||||
/// </summary>
|
||||
private static Process FindProcessInSession(string processName, uint sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processes = Process.GetProcessesByName(processName);
|
||||
foreach (var proc in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((uint)proc.SessionId == sessionId)
|
||||
{
|
||||
foreach (var other in processes)
|
||||
{
|
||||
if (other.Id != proc.Id)
|
||||
try { other.Dispose(); } catch { }
|
||||
}
|
||||
return proc;
|
||||
}
|
||||
proc.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds an explorer.exe process running in a non-SYSTEM user session.
|
||||
/// Returns the Process handle, or null if none found.
|
||||
/// </summary>
|
||||
private static Process FindExplorerProcess(AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var explorerProcesses = Process.GetProcessesByName("explorer");
|
||||
foreach (var proc in explorerProcesses)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (proc.SessionId == 0)
|
||||
{
|
||||
proc.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
var owner = GetProcessOwner(proc.Id);
|
||||
if (owner == null || IsExcludedUser(owner))
|
||||
{
|
||||
proc.Dispose();
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var other in explorerProcesses)
|
||||
{
|
||||
if (other.Id != proc.Id)
|
||||
{
|
||||
try { other.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
return proc;
|
||||
}
|
||||
catch
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Debug($"UserSessionProcessLauncher: Error finding explorer.exe: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetProcessOwner(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var outParams = new object[2];
|
||||
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
|
||||
if (result == 0)
|
||||
{
|
||||
var user = outParams[0]?.ToString();
|
||||
var domain = outParams[1]?.ToString();
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsExcludedUser(string fullUserName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fullUserName))
|
||||
return true;
|
||||
|
||||
var userName = fullUserName;
|
||||
var backslashIndex = fullUserName.LastIndexOf('\\');
|
||||
if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1)
|
||||
userName = fullUserName.Substring(backslashIndex + 1);
|
||||
|
||||
foreach (var excluded in ExcludedUserNames)
|
||||
{
|
||||
if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Consumes the three self-update marker files written by SelfUpdater on every agent startup
|
||||
/// (updated / skipped / checked) and converts them into a single <c>agent_version_check</c>
|
||||
/// event with an <c>outcome</c> discriminator. Applies session-scoped dedup for the
|
||||
/// up_to_date outcome so repeated reboots in the same session don't spam the timeline.
|
||||
///
|
||||
/// All filesystem paths are injectable so this can be exercised in unit tests without touching
|
||||
/// %ProgramData%.
|
||||
/// </summary>
|
||||
public static class VersionCheckEventBuilder
|
||||
{
|
||||
public sealed class Paths
|
||||
{
|
||||
public string UpdatedMarker { get; set; }
|
||||
public string SkippedMarker { get; set; }
|
||||
public string CheckedMarker { get; set; }
|
||||
public string LastEmit { get; set; }
|
||||
|
||||
public static Paths Default() => new Paths
|
||||
{
|
||||
UpdatedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile),
|
||||
SkippedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile),
|
||||
CheckedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile),
|
||||
LastEmit = Environment.ExpandEnvironmentVariables(Constants.LastVersionCheckEmitFile)
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class BuildResult
|
||||
{
|
||||
/// <summary>Event to emit; null when nothing to report (no marker) or when deduped.</summary>
|
||||
public EnrollmentEvent Event { get; set; }
|
||||
|
||||
/// <summary>True when a marker was read but the event was suppressed by dedup.</summary>
|
||||
public bool Deduped { get; set; }
|
||||
|
||||
/// <summary>Outcome that was detected (even if deduped), for logging. Null when no marker.</summary>
|
||||
public string Outcome { get; set; }
|
||||
|
||||
/// <summary>Human-readable message for warning logs when a marker file was malformed.</summary>
|
||||
public string ParseError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads available markers, applies dedup, updates the last-emit file and deletes consumed markers.
|
||||
/// Returns the event to emit (or null). Never throws — errors are returned via ParseError.
|
||||
///
|
||||
/// Priority: Updated > Skipped > Checked. If more than one marker exists (rare — stale files
|
||||
/// from a crashed run), the highest-priority one wins and the others are deleted defensively.
|
||||
/// </summary>
|
||||
public static BuildResult TryBuild(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
DateTime agentStartTimeUtc,
|
||||
Paths paths = null)
|
||||
{
|
||||
paths = paths ?? Paths.Default();
|
||||
var result = new BuildResult();
|
||||
|
||||
try
|
||||
{
|
||||
// Priority order — once consumed, all three are deleted.
|
||||
if (File.Exists(paths.UpdatedMarker))
|
||||
{
|
||||
result.Outcome = "updated";
|
||||
result.Event = BuildUpdatedEvent(paths.UpdatedMarker, sessionId, tenantId, agentStartTimeUtc);
|
||||
}
|
||||
else if (File.Exists(paths.SkippedMarker))
|
||||
{
|
||||
var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome);
|
||||
result.Outcome = skipOutcome;
|
||||
|
||||
// Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path
|
||||
// re-fires on every agent restart, and each restart would otherwise emit another
|
||||
// Warning event for the same current→latest pair. Suppress duplicates within the
|
||||
// same session when the same downgrade is advertised by the backend.
|
||||
if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal))
|
||||
{
|
||||
var latestVersion = ExtractLatestVersion(skipEvent);
|
||||
var lastEmit = TryReadLastEmit(paths.LastEmit);
|
||||
if (lastEmit != null &&
|
||||
string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) &&
|
||||
string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) &&
|
||||
string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal))
|
||||
{
|
||||
result.Deduped = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Event = skipEvent;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Event = skipEvent;
|
||||
}
|
||||
}
|
||||
else if (File.Exists(paths.CheckedMarker))
|
||||
{
|
||||
result.Outcome = "up_to_date";
|
||||
var marker = ReadMarker(paths.CheckedMarker);
|
||||
var latestVersion = (string)marker["latestVersion"] ?? "unknown";
|
||||
|
||||
// Session-scoped dedup: suppress repeated up_to_date events for the same
|
||||
// latestVersion within the same session. Update/Skip/Failure are never deduped.
|
||||
var lastEmit = TryReadLastEmit(paths.LastEmit);
|
||||
if (lastEmit != null &&
|
||||
string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) &&
|
||||
string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) &&
|
||||
string.Equals(lastEmit.Outcome, "up_to_date", StringComparison.Ordinal))
|
||||
{
|
||||
result.Deduped = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Event = BuildUpToDateEvent(marker, sessionId, tenantId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return result; // no marker — nothing to do
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.ParseError = ex.Message;
|
||||
}
|
||||
|
||||
// Persist the last-emit state before cleanup so a crash right after emit doesn't re-emit.
|
||||
if (result.Event != null)
|
||||
{
|
||||
var latestVersion = ExtractLatestVersion(result.Event);
|
||||
TryWriteLastEmit(paths.LastEmit, sessionId, latestVersion, result.Outcome);
|
||||
}
|
||||
|
||||
// Always delete markers once consumed (or attempted) — stale markers would cause loops.
|
||||
TryDelete(paths.UpdatedMarker);
|
||||
TryDelete(paths.SkippedMarker);
|
||||
TryDelete(paths.CheckedMarker);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Individual builders ─────────────────────────────────────────────
|
||||
|
||||
private static EnrollmentEvent BuildUpdatedEvent(string path, string sessionId, string tenantId, DateTime agentStartTimeUtc)
|
||||
{
|
||||
var marker = ReadMarker(path);
|
||||
|
||||
var previousVersion = (string)marker["previousVersion"] ?? "unknown";
|
||||
var newVersion = (string)marker["newVersion"] ?? "unknown";
|
||||
var updatedAtUtc = (string)marker["updatedAtUtc"] ?? "unknown";
|
||||
var triggerReason = (string)marker["triggerReason"] ?? "startup";
|
||||
var exitAtUtc = (string)marker["exitAtUtc"];
|
||||
|
||||
long? downtimeMs = null;
|
||||
if (!string.IsNullOrEmpty(exitAtUtc) &&
|
||||
DateTime.TryParse(exitAtUtc, System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.RoundtripKind, out var exitDt))
|
||||
{
|
||||
downtimeMs = (long)(agentStartTimeUtc - exitDt).TotalMilliseconds;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "outcome", "updated" },
|
||||
{ "previousVersion", previousVersion },
|
||||
{ "newVersion", newVersion },
|
||||
{ "latestVersion", newVersion },
|
||||
{ "updatedAtUtc", updatedAtUtc },
|
||||
{ "triggerReason", triggerReason }
|
||||
};
|
||||
if (exitAtUtc != null) data["exitAtUtc"] = exitAtUtc;
|
||||
CopyLong(marker, "versionCheckMs", data);
|
||||
CopyLong(marker, "downloadMs", data);
|
||||
CopyLong(marker, "zipSizeBytes", data);
|
||||
CopyLong(marker, "verifyMs", data);
|
||||
CopyLong(marker, "extractMs", data);
|
||||
CopyLong(marker, "swapMs", data);
|
||||
CopyLong(marker, "totalUpdateMs", data);
|
||||
if (downtimeMs.HasValue) data["downtimeMs"] = downtimeMs.Value;
|
||||
|
||||
var severity = string.Equals(triggerReason, "runtime_hash_mismatch", StringComparison.Ordinal)
|
||||
? EventSeverity.Warning
|
||||
: EventSeverity.Info;
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = tenantId,
|
||||
EventType = Constants.EventTypes.AgentVersionCheck,
|
||||
Severity = severity,
|
||||
Source = "Agent",
|
||||
Message = $"Agent self-updated from {previousVersion} to {newVersion} (trigger={triggerReason})",
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
};
|
||||
}
|
||||
|
||||
private static EnrollmentEvent BuildSkippedEvent(string path, string sessionId, string tenantId, out string outcome)
|
||||
{
|
||||
var marker = ReadMarker(path);
|
||||
|
||||
var reason = (string)marker["reason"] ?? "unknown";
|
||||
var currentVersion = (string)marker["currentVersion"] ?? "unknown";
|
||||
var latestVersion = (string)marker["latestVersion"];
|
||||
var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown";
|
||||
var errorDetail = (string)marker["errorDetail"] ?? string.Empty;
|
||||
var triggerReason = (string)marker["triggerReason"];
|
||||
|
||||
// Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers
|
||||
// lying around across an upgrade, derive it from the reason.
|
||||
outcome = (string)marker["outcome"]
|
||||
?? (string.Equals(reason, "version_check_failed", StringComparison.Ordinal) ? "check_failed" : "skipped");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "outcome", outcome },
|
||||
{ "reason", reason },
|
||||
{ "currentVersion", currentVersion },
|
||||
{ "skippedAtUtc", skippedAtUtc },
|
||||
{ "errorDetail", errorDetail }
|
||||
};
|
||||
if (latestVersion != null)
|
||||
data["latestVersion"] = latestVersion;
|
||||
if (!string.IsNullOrEmpty(triggerReason))
|
||||
data["triggerReason"] = triggerReason;
|
||||
|
||||
string message;
|
||||
if (outcome == "check_failed")
|
||||
message = $"Agent version check failed (reason={reason})";
|
||||
else if (outcome == "downgrade_blocked")
|
||||
message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)";
|
||||
else
|
||||
message = $"Agent self-update skipped (reason={reason})";
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = tenantId,
|
||||
EventType = Constants.EventTypes.AgentVersionCheck,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "Agent",
|
||||
Message = message,
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
};
|
||||
}
|
||||
|
||||
private static EnrollmentEvent BuildUpToDateEvent(JObject marker, string sessionId, string tenantId)
|
||||
{
|
||||
var currentVersion = (string)marker["currentVersion"] ?? "unknown";
|
||||
var latestVersion = (string)marker["latestVersion"] ?? "unknown";
|
||||
var checkedAtUtc = (string)marker["checkedAtUtc"] ?? "unknown";
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "outcome", "up_to_date" },
|
||||
{ "currentVersion", currentVersion },
|
||||
{ "latestVersion", latestVersion },
|
||||
{ "checkedAtUtc", checkedAtUtc }
|
||||
};
|
||||
CopyLong(marker, "versionCheckMs", data);
|
||||
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
SessionId = sessionId,
|
||||
TenantId = tenantId,
|
||||
EventType = Constants.EventTypes.AgentVersionCheck,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "Agent",
|
||||
Message = $"Agent version check: up to date ({currentVersion})",
|
||||
Data = data,
|
||||
ImmediateUpload = true
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class LastEmit
|
||||
{
|
||||
public string SessionId { get; set; }
|
||||
public string LatestVersion { get; set; }
|
||||
public string Outcome { get; set; }
|
||||
}
|
||||
|
||||
private static JObject ReadMarker(string path) => JObject.Parse(File.ReadAllText(path));
|
||||
|
||||
private static void CopyLong(JObject marker, string key, Dictionary<string, object> target)
|
||||
{
|
||||
var token = marker[key];
|
||||
if (token == null || token.Type == JTokenType.Null) return;
|
||||
try { target[key] = token.ToObject<long>(); } catch { /* skip malformed field */ }
|
||||
}
|
||||
|
||||
private static string ExtractLatestVersion(EnrollmentEvent evt)
|
||||
{
|
||||
if (evt?.Data == null) return "unknown";
|
||||
return evt.Data.TryGetValue("latestVersion", out var v) && v != null ? v.ToString() : "unknown";
|
||||
}
|
||||
|
||||
private static LastEmit TryReadLastEmit(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path)) return null;
|
||||
var obj = JObject.Parse(File.ReadAllText(path));
|
||||
return new LastEmit
|
||||
{
|
||||
SessionId = (string)obj["sessionId"],
|
||||
LatestVersion = (string)obj["latestVersion"],
|
||||
Outcome = (string)obj["outcome"]
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static void TryWriteLastEmit(string path, string sessionId, string latestVersion, string outcome)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var payload = new JObject
|
||||
{
|
||||
["sessionId"] = sessionId ?? string.Empty,
|
||||
["latestVersion"] = latestVersion ?? "unknown",
|
||||
["outcome"] = outcome ?? "unknown",
|
||||
["emittedAtUtc"] = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
File.WriteAllText(path, payload.ToString(Newtonsoft.Json.Formatting.None));
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an agent-side security or configuration analyzer.
|
||||
/// Unlike collectors which stream raw telemetry, analyzers run targeted checks,
|
||||
/// produce a confidence-scored finding, and emit a single structured event.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// AnalyzeAtStartup() — called once after agent starts, before enrollment phases begin
|
||||
/// AnalyzeAtShutdown() — called once at enrollment end / agent stop (enables delta detection)
|
||||
/// </summary>
|
||||
public interface IAgentAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable name for logging (e.g., "LocalAdminAnalyzer").
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Run analysis at agent startup.
|
||||
/// Emits a structured finding event via the callback supplied at construction.
|
||||
/// </summary>
|
||||
void AnalyzeAtStartup();
|
||||
|
||||
/// <summary>
|
||||
/// Run analysis at agent shutdown / enrollment completion (delta detection).
|
||||
/// Emits a second finding event for end-state comparison against the startup result.
|
||||
/// IMPORTANT: Emitted events MUST use Phase=Unknown. Analyzers are not phase-declaration
|
||||
/// events — see EnrollmentPhase doc for the phase strategy.
|
||||
/// </summary>
|
||||
void AnalyzeAtShutdown();
|
||||
}
|
||||
}
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects Windows 11 "unsupported hardware" installations where the PC Health Check /
|
||||
/// Setup-time hardware gates were bypassed via registry flags, and flags suspicious
|
||||
/// post-OOBE setup scripts. Such devices may complete enrollment successfully yet lack
|
||||
/// TPM-backed features (WHfB, BitLocker TPM protector, Device Health Attestation,
|
||||
/// Credential Guard) — a silent quality risk not otherwise surfaced by enrollment_complete.
|
||||
///
|
||||
/// Checks performed:
|
||||
/// 1. HKLM\SYSTEM\Setup\LabConfig\Bypass{TPM,SecureBoot,RAM,Storage,Disk,CPU}Check (install-time)
|
||||
/// 2. HKLM\SYSTEM\Setup\MoSetup\AllowUpgradesWithUnsupportedTPMOrCPU (in-place upgrade)
|
||||
/// 3. HKU\<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.
|
||||
/// </summary>
|
||||
public class IntegrityBypassAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public string Name => "IntegrityBypassAnalyzer";
|
||||
|
||||
// LabConfig bypass value names. Both BypassStorageCheck and BypassDiskCheck are observed
|
||||
// in the wild — scripts differ, both should be captured.
|
||||
private static readonly string[] LabConfigBypassValues = new[]
|
||||
{
|
||||
"BypassTPMCheck",
|
||||
"BypassSecureBootCheck",
|
||||
"BypassRAMCheck",
|
||||
"BypassStorageCheck",
|
||||
"BypassDiskCheck",
|
||||
"BypassCPUCheck"
|
||||
};
|
||||
|
||||
// Registry base SIDs that are NOT real user profiles
|
||||
private static readonly HashSet<string> SystemSids = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".DEFAULT",
|
||||
"S-1-5-18", // LocalSystem
|
||||
"S-1-5-19", // LocalService
|
||||
"S-1-5-20" // NetworkService
|
||||
};
|
||||
|
||||
public IntegrityBypassAnalyzer(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis");
|
||||
RunAnalysis("startup");
|
||||
}
|
||||
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
// Pre-enrollment state does not change during enrollment — skip the shutdown run.
|
||||
_logger.Debug($"{Name}: Shutdown no-op (bypass state is pre-enrollment, already captured at startup)");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core analysis
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void RunAnalysis(string trigger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var labConfig = CheckLabConfig();
|
||||
var moSetup = CheckMoSetupAllowUnsupported();
|
||||
var pchc = CheckPchcUpgradeEligibility();
|
||||
var setupScripts = CheckSetupScripts();
|
||||
|
||||
var tpmPresent = CheckTpmPresentAndEnabled();
|
||||
var secureBootEnabled = CheckSecureBootEnabled();
|
||||
|
||||
// --------- Severity aggregation ---------
|
||||
var severity = EventSeverity.Info;
|
||||
var findings = new List<string>();
|
||||
|
||||
bool anyLabConfigBypass = labConfig.ActiveBypasses.Count > 0;
|
||||
bool labConfigKeyExists = labConfig.KeyExists;
|
||||
bool moSetupBypass = moSetup.Value == 1;
|
||||
bool pchcBypass = pchc.AnyUserWithFlag;
|
||||
bool scriptsPresent = setupScripts.Any(s => s.Exists);
|
||||
|
||||
// Critical: bypass + corresponding feature missing
|
||||
if (labConfig.ActiveBypasses.Contains("BypassTPMCheck") && tpmPresent.HasValue && tpmPresent.Value == false)
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Critical);
|
||||
findings.Add("tpm_bypass_with_missing_tpm");
|
||||
}
|
||||
if (labConfig.ActiveBypasses.Contains("BypassSecureBootCheck") && secureBootEnabled.HasValue && secureBootEnabled.Value == false)
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Critical);
|
||||
findings.Add("secureboot_bypass_with_disabled_secureboot");
|
||||
}
|
||||
if (moSetupBypass && ((tpmPresent.HasValue && tpmPresent.Value == false) || (secureBootEnabled.HasValue && secureBootEnabled.Value == false)))
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Critical);
|
||||
findings.Add("upgrade_bypass_with_missing_feature");
|
||||
}
|
||||
|
||||
// Warning: bypass active but feature currently still OK, or scripts present, or PCHC flag
|
||||
if (anyLabConfigBypass && severity < EventSeverity.Warning)
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Warning);
|
||||
findings.Add("labconfig_bypass_active");
|
||||
}
|
||||
if (moSetupBypass && severity < EventSeverity.Warning)
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Warning);
|
||||
findings.Add("mosetup_upgrade_bypass_active");
|
||||
}
|
||||
if (pchcBypass)
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Warning);
|
||||
findings.Add("pchc_upgrade_eligibility_forced");
|
||||
}
|
||||
if (scriptsPresent)
|
||||
{
|
||||
severity = Escalate(severity, EventSeverity.Warning);
|
||||
findings.Add("suspicious_setup_scripts_present");
|
||||
}
|
||||
|
||||
// Info: key exists but nothing active
|
||||
if (severity == EventSeverity.Info)
|
||||
{
|
||||
if (labConfigKeyExists)
|
||||
findings.Add("labconfig_key_present_inactive");
|
||||
else
|
||||
findings.Add("no_bypass_indicators");
|
||||
}
|
||||
|
||||
string findingLabel = findings.FirstOrDefault() ?? "no_bypass_indicators";
|
||||
string message = severity >= EventSeverity.Warning
|
||||
? $"{Name}: Integrity bypass indicators detected ({string.Join(",", findings)})"
|
||||
: $"{Name}: No integrity bypass indicators";
|
||||
|
||||
_logger.Info(
|
||||
$"{Name}: severity={severity}, findings=[{string.Join(",", findings)}], " +
|
||||
$"labConfigBypasses={labConfig.ActiveBypasses.Count}, " +
|
||||
$"moSetupBypass={moSetupBypass}, pchcBypass={pchcBypass}, " +
|
||||
$"scriptsPresent={scriptsPresent}, tpm={FormatNullableBool(tpmPresent)}, " +
|
||||
$"secureBoot={FormatNullableBool(secureBootEnabled)}");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "severity", severity.ToString().ToLowerInvariant() },
|
||||
{ "finding", findingLabel },
|
||||
{ "findings", findings },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", EnrollmentPhase.Unknown.ToString() },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
{
|
||||
{ "lab_config", new Dictionary<string, object>
|
||||
{
|
||||
{ "key_exists", labConfig.KeyExists },
|
||||
{ "values", labConfig.Values },
|
||||
{ "active_bypasses", labConfig.ActiveBypasses }
|
||||
}
|
||||
},
|
||||
{ "mo_setup", new Dictionary<string, object>
|
||||
{
|
||||
{ "key_exists", moSetup.KeyExists },
|
||||
{ "value", moSetup.Value },
|
||||
{ "flagged", moSetupBypass }
|
||||
}
|
||||
},
|
||||
{ "pchc_upgrade_eligibility", new Dictionary<string, object>
|
||||
{
|
||||
{ "users_checked", pchc.UsersChecked },
|
||||
{ "users_with_flag", pchc.UsersWithFlag },
|
||||
{ "any_user_with_flag", pchc.AnyUserWithFlag }
|
||||
}
|
||||
},
|
||||
{ "setup_scripts", setupScripts.Select(s => new Dictionary<string, object>
|
||||
{
|
||||
{ "path", s.Path },
|
||||
{ "exists", s.Exists },
|
||||
{ "size_bytes", s.SizeBytes },
|
||||
{ "last_modified_utc", s.LastModifiedUtc.HasValue
|
||||
? s.LastModifiedUtc.Value.ToString("O")
|
||||
: null }
|
||||
}).ToList()
|
||||
},
|
||||
{ "correlation", new Dictionary<string, object>
|
||||
{
|
||||
{ "tpm_present_and_enabled", tpmPresent },
|
||||
{ "secure_boot_enabled", secureBootEnabled }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "integrity_bypass_analysis",
|
||||
Severity = severity,
|
||||
Source = Name,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name}: Analysis failed unexpectedly", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static EventSeverity Escalate(EventSeverity current, EventSeverity candidate)
|
||||
=> candidate > current ? candidate : current;
|
||||
|
||||
private static string FormatNullableBool(bool? value)
|
||||
=> value.HasValue ? value.Value.ToString() : "unknown";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Individual checks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private LabConfigCheckResult CheckLabConfig()
|
||||
{
|
||||
var result = new LabConfigCheckResult();
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\LabConfig", writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: HKLM\\SYSTEM\\Setup\\LabConfig not present");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.KeyExists = true;
|
||||
foreach (var valueName in LabConfigBypassValues)
|
||||
{
|
||||
var raw = key.GetValue(valueName);
|
||||
int intValue = 0;
|
||||
if (raw != null)
|
||||
{
|
||||
try { intValue = Convert.ToInt32(raw); }
|
||||
catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException)
|
||||
{
|
||||
_logger.Warning($"{Name}: LabConfig '{valueName}' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}");
|
||||
intValue = 0;
|
||||
}
|
||||
}
|
||||
result.Values[valueName] = intValue;
|
||||
if (intValue == 1)
|
||||
{
|
||||
result.ActiveBypasses.Add(valueName);
|
||||
_logger.Debug($"{Name}: LabConfig {valueName}=1 (active bypass)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to read LabConfig registry: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private MoSetupCheckResult CheckMoSetupAllowUnsupported()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\MoSetup", writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
return new MoSetupCheckResult { KeyExists = false, Value = 0 };
|
||||
|
||||
var raw = key.GetValue("AllowUpgradesWithUnsupportedTPMOrCPU");
|
||||
if (raw == null)
|
||||
return new MoSetupCheckResult { KeyExists = true, Value = 0 };
|
||||
|
||||
int intValue = 0;
|
||||
try { intValue = Convert.ToInt32(raw); }
|
||||
catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException)
|
||||
{
|
||||
_logger.Warning($"{Name}: MoSetup 'AllowUpgradesWithUnsupportedTPMOrCPU' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}");
|
||||
}
|
||||
return new MoSetupCheckResult { KeyExists = true, Value = intValue };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to read MoSetup registry: {ex.Message}");
|
||||
return new MoSetupCheckResult { KeyExists = false, Value = 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates HKEY_USERS and checks SOFTWARE\Microsoft\PCHC\UpgradeEligibility for each
|
||||
/// real user profile. Agent runs as SYSTEM — loaded hives include all interactive user
|
||||
/// profiles that Windows has mounted. SIDs for .DEFAULT and system accounts are skipped.
|
||||
/// Profiles whose hive is not currently loaded are not visible here; that is acceptable —
|
||||
/// the relevant user during enrollment is the primary logged-in user whose hive is loaded.
|
||||
/// </summary>
|
||||
private PchcCheckResult CheckPchcUpgradeEligibility()
|
||||
{
|
||||
var result = new PchcCheckResult();
|
||||
try
|
||||
{
|
||||
using (var users = Registry.Users)
|
||||
{
|
||||
foreach (var sid in users.GetSubKeyNames())
|
||||
{
|
||||
if (string.IsNullOrEmpty(sid)) continue;
|
||||
if (SystemSids.Contains(sid)) continue;
|
||||
// Skip the Classes-hive companions
|
||||
if (sid.EndsWith("_Classes", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
result.UsersChecked.Add(sid);
|
||||
|
||||
try
|
||||
{
|
||||
using (var pchcKey = users.OpenSubKey($@"{sid}\SOFTWARE\Microsoft\PCHC", writable: false))
|
||||
{
|
||||
if (pchcKey == null) continue;
|
||||
|
||||
var raw = pchcKey.GetValue("UpgradeEligibility");
|
||||
if (raw == null) continue;
|
||||
|
||||
int intValue = 0;
|
||||
try { intValue = Convert.ToInt32(raw); }
|
||||
catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException)
|
||||
{
|
||||
_logger.Warning($"{Name}: PCHC UpgradeEligibility for SID {sid} has unparseable value (raw type={raw.GetType().Name}): {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (intValue == 1)
|
||||
{
|
||||
result.UsersWithFlag.Add(sid);
|
||||
_logger.Debug($"{Name}: PCHC UpgradeEligibility=1 for SID {sid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: Failed to read PCHC for SID {sid}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to enumerate HKEY_USERS: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<SetupScriptCheckResult> CheckSetupScripts()
|
||||
{
|
||||
var results = new List<SetupScriptCheckResult>();
|
||||
var windir = Environment.GetEnvironmentVariable("WINDIR");
|
||||
if (string.IsNullOrEmpty(windir))
|
||||
windir = @"C:\Windows";
|
||||
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(windir, @"Setup\Scripts\SetupComplete.cmd"),
|
||||
Path.Combine(windir, @"Setup\Scripts\ErrorHandler.cmd")
|
||||
};
|
||||
|
||||
foreach (var path in candidates)
|
||||
{
|
||||
var entry = new SetupScriptCheckResult { Path = path, Exists = false };
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
entry.Exists = true;
|
||||
var fi = new FileInfo(path);
|
||||
entry.SizeBytes = fi.Length;
|
||||
entry.LastModifiedUtc = fi.LastWriteTimeUtc;
|
||||
_logger.Debug($"{Name}: Setup script present: {path} ({fi.Length} bytes)");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: Failed to stat {path}: {ex.Message}");
|
||||
}
|
||||
results.Add(entry);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if a TPM is present and enabled, false if definitely absent/disabled,
|
||||
/// null if indeterminate (query failed). Uses Win32_Tpm in root\CIMV2\Security\MicrosoftTpm.
|
||||
/// </summary>
|
||||
private bool? CheckTpmPresentAndEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
@"root\CIMV2\Security\MicrosoftTpm",
|
||||
"SELECT IsEnabled_InitialValue, IsActivated_InitialValue FROM Win32_Tpm"))
|
||||
using (var results = searcher.Get())
|
||||
{
|
||||
foreach (ManagementObject tpm in results)
|
||||
{
|
||||
using (tpm)
|
||||
{
|
||||
var isEnabled = tpm["IsEnabled_InitialValue"] != null
|
||||
&& Convert.ToBoolean(tpm["IsEnabled_InitialValue"]);
|
||||
// At least one Tpm instance exists — return its enabled state
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No Win32_Tpm instance returned — no TPM present
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: TPM WMI query failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if UEFI Secure Boot is enabled, false if disabled, null if not
|
||||
/// queryable (e.g. BIOS firmware without the registry key).
|
||||
/// </summary>
|
||||
private bool? CheckSecureBootEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(
|
||||
@"SYSTEM\CurrentControlSet\Control\SecureBoot\State", writable: false))
|
||||
{
|
||||
if (key == null) return null;
|
||||
var raw = key.GetValue("UEFISecureBootEnabled");
|
||||
if (raw == null) return null;
|
||||
return Convert.ToInt32(raw) == 1;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: SecureBoot registry read failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Private result types
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private class LabConfigCheckResult
|
||||
{
|
||||
public bool KeyExists { get; set; }
|
||||
public Dictionary<string, int> Values { get; set; } = new Dictionary<string, int>();
|
||||
public List<string> ActiveBypasses { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
private class MoSetupCheckResult
|
||||
{
|
||||
public bool KeyExists { get; set; }
|
||||
public int Value { get; set; }
|
||||
}
|
||||
|
||||
private class PchcCheckResult
|
||||
{
|
||||
public List<string> UsersChecked { get; set; } = new List<string>();
|
||||
public List<string> UsersWithFlag { get; set; } = new List<string>();
|
||||
public bool AnyUserWithFlag => UsersWithFlag.Count > 0;
|
||||
}
|
||||
|
||||
private class SetupScriptCheckResult
|
||||
{
|
||||
public string Path { get; set; }
|
||||
public bool Exists { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
public DateTime? LastModifiedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyzes local administrator accounts and user profiles on the device
|
||||
/// to detect pre-enrollment admin account creation — a known Autopilot bypass technique.
|
||||
///
|
||||
/// Checks performed:
|
||||
/// 1. BypassNRO registry flag (HKLM\...\OOBE\BypassNRO = 1)
|
||||
/// 2. Unexpected local user accounts (via WMI Win32_UserAccount)
|
||||
/// 3. Unexpected C:\Users profile directories
|
||||
///
|
||||
/// Confidence scoring:
|
||||
/// BypassNRO = 1 → +20 (low indicator)
|
||||
/// Unexpected local account found → +40 (medium indicator)
|
||||
/// Account + matching C:\Users profile → +40 (high indicator, profile overlap)
|
||||
///
|
||||
/// Emits a single "local_admin_analysis" event at startup and at shutdown,
|
||||
/// enabling delta detection between pre- and post-enrollment state.
|
||||
/// </summary>
|
||||
public class LocalAdminAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly List<string> _allowedAccounts;
|
||||
|
||||
// Built-in accounts and profile folders always present on a freshly imaged Windows device.
|
||||
// "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts.
|
||||
// "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment.
|
||||
private static readonly List<string> BuiltInAllowedAccounts = new List<string>
|
||||
{
|
||||
"Administrator",
|
||||
"Guest",
|
||||
"DefaultAccount",
|
||||
"WDAGUtilityAccount",
|
||||
"defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment
|
||||
"defaultuser1", // Sometimes seen in OOBE, but not always present
|
||||
"defaultuser2", // Sometimes seen in OOBE, but not always present
|
||||
"Public", // Profile folder (not a user account)
|
||||
"Default", // Default user profile template
|
||||
"Default User", // Symlink to Default in some Windows versions
|
||||
"All Users" // Junction pointing to C:\ProgramData, always present
|
||||
};
|
||||
|
||||
public string Name => "LocalAdminAnalyzer";
|
||||
|
||||
public LocalAdminAnalyzer(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger,
|
||||
List<string> allowedAccounts = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
|
||||
_allowedAccounts = new List<string>(BuiltInAllowedAccounts);
|
||||
if (allowedAccounts != null)
|
||||
{
|
||||
foreach (var account in allowedAccounts)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(account) &&
|
||||
!_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_allowedAccounts.Add(account);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis");
|
||||
RunAnalysis("startup", EnrollmentPhase.Unknown);
|
||||
}
|
||||
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown analysis");
|
||||
|
||||
// At shutdown a user has logged in — their profile folder and account are expected.
|
||||
// Detect logged-in users via explorer.exe owner (same technique as DesktopArrivalDetector)
|
||||
// and add them dynamically so they don't trigger false positives.
|
||||
var loggedInUsers = GetLoggedInUserNames();
|
||||
// Phase=Unknown: this is an analysis event, not a phase declaration.
|
||||
// Phase-tagged events are reserved for explicit phase transitions (esp_phase_changed etc.).
|
||||
RunAnalysis("shutdown", EnrollmentPhase.Unknown, loggedInUsers);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Core analysis
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void RunAnalysis(string trigger, EnrollmentPhase phase, List<string> dynamicAllowedUsers = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Build effective allowed list: static + optional dynamic (logged-in users at shutdown)
|
||||
var effectiveAllowed = _allowedAccounts;
|
||||
if (dynamicAllowedUsers != null && dynamicAllowedUsers.Count > 0)
|
||||
{
|
||||
effectiveAllowed = new List<string>(_allowedAccounts);
|
||||
foreach (var user in dynamicAllowedUsers)
|
||||
{
|
||||
if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
effectiveAllowed.Add(user);
|
||||
_logger.Info($"{Name}: Dynamically allowing logged-in user: {user}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var bypassNroResult = CheckBypassNroRegistry();
|
||||
var accountsResult = CheckLocalAdminAccounts(effectiveAllowed);
|
||||
var profilesResult = CheckUserProfiles(effectiveAllowed);
|
||||
|
||||
int confidenceScore = 0;
|
||||
|
||||
if (bypassNroResult.Value == 1)
|
||||
confidenceScore += 20;
|
||||
|
||||
if (accountsResult.Unexpected.Count > 0)
|
||||
confidenceScore += 40;
|
||||
|
||||
// Profile overlap: unexpected account AND matching C:\Users folder
|
||||
bool profileOverlap = accountsResult.Unexpected.Any(a =>
|
||||
profilesResult.Unexpected.Any(p =>
|
||||
string.Equals(a, p, StringComparison.OrdinalIgnoreCase)));
|
||||
if (profileOverlap)
|
||||
confidenceScore += 40;
|
||||
|
||||
confidenceScore = Math.Min(confidenceScore, 100);
|
||||
|
||||
EventSeverity severity;
|
||||
string findingLabel;
|
||||
|
||||
if (confidenceScore == 0)
|
||||
{
|
||||
severity = EventSeverity.Info;
|
||||
findingLabel = "no_unexpected_admins_detected";
|
||||
}
|
||||
else if (confidenceScore < 40)
|
||||
{
|
||||
severity = EventSeverity.Info;
|
||||
findingLabel = "bypass_nro_flag_only";
|
||||
}
|
||||
else if (confidenceScore < 80)
|
||||
{
|
||||
severity = EventSeverity.Warning;
|
||||
findingLabel = "unexpected_local_admins_detected";
|
||||
}
|
||||
else
|
||||
{
|
||||
severity = EventSeverity.Error;
|
||||
findingLabel = "unexpected_local_admins_detected";
|
||||
}
|
||||
|
||||
_logger.Info(
|
||||
$"{Name}: confidence={confidenceScore}, finding={findingLabel}, " +
|
||||
$"bypassNro={bypassNroResult.Value}, " +
|
||||
$"unexpectedAccounts={accountsResult.Unexpected.Count}, " +
|
||||
$"unexpectedProfiles={profilesResult.Unexpected.Count}");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "confidence_score", confidenceScore },
|
||||
{ "severity", severity.ToString().ToLower() },
|
||||
{ "finding", findingLabel },
|
||||
{ "triggered_at", trigger },
|
||||
{ "enrollment_phase_at_check", phase.ToString() },
|
||||
{ "allowed_accounts", effectiveAllowed },
|
||||
{ "dynamically_allowed_users", dynamicAllowedUsers ?? new List<string>() },
|
||||
{ "checks", new Dictionary<string, object>
|
||||
{
|
||||
{ "bypass_nro", new Dictionary<string, object>
|
||||
{
|
||||
{ "value", bypassNroResult.Value },
|
||||
{ "flagged", bypassNroResult.Value == 1 }
|
||||
}
|
||||
},
|
||||
{ "unexpected_accounts", accountsResult.Unexpected },
|
||||
{ "unexpected_profiles", profilesResult.Unexpected },
|
||||
{ "accounts_checked", accountsResult.AllChecked },
|
||||
{ "profiles_found", profilesResult.AllFound }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var message = confidenceScore == 0
|
||||
? $"{Name}: No unexpected local admins detected"
|
||||
: $"{Name}: Unexpected admin activity detected (confidence={confidenceScore})";
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "local_admin_analysis",
|
||||
Severity = severity,
|
||||
Source = Name,
|
||||
Phase = phase,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name}: Analysis failed unexpectedly", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Individual checks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private BypassNroCheckResult CheckBypassNroRegistry()
|
||||
{
|
||||
try
|
||||
{
|
||||
const string keyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE";
|
||||
const string valueName = "BypassNRO";
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: BypassNRO registry key not found");
|
||||
return new BypassNroCheckResult { Value = 0, KeyExists = false };
|
||||
}
|
||||
|
||||
var raw = key.GetValue(valueName);
|
||||
if (raw == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: BypassNRO value not present");
|
||||
return new BypassNroCheckResult { Value = 0, KeyExists = true };
|
||||
}
|
||||
|
||||
var intValue = Convert.ToInt32(raw);
|
||||
_logger.Debug($"{Name}: BypassNRO = {intValue}");
|
||||
return new BypassNroCheckResult { Value = intValue, KeyExists = true };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to read BypassNRO registry: {ex.Message}");
|
||||
return new BypassNroCheckResult { Value = 0, KeyExists = false };
|
||||
}
|
||||
}
|
||||
|
||||
private LocalAccountCheckResult CheckLocalAdminAccounts(List<string> allowedAccounts)
|
||||
{
|
||||
var allChecked = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, Disabled FROM Win32_UserAccount WHERE LocalAccount = True"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var name = obj["Name"]?.ToString() ?? string.Empty;
|
||||
var disabled = obj["Disabled"] != null && Convert.ToBoolean(obj["Disabled"]);
|
||||
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
allChecked.Add(name);
|
||||
|
||||
// Skip disabled accounts — they cannot be used to log in
|
||||
if (disabled)
|
||||
continue;
|
||||
|
||||
if (!allowedAccounts.Any(a =>
|
||||
string.Equals(a, name, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
unexpected.Add(name);
|
||||
_logger.Debug($"{Name}: Unexpected local account: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}");
|
||||
}
|
||||
|
||||
return new LocalAccountCheckResult { AllChecked = allChecked, Unexpected = unexpected };
|
||||
}
|
||||
|
||||
private UserProfileCheckResult CheckUserProfiles(List<string> allowedAccounts)
|
||||
{
|
||||
var allFound = new List<string>();
|
||||
var unexpected = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
const string usersRoot = @"C:\Users";
|
||||
|
||||
if (!Directory.Exists(usersRoot))
|
||||
{
|
||||
_logger.Debug($"{Name}: C:\\Users does not exist");
|
||||
return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected };
|
||||
}
|
||||
|
||||
var dirs = Directory.GetDirectories(usersRoot, "*", SearchOption.TopDirectoryOnly);
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
var folderName = Path.GetFileName(dir);
|
||||
if (string.IsNullOrEmpty(folderName))
|
||||
continue;
|
||||
|
||||
allFound.Add(folderName);
|
||||
|
||||
if (!allowedAccounts.Any(a =>
|
||||
string.Equals(a, folderName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
unexpected.Add(folderName);
|
||||
_logger.Debug($"{Name}: Unexpected profile folder: {folderName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to enumerate user profiles: {ex.Message}");
|
||||
}
|
||||
|
||||
return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects currently logged-in user(s) by finding explorer.exe processes owned by
|
||||
/// real (non-system) users. Returns the plain username part (without domain prefix).
|
||||
/// Uses the same approach as DesktopArrivalDetector.
|
||||
/// </summary>
|
||||
private List<string> GetLoggedInUserNames()
|
||||
{
|
||||
var users = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var explorerProcesses = System.Diagnostics.Process.GetProcessesByName("explorer");
|
||||
foreach (var proc in explorerProcesses)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Session 0 = SYSTEM session, skip
|
||||
if (proc.SessionId == 0)
|
||||
continue;
|
||||
|
||||
string owner = GetExplorerOwner(proc.Id);
|
||||
if (string.IsNullOrEmpty(owner))
|
||||
continue;
|
||||
|
||||
// Extract username part (after backslash if DOMAIN\User format)
|
||||
var userName = owner;
|
||||
var bsIdx = owner.LastIndexOf('\\');
|
||||
if (bsIdx >= 0 && bsIdx < owner.Length - 1)
|
||||
userName = owner.Substring(bsIdx + 1);
|
||||
|
||||
// Skip system/service accounts
|
||||
if (IsSystemAccount(userName))
|
||||
continue;
|
||||
|
||||
if (!users.Any(u => string.Equals(u, userName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
users.Add(userName);
|
||||
_logger.Debug($"{Name}: Detected logged-in user: {userName}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: Error checking explorer.exe PID {proc.Id}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
proc.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to detect logged-in users: {ex.Message}");
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owner of a process via WMI Win32_Process.GetOwner.
|
||||
/// Returns "DOMAIN\User" or "User", or null on failure.
|
||||
/// </summary>
|
||||
private string GetExplorerOwner(int processId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
$"SELECT * FROM Win32_Process WHERE ProcessId = {processId}"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var outParams = new object[2];
|
||||
var result = (uint)obj.InvokeMethod("GetOwner", outParams);
|
||||
if (result == 0)
|
||||
{
|
||||
var user = outParams[0]?.ToString();
|
||||
var domain = outParams[1]?.ToString();
|
||||
return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: WMI GetOwner failed for PID {processId}: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true for system/service accounts that are not real enrolled users.
|
||||
/// </summary>
|
||||
private static bool IsSystemAccount(string userName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userName))
|
||||
return true;
|
||||
|
||||
var systemNames = new[] { "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE" };
|
||||
foreach (var sn in systemNames)
|
||||
{
|
||||
if (string.Equals(userName, sn, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
// DefaultUser* pattern (OOBE system accounts)
|
||||
if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Private result types
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private class BypassNroCheckResult
|
||||
{
|
||||
public int Value { get; set; }
|
||||
public bool KeyExists { get; set; }
|
||||
}
|
||||
|
||||
private class LocalAccountCheckResult
|
||||
{
|
||||
public List<string> AllChecked { get; set; } = new List<string>();
|
||||
public List<string> Unexpected { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
private class UserProfileCheckResult
|
||||
{
|
||||
public List<string> AllFound { get; set; } = new List<string>();
|
||||
public List<string> Unexpected { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
+929
@@ -0,0 +1,929 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
|
||||
{
|
||||
/// <summary>
|
||||
/// Collects installed software inventory from the Windows registry, normalizes
|
||||
/// vendor/product/version strings, and emits structured events for vulnerability correlation.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// AnalyzeAtStartup() — baseline inventory snapshot (before enrollment installs)
|
||||
/// AnalyzeAtShutdown() — final inventory + delta (what was installed during enrollment)
|
||||
///
|
||||
/// The actual CVE/KEV correlation happens server-side after the events are ingested.
|
||||
/// </summary>
|
||||
public class SoftwareInventoryAnalyzer : IAgentAnalyzer
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
// Captured at startup for delta detection at shutdown
|
||||
private List<SoftwareEntry> _startupInventory;
|
||||
|
||||
public string Name => "SoftwareInventoryAnalyzer";
|
||||
|
||||
// Maximum items per event chunk to stay within Table Storage property size limits (~64 KB)
|
||||
private const int ChunkSize = 75;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Registry paths for installed software
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly string[] UninstallRegistryPaths = new[]
|
||||
{
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
};
|
||||
|
||||
private const string UninstallRegistryPathHkcu =
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
|
||||
|
||||
private const string ProfileListPath =
|
||||
@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList";
|
||||
|
||||
private const string AppxAllUserStorePath =
|
||||
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore\Applications";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Publisher normalization map
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly Dictionary<string, string> PublisherMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "microsoft corporation", "microsoft" },
|
||||
{ "microsoft", "microsoft" },
|
||||
{ "google llc", "google" },
|
||||
{ "google inc", "google" },
|
||||
{ "google inc.", "google" },
|
||||
{ "adobe inc.", "adobe" },
|
||||
{ "adobe systems incorporated", "adobe" },
|
||||
{ "adobe systems, incorporated", "adobe" },
|
||||
{ "mozilla corporation", "mozilla" },
|
||||
{ "mozilla", "mozilla" },
|
||||
{ "oracle corporation", "oracle" },
|
||||
{ "oracle", "oracle" },
|
||||
{ "apple inc.", "apple" },
|
||||
{ "apple inc", "apple" },
|
||||
{ "dell inc.", "dell" },
|
||||
{ "dell inc", "dell" },
|
||||
{ "dell technologies", "dell" },
|
||||
{ "hewlett-packard", "hp" },
|
||||
{ "hewlett packard enterprise", "hp" },
|
||||
{ "hp inc.", "hp" },
|
||||
{ "hp inc", "hp" },
|
||||
{ "lenovo", "lenovo" },
|
||||
{ "lenovo group limited", "lenovo" },
|
||||
{ "cisco systems, inc.", "cisco" },
|
||||
{ "cisco systems", "cisco" },
|
||||
{ "vmware, inc.", "vmware" },
|
||||
{ "vmware", "vmware" },
|
||||
{ "citrix systems, inc.", "citrix" },
|
||||
{ "citrix", "citrix" },
|
||||
{ "zoom video communications, inc.", "zoom" },
|
||||
{ "slack technologies, inc.", "slack" },
|
||||
{ "slack technologies, llc", "slack" },
|
||||
{ "7-zip", "7-zip" },
|
||||
{ "igor pavlov", "7-zip" },
|
||||
{ "the 7-zip developers", "7-zip" },
|
||||
{ "notepad++ team", "notepad++" },
|
||||
{ "don ho", "notepad++" },
|
||||
{ "putty", "putty" },
|
||||
{ "simon tatham", "putty" },
|
||||
{ "winscp", "winscp" },
|
||||
{ "martin prikryl", "winscp" },
|
||||
{ "teamviewer", "teamviewer" },
|
||||
{ "teamviewer germany gmbh", "teamviewer" },
|
||||
{ "fortinet technologies (canada) inc.", "fortinet" },
|
||||
{ "fortinet inc", "fortinet" },
|
||||
{ "palo alto networks", "paloaltonetworks" },
|
||||
{ "crowdstrike, inc.", "crowdstrike" },
|
||||
{ "crowdstrike inc.", "crowdstrike" },
|
||||
{ "git", "git" },
|
||||
{ "the git development community", "git" },
|
||||
{ "python software foundation", "python" },
|
||||
{ "node.js", "nodejs" },
|
||||
{ "node.js foundation", "nodejs" },
|
||||
{ "openjs foundation", "nodejs" },
|
||||
|
||||
// Endpoint security & AV
|
||||
{ "sophos", "sophos" },
|
||||
{ "sophos limited", "sophos" },
|
||||
{ "eset, spol. s r.o.", "eset" },
|
||||
{ "eset", "eset" },
|
||||
{ "malwarebytes", "malwarebytes" },
|
||||
{ "kaspersky", "kaspersky" },
|
||||
{ "kaspersky lab", "kaspersky" },
|
||||
{ "trend micro", "trendmicro" },
|
||||
{ "trend micro inc.", "trendmicro" },
|
||||
{ "mcafee, inc.", "mcafee" },
|
||||
{ "mcafee, llc", "mcafee" },
|
||||
{ "symantec corporation", "symantec" },
|
||||
{ "broadcom inc.", "broadcom" },
|
||||
{ "carbon black, inc.", "carbonblack" },
|
||||
{ "sentinelone", "sentinelone" },
|
||||
|
||||
// VPN & network security
|
||||
{ "zscaler, inc.", "zscaler" },
|
||||
{ "zscaler", "zscaler" },
|
||||
{ "netskope", "netskope" },
|
||||
{ "netskope, inc.", "netskope" },
|
||||
{ "pulse secure, llc", "pulsesecure" },
|
||||
{ "juniper networks", "juniper" },
|
||||
{ "openvpn inc.", "openvpn" },
|
||||
{ "openvpn technologies", "openvpn" },
|
||||
{ "wireguard", "wireguard" },
|
||||
|
||||
// Device management & enterprise
|
||||
{ "glueckkanja ag", "glueckkanja" },
|
||||
{ "glueckkanja-gab ag", "glueckkanja" },
|
||||
{ "beyond trust", "beyondtrust" },
|
||||
{ "beyondtrust software", "beyondtrust" },
|
||||
{ "beyondtrust corporation", "beyondtrust" },
|
||||
{ "ivanti", "ivanti" },
|
||||
{ "ivanti, inc.", "ivanti" },
|
||||
{ "1e", "1e" },
|
||||
{ "tanium", "tanium" },
|
||||
{ "tanium inc.", "tanium" },
|
||||
{ "bigfix", "bigfix" },
|
||||
{ "hcl technologies limited", "hcl" },
|
||||
{ "sap se", "sap" },
|
||||
{ "sap", "sap" },
|
||||
{ "servicenow", "servicenow" },
|
||||
|
||||
// Communication & collaboration
|
||||
{ "webex communications", "webex" },
|
||||
{ "discord inc.", "discord" },
|
||||
{ "signal messenger, llc", "signal" },
|
||||
{ "signal", "signal" },
|
||||
|
||||
// Multimedia & documents
|
||||
{ "videolan", "videolan" },
|
||||
{ "videolan team", "videolan" },
|
||||
{ "the document foundation", "libreoffice" },
|
||||
{ "foxit software inc.", "foxit" },
|
||||
{ "foxit", "foxit" },
|
||||
{ "tracker software products", "pdfxchange" },
|
||||
{ "audacity team", "audacity" },
|
||||
{ "gimp", "gimp" },
|
||||
{ "the gimp team", "gimp" },
|
||||
{ "irfanview", "irfanview" },
|
||||
{ "irfan skiljan", "irfanview" },
|
||||
|
||||
// Remote access
|
||||
{ "anydesk software gmbh", "anydesk" },
|
||||
{ "anydesk", "anydesk" },
|
||||
{ "rustdesk", "rustdesk" },
|
||||
{ "realvnc", "realvnc" },
|
||||
{ "realvnc ltd", "realvnc" },
|
||||
{ "devolutions", "devolutions" },
|
||||
{ "devolutions inc.", "devolutions" },
|
||||
{ "splashtop", "splashtop" },
|
||||
{ "splashtop inc.", "splashtop" },
|
||||
{ "bomgar", "bomgar" },
|
||||
|
||||
// Development tools
|
||||
{ "jetbrains s.r.o.", "jetbrains" },
|
||||
{ "jetbrains", "jetbrains" },
|
||||
{ "postman, inc.", "postman" },
|
||||
{ "postman", "postman" },
|
||||
{ "docker inc.", "docker" },
|
||||
{ "docker", "docker" },
|
||||
{ "github, inc.", "github" },
|
||||
{ "github", "github" },
|
||||
{ "atlassian", "atlassian" },
|
||||
{ "atlassian pty ltd", "atlassian" },
|
||||
{ "hashicorp", "hashicorp" },
|
||||
{ "hashicorp, inc.", "hashicorp" },
|
||||
{ "sublime hq pty ltd", "sublimetext" },
|
||||
|
||||
// Utilities
|
||||
{ "filezilla project", "filezilla" },
|
||||
{ "tim kosse", "filezilla" },
|
||||
{ "keepass", "keepass" },
|
||||
{ "dominik reichl", "keepass" },
|
||||
{ "greenshot", "greenshot" },
|
||||
{ "paint.net", "paintnet" },
|
||||
{ "dotpdn llc", "paintnet" },
|
||||
{ "win32diskimager", "win32diskimager" },
|
||||
{ "angusj", "7-zip" },
|
||||
{ "rarlab", "winrar" },
|
||||
{ "alexander roshal", "winrar" },
|
||||
{ "winrar", "winrar" },
|
||||
{ "wireshark foundation", "wireshark" },
|
||||
{ "the wireshark team", "wireshark" },
|
||||
{ "nmap project", "nmap" },
|
||||
{ "insecure.com", "nmap" },
|
||||
{ "voidtools", "everything" },
|
||||
|
||||
// Hardware vendors
|
||||
{ "intel corporation", "intel" },
|
||||
{ "intel", "intel" },
|
||||
{ "nvidia corporation", "nvidia" },
|
||||
{ "nvidia", "nvidia" },
|
||||
{ "amd", "amd" },
|
||||
{ "advanced micro devices, inc.", "amd" },
|
||||
{ "realtek semiconductor", "realtek" },
|
||||
{ "realtek", "realtek" },
|
||||
{ "logitech", "logitech" },
|
||||
{ "logitech inc.", "logitech" },
|
||||
|
||||
// Database & data tools
|
||||
{ "dbeaver corp", "dbeaver" },
|
||||
{ "mysql ab", "mysql" },
|
||||
{ "mariadb", "mariadb" },
|
||||
{ "the postgresql global development group", "postgresql" },
|
||||
{ "pgadmin development team", "pgadmin" },
|
||||
|
||||
// Backup & imaging
|
||||
{ "veeam software", "veeam" },
|
||||
{ "veeam", "veeam" },
|
||||
{ "acronis", "acronis" },
|
||||
{ "acronis international gmbh", "acronis" },
|
||||
{ "macrium software", "macrium" },
|
||||
{ "corel corporation", "corel" },
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Publisher suffix stripping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly string[] PublisherSuffixes = new[]
|
||||
{
|
||||
", inc.", " inc.", " inc", ", llc", " llc", ", ltd", " ltd", " ltd.",
|
||||
" corporation", " corp.", " corp", " gmbh", " ag", " se",
|
||||
" co.", " co", " group", " limited"
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exclude patterns for filtering noise
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly Regex KbUpdatePattern = new Regex(@"^KB\d{6,}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly string[] ExcludeContains = new[]
|
||||
{
|
||||
"Language Pack",
|
||||
"MUI Pack",
|
||||
"Spell Checking",
|
||||
"Proofing Tools",
|
||||
"Microsoft .NET Host",
|
||||
"Microsoft .NET Runtime",
|
||||
"Microsoft .NET AppHost",
|
||||
"Microsoft ASP.NET",
|
||||
"Microsoft Windows Desktop Runtime",
|
||||
"Additional Runtime",
|
||||
"Minimum Runtime",
|
||||
"Microsoft Update Health Tools",
|
||||
"Update for Windows",
|
||||
"Security Update for",
|
||||
"Hotfix for",
|
||||
};
|
||||
|
||||
private static readonly string[] ExcludeStartsWith = new[]
|
||||
{
|
||||
"vs_", // Visual Studio installer bootstrapper components
|
||||
"MSVC", // MSVC redistributable sub-components (the parent is kept)
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// AppX package filtering — strict whitelist for all publishers
|
||||
// Sandboxed AppX/MSIX apps rarely have CVEs and the sandbox limits
|
||||
// blast radius, so we only surface packages with known security or
|
||||
// enterprise relevance to keep the report clean.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly HashSet<string> AppxWhitelist = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// Intune / device management
|
||||
"Microsoft.CompanyPortal",
|
||||
"Microsoft.ManagementApp",
|
||||
|
||||
// Communication & productivity (CVE-relevant, enterprise-deployed)
|
||||
"Microsoft.Teams",
|
||||
"MSTeams",
|
||||
"MicrosoftTeams",
|
||||
"Microsoft.Office.Desktop",
|
||||
"Microsoft.OutlookForWindows",
|
||||
|
||||
// Remote access (security surface)
|
||||
"MicrosoftCorporationII.WindowsApp",
|
||||
"MicrosoftCorporationII.WindowsSubsystemForLinux",
|
||||
|
||||
// Developer tools (CVE-relevant)
|
||||
"Microsoft.WindowsTerminal",
|
||||
"Microsoft.VisualStudioCode",
|
||||
|
||||
// Power Platform (enterprise)
|
||||
"Microsoft.PowerAutomateDesktop",
|
||||
};
|
||||
|
||||
// Pattern: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash}
|
||||
private static readonly Regex AppxPackagePattern = new Regex(
|
||||
@"^(.+?)_(\d+(?:\.\d+)*)_([^_]*)__([a-z0-9]+)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regex patterns for normalization
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static readonly Regex ArchitecturePattern = new Regex(
|
||||
@"\s*[\(\-]\s*(?:x64|x86|64-bit|32-bit|amd64|arm64)\s*[\)]?",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex TrailingVersionPattern = new Regex(
|
||||
@"\s+v?\d+(?:\.\d+)*\s*$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex VersionExtractPattern = new Regex(
|
||||
@"(\d+(?:\.\d+){0,3})",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
public SoftwareInventoryAnalyzer(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void AnalyzeAtStartup()
|
||||
{
|
||||
_logger.Info($"{Name}: Running startup analysis (baseline inventory)");
|
||||
try
|
||||
{
|
||||
_startupInventory = CollectAndNormalize();
|
||||
EmitInventoryEvents("startup", EnrollmentPhase.Unknown, _startupInventory, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name}: Startup analysis failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void AnalyzeAtShutdown()
|
||||
{
|
||||
AnalyzeAtShutdown(whiteGlovePart: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown analysis with optional WhiteGlove part tag.
|
||||
/// Phase is always Unknown — analyzer events are NOT phase-declaration events.
|
||||
/// Only explicit phase-transition events (esp_phase_changed, agent_started) may carry
|
||||
/// a non-Unknown phase. Context is conveyed via DataJson (triggered_at, whiteglove_part).
|
||||
/// </summary>
|
||||
public void AnalyzeAtShutdown(int? whiteGlovePart)
|
||||
{
|
||||
_logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})");
|
||||
try
|
||||
{
|
||||
var currentInventory = CollectAndNormalize();
|
||||
var newInstalls = ComputeDelta(_startupInventory ?? new List<SoftwareEntry>(), currentInventory);
|
||||
EmitInventoryEvents("shutdown", EnrollmentPhase.Unknown, currentInventory, newInstalls, whiteGlovePart);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"{Name}: Shutdown analysis failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Collection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private List<SoftwareEntry> CollectAndNormalize()
|
||||
{
|
||||
var entries = new List<SoftwareEntry>();
|
||||
|
||||
// HKLM 64-bit and WOW6432Node (32-bit)
|
||||
foreach (var path in UninstallRegistryPaths)
|
||||
{
|
||||
var source = path.Contains("WOW6432Node") ? "HKLM_32" : "HKLM_64";
|
||||
CollectFromKey(Registry.LocalMachine, path, source, entries);
|
||||
}
|
||||
|
||||
// HKCU (may be empty during OOBE when running as SYSTEM)
|
||||
try
|
||||
{
|
||||
CollectFromKey(Registry.CurrentUser, UninstallRegistryPathHkcu, "HKCU", entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: HKCU read skipped (expected during OOBE): {ex.Message}");
|
||||
}
|
||||
|
||||
// HKU per-user Uninstall keys — catches per-user installs (VS Code user, Teams Classic, Spotify, etc.)
|
||||
CollectFromAllUserProfiles(entries);
|
||||
|
||||
// AppX/MSIX packages — catches modern packaged apps (Company Portal, new Teams, etc.)
|
||||
CollectAppxPackages(entries);
|
||||
|
||||
// Normalize all entries
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
NormalizeEntry(entry);
|
||||
}
|
||||
|
||||
_logger.Info($"{Name}: Collected {entries.Count} software entries");
|
||||
return entries;
|
||||
}
|
||||
|
||||
private void CollectFromKey(RegistryKey rootKey, string subKeyPath, string source, List<SoftwareEntry> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = rootKey.OpenSubKey(subKeyPath, writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: Registry key not found: {source}\\{subKeyPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
var subKeyNames = key.GetSubKeyNames();
|
||||
foreach (var subKeyName in subKeyNames)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var subKey = key.OpenSubKey(subKeyName, writable: false))
|
||||
{
|
||||
if (subKey == null)
|
||||
continue;
|
||||
|
||||
var displayName = subKey.GetValue("DisplayName")?.ToString();
|
||||
var systemComponent = subKey.GetValue("SystemComponent");
|
||||
var parentKeyName = subKey.GetValue("ParentKeyName")?.ToString();
|
||||
|
||||
if (ShouldExclude(displayName, systemComponent, parentKeyName))
|
||||
continue;
|
||||
|
||||
results.Add(new SoftwareEntry
|
||||
{
|
||||
DisplayName = displayName,
|
||||
DisplayVersion = subKey.GetValue("DisplayVersion")?.ToString(),
|
||||
Publisher = subKey.GetValue("Publisher")?.ToString(),
|
||||
InstallDate = subKey.GetValue("InstallDate")?.ToString(),
|
||||
InstallLocation = subKey.GetValue("InstallLocation")?.ToString(),
|
||||
UninstallString = subKey.GetValue("UninstallString")?.ToString(),
|
||||
IsWindowsInstaller = Convert.ToInt32(subKey.GetValue("WindowsInstaller") ?? 0) == 1,
|
||||
RegistrySource = source
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: Error reading subkey {subKeyName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"{Name}: Failed to read {source}\\{subKeyPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HKU per-user profile enumeration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void CollectFromAllUserProfiles(List<SoftwareEntry> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var profileList = Registry.LocalMachine.OpenSubKey(ProfileListPath, writable: false))
|
||||
{
|
||||
if (profileList == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: ProfileList key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Track SIDs we already read via HKCU to avoid duplicates
|
||||
var currentUserSid = GetCurrentUserSid();
|
||||
|
||||
foreach (var sid in profileList.GetSubKeyNames())
|
||||
{
|
||||
// Only real user profiles (S-1-5-21-*), skip well-known SIDs
|
||||
if (!sid.StartsWith("S-1-5-21-", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Skip current user SID — already covered by HKCU read above
|
||||
if (string.Equals(sid, currentUserSid, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
// HKU\<SID> is only available if the user's hive is loaded
|
||||
var hkuPath = $@"{sid}\{UninstallRegistryPathHkcu}";
|
||||
CollectFromKey(Registry.Users, hkuPath, $"HKU_{sid.Substring(sid.LastIndexOf('-') + 1)}", results);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: HKU read skipped for {sid}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: Per-user profile enumeration failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetCurrentUserSid()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent())
|
||||
{
|
||||
return identity.User?.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// AppX/MSIX package enumeration
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void CollectAppxPackages(List<SoftwareEntry> results)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var appxKey = Registry.LocalMachine.OpenSubKey(AppxAllUserStorePath, writable: false))
|
||||
{
|
||||
if (appxKey == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: AppX AllUserStore key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
int appxCount = 0;
|
||||
|
||||
foreach (var subKeyName in appxKey.GetSubKeyNames())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ShouldExcludeAppx(subKeyName))
|
||||
continue;
|
||||
|
||||
var parsed = ParseAppxPackageName(subKeyName);
|
||||
if (parsed == null)
|
||||
continue;
|
||||
|
||||
results.Add(parsed);
|
||||
appxCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: Error parsing AppX entry {subKeyName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"{Name}: Collected {appxCount} AppX packages");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"{Name}: AppX enumeration failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldExcludeAppx(string packageFullName)
|
||||
{
|
||||
// Extract the package identity (everything before the first underscore)
|
||||
var underscoreIndex = packageFullName.IndexOf('_');
|
||||
var packageId = underscoreIndex > 0
|
||||
? packageFullName.Substring(0, underscoreIndex)
|
||||
: packageFullName;
|
||||
|
||||
// Strict whitelist — only explicitly listed packages pass through
|
||||
return !AppxWhitelist.Contains(packageId);
|
||||
}
|
||||
|
||||
private static SoftwareEntry ParseAppxPackageName(string packageFullName)
|
||||
{
|
||||
// Format: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash}
|
||||
// Example: Microsoft.CompanyPortal_5.0.6155.0_x64__8wekyb3d8bbwe
|
||||
var match = AppxPackagePattern.Match(packageFullName);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var packageId = match.Groups[1].Value; // e.g. "Microsoft.CompanyPortal"
|
||||
var version = match.Groups[2].Value; // e.g. "5.0.6155.0"
|
||||
|
||||
// Split publisher from product: "Microsoft.CompanyPortal" → ("Microsoft", "CompanyPortal")
|
||||
var dotIndex = packageId.IndexOf('.');
|
||||
string publisher;
|
||||
string productName;
|
||||
if (dotIndex > 0)
|
||||
{
|
||||
publisher = packageId.Substring(0, dotIndex);
|
||||
productName = packageId.Substring(dotIndex + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
publisher = packageId;
|
||||
productName = packageId;
|
||||
}
|
||||
|
||||
// Make display name human-readable: "CompanyPortal" → "Company Portal"
|
||||
var displayName = SpacePascalCase(productName);
|
||||
|
||||
return new SoftwareEntry
|
||||
{
|
||||
DisplayName = displayName,
|
||||
DisplayVersion = version,
|
||||
Publisher = publisher,
|
||||
RegistrySource = "AppX",
|
||||
IsWindowsInstaller = false
|
||||
};
|
||||
}
|
||||
|
||||
private static string SpacePascalCase(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return input;
|
||||
|
||||
// Insert space before uppercase letters that follow lowercase letters
|
||||
// "CompanyPortal" → "Company Portal", but "MSTeams" → "MS Teams"
|
||||
var result = new System.Text.StringBuilder(input.Length + 4);
|
||||
result.Append(input[0]);
|
||||
for (int i = 1; i < input.Length; i++)
|
||||
{
|
||||
if (char.IsUpper(input[i]) && char.IsLower(input[i - 1]))
|
||||
result.Append(' ');
|
||||
result.Append(input[i]);
|
||||
}
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Filtering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static bool ShouldExclude(string displayName, object systemComponent, string parentKeyName)
|
||||
{
|
||||
// No display name = not a user-visible application
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return true;
|
||||
|
||||
// System component flag
|
||||
if (systemComponent != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Convert.ToInt32(systemComponent) == 1)
|
||||
return true;
|
||||
}
|
||||
catch { /* non-integer value, ignore */ }
|
||||
}
|
||||
|
||||
// Child component / update (has a parent)
|
||||
if (!string.IsNullOrEmpty(parentKeyName))
|
||||
return true;
|
||||
|
||||
// KB updates (Windows Updates)
|
||||
if (KbUpdatePattern.IsMatch(displayName))
|
||||
return true;
|
||||
|
||||
// Known noise patterns (contains)
|
||||
foreach (var pattern in ExcludeContains)
|
||||
{
|
||||
if (displayName.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Known noise patterns (starts with)
|
||||
foreach (var pattern in ExcludeStartsWith)
|
||||
{
|
||||
if (displayName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Normalization
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void NormalizeEntry(SoftwareEntry entry)
|
||||
{
|
||||
entry.NormalizedPublisher = NormalizePublisher(entry.Publisher);
|
||||
entry.NormalizedName = NormalizeName(entry.DisplayName);
|
||||
entry.NormalizedVersion = NormalizeVersion(entry.DisplayVersion);
|
||||
AssessConfidence(entry);
|
||||
}
|
||||
|
||||
private static string NormalizePublisher(string publisher)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(publisher))
|
||||
return string.Empty;
|
||||
|
||||
var trimmed = publisher.Trim();
|
||||
|
||||
// Check known publisher map
|
||||
if (PublisherMap.TryGetValue(trimmed, out var mapped))
|
||||
return mapped;
|
||||
|
||||
// Fallback: lowercase + strip common suffixes
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
foreach (var suffix in PublisherSuffixes)
|
||||
{
|
||||
if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lower = lower.Substring(0, lower.Length - suffix.Length).TrimEnd();
|
||||
break; // strip only one suffix
|
||||
}
|
||||
}
|
||||
|
||||
return lower;
|
||||
}
|
||||
|
||||
private static string NormalizeName(string displayName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return string.Empty;
|
||||
|
||||
var name = displayName.Trim().ToLowerInvariant();
|
||||
|
||||
// Strip architecture markers: (x64), (x86), (64-bit), - x64, etc.
|
||||
name = ArchitecturePattern.Replace(name, "");
|
||||
|
||||
// Strip trailing version-like suffix: "chrome 134.0.6998.89" → "chrome"
|
||||
name = TrailingVersionPattern.Replace(name, "");
|
||||
|
||||
return name.Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeVersion(string displayVersion)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayVersion))
|
||||
return string.Empty;
|
||||
|
||||
var match = VersionExtractPattern.Match(displayVersion.Trim());
|
||||
return match.Success ? match.Groups[1].Value : displayVersion.Trim();
|
||||
}
|
||||
|
||||
private void AssessConfidence(SoftwareEntry entry)
|
||||
{
|
||||
bool publisherKnown = !string.IsNullOrEmpty(entry.Publisher) &&
|
||||
PublisherMap.ContainsKey(entry.Publisher.Trim());
|
||||
bool versionClean = !string.IsNullOrEmpty(entry.DisplayVersion) &&
|
||||
VersionExtractPattern.IsMatch(entry.DisplayVersion);
|
||||
|
||||
if (publisherKnown && versionClean)
|
||||
entry.NormalizationConfidence = "high";
|
||||
else if (publisherKnown || versionClean)
|
||||
entry.NormalizationConfidence = "medium";
|
||||
else
|
||||
entry.NormalizationConfidence = "low";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Delta computation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static List<SoftwareEntry> ComputeDelta(List<SoftwareEntry> baseline, List<SoftwareEntry> current)
|
||||
{
|
||||
var baselineKeys = new HashSet<string>(
|
||||
baseline.Select(e => MakeKey(e)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return current.Where(e => !baselineKeys.Contains(MakeKey(e))).ToList();
|
||||
}
|
||||
|
||||
private static string MakeKey(SoftwareEntry e)
|
||||
{
|
||||
return $"{e.RegistrySource}|{e.DisplayName}|{e.DisplayVersion ?? ""}";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event emission (with chunking for large inventories)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void EmitInventoryEvents(
|
||||
string trigger,
|
||||
EnrollmentPhase phase,
|
||||
List<SoftwareEntry> inventory,
|
||||
List<SoftwareEntry> newInstalls,
|
||||
int? whiteGlovePart = null)
|
||||
{
|
||||
int totalChunks = Math.Max(1, (int)Math.Ceiling(inventory.Count / (double)ChunkSize));
|
||||
|
||||
var highCount = inventory.Count(e => e.NormalizationConfidence == "high");
|
||||
var medCount = inventory.Count(e => e.NormalizationConfidence == "medium");
|
||||
var lowCount = inventory.Count(e => e.NormalizationConfidence == "low");
|
||||
|
||||
for (int i = 0; i < totalChunks; i++)
|
||||
{
|
||||
var chunk = inventory.Skip(i * ChunkSize).Take(ChunkSize).ToList();
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "triggered_at", trigger },
|
||||
{ "total_count", inventory.Count },
|
||||
{ "chunk_index", i },
|
||||
{ "chunk_count", totalChunks },
|
||||
{ "inventory", chunk.Select(SerializeEntry).ToList() }
|
||||
};
|
||||
|
||||
// White Glove part tag (1 = pre-provisioning, 2 = user enrollment)
|
||||
if (whiteGlovePart.HasValue)
|
||||
{
|
||||
data["whiteglove_part"] = whiteGlovePart.Value;
|
||||
}
|
||||
|
||||
// Confidence summary only in first chunk
|
||||
if (i == 0)
|
||||
{
|
||||
data["confidence_summary"] = new Dictionary<string, object>
|
||||
{
|
||||
{ "high", highCount },
|
||||
{ "medium", medCount },
|
||||
{ "low", lowCount }
|
||||
};
|
||||
}
|
||||
|
||||
// New installs only in last chunk
|
||||
if (i == totalChunks - 1 && newInstalls != null)
|
||||
{
|
||||
data["new_installs_during_enrollment"] = newInstalls.Select(SerializeEntry).ToList();
|
||||
data["new_installs_count"] = newInstalls.Count;
|
||||
}
|
||||
|
||||
var message = i == 0
|
||||
? (trigger == "startup"
|
||||
? $"{Name}: Baseline inventory ({inventory.Count} items, {highCount} high-confidence)"
|
||||
: $"{Name}: Shutdown inventory ({inventory.Count} items, {newInstalls?.Count ?? 0} new during enrollment)")
|
||||
: $"{Name}: Inventory chunk {i + 1}/{totalChunks}";
|
||||
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "software_inventory_analysis",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = Name,
|
||||
Phase = phase,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> SerializeEntry(SoftwareEntry e)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "displayName", e.DisplayName ?? "" },
|
||||
{ "displayVersion", e.DisplayVersion ?? "" },
|
||||
{ "publisher", e.Publisher ?? "" },
|
||||
{ "installDate", e.InstallDate ?? "" },
|
||||
{ "registrySource", e.RegistrySource ?? "" },
|
||||
{ "normalizedPublisher", e.NormalizedPublisher ?? "" },
|
||||
{ "normalizedName", e.NormalizedName ?? "" },
|
||||
{ "normalizedVersion", e.NormalizedVersion ?? "" },
|
||||
{ "normalizationConfidence", e.NormalizationConfidence ?? "low" }
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Private model
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private class SoftwareEntry
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public string DisplayVersion { get; set; }
|
||||
public string Publisher { get; set; }
|
||||
public string InstallDate { get; set; }
|
||||
public string InstallLocation { get; set; }
|
||||
public string UninstallString { get; set; }
|
||||
public bool IsWindowsInstaller { get; set; }
|
||||
public string RegistrySource { get; set; }
|
||||
|
||||
// Normalized fields
|
||||
public string NormalizedPublisher { get; set; }
|
||||
public string NormalizedName { get; set; }
|
||||
public string NormalizedVersion { get; set; }
|
||||
public string NormalizationConfidence { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstract base for interval-based event collectors.
|
||||
/// Handles Timer lifecycle (Start/Stop/Dispose) so subclasses only implement Collect().
|
||||
/// </summary>
|
||||
public abstract class CollectorBase : IDisposable
|
||||
{
|
||||
protected readonly string SessionId;
|
||||
protected readonly string TenantId;
|
||||
protected readonly Action<EnrollmentEvent> EmitEvent;
|
||||
protected readonly AgentLogger Logger;
|
||||
|
||||
private readonly int _intervalSeconds;
|
||||
private Timer _timer;
|
||||
private bool _disposed;
|
||||
|
||||
protected CollectorBase(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger,
|
||||
int intervalSeconds)
|
||||
{
|
||||
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
EmitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_intervalSeconds = intervalSeconds;
|
||||
}
|
||||
|
||||
/// <summary>Starts the collection interval timer.</summary>
|
||||
public void Start()
|
||||
{
|
||||
if (_disposed) return;
|
||||
OnBeforeStart();
|
||||
var interval = TimeSpan.FromSeconds(_intervalSeconds);
|
||||
_timer = new Timer(_ => CollectSafe(), null, GetInitialDelay(), interval);
|
||||
Logger.Info($"[{GetType().Name}] Started (interval={_intervalSeconds}s)");
|
||||
}
|
||||
|
||||
/// <summary>Stops the collection timer without disposing the collector.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
Logger.Info($"[{GetType().Name}] Stopped");
|
||||
OnAfterStop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Stop();
|
||||
}
|
||||
|
||||
/// <summary>Override to perform collection. Called on each timer tick.</summary>
|
||||
protected abstract void Collect();
|
||||
|
||||
/// <summary>
|
||||
/// Override to return the initial delay before the first tick.
|
||||
/// Default: one full interval (same as the repeat interval).
|
||||
/// </summary>
|
||||
protected virtual TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(_intervalSeconds);
|
||||
|
||||
/// <summary>Optional hook called before the timer starts (e.g. to initialise counters).</summary>
|
||||
protected virtual void OnBeforeStart() { }
|
||||
|
||||
/// <summary>Optional hook called after the timer stops (e.g. to dispose counters).</summary>
|
||||
protected virtual void OnAfterStop() { }
|
||||
|
||||
/// <summary>Pauses the collection timer without stopping/disposing the collector.</summary>
|
||||
protected void PauseTimer()
|
||||
{
|
||||
_timer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
/// <summary>Resumes the collection timer after a pause.</summary>
|
||||
protected void ResumeTimer()
|
||||
{
|
||||
if (_disposed || _timer == null) return;
|
||||
var interval = TimeSpan.FromSeconds(_intervalSeconds);
|
||||
_timer.Change(TimeSpan.Zero, interval);
|
||||
}
|
||||
|
||||
private void CollectSafe()
|
||||
{
|
||||
try { Collect(); }
|
||||
catch (Exception ex) { Logger.Error($"[{GetType().Name}] Collection error: {ex.Message}", ex); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+457
@@ -0,0 +1,457 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Static hardware specifications (CPU, RAM, Disk, BIOS, Battery, GPU).
|
||||
/// Collected once at agent startup — these values do not change during enrollment.
|
||||
/// </summary>
|
||||
public partial class DeviceInfoCollector
|
||||
{
|
||||
private void CollectHardwareSpec()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
try
|
||||
{
|
||||
CollectCpuInfo(data);
|
||||
CollectMemoryInfo(data);
|
||||
CollectDiskInfo(data);
|
||||
CollectBiosInfo(data);
|
||||
CollectBatteryInfo(data);
|
||||
CollectGpuInfo(data);
|
||||
|
||||
var message = BuildHardwareSpecMessage(data);
|
||||
EmitDeviceInfoEvent("hardware_spec", message, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect hardware spec: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectCpuInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed FROM Win32_Processor"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
data["cpuName"] = obj["Name"]?.ToString()?.Trim();
|
||||
|
||||
if (obj["NumberOfCores"] != null)
|
||||
data["cpuCores"] = Convert.ToInt32(obj["NumberOfCores"]);
|
||||
|
||||
if (obj["NumberOfLogicalProcessors"] != null)
|
||||
data["cpuLogicalProcessors"] = Convert.ToInt32(obj["NumberOfLogicalProcessors"]);
|
||||
|
||||
if (obj["MaxClockSpeed"] != null)
|
||||
{
|
||||
var mhz = Convert.ToInt32(obj["MaxClockSpeed"]);
|
||||
data["cpuMaxClockSpeedGHz"] = Math.Round(mhz / 1000.0, 2);
|
||||
}
|
||||
|
||||
break; // first processor only
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect CPU info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectMemoryInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Total physical memory from Win32_ComputerSystem
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT TotalPhysicalMemory FROM Win32_ComputerSystem"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
if (obj["TotalPhysicalMemory"] != null)
|
||||
{
|
||||
var bytes = Convert.ToInt64(obj["TotalPhysicalMemory"]);
|
||||
data["ramTotalGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// DIMM details from Win32_PhysicalMemory
|
||||
var dimms = new List<Dictionary<string, object>>();
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Capacity, Speed, SMBIOSMemoryType, FormFactor FROM Win32_PhysicalMemory"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var dimm = new Dictionary<string, object>();
|
||||
|
||||
if (obj["Capacity"] != null)
|
||||
{
|
||||
var bytes = Convert.ToInt64(obj["Capacity"]);
|
||||
dimm["capacityGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1);
|
||||
}
|
||||
|
||||
if (obj["Speed"] != null)
|
||||
dimm["speed"] = Convert.ToInt32(obj["Speed"]);
|
||||
|
||||
if (obj["SMBIOSMemoryType"] != null)
|
||||
dimm["type"] = MapMemoryType(Convert.ToInt32(obj["SMBIOSMemoryType"]));
|
||||
|
||||
if (obj["FormFactor"] != null)
|
||||
dimm["formFactor"] = MapFormFactor(Convert.ToInt32(obj["FormFactor"]));
|
||||
|
||||
dimms.Add(dimm);
|
||||
}
|
||||
}
|
||||
|
||||
data["ramDimmCount"] = dimms.Count;
|
||||
|
||||
if (dimms.Count > 0)
|
||||
{
|
||||
var first = dimms[0];
|
||||
if (first.ContainsKey("speed"))
|
||||
data["ramSpeed"] = first["speed"];
|
||||
if (first.ContainsKey("type"))
|
||||
data["ramType"] = first["type"];
|
||||
if (first.ContainsKey("formFactor"))
|
||||
data["ramFormFactor"] = first["formFactor"];
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect memory info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectDiskInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var disks = new List<Dictionary<string, object>>();
|
||||
bool collected = false;
|
||||
|
||||
// Primary: MSFT_PhysicalDisk from Storage namespace (better SSD/NVMe detection)
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\Microsoft\Windows\Storage"),
|
||||
new ObjectQuery("SELECT FriendlyName, MediaType, Size, BusType FROM MSFT_PhysicalDisk")))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var disk = new Dictionary<string, object>();
|
||||
|
||||
disk["model"] = obj["FriendlyName"]?.ToString()?.Trim();
|
||||
|
||||
if (obj["Size"] != null)
|
||||
{
|
||||
var bytes = Convert.ToInt64(obj["Size"]);
|
||||
disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0);
|
||||
}
|
||||
|
||||
var mediaType = obj["MediaType"] != null ? Convert.ToInt32(obj["MediaType"]) : 0;
|
||||
var busType = obj["BusType"] != null ? Convert.ToInt32(obj["BusType"]) : 0;
|
||||
disk["mediaType"] = MapDiskMediaType(mediaType, busType);
|
||||
disk["busType"] = MapDiskBusType(busType);
|
||||
|
||||
disks.Add(disk);
|
||||
}
|
||||
collected = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Storage namespace not available — fall back to Win32_DiskDrive
|
||||
}
|
||||
|
||||
// Fallback: Win32_DiskDrive
|
||||
if (!collected)
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Model, Size, InterfaceType, MediaType FROM Win32_DiskDrive"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var disk = new Dictionary<string, object>();
|
||||
|
||||
disk["model"] = obj["Model"]?.ToString()?.Trim();
|
||||
|
||||
if (obj["Size"] != null)
|
||||
{
|
||||
var bytes = Convert.ToInt64(obj["Size"]);
|
||||
disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0);
|
||||
}
|
||||
|
||||
disk["mediaType"] = obj["MediaType"]?.ToString() ?? "Unknown";
|
||||
disk["busType"] = obj["InterfaceType"]?.ToString() ?? "Unknown";
|
||||
|
||||
disks.Add(disk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data["disks"] = disks;
|
||||
data["diskCount"] = disks.Count;
|
||||
|
||||
// System drive free space
|
||||
try
|
||||
{
|
||||
var systemDrive = new DriveInfo("C");
|
||||
if (systemDrive.IsReady)
|
||||
{
|
||||
data["systemDriveFreeGB"] = Math.Round(systemDrive.AvailableFreeSpace / (1024.0 * 1024.0 * 1024.0), 1);
|
||||
data["systemDriveTotalGB"] = Math.Round(systemDrive.TotalSize / (1024.0 * 1024.0 * 1024.0), 0);
|
||||
}
|
||||
}
|
||||
catch { /* C: drive info not available */ }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect disk info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectBiosInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT SMBIOSBIOSVersion, ReleaseDate, SMBIOSMajorVersion, SMBIOSMinorVersion FROM Win32_BIOS"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
data["biosVersion"] = obj["SMBIOSBIOSVersion"]?.ToString();
|
||||
|
||||
if (obj["ReleaseDate"] != null)
|
||||
{
|
||||
var wmiDate = obj["ReleaseDate"].ToString();
|
||||
data["biosReleaseDate"] = ParseWmiDateTime(wmiDate);
|
||||
}
|
||||
|
||||
var major = obj["SMBIOSMajorVersion"]?.ToString();
|
||||
var minor = obj["SMBIOSMinorVersion"]?.ToString();
|
||||
if (major != null && minor != null)
|
||||
data["smbiosVersion"] = $"{major}.{minor}";
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect BIOS info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectBatteryInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT DesignCapacity, FullChargeCapacity, EstimatedChargeRemaining, BatteryStatus FROM Win32_Battery"))
|
||||
{
|
||||
var results = searcher.Get();
|
||||
bool found = false;
|
||||
|
||||
foreach (ManagementObject obj in results)
|
||||
{
|
||||
found = true;
|
||||
data["batteryPresent"] = true;
|
||||
|
||||
var designCapacity = obj["DesignCapacity"] != null ? Convert.ToInt32(obj["DesignCapacity"]) : 0;
|
||||
var fullChargeCapacity = obj["FullChargeCapacity"] != null ? Convert.ToInt32(obj["FullChargeCapacity"]) : 0;
|
||||
|
||||
if (designCapacity > 0)
|
||||
data["batteryDesignCapacityMWh"] = designCapacity;
|
||||
|
||||
if (fullChargeCapacity > 0)
|
||||
data["batteryFullChargeCapacityMWh"] = fullChargeCapacity;
|
||||
|
||||
if (designCapacity > 0 && fullChargeCapacity > 0)
|
||||
data["batteryHealthPercent"] = (int)Math.Round(fullChargeCapacity * 100.0 / designCapacity);
|
||||
|
||||
if (obj["EstimatedChargeRemaining"] != null)
|
||||
data["batteryChargePercent"] = Convert.ToInt32(obj["EstimatedChargeRemaining"]);
|
||||
|
||||
if (obj["BatteryStatus"] != null)
|
||||
data["batteryStatus"] = MapBatteryStatus(Convert.ToInt32(obj["BatteryStatus"]));
|
||||
|
||||
break; // first battery only
|
||||
}
|
||||
|
||||
if (!found)
|
||||
data["batteryPresent"] = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect battery info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectGpuInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var gpus = new List<Dictionary<string, object>>();
|
||||
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var gpu = new Dictionary<string, object>();
|
||||
|
||||
gpu["name"] = obj["Name"]?.ToString()?.Trim();
|
||||
|
||||
if (obj["AdapterRAM"] != null)
|
||||
{
|
||||
var bytes = Convert.ToUInt32(obj["AdapterRAM"]);
|
||||
// WMI AdapterRAM is uint32 — caps at ~4GB. If value equals uint.MaxValue, it's unreliable.
|
||||
if (bytes > 0 && bytes < uint.MaxValue)
|
||||
gpu["adapterRAMGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1);
|
||||
}
|
||||
|
||||
gpu["driverVersion"] = obj["DriverVersion"]?.ToString();
|
||||
|
||||
gpus.Add(gpu);
|
||||
}
|
||||
}
|
||||
|
||||
data["gpus"] = gpus;
|
||||
data["gpuCount"] = gpus.Count;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect GPU info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#region Hardware Spec Helpers
|
||||
|
||||
private static string BuildHardwareSpecMessage(Dictionary<string, object> data)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (data.ContainsKey("cpuName"))
|
||||
parts.Add(data["cpuName"].ToString());
|
||||
|
||||
if (data.ContainsKey("ramTotalGB"))
|
||||
{
|
||||
var ram = $"{data["ramTotalGB"]} GB";
|
||||
if (data.ContainsKey("ramType"))
|
||||
ram += $" {data["ramType"]}";
|
||||
parts.Add(ram + " RAM");
|
||||
}
|
||||
|
||||
if (data.ContainsKey("disks") && data["disks"] is List<Dictionary<string, object>> disks && disks.Count > 0)
|
||||
{
|
||||
// Prefer non-USB disk for the summary message (USB drives are often boot media, not the actual system disk)
|
||||
var d = disks.FirstOrDefault(dk => dk.ContainsKey("busType") && !string.Equals(dk["busType"]?.ToString(), "USB", StringComparison.OrdinalIgnoreCase))
|
||||
?? disks[0];
|
||||
var diskDesc = d.ContainsKey("sizeGB") ? $"{d["sizeGB"]} GB" : "";
|
||||
if (d.ContainsKey("mediaType"))
|
||||
diskDesc += $" {d["mediaType"]}";
|
||||
parts.Add(diskDesc.Trim());
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : "Hardware spec collected";
|
||||
}
|
||||
|
||||
private static string MapMemoryType(int smbiosType)
|
||||
{
|
||||
switch (smbiosType)
|
||||
{
|
||||
case 20: return "DDR";
|
||||
case 21: return "DDR2";
|
||||
case 24: return "DDR3";
|
||||
case 26: return "DDR4";
|
||||
case 34: return "DDR5";
|
||||
default: return smbiosType > 0 ? $"Type {smbiosType}" : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapFormFactor(int formFactor)
|
||||
{
|
||||
switch (formFactor)
|
||||
{
|
||||
case 8: return "DIMM";
|
||||
case 12: return "SODIMM";
|
||||
default: return formFactor > 0 ? $"FormFactor {formFactor}" : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapDiskMediaType(int mediaType, int busType)
|
||||
{
|
||||
// MSFT_PhysicalDisk MediaType: 3=HDD, 4=SSD, 5=SCM
|
||||
// BusType: 17=NVMe
|
||||
switch (mediaType)
|
||||
{
|
||||
case 3: return "HDD";
|
||||
case 4: return busType == 17 ? "NVMe SSD" : "SSD";
|
||||
case 5: return "SCM";
|
||||
default: return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapDiskBusType(int busType)
|
||||
{
|
||||
switch (busType)
|
||||
{
|
||||
case 3: return "ATA";
|
||||
case 7: return "USB";
|
||||
case 11: return "SATA";
|
||||
case 17: return "NVMe";
|
||||
default: return busType > 0 ? $"BusType {busType}" : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
private static string MapBatteryStatus(int status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case 1: return "Discharging";
|
||||
case 2: return "AC Power";
|
||||
case 3: return "Fully Charged";
|
||||
case 4: return "Low";
|
||||
case 5: return "Critical";
|
||||
case 6: return "Charging";
|
||||
case 7: return "Charging High";
|
||||
case 8: return "Charging Low";
|
||||
case 9: return "Charging Critical";
|
||||
default: return $"Unknown ({status})";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParseWmiDateTime(string wmiDate)
|
||||
{
|
||||
// WMI datetime format: yyyyMMddHHmmss.ffffff+zzz (e.g., "20240115000000.000000+000")
|
||||
if (string.IsNullOrEmpty(wmiDate) || wmiDate.Length < 8)
|
||||
return wmiDate;
|
||||
|
||||
try
|
||||
{
|
||||
var year = wmiDate.Substring(0, 4);
|
||||
var month = wmiDate.Substring(4, 2);
|
||||
var day = wmiDate.Substring(6, 2);
|
||||
return $"{year}-{month}-{day}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return wmiDate;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+789
@@ -0,0 +1,789 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join),
|
||||
/// ESP configuration, WiFi signal, and helper methods.
|
||||
/// </summary>
|
||||
public partial class DeviceInfoCollector
|
||||
{
|
||||
/// <summary>
|
||||
/// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail
|
||||
/// that is NOT a provisioning placeholder (see <see cref="IsFooUserDetected"/>).
|
||||
/// Used by EnrollmentTracker to distinguish user-driven from device-only deployments
|
||||
/// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too).
|
||||
/// </summary>
|
||||
public bool HasAadJoinedUser { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the AAD join userEmail matches a known transient provisioning-account
|
||||
/// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot
|
||||
/// pre-provisioning (WhiteGlove) and are NOT a real AAD join — they are a soft
|
||||
/// positive WhiteGlove indicator and feed into <see cref="AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion.WhiteGloveClassifier"/>.
|
||||
/// </summary>
|
||||
public bool IsFooUserDetected { get; private set; }
|
||||
|
||||
private void CollectNetworkAndDnsConfiguration()
|
||||
{
|
||||
try
|
||||
{
|
||||
var adapters = new List<Dictionary<string, object>>();
|
||||
var dnsServers = new List<Dictionary<string, object>>();
|
||||
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
"SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " +
|
||||
"FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
// Network adapter info
|
||||
var adapter = new Dictionary<string, object>
|
||||
{
|
||||
{ "description", obj["Description"]?.ToString() },
|
||||
{ "macAddress", obj["MACAddress"]?.ToString() },
|
||||
{ "dhcpEnabled", obj["DHCPEnabled"]?.ToString() },
|
||||
{ "dhcpServer", obj["DHCPServer"]?.ToString() }
|
||||
};
|
||||
|
||||
var ipAddresses = obj["IPAddress"] as string[];
|
||||
if (ipAddresses != null)
|
||||
adapter["ipAddresses"] = string.Join(", ", ipAddresses);
|
||||
|
||||
var subnets = obj["IPSubnet"] as string[];
|
||||
if (subnets != null)
|
||||
adapter["subnets"] = string.Join(", ", subnets);
|
||||
|
||||
var gateways = obj["DefaultIPGateway"] as string[];
|
||||
if (gateways != null)
|
||||
adapter["gateways"] = string.Join(", ", gateways);
|
||||
|
||||
adapters.Add(adapter);
|
||||
|
||||
// DNS info (extracted from the same result set)
|
||||
var servers = obj["DNSServerSearchOrder"] as string[];
|
||||
if (servers != null && servers.Length > 0)
|
||||
{
|
||||
dnsServers.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "adapter", obj["Description"]?.ToString() },
|
||||
{ "servers", string.Join(", ", servers) }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EmitDeviceInfoEvent("network_adapters", "Network adapters configuration",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "adapterCount", adapters.Count },
|
||||
{ "adapters", adapters }
|
||||
});
|
||||
|
||||
EmitDeviceInfoEvent("dns_configuration", "DNS server configuration",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "dnsEntries", dnsServers }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectProxyConfiguration()
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1";
|
||||
data["proxyServer"] = key.GetValue("ProxyServer")?.ToString();
|
||||
data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString();
|
||||
data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Also check WinHTTP proxy
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"))
|
||||
{
|
||||
data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null;
|
||||
}
|
||||
|
||||
var proxyType = "Direct";
|
||||
if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"])
|
||||
proxyType = "Proxy";
|
||||
else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null)
|
||||
proxyType = "PAC";
|
||||
|
||||
data["proxyType"] = proxyType;
|
||||
|
||||
EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status.
|
||||
/// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join.
|
||||
/// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed),
|
||||
/// not the actual live enrollment state.
|
||||
/// </summary>
|
||||
private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile()
|
||||
{
|
||||
var detectedType = "v1";
|
||||
var isHybridJoin = false;
|
||||
int? detectedAutopilotMode = null;
|
||||
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
// Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info)
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
// The registry key contains individual values that together form the Autopilot profile
|
||||
// Read all available values and add them to data dictionary
|
||||
foreach (var valueName in key.GetValueNames())
|
||||
{
|
||||
var value = key.GetValue(valueName);
|
||||
if (value != null)
|
||||
{
|
||||
// Store with original casing from registry
|
||||
var valueAsString = value.ToString();
|
||||
if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
valueAsString = TryFormatJson(valueAsString);
|
||||
}
|
||||
|
||||
data[valueName] = valueAsString;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract key values for enrollment type detection
|
||||
var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
|
||||
? data["CloudAssignedDeviceRegistration"]?.ToString()
|
||||
: null;
|
||||
var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
|
||||
? data["CloudAssignedEspEnabled"]?.ToString()
|
||||
: null;
|
||||
|
||||
// Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
|
||||
if (deviceReg == "2" || espEnabled == "0")
|
||||
detectedType = "v2";
|
||||
else
|
||||
detectedType = "v1";
|
||||
|
||||
// --- Interpret AutopilotMode (profile capability, not live state) ---
|
||||
// 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
|
||||
var autopilotModeStr = data.ContainsKey("AutopilotMode")
|
||||
? data["AutopilotMode"]?.ToString()
|
||||
: null;
|
||||
if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode))
|
||||
{
|
||||
detectedAutopilotMode = autopilotMode;
|
||||
// Only mode 0 is confirmed — modes 1, 2, 3+ are shown as raw integers
|
||||
// until their meaning is verified across all Autopilot profile variants.
|
||||
string modeLabel;
|
||||
switch (autopilotMode)
|
||||
{
|
||||
case 0: modeLabel = $"User Driven ({autopilotMode})"; break;
|
||||
default: modeLabel = $"{autopilotMode}"; break;
|
||||
}
|
||||
data["autopilotModeLabel"] = modeLabel;
|
||||
_logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})");
|
||||
}
|
||||
|
||||
// --- Interpret CloudAssignedDomainJoinMethod ---
|
||||
// 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join
|
||||
// This value may be a top-level registry value OR embedded in PolicyJsonCache JSON.
|
||||
var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod")
|
||||
? data["CloudAssignedDomainJoinMethod"]?.ToString()
|
||||
: null;
|
||||
|
||||
// Fallback: extract from PolicyJsonCache if not a top-level registry value
|
||||
if (domainJoinMethodStr == null && data.ContainsKey("PolicyJsonCache"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var policyRaw = data["PolicyJsonCache"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(policyRaw))
|
||||
{
|
||||
using (var pDoc = System.Text.Json.JsonDocument.Parse(policyRaw))
|
||||
{
|
||||
if (pDoc.RootElement.TryGetProperty("CloudAssignedDomainJoinMethod", out var djmProp))
|
||||
{
|
||||
domainJoinMethodStr = djmProp.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Trace($"EnrollmentTracker: Failed to parse PolicyJsonCache for DomainJoinMethod: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod))
|
||||
{
|
||||
string joinLabel;
|
||||
switch (domainJoinMethod)
|
||||
{
|
||||
case 0: joinLabel = "Entra Join"; break;
|
||||
case 1: joinLabel = "Hybrid Azure AD Join"; break;
|
||||
default: joinLabel = $"Unknown ({domainJoinMethod})"; break;
|
||||
}
|
||||
data["domainJoinMethodLabel"] = joinLabel;
|
||||
|
||||
// Hybrid Join is reliably indicated by DomainJoinMethod == 1
|
||||
isHybridJoin = domainJoinMethod == 1;
|
||||
}
|
||||
|
||||
data["isHybridJoin"] = isHybridJoin;
|
||||
|
||||
if (isHybridJoin)
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected");
|
||||
}
|
||||
|
||||
_logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found");
|
||||
}
|
||||
}
|
||||
|
||||
// Include enrollment type in autopilot_profile event data
|
||||
data["enrollmentType"] = detectedType;
|
||||
|
||||
EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data);
|
||||
|
||||
// Emit dedicated enrollment_type_detected event for easy filtering
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "enrollment_type_detected",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = detectedType == "v2"
|
||||
? "Enrollment type: Autopilot v2 (Windows Device Preparation)"
|
||||
: "Enrollment type: Autopilot v1 (Classic ESP)",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "enrollmentType", detectedType },
|
||||
{ "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" },
|
||||
{ "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" },
|
||||
{ "detectionRule", detectedType == "v2"
|
||||
? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2"
|
||||
? "CloudAssignedDeviceRegistration=2"
|
||||
: "CloudAssignedEspEnabled=0")
|
||||
: "default (no v2 indicators)" }
|
||||
}
|
||||
});
|
||||
|
||||
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}");
|
||||
}
|
||||
|
||||
return (detectedType, isHybridJoin, detectedAutopilotMode);
|
||||
}
|
||||
|
||||
private void CollectSecureBootStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State"))
|
||||
{
|
||||
if (key != null)
|
||||
{
|
||||
var value = key.GetValue("UEFISecureBootEnabled");
|
||||
data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
data["uefiSecureBootEnabled"] = false;
|
||||
data["note"] = "SecureBoot registry key not found";
|
||||
}
|
||||
}
|
||||
|
||||
// UEFI CA 2023 certificate deployment status (expires June 2026)
|
||||
using (var servicingKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing"))
|
||||
{
|
||||
if (servicingKey != null)
|
||||
{
|
||||
data["uefiCA2023Status"] = servicingKey.GetValue("UEFICA2023Status")?.ToString() ?? "unknown";
|
||||
var capable = servicingKey.GetValue("WindowsUEFICA2023Capable");
|
||||
if (capable != null)
|
||||
data["windowsUefiCA2023Capable"] = Convert.ToInt32(capable);
|
||||
var error = servicingKey.GetValue("UEFICA2023Error");
|
||||
if (error != null && Convert.ToInt32(error) != 0)
|
||||
data["uefiCA2023Error"] = Convert.ToInt32(error);
|
||||
data["confidenceLevel"] = servicingKey.GetValue("ConfidenceLevel")?.ToString() ?? "unknown";
|
||||
}
|
||||
else
|
||||
{
|
||||
data["uefiCA2023Status"] = "notfound";
|
||||
}
|
||||
}
|
||||
|
||||
EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}, CA2023: {data["uefiCA2023Status"]}", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectBitLockerStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var volumes = new List<Dictionary<string, object>>();
|
||||
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"),
|
||||
new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume")))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
volumes.Add(new Dictionary<string, object>
|
||||
{
|
||||
{ "driveLetter", obj["DriveLetter"]?.ToString() },
|
||||
{ "protectionStatus", obj["ProtectionStatus"]?.ToString() },
|
||||
{ "conversionStatus", obj["ConversionStatus"]?.ToString() },
|
||||
{ "encryptionMethod", obj["EncryptionMethod"]?.ToString() }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
data["volumes"] = volumes;
|
||||
data["systemDriveProtected"] = volumes.Any(v =>
|
||||
v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1");
|
||||
|
||||
EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectTpmStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
using (var searcher = new ManagementObjectSearcher(
|
||||
new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"),
|
||||
new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm")))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True";
|
||||
data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True";
|
||||
data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True";
|
||||
data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString();
|
||||
data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString();
|
||||
|
||||
// SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version
|
||||
var specVersion = obj["SpecVersion"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(specVersion))
|
||||
{
|
||||
var parts = specVersion.Split(',');
|
||||
data["specVersion"] = parts[0].Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data);
|
||||
}
|
||||
else
|
||||
{
|
||||
data["available"] = false;
|
||||
EmitDeviceInfoEvent("tpm_status", "TPM: not available", data);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectAadJoinStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
// Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@)
|
||||
// are transient provisioning accounts and must NOT be treated as a real AAD join;
|
||||
// HasAadJoinedUser is only set for real user emails.
|
||||
if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser))
|
||||
{
|
||||
// Still need TenantId — open the subkey directly when we have a thumbprint.
|
||||
if (!string.IsNullOrEmpty(thumbprint))
|
||||
{
|
||||
using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath))
|
||||
using (var subKey = joinInfoKey?.OpenSubKey(thumbprint))
|
||||
{
|
||||
data["tenantId"] = subKey?.GetValue("TenantId")?.ToString();
|
||||
}
|
||||
|
||||
data["userEmail"] = userEmail;
|
||||
data["joinType"] = "Azure AD Joined";
|
||||
data["thumbprint"] = thumbprint;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userEmail))
|
||||
{
|
||||
if (isPlaceholderUser)
|
||||
{
|
||||
IsFooUserDetected = true;
|
||||
data["isFooUser"] = true;
|
||||
_logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join");
|
||||
}
|
||||
else
|
||||
{
|
||||
HasAadJoinedUser = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
data["joinType"] = "Not Joined";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
data["joinType"] = "Not Joined";
|
||||
}
|
||||
|
||||
object joinTypeValue;
|
||||
var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown";
|
||||
EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads ESP skip configuration from the enrollment FirstSync registry key.
|
||||
/// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
|
||||
/// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
|
||||
/// We use defensive interpretation: non-null AND non-zero = skip.
|
||||
/// </summary>
|
||||
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
|
||||
{
|
||||
bool? skipUser = null;
|
||||
bool? skipDevice = null;
|
||||
string enrollmentGuid = null;
|
||||
|
||||
try
|
||||
{
|
||||
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
|
||||
|
||||
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
|
||||
{
|
||||
if (enrollmentsKey == null)
|
||||
{
|
||||
_logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
// Find the MDM enrollment entry (EnrollmentType == 6)
|
||||
foreach (var guid in enrollmentsKey.GetSubKeyNames())
|
||||
{
|
||||
using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
|
||||
{
|
||||
if (enrollmentKey == null) continue;
|
||||
|
||||
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
|
||||
if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
|
||||
continue;
|
||||
|
||||
enrollmentGuid = guid;
|
||||
|
||||
// Read FirstSync subkey
|
||||
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
|
||||
{
|
||||
if (firstSyncKey == null)
|
||||
{
|
||||
_logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
|
||||
break;
|
||||
}
|
||||
|
||||
var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
|
||||
if (rawSkipUser != null)
|
||||
skipUser = Convert.ToInt32(rawSkipUser) != 0;
|
||||
|
||||
var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
|
||||
if (rawSkipDevice != null)
|
||||
skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
|
||||
}
|
||||
|
||||
break; // Found the MDM enrollment entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "source", "registry_firstsync" }
|
||||
};
|
||||
if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
|
||||
if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
|
||||
if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
|
||||
|
||||
var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}");
|
||||
}
|
||||
|
||||
return (skipUser, skipDevice);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects active network interface details (name, type, link speed, MAC) using
|
||||
/// System.Net.NetworkInformation — instant, no process spawn.
|
||||
/// If the active interface is WiFi, fires off a separate async task to collect
|
||||
/// WiFi signal info via netsh (fire-and-forget, never blocks).
|
||||
/// </summary>
|
||||
private void CollectActiveNetworkInterfaceInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeNic = FindActiveNetworkInterface();
|
||||
|
||||
if (activeNic == null)
|
||||
{
|
||||
EmitDeviceInfoEvent("network_interface_info", "No active network interface found",
|
||||
new Dictionary<string, object> { { "status", "no_active_interface" } });
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "adapterName", activeNic.Name },
|
||||
{ "adapterDescription", activeNic.Description },
|
||||
{ "macAddress", activeNic.GetPhysicalAddress().ToString() },
|
||||
{ "interfaceType", activeNic.NetworkInterfaceType.ToString() },
|
||||
{ "linkSpeedMbps", activeNic.Speed / 1_000_000 },
|
||||
{ "interfaceId", activeNic.Id }
|
||||
};
|
||||
|
||||
var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
|
||||
data["connectionType"] = isWifi ? "WiFi" : "Ethernet";
|
||||
|
||||
var gateways = activeNic.GetIPProperties().GatewayAddresses;
|
||||
if (gateways.Count > 0)
|
||||
{
|
||||
var gwList = new List<string>();
|
||||
foreach (var gw in gateways)
|
||||
gwList.Add(gw.Address.ToString());
|
||||
data["gateways"] = string.Join(", ", gwList);
|
||||
}
|
||||
|
||||
var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)";
|
||||
EmitDeviceInfoEvent("network_interface_info", message, data);
|
||||
|
||||
// Fire-and-forget WiFi signal collection — separate event, never blocks
|
||||
if (isWifi)
|
||||
{
|
||||
Task.Run(() => CollectWiFiSignalInfo());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
|
||||
/// </summary>
|
||||
private static NetworkInterface FindActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var nic in interfaces)
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
var ipProps = nic.GetIPProperties();
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
return nic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi signal info via netsh wlan show interfaces and emits a separate
|
||||
/// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run —
|
||||
/// never blocks other collection. VMs without WiFi service simply get no event.
|
||||
/// </summary>
|
||||
private void CollectWiFiSignalInfo()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiSsid"] = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var signal))
|
||||
data["wifiSignalPercent"] = signal;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
data["wifiRadioType"] = value;
|
||||
}
|
||||
else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value, out var channel))
|
||||
data["wifiChannel"] = channel;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
var message = data.ContainsKey("wifiSsid")
|
||||
? $"WiFi: {data["wifiSsid"]}"
|
||||
: "WiFi signal info";
|
||||
if (data.ContainsKey("wifiSignalPercent"))
|
||||
message += $", Signal: {data["wifiSignalPercent"]}%";
|
||||
if (data.ContainsKey("wifiRadioType"))
|
||||
message += $" ({data["wifiRadioType"]})";
|
||||
|
||||
EmitDeviceInfoEvent("wifi_signal_info", message, data);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"WiFi signal info collection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void EmitDeviceInfoEvent(string eventType, string message, Dictionary<string, object> data)
|
||||
{
|
||||
_emitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = eventType,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
|
||||
private static string TryFormatJson(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return input ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
using (var doc = JsonDocument.Parse(input))
|
||||
{
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return input;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Collects static and semi-static device information at enrollment startup and end.
|
||||
/// Emits device info events (os_info, boot_time, network_adapters, dns_configuration,
|
||||
/// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status,
|
||||
/// bitlocker_status, aad_join_status) via the provided emitEvent action.
|
||||
/// </summary>
|
||||
public partial class DeviceInfoCollector
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
|
||||
|
||||
public DeviceInfoCollector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all device info collectors at agent startup.
|
||||
/// Returns the detected enrollment type, Hybrid Join flag, and ESP skip configuration from registry.
|
||||
/// </summary>
|
||||
public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage, int? autopilotMode, bool hasAadJoinedUser, bool isFooUserDetected) CollectAll()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: collecting device info (at start)");
|
||||
|
||||
CollectOSInfoAndBootTime();
|
||||
CollectNetworkAndDnsConfiguration();
|
||||
CollectProxyConfiguration();
|
||||
var profileResult = CollectAutopilotProfile();
|
||||
var espConfig = CollectEspConfiguration();
|
||||
CollectSecureBootStatus();
|
||||
CollectBitLockerStatus();
|
||||
CollectTpmStatus();
|
||||
CollectAadJoinStatus();
|
||||
CollectActiveNetworkInterfaceInfo();
|
||||
CollectHardwareSpec();
|
||||
|
||||
return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage, profileResult.autopilotMode, HasAadJoinedUser, IsFooUserDetected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-collects enrollment-dependent device info that was likely empty/incomplete at startup.
|
||||
/// Called when DeviceSetup phase is first detected (ESP started = MDM enrollment + AAD join complete).
|
||||
/// Re-emits: aad_join_status, autopilot_profile, esp_config_detected, tpm_status.
|
||||
/// </summary>
|
||||
public void CollectAtEnrollmentStart()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: collecting device info (at enrollment start)");
|
||||
|
||||
CollectAadJoinStatus();
|
||||
CollectAutopilotProfile();
|
||||
CollectEspConfiguration();
|
||||
CollectTpmStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy).
|
||||
/// Called at enrollment complete / FinalizingSetup transition to capture final state.
|
||||
/// </summary>
|
||||
public void CollectAtEnd()
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: collecting device info (at end)");
|
||||
|
||||
// BitLocker can be enabled during enrollment via policy
|
||||
CollectBitLockerStatus();
|
||||
|
||||
// Re-collect active network interface info (link speed, WiFi signal may have changed)
|
||||
CollectActiveNetworkInterfaceInfo();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects OS info and boot time in a single WMI query against Win32_OperatingSystem.
|
||||
/// Previously these were three separate queries (Caption, LastBootUpTime via two searchers);
|
||||
/// consolidated to a single round-trip.
|
||||
/// Emits two events (os_info, boot_time) for backward compatibility.
|
||||
/// </summary>
|
||||
private void CollectOSInfoAndBootTime()
|
||||
{
|
||||
string osName = string.Empty;
|
||||
var bootData = new Dictionary<string, object>();
|
||||
|
||||
// Single WMI query for both Caption (OS name) and LastBootUpTime
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher("SELECT Caption, LastBootUpTime FROM Win32_OperatingSystem"))
|
||||
{
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
osName = obj["Caption"]?.ToString() ?? string.Empty;
|
||||
|
||||
var lastBootUpTimeStr = obj["LastBootUpTime"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(lastBootUpTimeStr))
|
||||
{
|
||||
var bootTimeLocal = ManagementDateTimeConverter.ToDateTime(lastBootUpTimeStr);
|
||||
var bootTimeUtc = DateTime.SpecifyKind(bootTimeLocal.ToUniversalTime(), DateTimeKind.Utc);
|
||||
|
||||
bootData["bootTimeUtc"] = bootTimeUtc.ToString("o");
|
||||
bootData["bootTime"] = bootTimeUtc.ToString("o");
|
||||
|
||||
var uptime = DateTime.UtcNow - bootTimeUtc;
|
||||
bootData["uptimeMinutes"] = (int)uptime.TotalMinutes;
|
||||
bootData["uptimeHours"] = uptime.TotalHours;
|
||||
|
||||
_logger.Debug($"Boot time (UTC): {bootTimeUtc:o}, Uptime: {uptime.TotalMinutes:F1} minutes");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to query Win32_OperatingSystem: {ex.Message}");
|
||||
}
|
||||
|
||||
// Emit os_info event
|
||||
try
|
||||
{
|
||||
// Read individual registry values; reuse DeviceInfoProvider helpers where possible
|
||||
var edition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "EditionID", string.Empty) ?? string.Empty).ToString();
|
||||
var compositionEdition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CompositionEditionID", string.Empty) ?? string.Empty).ToString();
|
||||
var currentBuild = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CurrentBuild", string.Empty) ?? string.Empty).ToString();
|
||||
var buildBranch = (Registry.GetValue(RegKeyWindowsCurrentVersion, "BuildBranch", string.Empty) ?? string.Empty).ToString();
|
||||
var displayVersion = DeviceInfoProvider.GetOsDisplayVersion();
|
||||
var buildRevision = (Registry.GetValue(RegKeyWindowsCurrentVersion, "UBR", string.Empty) ?? string.Empty).ToString();
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "version", Environment.OSVersion.Version.ToString() },
|
||||
{ "osVersion", osName },
|
||||
{ "edition", edition },
|
||||
{ "compositionEdition", compositionEdition },
|
||||
{ "currentBuild", currentBuild },
|
||||
{ "buildBranch", buildBranch },
|
||||
{ "displayVersion", displayVersion },
|
||||
{ "buildRevision", buildRevision }
|
||||
};
|
||||
|
||||
var osVersion = osName;
|
||||
|
||||
var message = string.IsNullOrWhiteSpace(osVersion) ? "OS information collected" : osVersion;
|
||||
if (!string.IsNullOrWhiteSpace(displayVersion))
|
||||
{
|
||||
message += $" {displayVersion}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(currentBuild))
|
||||
{
|
||||
message += string.IsNullOrWhiteSpace(buildRevision)
|
||||
? $" (Build {currentBuild})"
|
||||
: $" (Build {currentBuild}.{buildRevision})";
|
||||
}
|
||||
|
||||
EmitDeviceInfoEvent("os_info", message, data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect OS info: {ex.Message}");
|
||||
}
|
||||
|
||||
// Emit boot_time event
|
||||
try
|
||||
{
|
||||
if (!bootData.ContainsKey("bootTime"))
|
||||
{
|
||||
bootData["note"] = "Boot time could not be determined";
|
||||
}
|
||||
|
||||
EmitDeviceInfoEvent("boot_time",
|
||||
bootData.ContainsKey("bootTime")
|
||||
? $"Last boot: {bootData["bootTime"]}"
|
||||
: "Boot time unavailable",
|
||||
bootData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to emit boot time event: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects network adapter and DNS configuration in a single WMI query.
|
||||
/// Previously these were two separate queries against Win32_NetworkAdapterConfiguration;
|
||||
/// consolidated to halve the WMI round-trips (each creates a new COM connection).
|
||||
/// Emits two events (network_adapters, dns_configuration) for backward compatibility.
|
||||
/// </summary>
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides device hardware and OS information via WMI and registry queries.
|
||||
/// </summary>
|
||||
public static class DeviceInfoProvider
|
||||
{
|
||||
public static string GetSerialNumber()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
|
||||
{
|
||||
foreach (var obj in searcher.Get())
|
||||
{
|
||||
return obj["SerialNumber"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
public static string GetManufacturer()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem"))
|
||||
{
|
||||
foreach (var obj in searcher.Get())
|
||||
{
|
||||
return obj["Manufacturer"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
public static string GetModel()
|
||||
{
|
||||
try
|
||||
{
|
||||
var manufacturer = GetManufacturer();
|
||||
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
// Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9")
|
||||
// in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model
|
||||
// which only contains the internal type number (e.g. "20Y3S0FP00")
|
||||
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
|
||||
{
|
||||
foreach (var obj in searcher.Get())
|
||||
{
|
||||
return obj["Version"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem"))
|
||||
{
|
||||
foreach (var obj in searcher.Get())
|
||||
{
|
||||
return obj["Model"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
public static string GetOsEdition()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
|
||||
{
|
||||
return key?.GetValue("EditionID")?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the OS product name, e.g. "Microsoft Windows 11 Pro".
|
||||
/// Uses WMI Win32_OperatingSystem.Caption which is the authoritative source.
|
||||
/// </summary>
|
||||
public static string GetOsName()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem"))
|
||||
{
|
||||
foreach (var obj in searcher.Get())
|
||||
{
|
||||
return obj["Caption"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the real OS build string, e.g. "26220.7934" (CurrentBuild.UBR).
|
||||
/// Falls back to CurrentBuild alone if UBR is unavailable.
|
||||
/// </summary>
|
||||
public static string GetOsBuild()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
|
||||
{
|
||||
var currentBuild = key?.GetValue("CurrentBuild")?.ToString();
|
||||
if (string.IsNullOrEmpty(currentBuild))
|
||||
return string.Empty;
|
||||
|
||||
var ubr = key?.GetValue("UBR");
|
||||
return ubr != null ? $"{currentBuild}.{ubr}" : currentBuild;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the OS display version, e.g. "25H2", "24H2".
|
||||
/// </summary>
|
||||
public static string GetOsDisplayVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
|
||||
{
|
||||
return key?.GetValue("DisplayVersion")?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class CommandCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "command_allowlisted";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var command = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return data;
|
||||
|
||||
// SECURITY: Check command against allowlist
|
||||
if (!GatherRuleGuards.IsCommandAllowed(command, context.UnrestrictedMode))
|
||||
{
|
||||
context.Logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})");
|
||||
data["blocked"] = true;
|
||||
data["reason"] = "Command not on allowlist";
|
||||
data["command"] = command;
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "security_warning",
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})",
|
||||
Data = data
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) &&
|
||||
!command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) &&
|
||||
!command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) &&
|
||||
!command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) &&
|
||||
!command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
ProcessStartInfo psi;
|
||||
if (isPowerShell)
|
||||
{
|
||||
// Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection
|
||||
var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command));
|
||||
psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.PowerShell,
|
||||
Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Cmd,
|
||||
Arguments = $"/c {command}",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
}
|
||||
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(30000); // 30 second timeout
|
||||
|
||||
data["command"] = command;
|
||||
data["exit_code"] = process.ExitCode;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class EventLogCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "eventlog";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var logName = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(logName))
|
||||
return data;
|
||||
|
||||
// Optional: escalate severity when matching entries are found
|
||||
string severityIfFound = null;
|
||||
rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound);
|
||||
|
||||
try
|
||||
{
|
||||
int maxEntries = 10;
|
||||
if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr))
|
||||
{
|
||||
int.TryParse(maxEntriesStr, out maxEntries);
|
||||
maxEntries = Math.Min(maxEntries, 50); // Cap at 50
|
||||
}
|
||||
|
||||
// Build XPath query with optional filters
|
||||
var conditions = new List<string>();
|
||||
|
||||
string sourceFilter = null;
|
||||
if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter)
|
||||
&& !string.IsNullOrEmpty(sourceFilter))
|
||||
{
|
||||
conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']");
|
||||
}
|
||||
|
||||
string eventIdFilter = null;
|
||||
if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter)
|
||||
&& !string.IsNullOrEmpty(eventIdFilter))
|
||||
{
|
||||
conditions.Add($"EventID={eventIdFilter}");
|
||||
}
|
||||
|
||||
string xpath = conditions.Count > 0
|
||||
? $"*[System[{string.Join(" and ", conditions)}]]"
|
||||
: "*";
|
||||
|
||||
string messageFilter = null;
|
||||
rule.Parameters?.TryGetValue("messageFilter", out messageFilter);
|
||||
|
||||
var query = new EventLogQuery(logName, PathType.LogName, xpath)
|
||||
{
|
||||
ReverseDirection = true // newest first
|
||||
};
|
||||
|
||||
data["log_name"] = logName;
|
||||
var entries = new List<string>();
|
||||
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
var message = "";
|
||||
try { message = record.FormatDescription() ?? ""; }
|
||||
catch { /* Some events lack formatting resources */ }
|
||||
|
||||
// Apply message filter (wildcard contains)
|
||||
if (!string.IsNullOrEmpty(messageFilter))
|
||||
{
|
||||
var pattern = messageFilter.Trim('*');
|
||||
if (string.IsNullOrEmpty(pattern) ||
|
||||
message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
}
|
||||
|
||||
var level = "";
|
||||
try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; }
|
||||
catch { level = record.Level?.ToString() ?? "Unknown"; }
|
||||
|
||||
var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message;
|
||||
entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
data[$"entry_{i + 1}"] = entries[i];
|
||||
}
|
||||
data["entries_returned"] = entries.Count;
|
||||
|
||||
// Apply conditional severity when matching entries are found
|
||||
if (entries.Count > 0 && !string.IsNullOrEmpty(severityIfFound))
|
||||
data["_severityOverride"] = severityIfFound;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string EscapeXPath(string value)
|
||||
{
|
||||
return value.Replace("'", "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class FileCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "file";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var filePath = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return data;
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
// Guard: only allow enrollment-relevant file paths
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
|
||||
return context.EmitSecurityWarning(rule, "file", filePath);
|
||||
|
||||
// Optional conditional severity based on existence
|
||||
string severityIfExists = null;
|
||||
string severityIfNotExists = null;
|
||||
rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists);
|
||||
rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var info = new FileInfo(filePath);
|
||||
data["exists"] = true;
|
||||
data["path"] = filePath;
|
||||
data["size_bytes"] = info.Length;
|
||||
data["last_modified"] = info.LastWriteTimeUtc.ToString("o");
|
||||
data["created"] = info.CreationTimeUtc.ToString("o");
|
||||
|
||||
// Read content if requested and file is small enough
|
||||
string readContent;
|
||||
if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) &&
|
||||
readContent == "true" && info.Length < 50000) // Max 50KB
|
||||
{
|
||||
// Read the last 4000 chars — most useful for log files where recent entries matter
|
||||
const int maxChars = 4000;
|
||||
string content;
|
||||
bool truncated = false;
|
||||
using (var reader = new StreamReader(filePath))
|
||||
{
|
||||
var full = reader.ReadToEnd();
|
||||
if (full.Length > maxChars)
|
||||
{
|
||||
content = full.Substring(full.Length - maxChars);
|
||||
truncated = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
content = full;
|
||||
}
|
||||
}
|
||||
data["content"] = truncated ? "(truncated) ..." + content : content;
|
||||
}
|
||||
}
|
||||
else if (Directory.Exists(filePath))
|
||||
{
|
||||
data["exists"] = true;
|
||||
data["is_directory"] = true;
|
||||
data["path"] = filePath;
|
||||
|
||||
var files = Directory.GetFiles(filePath);
|
||||
data["file_count"] = files.Length;
|
||||
data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20));
|
||||
}
|
||||
else
|
||||
{
|
||||
data["exists"] = false;
|
||||
data["path"] = filePath;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
}
|
||||
|
||||
// Apply conditional severity based on existence
|
||||
if (data.ContainsKey("exists"))
|
||||
{
|
||||
var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True";
|
||||
if (exists && !string.IsNullOrEmpty(severityIfExists))
|
||||
data["_severityOverride"] = severityIfExists;
|
||||
else if (!exists && !string.IsNullOrEmpty(severityIfNotExists))
|
||||
data["_severityOverride"] = severityIfNotExists;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class JsonCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "json";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var filePath = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return data;
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
|
||||
return context.EmitSecurityWarning(rule, "json", filePath);
|
||||
|
||||
string jsonPath;
|
||||
if (rule.Parameters == null || !rule.Parameters.TryGetValue("jsonpath", out jsonPath) ||
|
||||
string.IsNullOrEmpty(jsonPath))
|
||||
{
|
||||
context.Logger.Warning($"JSON rule {rule.RuleId} has no 'jsonpath' parameter");
|
||||
data["error"] = "Missing required 'jsonpath' parameter";
|
||||
return data;
|
||||
}
|
||||
|
||||
int maxResults = 20;
|
||||
string maxResultsStr;
|
||||
if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr))
|
||||
int.TryParse(maxResultsStr, out maxResults);
|
||||
maxResults = Math.Min(maxResults, 100);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
data["exists"] = false;
|
||||
data["path"] = filePath;
|
||||
return data;
|
||||
}
|
||||
|
||||
var info = new FileInfo(filePath);
|
||||
if (info.Length > 200 * 1024) // 200 KB limit
|
||||
{
|
||||
data["error"] = $"File too large ({info.Length} bytes, max 200 KB)";
|
||||
data["path"] = filePath;
|
||||
return data;
|
||||
}
|
||||
|
||||
string content;
|
||||
using (var reader = new StreamReader(filePath))
|
||||
{
|
||||
content = reader.ReadToEnd();
|
||||
}
|
||||
|
||||
var token = JToken.Parse(content);
|
||||
var results = token.SelectTokens(jsonPath).Take(maxResults).ToList();
|
||||
|
||||
data["path"] = filePath;
|
||||
data["query"] = jsonPath;
|
||||
data["matchCount"] = results.Count;
|
||||
|
||||
if (results.Count == 1)
|
||||
{
|
||||
data["value"] = TruncateMessage(results[0].ToString(), 2000);
|
||||
}
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
var matchStrings = results.Select(r => TruncateMessage(r.ToString(), 500)).ToList();
|
||||
data["matches"] = string.Join("\n---\n", matchStrings);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
data["path"] = filePath;
|
||||
data["query"] = jsonPath;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string TruncateMessage(string message, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
|
||||
return message;
|
||||
return message.Substring(0, maxLength) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class LogParserCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "logparser";
|
||||
|
||||
/// <summary>
|
||||
/// Executes the log parser rule. Returns null because logparser emits events directly
|
||||
/// via context.OnEventCollected rather than returning a single result dictionary.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var filePath = rule.Target;
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return null;
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
return null; // Token present but no user logged on — skip silently
|
||||
|
||||
// Apply IME log path override if set
|
||||
if (!string.IsNullOrEmpty(context.ImeLogPathOverride))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
filePath = Path.Combine(context.ImeLogPathOverride, fileName);
|
||||
}
|
||||
|
||||
// Get parameters
|
||||
string patternStr;
|
||||
if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) ||
|
||||
string.IsNullOrEmpty(patternStr))
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter");
|
||||
return null;
|
||||
}
|
||||
|
||||
string trackPositionStr;
|
||||
bool trackPosition = true;
|
||||
if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr))
|
||||
bool.TryParse(trackPositionStr, out trackPosition);
|
||||
|
||||
string maxLinesStr;
|
||||
int maxLines = 1000;
|
||||
if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr))
|
||||
int.TryParse(maxLinesStr, out maxLines);
|
||||
|
||||
// Determine format: "cmtrace" (default) or "text" for plain text logs
|
||||
string formatStr;
|
||||
bool isTextMode = false;
|
||||
if (rule.Parameters.TryGetValue("format", out formatStr))
|
||||
isTextMode = string.Equals(formatStr, "text", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Regex pattern;
|
||||
try
|
||||
{
|
||||
pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve file paths — supports wildcards (* and ?) in the filename portion
|
||||
var resolvedPaths = ResolveLogPaths(filePath, rule.RuleId, context);
|
||||
if (resolvedPaths.Count == 0)
|
||||
{
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var resolvedPath in resolvedPaths)
|
||||
{
|
||||
ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string> ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context)
|
||||
{
|
||||
var fileNamePart = Path.GetFileName(filePath);
|
||||
|
||||
// No wildcards — single file
|
||||
if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?"))
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
return new List<string> { filePath };
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
// Wildcard expansion
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory))
|
||||
return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
// Return matched files sorted by last write time (newest first), capped at 20
|
||||
return Directory.GetFiles(directory, fileNamePart)
|
||||
.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern,
|
||||
bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(filePath);
|
||||
long startPosition = trackPosition
|
||||
? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length)
|
||||
: 0;
|
||||
|
||||
// Nothing new to read
|
||||
if (startPosition >= fileInfo.Length)
|
||||
return;
|
||||
|
||||
int matchCount = 0;
|
||||
int linesRead = 0;
|
||||
long endPosition = startPosition;
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
{
|
||||
stream.Seek(startPosition, SeekOrigin.Begin);
|
||||
|
||||
using (var reader = new StreamReader(stream, Encoding.UTF8))
|
||||
{
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) != null && linesRead < maxLines)
|
||||
{
|
||||
linesRead++;
|
||||
|
||||
if (isTextMode)
|
||||
{
|
||||
// Text mode: match regex directly against the raw line
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(line);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logLine"] = TruncateMessage(line, 500);
|
||||
data["logLineNumber"] = linesRead;
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// CMTrace mode: parse line as CMTrace format, match regex against message
|
||||
CmTraceLogEntry entry;
|
||||
if (!CmTraceLogParser.TryParseLine(line, out entry))
|
||||
continue;
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(entry.Message);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
data[groupName] = group.Value;
|
||||
}
|
||||
|
||||
data["logTimestamp"] = entry.Timestamp.ToString("o");
|
||||
data["logComponent"] = entry.Component;
|
||||
data["logType"] = entry.Type;
|
||||
data["logMessage"] = TruncateMessage(entry.Message, 500);
|
||||
data["logFile"] = Path.GetFileName(filePath);
|
||||
data["ruleId"] = rule.RuleId;
|
||||
data["ruleTitle"] = rule.Title;
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType)
|
||||
? rule.OutputEventType
|
||||
: "logparser_match";
|
||||
var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity);
|
||||
|
||||
context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = context.SessionId,
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = entry.Timestamp,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = data
|
||||
});
|
||||
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the final stream position after reading
|
||||
endPosition = stream.Position;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackPosition)
|
||||
context.FilePositionTracker.SetPosition(filePath, endPosition);
|
||||
|
||||
if (matchCount > 0)
|
||||
context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride)
|
||||
{
|
||||
// If the rule specifies a severity, use it
|
||||
if (!string.IsNullOrEmpty(ruleOverride))
|
||||
{
|
||||
switch (ruleOverride.ToLower())
|
||||
{
|
||||
case "debug": return EventSeverity.Debug;
|
||||
case "info": return EventSeverity.Info;
|
||||
case "warning": return EventSeverity.Warning;
|
||||
case "error": return EventSeverity.Error;
|
||||
case "critical": return EventSeverity.Critical;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise derive from CMTrace log type
|
||||
switch (cmTraceType)
|
||||
{
|
||||
case 2: return EventSeverity.Warning;
|
||||
case 3: return EventSeverity.Error;
|
||||
default: return EventSeverity.Info;
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateMessage(string message, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
|
||||
return message;
|
||||
return message.Substring(0, maxLength) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class RegistryCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "registry";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var path = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
return data;
|
||||
|
||||
// Determine hive
|
||||
RegistryKey hive = null;
|
||||
string subPath = path;
|
||||
|
||||
if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hive = Registry.LocalMachine;
|
||||
subPath = path.Substring(path.IndexOf('\\') + 1);
|
||||
}
|
||||
else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) ||
|
||||
path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
hive = Registry.CurrentUser;
|
||||
subPath = path.Substring(path.IndexOf('\\') + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default to HKLM
|
||||
hive = Registry.LocalMachine;
|
||||
}
|
||||
|
||||
// Guard: only allow enrollment-relevant registry paths
|
||||
if (!GatherRuleGuards.IsRegistryPathAllowed(subPath, context.UnrestrictedMode))
|
||||
return context.EmitSecurityWarning(rule, "registry", path);
|
||||
|
||||
// Determine explicit valueName from parameters
|
||||
string explicitValueName = null;
|
||||
rule.Parameters?.TryGetValue("valueName", out explicitValueName);
|
||||
|
||||
// Check if subkey enumeration is requested
|
||||
string listSubkeysStr = null;
|
||||
rule.Parameters?.TryGetValue("listSubkeys", out listSubkeysStr);
|
||||
bool listSubkeys = string.Equals(listSubkeysStr, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Optional conditional severity based on existence
|
||||
string severityIfExists = null;
|
||||
string severityIfNotExists = null;
|
||||
rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists);
|
||||
rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists);
|
||||
|
||||
try
|
||||
{
|
||||
using (var key = hive.OpenSubKey(subPath, false))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
// The key doesn't exist as a sub-key. If no explicit valueName was given,
|
||||
// check whether the last path segment is a value name in the parent key.
|
||||
// This handles the common case where the user specifies the full
|
||||
// "HKLM\...\KeyName\ValueName" path directly in Target.
|
||||
if (string.IsNullOrEmpty(explicitValueName) && !listSubkeys)
|
||||
{
|
||||
var lastBackslash = subPath.LastIndexOf('\\');
|
||||
if (lastBackslash > 0)
|
||||
{
|
||||
var parentSubPath = subPath.Substring(0, lastBackslash);
|
||||
var inferredValueName = subPath.Substring(lastBackslash + 1);
|
||||
|
||||
using (var parentKey = hive.OpenSubKey(parentSubPath, false))
|
||||
{
|
||||
if (parentKey != null)
|
||||
{
|
||||
var valueNames = parentKey.GetValueNames();
|
||||
if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var value = parentKey.GetValue(inferredValueName);
|
||||
data["exists"] = true;
|
||||
data["path"] = path.Substring(0, path.LastIndexOf('\\'));
|
||||
data[inferredValueName] = value?.ToString();
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data["exists"] = false;
|
||||
data["path"] = path;
|
||||
return data;
|
||||
}
|
||||
|
||||
data["exists"] = true;
|
||||
data["path"] = path;
|
||||
|
||||
if (listSubkeys)
|
||||
{
|
||||
// Enumerate subkey names (max 100)
|
||||
var subKeyNames = key.GetSubKeyNames().Take(100).ToArray();
|
||||
data["subkey_count"] = subKeyNames.Length;
|
||||
for (int i = 0; i < subKeyNames.Length; i++)
|
||||
{
|
||||
data[$"subkey_{i + 1}"] = subKeyNames[i];
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(explicitValueName))
|
||||
{
|
||||
// Read specific value if specified in parameters
|
||||
var value = key.GetValue(explicitValueName);
|
||||
data[explicitValueName] = value?.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Read all values
|
||||
foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = key.GetValue(name);
|
||||
var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name;
|
||||
data[displayName] = value?.ToString();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
}
|
||||
|
||||
// Apply conditional severity based on existence
|
||||
if (data.ContainsKey("exists"))
|
||||
{
|
||||
var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True";
|
||||
if (exists && !string.IsNullOrEmpty(severityIfExists))
|
||||
data["_severityOverride"] = severityIfExists;
|
||||
else if (!exists && !string.IsNullOrEmpty(severityIfNotExists))
|
||||
data["_severityOverride"] = severityIfNotExists;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class WmiCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "wmi";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var query = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(query))
|
||||
return data;
|
||||
|
||||
// Guard: only allow known-safe WMI classes
|
||||
if (!GatherRuleGuards.IsWmiQueryAllowed(query, context.UnrestrictedMode))
|
||||
return context.EmitSecurityWarning(rule, "wmi", query);
|
||||
|
||||
try
|
||||
{
|
||||
using (var searcher = new ManagementObjectSearcher(query))
|
||||
{
|
||||
int index = 0;
|
||||
foreach (ManagementObject obj in searcher.Get())
|
||||
{
|
||||
var item = new Dictionary<string, object>();
|
||||
foreach (var prop in obj.Properties)
|
||||
{
|
||||
try
|
||||
{
|
||||
item[prop.Name] = prop.Value?.ToString();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (item.Count > 0)
|
||||
{
|
||||
// For single result, flatten; for multiple, use indexed keys
|
||||
if (index == 0)
|
||||
{
|
||||
foreach (var kvp in item)
|
||||
data[kvp.Key] = kvp.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}"));
|
||||
}
|
||||
}
|
||||
index++;
|
||||
|
||||
if (index >= 20) break; // Limit results
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.XPath;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public class XmlCollector : IGatherRuleCollector
|
||||
{
|
||||
public string CollectorType => "xml";
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
var filePath = rule.Target;
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return data;
|
||||
|
||||
// Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables
|
||||
var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath)
|
||||
? UserProfileResolver.GetLoggedOnUserProfilePath() : null;
|
||||
filePath = UserProfileResolver.ExpandCustomTokens(filePath);
|
||||
if (filePath == null)
|
||||
return data; // Token present but no user logged on — skip silently
|
||||
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
|
||||
return context.EmitSecurityWarning(rule, "xml", filePath);
|
||||
|
||||
string xpath;
|
||||
if (rule.Parameters == null || !rule.Parameters.TryGetValue("xpath", out xpath) ||
|
||||
string.IsNullOrEmpty(xpath))
|
||||
{
|
||||
context.Logger.Warning($"XML rule {rule.RuleId} has no 'xpath' parameter");
|
||||
data["error"] = "Missing required 'xpath' parameter";
|
||||
return data;
|
||||
}
|
||||
|
||||
int maxResults = 20;
|
||||
string maxResultsStr;
|
||||
if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr))
|
||||
int.TryParse(maxResultsStr, out maxResults);
|
||||
maxResults = Math.Min(maxResults, 100);
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
data["exists"] = false;
|
||||
data["path"] = filePath;
|
||||
return data;
|
||||
}
|
||||
|
||||
var info = new FileInfo(filePath);
|
||||
if (info.Length > 200 * 1024) // 200 KB limit
|
||||
{
|
||||
data["error"] = $"File too large ({info.Length} bytes, max 200 KB)";
|
||||
data["path"] = filePath;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Parse XML with DTD processing disabled (XXE prevention)
|
||||
var settings = new XmlReaderSettings
|
||||
{
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
XmlResolver = null
|
||||
};
|
||||
|
||||
XDocument doc;
|
||||
using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
using (var xmlReader = XmlReader.Create(stream, settings))
|
||||
{
|
||||
doc = XDocument.Load(xmlReader);
|
||||
}
|
||||
|
||||
// Set up namespace resolver if namespaces are provided
|
||||
XmlNamespaceManager nsManager = null;
|
||||
string namespacesStr;
|
||||
if (rule.Parameters.TryGetValue("namespaces", out namespacesStr) &&
|
||||
!string.IsNullOrEmpty(namespacesStr))
|
||||
{
|
||||
nsManager = new XmlNamespaceManager(new NameTable());
|
||||
foreach (var ns in namespacesStr.Split(';'))
|
||||
{
|
||||
var parts = ns.Split(new[] { '=' }, 2);
|
||||
if (parts.Length == 2 && !string.IsNullOrEmpty(parts[0]) && !string.IsNullOrEmpty(parts[1]))
|
||||
{
|
||||
nsManager.AddNamespace(parts[0].Trim(), parts[1].Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute XPath — try element selection first, fall back to evaluate for scalar/attribute results
|
||||
var elements = nsManager != null
|
||||
? doc.XPathSelectElements(xpath, nsManager).Take(maxResults).ToList()
|
||||
: doc.XPathSelectElements(xpath).Take(maxResults).ToList();
|
||||
|
||||
data["path"] = filePath;
|
||||
data["query"] = xpath;
|
||||
|
||||
if (elements.Count > 0)
|
||||
{
|
||||
data["matchCount"] = elements.Count;
|
||||
if (elements.Count == 1)
|
||||
{
|
||||
data["value"] = TruncateMessage(elements[0].ToString(), 2000);
|
||||
}
|
||||
var matchStrings = elements.Select(e => TruncateMessage(e.ToString(), 500)).ToList();
|
||||
data["matches"] = string.Join("\n---\n", matchStrings);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try XPathEvaluate for scalar results (attributes, text(), count(), etc.)
|
||||
var evalResult = nsManager != null
|
||||
? doc.XPathEvaluate(xpath, nsManager)
|
||||
: doc.XPathEvaluate(xpath);
|
||||
|
||||
if (evalResult is IEnumerable<object> enumerable)
|
||||
{
|
||||
var items = enumerable.Take(maxResults).ToList();
|
||||
data["matchCount"] = items.Count;
|
||||
if (items.Count == 1)
|
||||
{
|
||||
data["value"] = TruncateMessage(items[0].ToString(), 2000);
|
||||
}
|
||||
if (items.Count > 0)
|
||||
{
|
||||
data["matches"] = string.Join("\n---\n",
|
||||
items.Select(i => TruncateMessage(i.ToString(), 500)));
|
||||
}
|
||||
}
|
||||
else if (evalResult != null)
|
||||
{
|
||||
data["matchCount"] = 1;
|
||||
data["value"] = TruncateMessage(evalResult.ToString(), 2000);
|
||||
}
|
||||
else
|
||||
{
|
||||
data["matchCount"] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
data["error"] = ex.Message;
|
||||
data["path"] = filePath;
|
||||
data["query"] = xpath;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static string TruncateMessage(string message, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message) || message.Length <= maxLength)
|
||||
return message;
|
||||
return message.Substring(0, maxLength) + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Privacy and security guards for configurable diagnostics log paths.
|
||||
///
|
||||
/// Restricts user-defined diagnostics paths to directories that contain only
|
||||
/// enrollment- and device-management-relevant log files, preventing PII leakage.
|
||||
///
|
||||
/// Rules:
|
||||
/// - The path (after environment variable expansion and full-path normalization)
|
||||
/// must start with one of the allowed prefixes.
|
||||
/// - Segment-bounded matching: next character after the prefix must be '\' or end of string.
|
||||
/// - Wildcards ('*', '?') are only permitted in the last path segment (filename part).
|
||||
/// - Path traversal sequences are blocked via Path.GetFullPath normalization.
|
||||
/// </summary>
|
||||
public static class DiagnosticsPathGuards
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Allowed directory prefixes for diagnostics log paths
|
||||
// (expanded, absolute paths — no environment variables)
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly IReadOnlyList<string> AllowedDiagnosticsPathPrefixes = new[]
|
||||
{
|
||||
// Autopilot Monitor agent logs
|
||||
@"C:\ProgramData\AutopilotMonitor",
|
||||
|
||||
// Intune Management Extension
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs",
|
||||
|
||||
// Windows Setup & OOBE
|
||||
@"C:\Windows\Panther",
|
||||
|
||||
// Windows general logs
|
||||
@"C:\Windows\Logs",
|
||||
|
||||
// Windows Setup Diagnostics
|
||||
@"C:\Windows\SetupDiag",
|
||||
|
||||
// Windows Update reporting
|
||||
@"C:\Windows\SoftwareDistribution\ReportingEvents.log",
|
||||
|
||||
// Windows Event Log files
|
||||
@"C:\Windows\System32\winevt\Logs",
|
||||
|
||||
// SCCM / ConfigMgr
|
||||
@"C:\Windows\CCM\Logs",
|
||||
|
||||
// Microsoft Diagnostic Log CSP
|
||||
@"C:\ProgramData\Microsoft\DiagnosticLogCSP",
|
||||
|
||||
// Windows Error Reporting
|
||||
@"C:\ProgramData\Microsoft\Windows\WER",
|
||||
|
||||
// Windows Component-Based Servicing
|
||||
@"C:\Windows\Logs\CBS",
|
||||
|
||||
// Custom install logs (e.g. app installers writing to C:\Install\Log\)
|
||||
@"C:\Install\Log",
|
||||
};
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
private static readonly string[] AdditionalHardBlockedPrefixes = new[]
|
||||
{
|
||||
@"C:\Windows\System32\config", // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
|
||||
// Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token)
|
||||
private static readonly string[] AllowedUserProfileSubdirs = new[]
|
||||
{
|
||||
@"AppData\Local",
|
||||
@"AppData\Roaming",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given path is allowed for diagnostics collection.
|
||||
/// Expands environment variables and normalises to a full path before checking.
|
||||
/// Wildcards in the last path segment are supported.
|
||||
/// </summary>
|
||||
public static bool IsDiagnosticsPathAllowed(string rawPath)
|
||||
=> IsDiagnosticsPathAllowed(rawPath, unrestrictedMode: false, userProfilePath: null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given path is allowed for diagnostics collection.
|
||||
/// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection).
|
||||
/// Path normalization and traversal protection always apply regardless of mode.
|
||||
/// </summary>
|
||||
public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode)
|
||||
=> IsDiagnosticsPathAllowed(rawPath, unrestrictedMode, userProfilePath: null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the given path is allowed for diagnostics collection.
|
||||
/// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under
|
||||
/// the user's AppData\Local and AppData\Roaming are additionally allowed.
|
||||
/// </summary>
|
||||
public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
// Split off a trailing wildcard segment before normalization
|
||||
var expanded = Environment.ExpandEnvironmentVariables(rawPath);
|
||||
var fileName = Path.GetFileName(expanded);
|
||||
var hasWildcard = fileName.Contains('*') || fileName.Contains('?');
|
||||
|
||||
// Normalize the directory part (or full path if no wildcard)
|
||||
string normalizedDir;
|
||||
if (hasWildcard)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(expanded);
|
||||
if (string.IsNullOrEmpty(dir))
|
||||
return false;
|
||||
normalizedDir = Path.GetFullPath(dir);
|
||||
}
|
||||
else
|
||||
{
|
||||
normalizedDir = Path.GetFullPath(expanded);
|
||||
}
|
||||
|
||||
// Symlink / junction / reparse point detection — fail-closed
|
||||
if (IsSymlinkOrReparsePoint(normalizedDir))
|
||||
return false;
|
||||
|
||||
// C:\Users block always applies (even in unrestricted mode)
|
||||
// Exception: paths under <userProfilePath>\AppData\Local or AppData\Roaming
|
||||
// are allowed when the %LOGGED_ON_USER_PROFILE% token was used.
|
||||
if (normalizedDir.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedDir.Length == BlockedUsersPrefix.Length ||
|
||||
normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
if (!IsUserProfileSubpathAllowed(normalizedDir, userProfilePath))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional hard-blocked paths (even in unrestricted mode)
|
||||
foreach (var blocked in AdditionalHardBlockedPrefixes)
|
||||
{
|
||||
var normalizedBlocked = Path.GetFullPath(blocked);
|
||||
if (normalizedDir.StartsWith(normalizedBlocked, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedDir.Length == normalizedBlocked.Length ||
|
||||
normalizedDir[normalizedBlocked.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// In unrestricted mode, everything except hard-blocked paths is allowed
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
foreach (var prefix in AllowedDiagnosticsPathPrefixes)
|
||||
{
|
||||
var normalizedPrefix = Path.GetFullPath(prefix);
|
||||
if (normalizedDir.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Segment-bounded: next char must be '\' or end-of-string
|
||||
if (normalizedDir.Length == normalizedPrefix.Length ||
|
||||
normalizedDir[normalizedPrefix.Length] == Path.DirectorySeparatorChar)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Any path normalization failure → deny
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the normalized path falls under one of the allowed subdirectories
|
||||
/// of the detected user profile (AppData\Local, AppData\Roaming).
|
||||
/// Only applies when %LOGGED_ON_USER_PROFILE% was used to resolve the path.
|
||||
/// </summary>
|
||||
internal static bool IsUserProfileSubpathAllowed(string normalizedPath, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userProfilePath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var normalizedProfile = Path.GetFullPath(userProfilePath);
|
||||
foreach (var subdir in AllowedUserProfileSubdirs)
|
||||
{
|
||||
var allowedPrefix = Path.GetFullPath(Path.Combine(normalizedProfile, subdir));
|
||||
if (normalizedPath.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == allowedPrefix.Length ||
|
||||
normalizedPath[allowedPrefix.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Path normalization failure — deny
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether any component of the path (directories or file) is a symlink,
|
||||
/// junction, or other reparse point. Returns true if a reparse point is detected.
|
||||
/// Fail-closed: returns true on any error.
|
||||
/// </summary>
|
||||
private static bool IsSymlinkOrReparsePoint(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Walk up the directory tree checking each component
|
||||
var current = path;
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
var dirInfo = new DirectoryInfo(current);
|
||||
if (dirInfo.Exists && dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return true;
|
||||
|
||||
var parent = Path.GetDirectoryName(current);
|
||||
if (parent == current) // root reached
|
||||
break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
// Check the target path itself (if it's a file)
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var fileInfo = new FileInfo(path);
|
||||
if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-closed: treat errors as suspicious
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared dependencies passed to each gather rule collector.
|
||||
/// </summary>
|
||||
public class GatherRuleContext
|
||||
{
|
||||
public AgentLogger Logger { get; }
|
||||
public string SessionId { get; }
|
||||
public string TenantId { get; }
|
||||
public Action<EnrollmentEvent> OnEventCollected { get; }
|
||||
public string ImeLogPathOverride { get; }
|
||||
public LogFilePositionTracker FilePositionTracker { get; }
|
||||
public bool UnrestrictedMode { get; set; }
|
||||
|
||||
public GatherRuleContext(
|
||||
AgentLogger logger,
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
string imeLogPathOverride,
|
||||
LogFilePositionTracker filePositionTracker)
|
||||
{
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
ImeLogPathOverride = imeLogPathOverride;
|
||||
FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a security_warning event and returns an empty result dictionary.
|
||||
/// Called when a gather rule targets a path/query not on the allowlist.
|
||||
/// </summary>
|
||||
public Dictionary<string, object> EmitSecurityWarning(GatherRule rule, string collectorType, string target)
|
||||
{
|
||||
Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})");
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["blocked"] = true,
|
||||
["reason"] = $"{collectorType} target not on allowlist",
|
||||
["target"] = target,
|
||||
["ruleId"] = rule.RuleId,
|
||||
};
|
||||
|
||||
OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = SessionId,
|
||||
TenantId = TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "security_warning",
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})",
|
||||
Data = data
|
||||
});
|
||||
|
||||
return new Dictionary<string, object>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a severity string into an EventSeverity enum value.
|
||||
/// </summary>
|
||||
public static EventSeverity ParseSeverity(string severity)
|
||||
{
|
||||
if (string.IsNullOrEmpty(severity))
|
||||
return EventSeverity.Info;
|
||||
|
||||
switch (severity.ToLower())
|
||||
{
|
||||
case "debug": return EventSeverity.Debug;
|
||||
case "info": return EventSeverity.Info;
|
||||
case "warning": return EventSeverity.Warning;
|
||||
case "error": return EventSeverity.Error;
|
||||
case "critical": return EventSeverity.Critical;
|
||||
default: return EventSeverity.Info;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes gather rules received from the backend API
|
||||
/// Supports registry, eventlog, wmi, file, command_allowlisted, logparser, json, and xml collector types
|
||||
/// </summary>
|
||||
public class GatherRuleExecutor : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly GatherRuleContext _context;
|
||||
private readonly Dictionary<string, IGatherRuleCollector> _collectors;
|
||||
|
||||
private List<GatherRule> _activeRules = new List<GatherRule>();
|
||||
private readonly Dictionary<string, Timer> _intervalTimers = new Dictionary<string, Timer>();
|
||||
private readonly HashSet<string> _startupRulesExecuted = new HashSet<string>();
|
||||
private readonly HashSet<string> _phaseRulesExecuted = new HashSet<string>();
|
||||
private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending
|
||||
|
||||
/// <summary>
|
||||
/// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed.
|
||||
/// File paths allow everything except C:\Users. Set from tenant configuration.
|
||||
/// </summary>
|
||||
public bool UnrestrictedMode
|
||||
{
|
||||
get { return _context.UnrestrictedMode; }
|
||||
set { _context.UnrestrictedMode = value; }
|
||||
}
|
||||
|
||||
public GatherRuleExecutor(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, string imeLogPathOverride = null)
|
||||
{
|
||||
if (sessionId == null) throw new ArgumentNullException(nameof(sessionId));
|
||||
if (tenantId == null) throw new ArgumentNullException(nameof(tenantId));
|
||||
if (onEventCollected == null) throw new ArgumentNullException(nameof(onEventCollected));
|
||||
if (logger == null) throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_logger = logger;
|
||||
|
||||
var filePositionTracker = new LogFilePositionTracker();
|
||||
_context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker);
|
||||
|
||||
// Register all collector strategies
|
||||
var collectorList = new IGatherRuleCollector[]
|
||||
{
|
||||
new RegistryCollector(),
|
||||
new WmiCollector(),
|
||||
new CommandCollector(),
|
||||
new FileCollector(),
|
||||
new EventLogCollector(),
|
||||
new LogParserCollector(),
|
||||
new JsonCollector(),
|
||||
new XmlCollector(),
|
||||
};
|
||||
|
||||
_collectors = new Dictionary<string, IGatherRuleCollector>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var collector in collectorList)
|
||||
{
|
||||
_collectors[collector.CollectorType] = collector;
|
||||
}
|
||||
|
||||
// Register legacy alias: "command" maps to the same collector as "command_allowlisted"
|
||||
_collectors["command"] = _collectors["command_allowlisted"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the active rules and starts/stops execution accordingly
|
||||
/// </summary>
|
||||
public void UpdateRules(List<GatherRule> rules)
|
||||
{
|
||||
if (rules == null)
|
||||
return;
|
||||
|
||||
_logger.Info($"GatherRuleExecutor: updating with {rules.Count} active rules");
|
||||
|
||||
// Stop existing interval timers
|
||||
StopAllTimers();
|
||||
|
||||
_activeRules = rules.Where(r => r.Enabled).ToList();
|
||||
|
||||
// Execute startup rules — track completion via CountdownEvent so callers can wait
|
||||
var pendingStartup = _activeRules
|
||||
.Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId))
|
||||
.ToList();
|
||||
|
||||
if (pendingStartup.Count > 0)
|
||||
{
|
||||
_startupRulesLatch?.Dispose();
|
||||
_startupRulesLatch = new CountdownEvent(pendingStartup.Count);
|
||||
|
||||
foreach (var rule in pendingStartup)
|
||||
{
|
||||
_startupRulesExecuted.Add(rule.RuleId);
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
try { ExecuteRule(rule); }
|
||||
finally { _startupRulesLatch?.Signal(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Set up interval timers
|
||||
foreach (var rule in _activeRules.Where(r => r.Trigger == "interval" && r.IntervalSeconds.HasValue))
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(rule.IntervalSeconds.Value);
|
||||
var timer = new Timer(
|
||||
_ => ExecuteRule(rule),
|
||||
null,
|
||||
interval, // Initial delay = one interval
|
||||
interval
|
||||
);
|
||||
_intervalTimers[rule.RuleId] = timer;
|
||||
_logger.Info($" Interval rule {rule.RuleId} scheduled every {rule.IntervalSeconds}s");
|
||||
}
|
||||
|
||||
_logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " +
|
||||
$"{_intervalTimers.Count} interval rules active");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a phase change event occurs - executes rules triggered by phase changes
|
||||
/// </summary>
|
||||
public void OnPhaseChanged(EnrollmentPhase newPhase)
|
||||
{
|
||||
var phaseName = newPhase.ToString();
|
||||
|
||||
foreach (var rule in _activeRules.Where(r => r.Trigger == "phase_change"))
|
||||
{
|
||||
if (string.IsNullOrEmpty(rule.TriggerPhase) ||
|
||||
string.Equals(rule.TriggerPhase, phaseName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Deduplicate: only fire once per (ruleId, phase) combination
|
||||
var deduplicationKey = $"{rule.RuleId}|{phaseName}";
|
||||
if (!_phaseRulesExecuted.Add(deduplicationKey))
|
||||
{
|
||||
_logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a specific event type is emitted - executes on_event rules
|
||||
/// </summary>
|
||||
public void OnEvent(string eventType)
|
||||
{
|
||||
foreach (var rule in _activeRules.Where(r => r.Trigger == "on_event"))
|
||||
{
|
||||
if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})");
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExecuteRule(GatherRule rule)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})");
|
||||
|
||||
var collectorType = rule.CollectorType?.ToLower();
|
||||
|
||||
IGatherRuleCollector collector;
|
||||
if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector))
|
||||
{
|
||||
_logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}");
|
||||
return;
|
||||
}
|
||||
|
||||
Dictionary<string, object> result = collector.Execute(rule, _context);
|
||||
|
||||
// LogParser (and potentially others) emit events directly and return null
|
||||
if (result == null)
|
||||
return;
|
||||
|
||||
if (result.Count > 0)
|
||||
{
|
||||
result["ruleId"] = rule.RuleId;
|
||||
result["ruleTitle"] = rule.Title;
|
||||
|
||||
var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : "gather_result";
|
||||
|
||||
// Allow collectors to override severity via _severityOverride in result
|
||||
object severityOverride;
|
||||
EventSeverity severity;
|
||||
if (result.TryGetValue("_severityOverride", out severityOverride) && severityOverride is string sev)
|
||||
{
|
||||
severity = GatherRuleContext.ParseSeverity(sev);
|
||||
result.Remove("_severityOverride");
|
||||
}
|
||||
else
|
||||
{
|
||||
severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity);
|
||||
}
|
||||
|
||||
_context.OnEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _context.SessionId,
|
||||
TenantId = _context.TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "GatherRuleExecutor",
|
||||
Message = $"Gather: {rule.Title}",
|
||||
Data = result
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private EventSeverity ParseSeverity(string severity)
|
||||
{
|
||||
return GatherRuleContext.ParseSeverity(severity);
|
||||
}
|
||||
|
||||
private void StopAllTimers()
|
||||
{
|
||||
foreach (var timer in _intervalTimers.Values)
|
||||
{
|
||||
timer.Dispose();
|
||||
}
|
||||
_intervalTimers.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until all startup rules that were queued by the most recent <see cref="UpdateRules"/>
|
||||
/// call have finished executing, or the timeout elapses.
|
||||
/// Returns true if all rules completed within the timeout; false if timed out.
|
||||
/// </summary>
|
||||
public bool WaitForStartupRules(int timeoutSeconds = 120)
|
||||
{
|
||||
var latch = _startupRulesLatch;
|
||||
if (latch == null)
|
||||
return true; // no startup rules were queued
|
||||
|
||||
_logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete...");
|
||||
var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
_logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}");
|
||||
return completed;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopAllTimers();
|
||||
_startupRulesLatch?.Dispose();
|
||||
_startupRulesLatch = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Privacy and security guards for GatherRule collectors.
|
||||
///
|
||||
/// All allowlists are loaded from the embedded guardrails.json resource
|
||||
/// (source of truth: rules/guardrails.json). They restrict data collection
|
||||
/// to enrollment-relevant information only. Rules targeting paths, queries,
|
||||
/// or commands outside these lists are blocked and reported as security_warning events.
|
||||
///
|
||||
/// To allow additional paths: edit rules/guardrails.json and run
|
||||
/// node rules/scripts/combine.js to regenerate all derived files.
|
||||
///
|
||||
/// Segment-bounded matching is used: the path must match the prefix exactly
|
||||
/// up to a path separator or end of string, preventing prefix spoofing.
|
||||
/// Registry paths are expected without the hive prefix (e.g. "SOFTWARE\\Microsoft\\..." not "HKLM\\...").
|
||||
/// File paths should be expanded (no environment variables).
|
||||
/// Commands must match exactly (trimmed) — no prefix matching for security.
|
||||
/// </summary>
|
||||
public static class GatherRuleGuards
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Loaded from embedded guardrails.json
|
||||
// -----------------------------------------------------------------------
|
||||
public static readonly IReadOnlyList<string> AllowedRegistryPrefixes;
|
||||
public static readonly IReadOnlyList<string> AllowedFilePrefixes;
|
||||
public static readonly IReadOnlyList<string> AllowedWmiQueryPrefixes;
|
||||
public static readonly IReadOnlyCollection<string> AllowedCommands;
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
{
|
||||
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
|
||||
// Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token)
|
||||
private static readonly string[] AllowedUserProfileSubdirs = new[]
|
||||
{
|
||||
@"AppData\Local",
|
||||
@"AppData\Roaming",
|
||||
};
|
||||
|
||||
// Hard limit: maximum command length (even in unrestricted mode)
|
||||
private const int MaxCommandLength = 2000;
|
||||
|
||||
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
|
||||
// These prevent privilege escalation, persistence, and data exfiltration.
|
||||
private static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
{
|
||||
// Download / exfiltration
|
||||
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
|
||||
"wget", "curl", "certutil -urlcache",
|
||||
// User / group manipulation
|
||||
"New-LocalUser", "Add-LocalGroupMember", "net user", "net localgroup",
|
||||
// Boot configuration
|
||||
"bcdedit", "bcdboot",
|
||||
// Persistence mechanisms
|
||||
"schtasks /create", "Register-ScheduledTask",
|
||||
// Destructive operations
|
||||
"Remove-Item -Recurse", "Format-Volume",
|
||||
// Execution policy bypass
|
||||
"Set-ExecutionPolicy",
|
||||
};
|
||||
|
||||
static GatherRuleGuards()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails();
|
||||
if (json != null)
|
||||
{
|
||||
var obj = JObject.Parse(json);
|
||||
|
||||
AllowedRegistryPrefixes = FlattenCategorized(obj, "registryPrefixes", "prefixes");
|
||||
AllowedFilePrefixes = obj["filePrefixes"]?.ToObject<List<string>>() ?? new List<string>();
|
||||
AllowedWmiQueryPrefixes = obj["wmiQueryPrefixes"]?.ToObject<List<string>>() ?? new List<string>();
|
||||
|
||||
var commands = FlattenCategorized(obj, "allowedCommands", "commands");
|
||||
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to empty lists on parse error — agent will block everything
|
||||
// and emit security_warning events, making the issue visible.
|
||||
}
|
||||
|
||||
AllowedRegistryPrefixes = new List<string>();
|
||||
AllowedFilePrefixes = new List<string>();
|
||||
AllowedWmiQueryPrefixes = new List<string>();
|
||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string LoadEmbeddedGuardrails()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using (var stream = assembly.GetManifestResourceStream(
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json"))
|
||||
{
|
||||
if (stream == null)
|
||||
return null;
|
||||
using (var reader = new StreamReader(stream))
|
||||
{
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flattens a categorized array structure like
|
||||
/// [{ "category": "...", "prefixes": ["a","b"] }, ...] into ["a","b",...].
|
||||
/// </summary>
|
||||
private static List<string> FlattenCategorized(JObject root, string arrayProp, string itemsProp)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var arr = root[arrayProp] as JArray;
|
||||
if (arr == null) return result;
|
||||
|
||||
foreach (var group in arr)
|
||||
{
|
||||
var items = group[itemsProp] as JArray;
|
||||
if (items == null) continue;
|
||||
foreach (var item in items)
|
||||
{
|
||||
var val = item.Value<string>();
|
||||
if (!string.IsNullOrEmpty(val))
|
||||
result.Add(val);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Guard methods
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the registry subPath (hive stripped) matches an allowed prefix
|
||||
/// with segment-bounded matching (next char must be '\' or end of string).
|
||||
/// </summary>
|
||||
public static bool IsRegistryPathAllowed(string subPath)
|
||||
=> IsRegistryPathAllowed(subPath, unrestrictedMode: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the registry subPath is allowed.
|
||||
/// When unrestrictedMode is true, all registry paths are allowed.
|
||||
/// </summary>
|
||||
public static bool IsRegistryPathAllowed(string subPath, bool unrestrictedMode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subPath))
|
||||
return false;
|
||||
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
return AllowedRegistryPrefixes.Any(prefix =>
|
||||
subPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(subPath.Length == prefix.Length || subPath[prefix.Length] == '\\'));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the expanded file path matches an allowed prefix
|
||||
/// with segment-bounded matching and path normalization to prevent traversal.
|
||||
/// </summary>
|
||||
public static bool IsFilePathAllowed(string expandedPath)
|
||||
=> IsFilePathAllowed(expandedPath, unrestrictedMode: false, userProfilePath: null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the expanded file path is allowed.
|
||||
/// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection).
|
||||
/// Path normalization and traversal protection always apply regardless of mode.
|
||||
/// </summary>
|
||||
public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode)
|
||||
=> IsFilePathAllowed(expandedPath, unrestrictedMode, userProfilePath: null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the expanded file path is allowed.
|
||||
/// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under
|
||||
/// the user's AppData\Local and AppData\Roaming are additionally allowed.
|
||||
/// </summary>
|
||||
public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode, string userProfilePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(expandedPath))
|
||||
return false;
|
||||
|
||||
string normalizedPath;
|
||||
try
|
||||
{
|
||||
normalizedPath = Path.GetFullPath(expandedPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// C:\Users block always applies (even in unrestricted mode)
|
||||
// Exception: paths under <userProfilePath>\AppData\Local or AppData\Roaming
|
||||
// are allowed when the %LOGGED_ON_USER_PROFILE% token was used.
|
||||
if (normalizedPath.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == BlockedUsersPrefix.Length ||
|
||||
normalizedPath[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
if (!DiagnosticsPathGuards.IsUserProfileSubpathAllowed(normalizedPath, userProfilePath))
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional hard-blocked paths (even in unrestricted mode)
|
||||
foreach (var blocked in AdditionalHardBlockedPathPrefixes)
|
||||
{
|
||||
if (normalizedPath.StartsWith(blocked, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == blocked.Length ||
|
||||
normalizedPath[blocked.Length] == Path.DirectorySeparatorChar))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
return AllowedFilePrefixes.Any(prefix =>
|
||||
normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\'));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the WMI query (trimmed) matches an allowed prefix
|
||||
/// with boundary matching (next char must be whitespace or end of string).
|
||||
/// </summary>
|
||||
public static bool IsWmiQueryAllowed(string query)
|
||||
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the WMI query is allowed.
|
||||
/// When unrestrictedMode is true, all WMI queries are allowed.
|
||||
/// </summary>
|
||||
public static bool IsWmiQueryAllowed(string query, bool unrestrictedMode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(query))
|
||||
return false;
|
||||
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
var trimmed = query.Trim();
|
||||
return AllowedWmiQueryPrefixes.Any(prefix =>
|
||||
trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the command (trimmed) exactly matches an entry in the allowlist.
|
||||
/// Exact matching is intentional — no prefix spoofing possible.
|
||||
/// </summary>
|
||||
public static bool IsCommandAllowed(string command)
|
||||
=> IsCommandAllowed(command, unrestrictedMode: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the command is allowed.
|
||||
/// When unrestrictedMode is true, all commands are allowed.
|
||||
/// </summary>
|
||||
public static bool IsCommandAllowed(string command, bool unrestrictedMode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return false;
|
||||
|
||||
// Hard guards apply even in unrestricted mode
|
||||
if (command.Length > MaxCommandLength)
|
||||
return false;
|
||||
|
||||
foreach (var blocked in HardBlockedCommandPatterns)
|
||||
{
|
||||
if (command.IndexOf(blocked, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
return AllowedCommands.Contains(command.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy interface for individual gather rule collector implementations.
|
||||
/// </summary>
|
||||
public interface IGatherRuleCollector
|
||||
{
|
||||
string CollectorType { get; }
|
||||
Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context);
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
/// <summary>
|
||||
/// Measures the agent's own resource footprint: process CPU, memory, threads, handles,
|
||||
/// and HTTP network traffic. Emits agent_metrics_snapshot events via the standard event pipeline.
|
||||
/// No WMI, no PerformanceCounters — only Process properties and Interlocked counters.
|
||||
/// </summary>
|
||||
public class AgentSelfMetricsCollector : CollectorBase
|
||||
{
|
||||
private readonly string _agentVersion;
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
private readonly EventSpool _spool;
|
||||
|
||||
// Previous sample for delta calculations
|
||||
private TimeSpan _prevCpuTime;
|
||||
private DateTime _prevWallTime;
|
||||
private NetworkMetricsSnapshot _prevNetSnapshot;
|
||||
|
||||
public AgentSelfMetricsCollector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
NetworkMetrics networkMetrics,
|
||||
EventSpool spool,
|
||||
AgentLogger logger,
|
||||
string agentVersion = "unknown",
|
||||
int intervalSeconds = 60)
|
||||
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
|
||||
{
|
||||
_networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics));
|
||||
_spool = spool;
|
||||
_agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion;
|
||||
}
|
||||
|
||||
protected override void OnBeforeStart()
|
||||
{
|
||||
// Prime the baseline for delta calculations
|
||||
try
|
||||
{
|
||||
var proc = Process.GetCurrentProcess();
|
||||
_prevCpuTime = proc.TotalProcessorTime;
|
||||
_prevWallTime = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Failed to prime CPU baseline: {ex.Message}");
|
||||
_prevCpuTime = TimeSpan.Zero;
|
||||
_prevWallTime = DateTime.UtcNow;
|
||||
}
|
||||
_prevNetSnapshot = _networkMetrics.GetSnapshot();
|
||||
}
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// --- Process metrics (no WMI, no PerformanceCounter) ---
|
||||
try
|
||||
{
|
||||
var proc = Process.GetCurrentProcess();
|
||||
proc.Refresh(); // ensure fresh values
|
||||
|
||||
// CPU %: (delta CPU time) / (delta wall time) / cores * 100
|
||||
var currentCpuTime = proc.TotalProcessorTime;
|
||||
var cpuDelta = currentCpuTime - _prevCpuTime;
|
||||
var wallDelta = now - _prevWallTime;
|
||||
|
||||
if (wallDelta.TotalMilliseconds > 0)
|
||||
{
|
||||
var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds
|
||||
/ Environment.ProcessorCount * 100.0;
|
||||
data["agent_cpu_percent"] = Math.Round(cpuPercent, 2);
|
||||
}
|
||||
|
||||
_prevCpuTime = currentCpuTime;
|
||||
_prevWallTime = now;
|
||||
|
||||
// Memory
|
||||
data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1);
|
||||
data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1);
|
||||
|
||||
// Threads & handles
|
||||
data["agent_thread_count"] = proc.Threads.Count;
|
||||
data["agent_handle_count"] = proc.HandleCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Process metrics read failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// --- Spool queue depth ---
|
||||
if (_spool != null)
|
||||
{
|
||||
try { data["spool_queue_depth"] = _spool.GetCount(); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
// --- Network delta ---
|
||||
try
|
||||
{
|
||||
var currentNet = _networkMetrics.GetSnapshot();
|
||||
var delta = currentNet.DeltaFrom(_prevNetSnapshot);
|
||||
_prevNetSnapshot = currentNet;
|
||||
|
||||
data["net_requests"] = delta.Requests;
|
||||
data["net_failures"] = delta.Failures;
|
||||
data["net_bytes_up"] = delta.BytesUp;
|
||||
data["net_bytes_down"] = delta.BytesDown;
|
||||
data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1);
|
||||
|
||||
// Cumulative totals for easy "total cost of this session" view
|
||||
data["net_total_bytes_up"] = currentNet.TotalBytesUp;
|
||||
data["net_total_bytes_down"] = currentNet.TotalBytesDown;
|
||||
data["net_total_requests"] = currentNet.RequestCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Network metrics read failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
EmitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = SessionId,
|
||||
TenantId = TenantId,
|
||||
Timestamp = now,
|
||||
EventType = "agent_metrics_snapshot",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "AgentSelfMetricsCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " +
|
||||
$"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " +
|
||||
$"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " +
|
||||
$"\u2191{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " +
|
||||
$"\u2193{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B",
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management.Automation;
|
||||
using System.Management.Automation.Runspaces;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
/// <summary>
|
||||
/// Polls OS-level Delivery Optimization status via a persistent PowerShell Runspace.
|
||||
/// Matches DO entries to tracked IME app downloads using the intunewin-bin FileId format.
|
||||
/// Emits download_progress events on every poll (for live UI) and do_telemetry once per app
|
||||
/// when the download completes.
|
||||
///
|
||||
/// Lifecycle: starts dormant, wakes up when ImeLogTracker detects a download,
|
||||
/// goes dormant again when all downloads are enriched.
|
||||
/// </summary>
|
||||
public class DeliveryOptimizationCollector : CollectorBase
|
||||
{
|
||||
private const string LogFileName = "do-status.jsonl";
|
||||
private const int InvokeTimeoutMs = 5000;
|
||||
|
||||
private readonly Func<AppPackageStateList> _getPackageStates;
|
||||
private readonly Action<AppPackageState> _onDoTelemetryReceived;
|
||||
private readonly string _logFilePath;
|
||||
|
||||
private Runspace _runspace;
|
||||
private bool _permanentlyDisabled;
|
||||
private int _consecutiveErrors;
|
||||
private volatile bool _dormant = true;
|
||||
private int _collecting; // concurrency guard (0 = idle, 1 = collecting)
|
||||
|
||||
// Track which apps we already sent final do_telemetry for (prevents duplicate callbacks)
|
||||
private readonly HashSet<string> _enrichedAppIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Change detection per app: only emit download_progress when bytes actually changed
|
||||
private readonly Dictionary<string, long> _lastBytesPerApp = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Change detection for JSONL log: only write when overall state changed
|
||||
private long _lastSnapshotFingerprint;
|
||||
|
||||
public DeliveryOptimizationCollector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
AgentLogger logger,
|
||||
int intervalSeconds,
|
||||
Func<AppPackageStateList> getPackageStates,
|
||||
Action<AppPackageState> onDoTelemetryReceived,
|
||||
string logDirectory)
|
||||
: base(sessionId, tenantId, emitEvent, logger, intervalSeconds)
|
||||
{
|
||||
_getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates));
|
||||
_onDoTelemetryReceived = onDoTelemetryReceived;
|
||||
_logFilePath = Path.Combine(logDirectory, LogFileName);
|
||||
}
|
||||
|
||||
/// <summary>Start dormant — timer does not fire until WakeUp() is called.</summary>
|
||||
protected override TimeSpan GetInitialDelay() => Timeout.InfiniteTimeSpan;
|
||||
|
||||
protected override void OnBeforeStart()
|
||||
{
|
||||
EnsureRunspace();
|
||||
}
|
||||
|
||||
protected override void OnAfterStop()
|
||||
{
|
||||
DisposeRunspace();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wakes the collector from dormant state. Called by MonitoringService when
|
||||
/// ImeLogTracker detects an app entering the Downloading state.
|
||||
/// Safe to call multiple times / from any thread.
|
||||
/// </summary>
|
||||
public void WakeUp()
|
||||
{
|
||||
if (_permanentlyDisabled || !_dormant) return;
|
||||
_dormant = false;
|
||||
Logger.Info("[DeliveryOptimizationCollector] Waking up — download activity detected");
|
||||
ResumeTimer();
|
||||
}
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
if (_permanentlyDisabled || _dormant) return;
|
||||
|
||||
// Concurrency guard: Runspace is not thread-safe. Skip if previous poll is still running.
|
||||
if (Interlocked.CompareExchange(ref _collecting, 1, 0) != 0)
|
||||
{
|
||||
Logger.Debug("[DeliveryOptimizationCollector] Skipping poll — previous invocation still running");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
CollectCore();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _collecting, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void CollectCore()
|
||||
{
|
||||
|
||||
// Guard: check if there's still work to do
|
||||
var packageStates = _getPackageStates();
|
||||
if (packageStates == null || packageStates.Count == 0) return;
|
||||
|
||||
var hasActiveDownloads = false;
|
||||
var hasPendingEnrichment = false;
|
||||
for (int i = 0; i < packageStates.Count; i++)
|
||||
{
|
||||
var p = packageStates[i];
|
||||
if (p.InstallationState == AppInstallationState.Downloading ||
|
||||
p.InstallationState == AppInstallationState.Installing)
|
||||
hasActiveDownloads = true;
|
||||
|
||||
if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) &&
|
||||
p.DownloadingOrInstallingSeen &&
|
||||
(p.InstallationState == AppInstallationState.Installed ||
|
||||
p.InstallationState == AppInstallationState.Error))
|
||||
hasPendingEnrichment = true;
|
||||
}
|
||||
|
||||
if (!hasActiveDownloads && !hasPendingEnrichment)
|
||||
{
|
||||
Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads or pending enrichment");
|
||||
_dormant = true;
|
||||
PauseTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Invoke PowerShell via persistent Runspace
|
||||
var results = InvokeDoStatus();
|
||||
if (results == null) return;
|
||||
|
||||
_consecutiveErrors = 0;
|
||||
|
||||
// Process results: emit progress events and match completed downloads
|
||||
ProcessResults(results, packageStates);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PowerShell Runspace management
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void EnsureRunspace()
|
||||
{
|
||||
if (_runspace != null && _runspace.RunspaceStateInfo.State == RunspaceState.Opened)
|
||||
return;
|
||||
|
||||
DisposeRunspace();
|
||||
|
||||
try
|
||||
{
|
||||
_runspace = RunspaceFactory.CreateRunspace();
|
||||
_runspace.Open();
|
||||
Logger.Info("[DeliveryOptimizationCollector] PowerShell Runspace opened");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] Failed to open Runspace: {ex.Message}");
|
||||
_runspace = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void DisposeRunspace()
|
||||
{
|
||||
if (_runspace == null) return;
|
||||
try
|
||||
{
|
||||
_runspace.Close();
|
||||
_runspace.Dispose();
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
_runspace = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes Get-DeliveryOptimizationStatus in the persistent Runspace.
|
||||
/// Returns a list of PSObject results, or null on error.
|
||||
/// Self-heals: recreates the Runspace on failure.
|
||||
/// </summary>
|
||||
private List<PSObject> InvokeDoStatus()
|
||||
{
|
||||
EnsureRunspace();
|
||||
if (_runspace == null)
|
||||
{
|
||||
HandleError("Runspace not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var ps = PowerShell.Create())
|
||||
{
|
||||
ps.Runspace = _runspace;
|
||||
ps.AddCommand("Get-DeliveryOptimizationStatus");
|
||||
|
||||
// Invoke with timeout protection
|
||||
var asyncResult = ps.BeginInvoke();
|
||||
if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs))
|
||||
{
|
||||
ps.Stop();
|
||||
Logger.Warning("[DeliveryOptimizationCollector] PS invoke timed out after 5s, stopping");
|
||||
HandleError("timeout");
|
||||
return null;
|
||||
}
|
||||
|
||||
var results = new List<PSObject>(ps.EndInvoke(asyncResult));
|
||||
|
||||
// Check for errors in the PS error stream
|
||||
if (ps.HadErrors && ps.Streams.Error.Count > 0)
|
||||
{
|
||||
var firstError = ps.Streams.Error[0].ToString();
|
||||
if (firstError.Contains("is not recognized as the name of a cmdlet") ||
|
||||
firstError.Contains("CommandNotFoundException"))
|
||||
{
|
||||
Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently");
|
||||
_permanentlyDisabled = true;
|
||||
return null;
|
||||
}
|
||||
Logger.Debug($"[DeliveryOptimizationCollector] PS error stream: {firstError}");
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
catch (PSInvalidOperationException ex)
|
||||
{
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] Runspace error, self-healing: {ex.Message}");
|
||||
DisposeRunspace();
|
||||
HandleError(ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (RuntimeException ex)
|
||||
{
|
||||
// Catches command-not-found and other PS runtime errors
|
||||
if (ex.ErrorRecord?.FullyQualifiedErrorId?.Contains("CommandNotFoundException") == true)
|
||||
{
|
||||
Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently");
|
||||
_permanentlyDisabled = true;
|
||||
return null;
|
||||
}
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] PS runtime error: {ex.Message}");
|
||||
HandleError(ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] Invoke failed: {ex.Message}");
|
||||
DisposeRunspace();
|
||||
HandleError(ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Result processing: progress events + final DO telemetry
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void ProcessResults(List<PSObject> results, AppPackageStateList packageStates)
|
||||
{
|
||||
if (results.Count == 0)
|
||||
{
|
||||
Logger.Verbose("[DeliveryOptimizationCollector] No DO entries returned");
|
||||
return;
|
||||
}
|
||||
|
||||
int progressCount = 0;
|
||||
int matchCount = 0;
|
||||
|
||||
// Build JSONL snapshot for log (only if fingerprint changes)
|
||||
var logEntries = new JArray();
|
||||
|
||||
foreach (var result in results)
|
||||
{
|
||||
var fileId = GetPropString(result, "FileId");
|
||||
if (string.IsNullOrEmpty(fileId)) continue;
|
||||
|
||||
long fileSize = GetPropLong(result, "FileSize");
|
||||
long totalBytes = GetPropLong(result, "TotalBytesDownloaded");
|
||||
long bytesFromPeers = GetPropLong(result, "BytesFromPeers");
|
||||
int peerCachingPct = GetPropInt(result, "PercentPeerCaching");
|
||||
long bytesLanPeers = GetPropLong(result, "BytesFromLanPeers");
|
||||
long bytesGroupPeers = GetPropLong(result, "BytesFromGroupPeers");
|
||||
long bytesInternetPeers = GetPropLong(result, "BytesFromInternetPeers");
|
||||
int downloadMode = GetPropInt(result, "DownloadMode", -1);
|
||||
long bytesFromHttp = GetPropLong(result, "BytesFromHttp");
|
||||
long bytesFromCacheServer = GetPropLong(result, "BytesFromCacheServer");
|
||||
var downloadDuration = GetPropTimeSpan(result, "DownloadDuration");
|
||||
var sourceUrl = GetPropString(result, "SourceURL");
|
||||
|
||||
// Build log entry for JSONL
|
||||
logEntries.Add(new JObject
|
||||
{
|
||||
["FileId"] = fileId,
|
||||
["FileSize"] = fileSize,
|
||||
["TotalBytesDownloaded"] = totalBytes,
|
||||
["PercentPeerCaching"] = peerCachingPct,
|
||||
["BytesFromPeers"] = bytesFromPeers,
|
||||
["BytesFromHttp"] = bytesFromHttp,
|
||||
["DownloadMode"] = downloadMode,
|
||||
["SourceURL"] = sourceUrl,
|
||||
["BytesFromCacheServer"] = bytesFromCacheServer
|
||||
});
|
||||
|
||||
// Try to match to an IME-tracked app
|
||||
var appId = ImeLogTracker.ExtractAppIdFromDoFileId(fileId);
|
||||
if (string.IsNullOrEmpty(appId)) continue;
|
||||
|
||||
var pkg = packageStates.GetPackage(appId);
|
||||
if (pkg == null) continue;
|
||||
|
||||
// --- Live download_progress (every poll where bytes changed) ---
|
||||
long lastBytes;
|
||||
_lastBytesPerApp.TryGetValue(appId, out lastBytes);
|
||||
|
||||
if (totalBytes != lastBytes)
|
||||
{
|
||||
_lastBytesPerApp[appId] = totalBytes;
|
||||
progressCount++;
|
||||
|
||||
int percentComplete = fileSize > 0 ? (int)((totalBytes * 100) / fileSize) : 0;
|
||||
|
||||
EmitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = SessionId,
|
||||
TenantId = TenantId,
|
||||
EventType = "download_progress",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "DeliveryOptimizationCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"{pkg.Name ?? appId}: {percentComplete}% ({totalBytes}/{fileSize}) peers={peerCachingPct}% mode={downloadMode}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["appId"] = appId,
|
||||
["appName"] = pkg.Name,
|
||||
// UI-compatible fields (DownloadProgress.tsx reads these)
|
||||
["bytesDownloaded"] = totalBytes,
|
||||
["bytesTotal"] = fileSize,
|
||||
["progressPercent"] = percentComplete,
|
||||
// DO-specific fields
|
||||
["doFileSize"] = fileSize,
|
||||
["doTotalBytesDownloaded"] = totalBytes,
|
||||
["doPercentComplete"] = percentComplete,
|
||||
["doBytesFromPeers"] = bytesFromPeers,
|
||||
["doBytesFromHttp"] = bytesFromHttp,
|
||||
["doPercentPeerCaching"] = peerCachingPct,
|
||||
["doDownloadMode"] = downloadMode,
|
||||
["doBytesFromCacheServer"] = bytesFromCacheServer,
|
||||
["doSource"] = "os_cmdlet"
|
||||
},
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
// --- Final DO telemetry (once per app when download is complete) ---
|
||||
if (!pkg.HasDoTelemetry && !_enrichedAppIds.Contains(appId) &&
|
||||
totalBytes >= fileSize && fileSize > 0)
|
||||
{
|
||||
var durationStr = downloadDuration?.ToString(@"hh\:mm\:ss\.fff");
|
||||
|
||||
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
|
||||
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
|
||||
downloadMode, durationStr, bytesFromHttp);
|
||||
|
||||
_enrichedAppIds.Add(appId);
|
||||
matchCount++;
|
||||
|
||||
Logger.Info($"[DeliveryOptimizationCollector] DO matched: {pkg.Name ?? appId} — " +
|
||||
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
|
||||
$"http={bytesFromHttp}, mode={downloadMode}, duration={durationStr}");
|
||||
|
||||
// Fire callback → EnrollmentTracker emits do_telemetry + download_progress events
|
||||
_onDoTelemetryReceived?.Invoke(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
// Write JSONL log only when overall state changed
|
||||
var fingerprint = ComputeFingerprint(logEntries);
|
||||
if (fingerprint != _lastSnapshotFingerprint)
|
||||
{
|
||||
_lastSnapshotFingerprint = fingerprint;
|
||||
WriteToLogFile(logEntries);
|
||||
}
|
||||
|
||||
Logger.Verbose($"[DeliveryOptimizationCollector] Poll: {results.Count} entries, " +
|
||||
$"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PSObject property helpers (safe extraction, no exceptions on missing props)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static string GetPropString(PSObject obj, string name)
|
||||
{
|
||||
return obj.Properties[name]?.Value?.ToString();
|
||||
}
|
||||
|
||||
private static long GetPropLong(PSObject obj, string name, long defaultValue = 0)
|
||||
{
|
||||
var val = obj.Properties[name]?.Value;
|
||||
if (val == null) return defaultValue;
|
||||
try { return Convert.ToInt64(val); }
|
||||
catch { return defaultValue; }
|
||||
}
|
||||
|
||||
private static int GetPropInt(PSObject obj, string name, int defaultValue = 0)
|
||||
{
|
||||
var val = obj.Properties[name]?.Value;
|
||||
if (val == null) return defaultValue;
|
||||
try { return Convert.ToInt32(val); }
|
||||
catch { return defaultValue; }
|
||||
}
|
||||
|
||||
private static TimeSpan? GetPropTimeSpan(PSObject obj, string name)
|
||||
{
|
||||
var val = obj.Properties[name]?.Value;
|
||||
if (val is TimeSpan ts) return ts;
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void HandleError(string error)
|
||||
{
|
||||
_consecutiveErrors++;
|
||||
|
||||
if (_consecutiveErrors >= 5)
|
||||
{
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] {_consecutiveErrors} consecutive errors, disabling permanently. Last: {error}");
|
||||
_permanentlyDisabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] Error ({_consecutiveErrors}/5): {error}");
|
||||
}
|
||||
|
||||
private static long ComputeFingerprint(JArray entries)
|
||||
{
|
||||
long sum = entries.Count;
|
||||
foreach (var e in entries)
|
||||
{
|
||||
sum += e["TotalBytesDownloaded"]?.Value<long>() ?? 0;
|
||||
// Include FileId hash to detect entry additions/removals
|
||||
var fileId = e["FileId"]?.ToString();
|
||||
if (fileId != null)
|
||||
sum += fileId.GetHashCode();
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
private void WriteToLogFile(JArray entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new JObject
|
||||
{
|
||||
["timestamp"] = DateTime.UtcNow.ToString("o"),
|
||||
["entryCount"] = entries.Count,
|
||||
["entries"] = entries
|
||||
};
|
||||
|
||||
var line = snapshot.ToString(Formatting.None) + Environment.NewLine;
|
||||
File.AppendAllText(_logFilePath, line, Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"[DeliveryOptimizationCollector] Failed to write DO log: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+598
@@ -0,0 +1,598 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects network changes (SSID switches, wired↔wireless, IP changes) during provisioning
|
||||
/// and runs MDM endpoint connectivity checks after each change.
|
||||
/// Always-on collector — fires network_state_change and network_connectivity_check events.
|
||||
/// </summary>
|
||||
public class NetworkChangeDetector : IDisposable
|
||||
{
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _apiBaseUrl;
|
||||
|
||||
private NetworkStateSnapshot _lastKnownState;
|
||||
private Timer _debounceTimer;
|
||||
private HttpClient _connectivityCheckClient;
|
||||
private readonly object _stateLock = new object();
|
||||
private bool _disposed;
|
||||
|
||||
private const int DebounceDelayMs = 3000;
|
||||
private const int ConnectivityCheckDelayMs = 5000;
|
||||
private const int ConnectivityCheckTimeoutMs = 8000;
|
||||
private const int WifiCollectTimeoutMs = 3000;
|
||||
private const string Source = "NetworkChangeDetector";
|
||||
|
||||
/// <summary>
|
||||
/// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed.
|
||||
/// </summary>
|
||||
private static readonly (string Name, string Url)[] MdmEndpoints =
|
||||
{
|
||||
("Entra ID", "https://login.microsoftonline.com"),
|
||||
("Device Registration", "https://enterpriseregistration.windows.net"),
|
||||
("Intune Portal", "https://portal.manage.microsoft.com"),
|
||||
("Microsoft Graph", "https://graph.microsoft.com"),
|
||||
};
|
||||
|
||||
public NetworkChangeDetector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger,
|
||||
string apiBaseUrl = null)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_apiBaseUrl = apiBaseUrl;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info("NetworkChangeDetector: starting (monitoring network changes)");
|
||||
|
||||
_connectivityCheckClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMilliseconds(ConnectivityCheckTimeoutMs)
|
||||
};
|
||||
|
||||
// Capture initial network state as baseline
|
||||
_lastKnownState = CaptureCurrentState();
|
||||
_logger.Info($"NetworkChangeDetector: initial state — {FormatSnapshotSummary(_lastKnownState)}");
|
||||
|
||||
NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = null;
|
||||
}
|
||||
|
||||
_connectivityCheckClient?.Dispose();
|
||||
_connectivityCheckClient = null;
|
||||
|
||||
_logger.Info("NetworkChangeDetector: stopped");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
Stop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Event handling & debounce
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private void OnNetworkAddressChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
// Reset debounce timer — collapses rapid OS events into one logical change
|
||||
_debounceTimer?.Dispose();
|
||||
_debounceTimer = new Timer(
|
||||
OnDebounceElapsed,
|
||||
null,
|
||||
DebounceDelayMs,
|
||||
Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDebounceElapsed(object state)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessNetworkChange();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"NetworkChangeDetector: error processing network change: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Core logic
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private void ProcessNetworkChange()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
var newState = CaptureCurrentState();
|
||||
|
||||
NetworkStateSnapshot previousState;
|
||||
lock (_stateLock)
|
||||
{
|
||||
previousState = _lastKnownState;
|
||||
_lastKnownState = newState;
|
||||
}
|
||||
|
||||
// Suppress events when network hasn't actually changed (DHCP renewals, OS chatter)
|
||||
if (previousState != null && previousState.IsSameNetwork(newState))
|
||||
{
|
||||
_logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"NetworkChangeDetector: network change detected — {FormatSnapshotSummary(previousState)} → {FormatSnapshotSummary(newState)}");
|
||||
|
||||
// Emit network_state_change event
|
||||
var changeType = ClassifyChange(previousState, newState);
|
||||
var message = BuildChangeMessage(previousState, newState);
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
// Before state
|
||||
{ "before_connectionType", previousState?.ConnectionType ?? "None" },
|
||||
{ "before_adapterName", previousState?.AdapterName ?? "None" },
|
||||
{ "before_ipAddress", previousState?.IpAddress ?? "None" },
|
||||
{ "before_gateway", previousState?.Gateway ?? "None" },
|
||||
{ "before_wifiSsid", previousState?.WifiSsid ?? "n/a" },
|
||||
{ "before_linkSpeedMbps", previousState?.LinkSpeedMbps ?? 0 },
|
||||
|
||||
// After state
|
||||
{ "after_connectionType", newState.ConnectionType ?? "None" },
|
||||
{ "after_adapterName", newState.AdapterName ?? "None" },
|
||||
{ "after_ipAddress", newState.IpAddress ?? "None" },
|
||||
{ "after_gateway", newState.Gateway ?? "None" },
|
||||
{ "after_wifiSsid", newState.WifiSsid ?? "n/a" },
|
||||
{ "after_linkSpeedMbps", newState.LinkSpeedMbps },
|
||||
|
||||
// Change classification
|
||||
{ "changeType", changeType },
|
||||
{ "hadNetwork", previousState?.HasNetwork ?? false },
|
||||
{ "hasNetwork", newState.HasNetwork }
|
||||
};
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.NetworkStateChange,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data
|
||||
});
|
||||
|
||||
// Wait for new connection to stabilize, then check MDM endpoint connectivity
|
||||
try
|
||||
{
|
||||
Task.Delay(ConnectivityCheckDelayMs).Wait();
|
||||
if (!_disposed)
|
||||
{
|
||||
RunConnectivityChecksAsync().Wait();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: connectivity check failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunConnectivityChecksAsync()
|
||||
{
|
||||
if (_disposed || _connectivityCheckClient == null) return;
|
||||
|
||||
// Build endpoint list: static MDM endpoints + dynamic backend URL
|
||||
var endpoints = new List<(string Name, string Url)>(MdmEndpoints);
|
||||
// Optionally add the Autopilot Monitor API endpoint, but we don't want to cause false alarms if it's down or unreachable,
|
||||
// since it's not critical for enrollment success and may be available only after certain network changes (e.g. if it's behind a captive portal).
|
||||
// For now, we can omit it from the critical checks.
|
||||
// if (!string.IsNullOrEmpty(_apiBaseUrl))
|
||||
// {
|
||||
// endpoints.Add(("Autopilot Monitor API", _apiBaseUrl));
|
||||
// }
|
||||
|
||||
// Run all checks in parallel
|
||||
var tasks = endpoints.Select(ep => CheckEndpointAsync(ep.Name, ep.Url)).ToArray();
|
||||
|
||||
Dictionary<string, object>[] results;
|
||||
try
|
||||
{
|
||||
results = await Task.WhenAll(tasks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: connectivity check tasks failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disposed) return;
|
||||
|
||||
var reachableCount = results.Count(r => r.ContainsKey("reachable") && (bool)r["reachable"]);
|
||||
var totalCount = results.Length;
|
||||
var allReachable = reachableCount == totalCount;
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.NetworkConnectivityCheck,
|
||||
Severity = allReachable ? EventSeverity.Info : EventSeverity.Warning,
|
||||
Source = Source,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Connectivity check after network change: {reachableCount}/{totalCount} endpoints reachable",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "reachableCount", reachableCount },
|
||||
{ "totalCount", totalCount },
|
||||
{ "allReachable", allReachable },
|
||||
{ "checkDelayMs", ConnectivityCheckDelayMs },
|
||||
{ "timeoutMs", ConnectivityCheckTimeoutMs },
|
||||
{ "results", results.ToList() }
|
||||
}
|
||||
});
|
||||
|
||||
if (allReachable)
|
||||
_logger.Info($"NetworkChangeDetector: connectivity check passed ({reachableCount}/{totalCount} reachable)");
|
||||
else
|
||||
_logger.Warning($"NetworkChangeDetector: connectivity check partial — {reachableCount}/{totalCount} endpoints reachable");
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object>> CheckEndpointAsync(string name, string url)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
// Use GET — any HTTP response (even 401/403) means the network path works
|
||||
var response = await _connectivityCheckClient.GetAsync(url);
|
||||
sw.Stop();
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "endpoint", name },
|
||||
{ "url", url },
|
||||
{ "reachable", true },
|
||||
{ "httpStatus", (int)response.StatusCode },
|
||||
{ "latencyMs", sw.ElapsedMilliseconds },
|
||||
{ "error", (object)null }
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
// Unwrap AggregateException from async
|
||||
var innerEx = ex is AggregateException agg ? agg.InnerException ?? ex : ex;
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "endpoint", name },
|
||||
{ "url", url },
|
||||
{ "reachable", false },
|
||||
{ "httpStatus", (object)null },
|
||||
{ "latencyMs", sw.ElapsedMilliseconds },
|
||||
{ "error", $"{innerEx.GetType().Name}: {innerEx.Message}" }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// State capture
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private NetworkStateSnapshot CaptureCurrentState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeNic = FindActiveNetworkInterface();
|
||||
if (activeNic == null)
|
||||
{
|
||||
return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow };
|
||||
}
|
||||
|
||||
var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211;
|
||||
var ipProps = activeNic.GetIPProperties();
|
||||
|
||||
// First IPv4 unicast address
|
||||
string ipAddress = null;
|
||||
foreach (var addr in ipProps.UnicastAddresses)
|
||||
{
|
||||
if (addr.Address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
ipAddress = addr.Address.ToString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// First non-0.0.0.0 gateway
|
||||
string gateway = null;
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
{
|
||||
gateway = gw.Address.ToString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = new NetworkStateSnapshot
|
||||
{
|
||||
HasNetwork = true,
|
||||
AdapterName = activeNic.Name,
|
||||
AdapterDescription = activeNic.Description,
|
||||
InterfaceType = activeNic.NetworkInterfaceType.ToString(),
|
||||
ConnectionType = isWifi ? "WiFi" : "Ethernet",
|
||||
MacAddress = activeNic.GetPhysicalAddress().ToString(),
|
||||
InterfaceId = activeNic.Id,
|
||||
LinkSpeedMbps = activeNic.Speed / 1_000_000,
|
||||
IpAddress = ipAddress,
|
||||
Gateway = gateway,
|
||||
CapturedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Collect WiFi SSID synchronously (needed for before/after comparison)
|
||||
if (isWifi)
|
||||
{
|
||||
var wifiInfo = CollectWiFiInfo();
|
||||
snapshot.WifiSsid = wifiInfo.ssid;
|
||||
snapshot.WifiSignalPercent = wifiInfo.signal;
|
||||
snapshot.WifiRadioType = wifiInfo.radioType;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: state capture failed: {ex.Message}");
|
||||
return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
|
||||
/// Synchronous with timeout — needed before emitting the event.
|
||||
/// </summary>
|
||||
private (string ssid, int? signal, string radioType) CollectWiFiInfo()
|
||||
{
|
||||
string ssid = null;
|
||||
int? signal = null;
|
||||
string radioType = null;
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.Netsh,
|
||||
Arguments = "wlan show interfaces",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
string output;
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(WifiCollectTimeoutMs))
|
||||
{
|
||||
try { process.Kill(); } catch { }
|
||||
_logger.Debug("NetworkChangeDetector: netsh timed out");
|
||||
return (null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return (null, null, null);
|
||||
|
||||
foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
var colonIndex = trimmed.IndexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
|
||||
var key = trimmed.Substring(0, colonIndex).Trim();
|
||||
var value = trimmed.Substring(colonIndex + 1).Trim();
|
||||
|
||||
if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!key.Equals("BSSID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ssid = value;
|
||||
}
|
||||
else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (int.TryParse(value.TrimEnd('%'), out var sig))
|
||||
signal = sig;
|
||||
}
|
||||
else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
radioType = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return (ssid, signal, radioType);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
|
||||
/// </summary>
|
||||
private static NetworkInterface FindActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var nic in interfaces)
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
var ipProps = nic.GetIPProperties();
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
return nic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after)
|
||||
{
|
||||
var beforeDesc = FormatSnapshotBrief(before);
|
||||
var afterDesc = FormatSnapshotBrief(after);
|
||||
|
||||
if (before == null || !before.HasNetwork)
|
||||
{
|
||||
if (after.HasNetwork)
|
||||
return $"Network connected: {afterDesc}";
|
||||
return "Network state changed (no connectivity)";
|
||||
}
|
||||
|
||||
if (!after.HasNetwork)
|
||||
return $"Network lost (was {beforeDesc})";
|
||||
|
||||
return $"Network changed: {beforeDesc} \u2192 {afterDesc}";
|
||||
}
|
||||
|
||||
private static string FormatSnapshotBrief(NetworkStateSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null || !snapshot.HasNetwork)
|
||||
return "None";
|
||||
|
||||
if (snapshot.ConnectionType == "WiFi" && !string.IsNullOrEmpty(snapshot.WifiSsid))
|
||||
return $"WiFi '{snapshot.WifiSsid}'";
|
||||
|
||||
return $"{snapshot.ConnectionType} ({snapshot.IpAddress ?? "no IP"})";
|
||||
}
|
||||
|
||||
private static string FormatSnapshotSummary(NetworkStateSnapshot snapshot)
|
||||
{
|
||||
if (snapshot == null || !snapshot.HasNetwork)
|
||||
return "no network";
|
||||
|
||||
var summary = $"{snapshot.ConnectionType}";
|
||||
if (!string.IsNullOrEmpty(snapshot.WifiSsid))
|
||||
summary += $" SSID='{snapshot.WifiSsid}'";
|
||||
if (!string.IsNullOrEmpty(snapshot.IpAddress))
|
||||
summary += $" IP={snapshot.IpAddress}";
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static string ClassifyChange(NetworkStateSnapshot before, NetworkStateSnapshot after)
|
||||
{
|
||||
if (before == null || !before.HasNetwork)
|
||||
return after.HasNetwork ? "network_restored" : "no_change";
|
||||
|
||||
if (!after.HasNetwork)
|
||||
return "network_lost";
|
||||
|
||||
// Both have network — classify the change
|
||||
if (before.ConnectionType != after.ConnectionType)
|
||||
return "type_change";
|
||||
|
||||
if (before.ConnectionType == "WiFi" && after.ConnectionType == "WiFi" &&
|
||||
before.WifiSsid != after.WifiSsid)
|
||||
return "ssid_change";
|
||||
|
||||
if (before.InterfaceId != after.InterfaceId)
|
||||
return "adapter_change";
|
||||
|
||||
return "ip_change";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Inner type: network state snapshot
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private class NetworkStateSnapshot
|
||||
{
|
||||
public bool HasNetwork { get; set; }
|
||||
public string AdapterName { get; set; }
|
||||
public string AdapterDescription { get; set; }
|
||||
public string InterfaceType { get; set; }
|
||||
public string ConnectionType { get; set; }
|
||||
public string MacAddress { get; set; }
|
||||
public string InterfaceId { get; set; }
|
||||
public long LinkSpeedMbps { get; set; }
|
||||
public string IpAddress { get; set; }
|
||||
public string Gateway { get; set; }
|
||||
public string WifiSsid { get; set; }
|
||||
public int? WifiSignalPercent { get; set; }
|
||||
public string WifiRadioType { get; set; }
|
||||
public DateTime CapturedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if both snapshots represent the same logical network connection.
|
||||
/// Suppresses events for DHCP renewals and other OS chatter.
|
||||
/// </summary>
|
||||
public bool IsSameNetwork(NetworkStateSnapshot other)
|
||||
{
|
||||
if (other == null) return false;
|
||||
if (HasNetwork != other.HasNetwork) return false;
|
||||
if (!HasNetwork && !other.HasNetwork) return true;
|
||||
|
||||
// Same adapter + same IP + same SSID = same logical network
|
||||
return InterfaceId == other.InterfaceId &&
|
||||
IpAddress == other.IpAddress &&
|
||||
WifiSsid == other.WifiSsid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
{
|
||||
/// <summary>
|
||||
/// Collects system performance metrics (CPU, memory, disk) on a configurable interval.
|
||||
/// Optional collector - toggled on/off via remote config.
|
||||
/// </summary>
|
||||
public class PerformanceCollector : CollectorBase
|
||||
{
|
||||
private PerformanceCounter _cpuCounter;
|
||||
private PerformanceCounter _diskQueueCounter;
|
||||
|
||||
// Network throughput tracking
|
||||
private string _activeNicId;
|
||||
private string _activeNicName;
|
||||
private long _prevBytesSent;
|
||||
private long _prevBytesReceived;
|
||||
private DateTime _prevNetSampleTime;
|
||||
private bool _networkInitialized;
|
||||
|
||||
public PerformanceCollector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected,
|
||||
AgentLogger logger, int intervalSeconds = 60)
|
||||
: base(sessionId, tenantId, onEventCollected, logger, intervalSeconds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Use a 5-second warm-up delay so counters are primed before first read.</summary>
|
||||
protected override TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(5);
|
||||
|
||||
protected override void OnBeforeStart()
|
||||
{
|
||||
try
|
||||
{
|
||||
_cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
|
||||
_diskQueueCounter = new PerformanceCounter("PhysicalDisk", "Current Disk Queue Length", "_Total");
|
||||
|
||||
// Initial read to prime the counters (first reading is always 0)
|
||||
_cpuCounter.NextValue();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warning($"Failed to initialize performance counters: {ex.Message}");
|
||||
}
|
||||
|
||||
// Initialize network throughput baseline
|
||||
try
|
||||
{
|
||||
var activeNic = FindActiveNetworkInterface();
|
||||
if (activeNic != null)
|
||||
{
|
||||
_activeNicId = activeNic.Id;
|
||||
_activeNicName = activeNic.Description;
|
||||
var stats = activeNic.GetIPStatistics();
|
||||
_prevBytesSent = stats.BytesSent;
|
||||
_prevBytesReceived = stats.BytesReceived;
|
||||
_prevNetSampleTime = DateTime.UtcNow;
|
||||
_networkInitialized = true;
|
||||
Logger.Debug($"Network throughput tracking initialized on: {_activeNicName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Debug("No active network interface found for throughput tracking");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnAfterStop()
|
||||
{
|
||||
_cpuCounter?.Dispose();
|
||||
_cpuCounter = null;
|
||||
_diskQueueCounter?.Dispose();
|
||||
_diskQueueCounter = null;
|
||||
}
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
// CPU usage
|
||||
try
|
||||
{
|
||||
var cpuPercent = _cpuCounter?.NextValue() ?? 0;
|
||||
data["cpu_percent"] = Math.Round(cpuPercent, 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"CPU counter read failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Memory info via GlobalMemoryStatusEx (kernel32)
|
||||
try
|
||||
{
|
||||
if (MemoryNativeMethods.TryGetMemoryInfo(out var availBytes, out var totalBytes, out var loadPercent)
|
||||
&& totalBytes > 0)
|
||||
{
|
||||
data["memory_available_mb"] = Math.Round(availBytes / (1024.0 * 1024.0), 0);
|
||||
data["memory_total_mb"] = Math.Round(totalBytes / (1024.0 * 1024.0), 0);
|
||||
data["memory_used_percent"] = Math.Round((double)loadPercent, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Debug("GlobalMemoryStatusEx returned no data");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Memory query failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Disk queue length
|
||||
try
|
||||
{
|
||||
var diskQueue = _diskQueueCounter?.NextValue() ?? 0;
|
||||
data["disk_queue_length"] = Math.Round(diskQueue, 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Disk queue counter read failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Disk free space on system drive
|
||||
try
|
||||
{
|
||||
var systemDrive = Path.GetPathRoot(Environment.SystemDirectory);
|
||||
var driveInfo = new DriveInfo(systemDrive);
|
||||
data["disk_free_gb"] = Math.Round(driveInfo.AvailableFreeSpace / (1024.0 * 1024 * 1024), 1);
|
||||
data["disk_total_gb"] = Math.Round(driveInfo.TotalSize / (1024.0 * 1024 * 1024), 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Disk space query failed: {ex.Message}");
|
||||
}
|
||||
|
||||
// Network throughput (bytes sent/received delta + rate)
|
||||
try
|
||||
{
|
||||
if (_networkInitialized)
|
||||
{
|
||||
// Re-find the NIC by cached ID
|
||||
NetworkInterface currentNic = null;
|
||||
try
|
||||
{
|
||||
foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (nic.Id == _activeNicId)
|
||||
{
|
||||
currentNic = nic;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
|
||||
// If cached NIC is gone, try to find a new active one
|
||||
if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up)
|
||||
{
|
||||
currentNic = FindActiveNetworkInterface();
|
||||
if (currentNic != null)
|
||||
{
|
||||
_activeNicId = currentNic.Id;
|
||||
_activeNicName = currentNic.Description;
|
||||
var freshStats = currentNic.GetIPStatistics();
|
||||
_prevBytesSent = freshStats.BytesSent;
|
||||
_prevBytesReceived = freshStats.BytesReceived;
|
||||
_prevNetSampleTime = DateTime.UtcNow;
|
||||
Logger.Debug($"Network throughput tracking switched to: {_activeNicName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_networkInitialized = false;
|
||||
Logger.Debug("Active network interface lost, throughput tracking paused");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up)
|
||||
{
|
||||
var stats = currentNic.GetIPStatistics();
|
||||
var now = DateTime.UtcNow;
|
||||
var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds;
|
||||
|
||||
if (elapsedSeconds > 0)
|
||||
{
|
||||
var deltaSent = stats.BytesSent - _prevBytesSent;
|
||||
var deltaReceived = stats.BytesReceived - _prevBytesReceived;
|
||||
|
||||
// Guard against counter reset (adapter reconnect)
|
||||
if (deltaSent < 0) deltaSent = 0;
|
||||
if (deltaReceived < 0) deltaReceived = 0;
|
||||
|
||||
data["net_adapter"] = _activeNicName;
|
||||
data["net_bytes_sent_delta"] = deltaSent;
|
||||
data["net_bytes_received_delta"] = deltaReceived;
|
||||
data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1);
|
||||
data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1);
|
||||
}
|
||||
|
||||
_prevBytesSent = stats.BytesSent;
|
||||
_prevBytesReceived = stats.BytesReceived;
|
||||
_prevNetSampleTime = now;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to re-initialize (e.g., network came up after boot)
|
||||
var activeNic = FindActiveNetworkInterface();
|
||||
if (activeNic != null)
|
||||
{
|
||||
_activeNicId = activeNic.Id;
|
||||
_activeNicName = activeNic.Description;
|
||||
var stats = activeNic.GetIPStatistics();
|
||||
_prevBytesSent = stats.BytesSent;
|
||||
_prevBytesReceived = stats.BytesReceived;
|
||||
_prevNetSampleTime = DateTime.UtcNow;
|
||||
_networkInitialized = true;
|
||||
Logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Debug($"Network throughput collection failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (data.Count > 0)
|
||||
{
|
||||
EmitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = SessionId,
|
||||
TenantId = TenantId,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
EventType = "performance_snapshot",
|
||||
Severity = EventSeverity.Debug,
|
||||
Source = "PerformanceCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " +
|
||||
$"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " +
|
||||
$"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" +
|
||||
(data.ContainsKey("net_receive_rate_kbps")
|
||||
? $", Net: \u2193{data["net_receive_rate_kbps"]} / \u2191{data["net_send_rate_kbps"]} kbps"
|
||||
: ""),
|
||||
Data = data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
|
||||
/// </summary>
|
||||
private static NetworkInterface FindActiveNetworkInterface()
|
||||
{
|
||||
try
|
||||
{
|
||||
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
|
||||
foreach (var nic in interfaces)
|
||||
{
|
||||
if (nic.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback ||
|
||||
nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
|
||||
continue;
|
||||
|
||||
var ipProps = nic.GetIPProperties();
|
||||
foreach (var gw in ipProps.GatewayAddresses)
|
||||
{
|
||||
if (gw.Address.ToString() != "0.0.0.0")
|
||||
return nic;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller handles null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Client for communicating with the backend API
|
||||
/// </summary>
|
||||
public class BackendApiClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
private readonly X509Certificate2 _clientCertificate;
|
||||
private readonly string _manufacturer;
|
||||
private readonly string _model;
|
||||
private readonly string _serialNumber;
|
||||
private readonly Logging.AgentLogger _logger;
|
||||
private readonly NetworkMetrics _networkMetrics = new NetworkMetrics();
|
||||
private readonly string _bootstrapToken;
|
||||
private readonly bool _useBootstrapTokenAuth;
|
||||
private readonly string _agentVersion;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the network metrics counters for AgentSelfMetricsCollector to read.
|
||||
/// </summary>
|
||||
public NetworkMetrics NetworkMetrics => _networkMetrics;
|
||||
|
||||
/// <summary>
|
||||
/// The MDM client certificate loaded at construction time, or null if not found.
|
||||
/// Used by DistressReporter to detect cert-missing scenarios.
|
||||
/// </summary>
|
||||
public X509Certificate2 ClientCertificate => _clientCertificate;
|
||||
|
||||
/// <summary>
|
||||
/// Protected constructor for testability (Moq proxy creation).
|
||||
/// </summary>
|
||||
protected BackendApiClient() { }
|
||||
|
||||
public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null, string agentVersion = null)
|
||||
{
|
||||
_baseUrl = baseUrl.TrimEnd('/');
|
||||
_logger = logger;
|
||||
_bootstrapToken = configuration?.BootstrapToken;
|
||||
_useBootstrapTokenAuth = configuration?.UseBootstrapTokenAuth ?? false;
|
||||
_agentVersion = agentVersion;
|
||||
|
||||
// Find MDM certificate for client authentication if enabled
|
||||
if (configuration?.UseClientCertAuth == true)
|
||||
{
|
||||
logger?.Debug("Client certificate authentication enabled - searching for certificate...");
|
||||
|
||||
_clientCertificate = CertificateHelper.FindMdmCertificate(
|
||||
logger: logger
|
||||
);
|
||||
|
||||
if (_clientCertificate != null)
|
||||
{
|
||||
logger?.Info($"Client certificate loaded successfully");
|
||||
logger?.Debug($" Thumbprint: {_clientCertificate.Thumbprint}");
|
||||
logger?.Debug($" Subject: {_clientCertificate.Subject}");
|
||||
logger?.Debug($" Issuer: {_clientCertificate.Issuer}");
|
||||
logger?.Debug($" Valid: {_clientCertificate.NotBefore:yyyy-MM-dd} to {_clientCertificate.NotAfter:yyyy-MM-dd}");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Warning("Client certificate authentication enabled but no certificate found");
|
||||
logger?.Warning(" Request will be sent WITHOUT certificate (will likely fail security validation)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Debug("Client certificate authentication disabled");
|
||||
}
|
||||
|
||||
// Get hardware information for whitelist validation and Autopilot device verification
|
||||
var hardwareInfo = HardwareInfo.GetHardwareInfo(logger);
|
||||
_manufacturer = hardwareInfo.Manufacturer;
|
||||
_model = hardwareInfo.Model;
|
||||
_serialNumber = hardwareInfo.SerialNumber;
|
||||
|
||||
// Use HttpClientHandler with client certificate for TLS-level mTLS negotiation.
|
||||
// Azure App Service extracts the cert from the TLS handshake and forwards it
|
||||
// in the X-ARR-ClientCert header to the backend function.
|
||||
var handler = new HttpClientHandler();
|
||||
// AutomaticDecompression sends Accept-Encoding and transparently decompresses
|
||||
// gzip/deflate responses. Bandwidth-sensitive links benefit a lot on JSON-heavy
|
||||
// endpoints like GetAgentConfig.
|
||||
if (handler.SupportsAutomaticDecompression)
|
||||
{
|
||||
handler.AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate;
|
||||
}
|
||||
if (_clientCertificate != null)
|
||||
{
|
||||
handler.ClientCertificates.Add(_clientCertificate);
|
||||
logger?.Info("Client certificate attached to TLS handler for mTLS negotiation");
|
||||
}
|
||||
|
||||
_httpClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Set User-Agent for firewall allowlisting and server-side logging
|
||||
var ua = string.IsNullOrEmpty(_agentVersion)
|
||||
? "AutopilotMonitor.Agent"
|
||||
: $"AutopilotMonitor.Agent/{_agentVersion}";
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a new enrollment session
|
||||
/// </summary>
|
||||
public async Task<RegisterSessionResponse> RegisterSessionAsync(SessionRegistration registration)
|
||||
{
|
||||
var request = new RegisterSessionRequest { Registration = registration };
|
||||
var endpoint = _useBootstrapTokenAuth
|
||||
? Constants.ApiEndpoints.BootstrapRegisterSession
|
||||
: Constants.ApiEndpoints.RegisterSession;
|
||||
var url = $"{_baseUrl}{endpoint}";
|
||||
|
||||
var response = await PostAsync<RegisterSessionRequest, RegisterSessionResponse>(url, request);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization
|
||||
/// </summary>
|
||||
public virtual async Task<IngestEventsResponse> IngestEventsAsync(IngestEventsRequest request)
|
||||
{
|
||||
var ingestEndpoint = _useBootstrapTokenAuth
|
||||
? Constants.ApiEndpoints.BootstrapIngestEvents
|
||||
: Constants.ApiEndpoints.IngestEvents;
|
||||
var url = $"{_baseUrl}{ingestEndpoint}";
|
||||
|
||||
// Use NDJSON (newline-delimited JSON) + gzip for efficient event upload
|
||||
// This reduces bandwidth significantly (70-80% compression typical for JSON)
|
||||
var ndjson = CreateNdjson(request);
|
||||
var ndjsonLength = System.Text.Encoding.UTF8.GetByteCount(ndjson);
|
||||
var compressedContent = CompressWithGzip(ndjson);
|
||||
|
||||
_logger?.Debug($"IngestEventsAsync: POST {url} — {request.Events.Count} events, {compressedContent.Length} bytes compressed");
|
||||
_logger?.Verbose($"IngestEventsAsync: NDJSON {ndjsonLength} bytes → gzip {compressedContent.Length} bytes ({(ndjsonLength > 0 ? (100 - compressedContent.Length * 100 / ndjsonLength) : 0)}% reduction)");
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new ByteArrayContent(compressedContent)
|
||||
})
|
||||
{
|
||||
httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson");
|
||||
httpRequest.Content.Headers.Add("Content-Encoding", "gzip");
|
||||
|
||||
// Add TenantId header so the backend can run security checks before parsing the body
|
||||
httpRequest.Headers.Add("X-Tenant-Id", request.TenantId);
|
||||
|
||||
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
|
||||
AddSecurityHeaders(httpRequest);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var failed = false;
|
||||
long bytesDown = 0;
|
||||
try
|
||||
{
|
||||
using (var httpResponse = await _httpClient.SendAsync(httpRequest))
|
||||
{
|
||||
ThrowOnAuthFailure(httpResponse);
|
||||
httpResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await httpResponse.Content.ReadAsStringAsync();
|
||||
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
|
||||
var response = JsonConvert.DeserializeObject<IngestEventsResponse>(responseJson);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed = true;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the agent configuration (collector toggles + gather rules) from the backend
|
||||
/// </summary>
|
||||
public async Task<AgentConfigResponse> GetAgentConfigAsync(string tenantId)
|
||||
{
|
||||
var configEndpoint = _useBootstrapTokenAuth
|
||||
? Constants.ApiEndpoints.BootstrapGetAgentConfig
|
||||
: Constants.ApiEndpoints.GetAgentConfig;
|
||||
var url = $"{_baseUrl}{configEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}";
|
||||
|
||||
// Retry on 503 (backend signals transient device validation failure)
|
||||
const int maxAttempts = 3;
|
||||
const int maxRetryAfterSeconds = 120; // Cap Retry-After to prevent indefinite blocking
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, url))
|
||||
{
|
||||
AddSecurityHeaders(httpRequest);
|
||||
|
||||
_logger?.Debug($"GetAgentConfigAsync: GET {url} (attempt {attempt}/{maxAttempts})");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var failed = false;
|
||||
long bytesDown = 0;
|
||||
try
|
||||
{
|
||||
using (var response = await _httpClient.SendAsync(httpRequest))
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && attempt < maxAttempts)
|
||||
{
|
||||
// Backend says "try again" — parse Retry-After or default to 30s
|
||||
var retryAfterSeconds = 30;
|
||||
if (response.Headers.TryGetValues("Retry-After", out var retryValues) &&
|
||||
int.TryParse(System.Linq.Enumerable.FirstOrDefault(retryValues), out var parsedRetry))
|
||||
{
|
||||
retryAfterSeconds = Math.Min(parsedRetry, maxRetryAfterSeconds);
|
||||
}
|
||||
|
||||
_logger?.Warning($"GetAgentConfigAsync: 503 Service Unavailable (attempt {attempt}/{maxAttempts}). Retrying in {retryAfterSeconds}s...");
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(0, 0, sw.ElapsedMilliseconds, true);
|
||||
await System.Threading.Tasks.Task.Delay(retryAfterSeconds * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
ThrowOnAuthFailure(response);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
|
||||
var result = JsonConvert.DeserializeObject<AgentConfigResponse>(responseJson);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed = true;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final 503 after all retries — throw so caller falls back to cached/default config
|
||||
throw new Exception("GetAgentConfigAsync: backend returned 503 after all retry attempts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a short-lived SAS URL for diagnostics package upload.
|
||||
/// Called just before upload so the URL is never stored in config or on disk.
|
||||
/// Returns null if the request fails (non-fatal — diagnostics upload is best-effort).
|
||||
/// </summary>
|
||||
public async Task<GetDiagnosticsUploadUrlResponse> GetDiagnosticsUploadUrlAsync(
|
||||
string tenantId, string sessionId, string fileName)
|
||||
{
|
||||
var url = $"{_baseUrl}{Constants.ApiEndpoints.GetDiagnosticsUploadUrl}";
|
||||
var request = new GetDiagnosticsUploadUrlRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
SessionId = sessionId,
|
||||
FileName = fileName
|
||||
};
|
||||
var response = await PostAsync<GetDiagnosticsUploadUrlRequest, GetDiagnosticsUploadUrlResponse>(url, request);
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a critical error report to the emergency channel endpoint.
|
||||
/// Fire-and-forget: swallows all exceptions so a failure here never cascades.
|
||||
/// Uses a 5-second per-request timeout so it cannot block the upload loop.
|
||||
/// </summary>
|
||||
public async Task ReportAgentErrorAsync(AgentErrorReport report)
|
||||
{
|
||||
try
|
||||
{
|
||||
var errorEndpoint = _useBootstrapTokenAuth
|
||||
? Constants.ApiEndpoints.BootstrapReportError
|
||||
: Constants.ApiEndpoints.ReportAgentError;
|
||||
var url = $"{_baseUrl}{errorEndpoint}";
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
})
|
||||
{
|
||||
// Security validation on the backend requires tenant ID before parsing the body
|
||||
httpRequest.Headers.Add("X-Tenant-Id", report.TenantId);
|
||||
|
||||
// Hardware headers are required for full security validation
|
||||
AddSecurityHeaders(httpRequest);
|
||||
|
||||
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
|
||||
using (var response = await _httpClient.SendAsync(httpRequest, cts.Token))
|
||||
{
|
||||
// Response status is deliberately ignored — endpoint always returns 200
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow all exceptions — the emergency channel must never cascade failures
|
||||
_logger?.Debug($"ReportAgentErrorAsync: emergency channel failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TResponse> PostAsync<TRequest, TResponse>(string url, TRequest data)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(data);
|
||||
var bytesUp = Encoding.UTF8.GetByteCount(json);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
})
|
||||
{
|
||||
// Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification)
|
||||
AddSecurityHeaders(httpRequest);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var failed = false;
|
||||
long bytesDown = 0;
|
||||
try
|
||||
{
|
||||
using (var response = await _httpClient.SendAsync(httpRequest))
|
||||
{
|
||||
ThrowOnAuthFailure(response);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
|
||||
var result = JsonConvert.DeserializeObject<TResponse>(responseJson);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed = true;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throws BackendAuthException for 401/403 responses so callers can distinguish
|
||||
/// authentication failures from transient server errors.
|
||||
/// </summary>
|
||||
private static void ThrowOnAuthFailure(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
throw new BackendAuthException(
|
||||
$"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
|
||||
"The device is not authorized. Check client certificate and Autopilot device validation.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest
|
||||
/// Format: metadata line + one event per line
|
||||
/// </summary>
|
||||
private string CreateNdjson(IngestEventsRequest request)
|
||||
{
|
||||
var lines = new System.Collections.Generic.List<string>();
|
||||
|
||||
// First line: metadata (sessionId, tenantId)
|
||||
var metadata = new
|
||||
{
|
||||
sessionId = request.SessionId,
|
||||
tenantId = request.TenantId
|
||||
};
|
||||
lines.Add(JsonConvert.SerializeObject(metadata));
|
||||
|
||||
// Subsequent lines: one event per line
|
||||
foreach (var evt in request.Events)
|
||||
{
|
||||
lines.Add(JsonConvert.SerializeObject(evt));
|
||||
}
|
||||
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compresses a string using gzip
|
||||
/// </summary>
|
||||
private byte[] CompressWithGzip(string text)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(text);
|
||||
|
||||
using (var output = new MemoryStream())
|
||||
{
|
||||
using (var gzip = new GZipStream(output, CompressionMode.Compress))
|
||||
{
|
||||
gzip.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds additional security headers for device authorization.
|
||||
/// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates.
|
||||
/// </summary>
|
||||
private void AddSecurityHeaders(HttpRequestMessage request)
|
||||
{
|
||||
_logger?.Trace("Adding security headers to request...");
|
||||
|
||||
// Bootstrap token auth (pre-MDM, OOBE bootstrapped agents)
|
||||
if (_useBootstrapTokenAuth && !string.IsNullOrEmpty(_bootstrapToken))
|
||||
{
|
||||
request.Headers.Add("X-Bootstrap-Token", _bootstrapToken);
|
||||
_logger?.Trace(" X-Bootstrap-Token: [set]");
|
||||
}
|
||||
|
||||
// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates (mTLS)
|
||||
|
||||
// Add hardware information for whitelist validation
|
||||
if (!string.IsNullOrEmpty(_manufacturer))
|
||||
{
|
||||
request.Headers.Add("X-Device-Manufacturer", _manufacturer);
|
||||
_logger?.Trace($" X-Device-Manufacturer: {_manufacturer}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_model))
|
||||
{
|
||||
request.Headers.Add("X-Device-Model", _model);
|
||||
_logger?.Trace($" X-Device-Model: {_model}");
|
||||
}
|
||||
|
||||
// Add serial number for Autopilot device validation against Intune registration
|
||||
if (!string.IsNullOrEmpty(_serialNumber))
|
||||
{
|
||||
request.Headers.Add("X-Device-SerialNumber", _serialNumber);
|
||||
_logger?.Trace($" X-Device-SerialNumber: {_serialNumber}");
|
||||
}
|
||||
|
||||
// Add agent version for version-based block/kill management
|
||||
if (!string.IsNullOrEmpty(_agentVersion))
|
||||
{
|
||||
request.Headers.Add("X-Agent-Version", _agentVersion);
|
||||
_logger?.Trace($" X-Agent-Version: {_agentVersion}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
|
||||
/// </summary>
|
||||
public class BackendAuthException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP status code returned by the backend (401 or 403).
|
||||
/// Used by the DistressReporter to classify the failure type.
|
||||
/// </summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends pre-auth distress signals to the backend when the authenticated error channel
|
||||
/// is unreachable (cert missing, hardware blocked, device not registered, etc.).
|
||||
///
|
||||
/// Uses its own plain HttpClient (no client certificate, no BackendApiClient dependency)
|
||||
/// so it works even when BackendApiClient cannot be constructed.
|
||||
///
|
||||
/// Anti-flood guarantees (per session):
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> distress reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
public class DistressReporter : IDisposable
|
||||
{
|
||||
private const int MaxReportsPerSession = 3;
|
||||
private const int MinIntervalMinutes = 30;
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _distressUrl;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _manufacturer;
|
||||
private readonly string _model;
|
||||
private readonly string _serialNumber;
|
||||
private readonly string _agentVersion;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
|
||||
private readonly object _antiFloodLock = new object();
|
||||
private DateTime? _lastReportTime;
|
||||
private int _reportCount;
|
||||
|
||||
public DistressReporter(
|
||||
string baseUrl,
|
||||
string tenantId,
|
||||
string manufacturer,
|
||||
string model,
|
||||
string serialNumber,
|
||||
string agentVersion,
|
||||
AgentLogger logger)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
_manufacturer = manufacturer;
|
||||
_model = model;
|
||||
_serialNumber = serialNumber;
|
||||
_agentVersion = agentVersion;
|
||||
_logger = logger;
|
||||
|
||||
var cleanBaseUrl = baseUrl?.TrimEnd('/') ?? Constants.ApiBaseUrl;
|
||||
_distressUrl = $"{cleanBaseUrl}{Constants.ApiEndpoints.ReportDistress}";
|
||||
|
||||
// Plain HttpClient — no client cert, no mTLS.
|
||||
// Deliberately isolated from BackendApiClient.
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
|
||||
var ua = string.IsNullOrEmpty(agentVersion)
|
||||
? "AutopilotMonitor.Agent"
|
||||
: $"AutopilotMonitor.Agent/{agentVersion}";
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to send a distress report. Returns immediately without awaiting
|
||||
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
|
||||
///
|
||||
/// All anti-flood checks are applied synchronously before the async HTTP call,
|
||||
/// so repeated calls are cheap when suppressed.
|
||||
/// </summary>
|
||||
public virtual async Task TrySendAsync(
|
||||
DistressErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null)
|
||||
{
|
||||
var key = $"{errorType}:{httpStatusCode}";
|
||||
int currentReportCount;
|
||||
|
||||
lock (_antiFloodLock)
|
||||
{
|
||||
if (_reportCount >= MaxReportsPerSession)
|
||||
{
|
||||
_logger?.Debug($"[DistressChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_reportedKeys.Contains(key))
|
||||
{
|
||||
_logger?.Debug($"[DistressChannel] Suppressed (already reported): {key}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_lastReportTime != null &&
|
||||
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
_logger?.Debug($"[DistressChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
|
||||
return;
|
||||
}
|
||||
|
||||
_reportedKeys.Add(key);
|
||||
_reportCount++;
|
||||
_lastReportTime = DateTime.UtcNow;
|
||||
currentReportCount = _reportCount;
|
||||
}
|
||||
|
||||
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
|
||||
_logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
|
||||
|
||||
var report = new DistressReport
|
||||
{
|
||||
TenantId = _tenantId,
|
||||
ErrorType = errorType,
|
||||
Manufacturer = _manufacturer,
|
||||
Model = _model,
|
||||
SerialNumber = _serialNumber,
|
||||
AgentVersion = _agentVersion,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(report);
|
||||
|
||||
using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
})
|
||||
{
|
||||
// Only header needed: TenantId for the backend's first gate
|
||||
httpRequest.Headers.Add("X-Tenant-Id", _tenantId);
|
||||
|
||||
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
|
||||
using (var response = await _httpClient.SendAsync(httpRequest, cts.Token))
|
||||
{
|
||||
// Response status is deliberately ignored — endpoint always returns 200
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow — distress channel must never cascade failures into the caller
|
||||
_logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
return value.Length <= maxLength ? value : value.Substring(0, maxLength);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends critical agent-side errors to the backend emergency channel endpoint.
|
||||
///
|
||||
/// Anti-flood guarantees (per session):
|
||||
/// - Only fires after <see cref="ConsecutiveFailureThreshold"/> consecutive upload failures
|
||||
/// - Same error key (ErrorType + HTTP status code) is only ever sent once per session
|
||||
/// - At most <see cref="MaxReportsPerSession"/> emergency reports total per session
|
||||
/// - At most one report every <see cref="MinIntervalMinutes"/> minutes regardless of error type
|
||||
///
|
||||
/// A failure in this reporter is silently swallowed — it must never cascade into the main loop.
|
||||
/// </summary>
|
||||
public class EmergencyReporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of consecutive upload failures required before the first emergency report is sent.
|
||||
/// </summary>
|
||||
public const int ConsecutiveFailureThreshold = 3;
|
||||
|
||||
private const int MaxReportsPerSession = 5;
|
||||
private const int MinIntervalMinutes = 10;
|
||||
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _agentVersion;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
private readonly HashSet<string> _reportedKeys = new HashSet<string>();
|
||||
private readonly object _antiFloodLock = new object();
|
||||
private DateTime? _lastReportTime;
|
||||
private int _reportCount;
|
||||
|
||||
public EmergencyReporter(
|
||||
BackendApiClient apiClient,
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
string agentVersion,
|
||||
AgentLogger logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_agentVersion = agentVersion;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to send an emergency report. Returns immediately without awaiting
|
||||
/// (intended to be called as fire-and-forget via <c>_ = reporter.TrySendAsync(...)</c>).
|
||||
///
|
||||
/// All anti-flood checks are applied synchronously before the async HTTP call,
|
||||
/// so repeated calls are cheap when suppressed.
|
||||
/// </summary>
|
||||
public virtual async Task TrySendAsync(
|
||||
AgentErrorType errorType,
|
||||
string message,
|
||||
int? httpStatusCode = null,
|
||||
long? sequenceNumber = null)
|
||||
{
|
||||
// Deduplicate by error type + status code: same failure category sent only once per session.
|
||||
// All anti-flood checks are protected by a lock to prevent race conditions when
|
||||
// TrySendAsync is called concurrently (fire-and-forget from multiple threads).
|
||||
var key = $"{errorType}:{httpStatusCode}";
|
||||
int currentReportCount;
|
||||
|
||||
lock (_antiFloodLock)
|
||||
{
|
||||
if (_reportCount >= MaxReportsPerSession)
|
||||
{
|
||||
_logger?.Debug($"[EmergencyChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_reportedKeys.Contains(key))
|
||||
{
|
||||
_logger?.Debug($"[EmergencyChannel] Suppressed (already reported): {key}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_lastReportTime != null &&
|
||||
(DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes)
|
||||
{
|
||||
_logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}");
|
||||
return;
|
||||
}
|
||||
|
||||
_reportedKeys.Add(key);
|
||||
_reportCount++;
|
||||
_lastReportTime = DateTime.UtcNow;
|
||||
currentReportCount = _reportCount;
|
||||
}
|
||||
|
||||
var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty;
|
||||
_logger?.Warning($"[EmergencyChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}");
|
||||
|
||||
var report = new AgentErrorReport
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
ErrorType = errorType,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
SequenceNumber = sequenceNumber,
|
||||
AgentVersion = _agentVersion,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _apiClient.ReportAgentErrorAsync(report);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow — emergency channel must never cascade failures into the caller
|
||||
_logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages offline event storage (spool) for resilient uploads
|
||||
/// </summary>
|
||||
public class EventSpool : IDisposable
|
||||
{
|
||||
private readonly string _spoolDirectory;
|
||||
private readonly object _lockObject = new object();
|
||||
private readonly FileSystemWatcher _fileWatcher;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when new events are added to the spool
|
||||
/// </summary>
|
||||
public event EventHandler EventsAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Protected constructor for testability (Moq proxy creation).
|
||||
/// </summary>
|
||||
protected EventSpool() { }
|
||||
|
||||
public EventSpool(string spoolDirectory)
|
||||
{
|
||||
_spoolDirectory = spoolDirectory;
|
||||
|
||||
if (!Directory.Exists(_spoolDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(_spoolDirectory);
|
||||
}
|
||||
|
||||
// Initialize FileSystemWatcher for efficient event detection
|
||||
_fileWatcher = new FileSystemWatcher(_spoolDirectory)
|
||||
{
|
||||
Filter = "event_*.json",
|
||||
NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime,
|
||||
EnableRaisingEvents = false // Will be enabled when service starts
|
||||
};
|
||||
|
||||
_fileWatcher.Created += OnFileCreated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts watching for new events
|
||||
/// </summary>
|
||||
public void StartWatching()
|
||||
{
|
||||
_fileWatcher.EnableRaisingEvents = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops watching for new events
|
||||
/// </summary>
|
||||
public virtual void StopWatching()
|
||||
{
|
||||
_fileWatcher.EnableRaisingEvents = false;
|
||||
}
|
||||
|
||||
private void OnFileCreated(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// Raise event to trigger immediate upload
|
||||
EventsAvailable?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to the spool
|
||||
/// </summary>
|
||||
public void Add(EnrollmentEvent evt)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
|
||||
var filePath = Path.Combine(_spoolDirectory, fileName);
|
||||
|
||||
var json = JsonConvert.SerializeObject(evt, Formatting.None);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a batch of events from the spool (oldest first, sorted by sequence)
|
||||
/// </summary>
|
||||
public virtual List<EnrollmentEvent> GetBatch(int maxBatchSize)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
var events = new List<EnrollmentEvent>();
|
||||
var files = Directory.GetFiles(_spoolDirectory, "event_*.json")
|
||||
.Select(f => new { Path = f, Sequence = ExtractSequenceFromFilename(f) })
|
||||
.OrderBy(x => x.Sequence)
|
||||
.Take(maxBatchSize)
|
||||
.Select(x => x.Path)
|
||||
.ToList();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var evt = JsonConvert.DeserializeObject<EnrollmentEvent>(json);
|
||||
if (evt != null)
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip corrupted files
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the sequence number from event filename
|
||||
/// Filename format: event_{timestamp}_{sequence}.json
|
||||
/// </summary>
|
||||
private long ExtractSequenceFromFilename(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(filePath);
|
||||
var parts = fileName.Split('_');
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
if (long.TryParse(parts[2], out var sequence))
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to 0 if parsing fails
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes events from the spool after successful upload
|
||||
/// </summary>
|
||||
public virtual void RemoveEvents(List<EnrollmentEvent> events)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var evt in events)
|
||||
{
|
||||
// Reconstruct the exact filename used in Add() — no glob, no cross-boot collision
|
||||
var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json";
|
||||
var filePath = Path.Combine(_spoolDirectory, fileName);
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
File.Delete(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore deletion errors — file may already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of events in the spool
|
||||
/// </summary>
|
||||
public virtual int GetCount()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return Directory.GetFiles(_spoolDirectory, "event_*.json").Length;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all events from the spool
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
var files = Directory.GetFiles(_spoolDirectory, "event_*.json");
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the highest sequence number found in spool files, or 0 if empty.
|
||||
/// Used at startup for crash recovery: ensures new sequences never collide
|
||||
/// with unsent events still in the spool.
|
||||
/// </summary>
|
||||
public long GetMaxSequence()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
long max = 0;
|
||||
foreach (var file in Directory.GetFiles(_spoolDirectory, "event_*.json"))
|
||||
{
|
||||
var seq = ExtractSequenceFromFilename(file);
|
||||
if (seq > max) max = seq;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_fileWatcher != null)
|
||||
{
|
||||
_fileWatcher.EnableRaisingEvents = false;
|
||||
_fileWatcher.Created -= OnFileCreated;
|
||||
_fileWatcher.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages event upload lifecycle: debouncing, batched upload, auth-failure circuit breaker,
|
||||
/// and admin signal detection (kill, block, override) from upload responses.
|
||||
/// </summary>
|
||||
public class EventUploadOrchestrator : IDisposable
|
||||
{
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly EventSpool _spool;
|
||||
private readonly BackendApiClient _apiClient;
|
||||
private readonly EmergencyReporter _emergencyReporter;
|
||||
private readonly DistressReporter _distressReporter;
|
||||
private readonly CleanupService _cleanupService;
|
||||
private readonly Action<EnrollmentEvent> _emitEvent;
|
||||
private readonly Action<string, string, Dictionary<string, object>> _emitShutdownEvent;
|
||||
private readonly Func<bool> _isTerminalEventSeen;
|
||||
private ServerActionDispatcher _serverActionDispatcher;
|
||||
|
||||
private readonly Timer _uploadTimer;
|
||||
private readonly Timer _debounceTimer;
|
||||
private readonly object _timerLock = new object();
|
||||
private readonly SemaphoreSlim _uploadSemaphore = new SemaphoreSlim(1, 1);
|
||||
|
||||
private int _consecutiveAuthFailures;
|
||||
private DateTime? _firstAuthFailureTime;
|
||||
private int _consecutiveUploadFailures;
|
||||
|
||||
public EventUploadOrchestrator(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
EventSpool spool,
|
||||
BackendApiClient apiClient,
|
||||
EmergencyReporter emergencyReporter,
|
||||
DistressReporter distressReporter,
|
||||
CleanupService cleanupService,
|
||||
Action<EnrollmentEvent> emitEvent,
|
||||
Action<string, string, Dictionary<string, object>> emitShutdownEvent,
|
||||
Func<bool> isTerminalEventSeen)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_spool = spool;
|
||||
_apiClient = apiClient;
|
||||
_emergencyReporter = emergencyReporter;
|
||||
_distressReporter = distressReporter;
|
||||
_cleanupService = cleanupService;
|
||||
_emitEvent = emitEvent;
|
||||
_emitShutdownEvent = emitShutdownEvent;
|
||||
_isTerminalEventSeen = isTerminalEventSeen;
|
||||
|
||||
_debounceTimer = new Timer(
|
||||
DebounceTimerCallback,
|
||||
null,
|
||||
Timeout.Infinite,
|
||||
Timeout.Infinite
|
||||
);
|
||||
|
||||
_uploadTimer = new Timer(
|
||||
UploadTimerCallback,
|
||||
null,
|
||||
Timeout.InfiniteTimeSpan,
|
||||
Timeout.InfiniteTimeSpan
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the upload and fallback timers. Call after spool watcher is started.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_uploadTimer.Change(
|
||||
TimeSpan.FromMinutes(1),
|
||||
TimeSpan.FromMinutes(5)
|
||||
);
|
||||
_logger.Info("EventUploadOrchestrator started (debounce + fallback timers)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wires the server-action dispatcher. Called by <see cref="MonitoringService"/> after the
|
||||
/// orchestrator is constructed, once all other services exist. Optional — if null, actions
|
||||
/// in ingest responses are simply ignored.
|
||||
/// </summary>
|
||||
public void SetServerActionDispatcher(ServerActionDispatcher dispatcher)
|
||||
{
|
||||
_serverActionDispatcher = dispatcher;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops all upload timers. Called during terminal event processing to prevent
|
||||
/// further automatic uploads while the completion handler drives uploads manually.
|
||||
/// </summary>
|
||||
public void StopTimers()
|
||||
{
|
||||
_uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when new events are available in the spool (FileSystemWatcher detected new files).
|
||||
/// Uses debouncing to batch events before uploading.
|
||||
/// </summary>
|
||||
public void OnEventsAvailable(object sender, EventArgs e)
|
||||
{
|
||||
_logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer");
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
_debounceTimer.Change(
|
||||
TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds),
|
||||
Timeout.InfiniteTimeSpan
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void DebounceTimerCallback(object state)
|
||||
{
|
||||
_logger.Debug("Debounce timer expired, uploading batched events");
|
||||
Task.Run(() => UploadEventsAsync());
|
||||
}
|
||||
|
||||
private void UploadTimerCallback(object state)
|
||||
{
|
||||
Task.Run(() => UploadEventsAsync());
|
||||
}
|
||||
|
||||
public async Task UploadEventsAsync()
|
||||
{
|
||||
if (!_uploadSemaphore.Wait(0))
|
||||
{
|
||||
_logger.Verbose("UploadEventsAsync: skipped — concurrent upload already in flight");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var events = _spool.GetBatch(_configuration.MaxBatchSize);
|
||||
|
||||
if (events.Count == 0)
|
||||
{
|
||||
_logger.Debug("No events to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new IngestEventsRequest
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
Events = events
|
||||
};
|
||||
|
||||
var response = await _apiClient.IngestEventsAsync(request);
|
||||
|
||||
// DeviceBlocked is intentionally NOT synthesized into a ServerAction: it's a
|
||||
// non-terminal quarantine (UnblockAt can auto-resume the session later). Treat it
|
||||
// as a straight "stop uploads for now" signal.
|
||||
if (response.DeviceBlocked)
|
||||
{
|
||||
var unblockMsg = response.UnblockAt.HasValue
|
||||
? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC"
|
||||
: string.Empty;
|
||||
_logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ===");
|
||||
|
||||
StopTimers();
|
||||
return;
|
||||
}
|
||||
|
||||
// Synthesize legacy kill + admin-override signals into ServerActions so the whole
|
||||
// "server told us to stop" surface runs through a single shutdown handler. This
|
||||
// removes three divergent code paths (kill / admin / terminate) and guarantees
|
||||
// every terminal path respects SelfDestructOnComplete — no more zombie agents
|
||||
// after the operator clicks Mark-Failed in the portal.
|
||||
List<ServerAction> synthesized = null;
|
||||
|
||||
if (response.DeviceKillSignal)
|
||||
{
|
||||
synthesized = new List<ServerAction>
|
||||
{
|
||||
new ServerAction
|
||||
{
|
||||
Type = ServerActionTypes.TerminateSession,
|
||||
Reason = "DeviceKillSignal from administrator",
|
||||
QueuedAt = DateTime.UtcNow,
|
||||
Params = new Dictionary<string, string>
|
||||
{
|
||||
// Kill overrides everything: force self-destruct even if local config says otherwise,
|
||||
// and no grace period — the operator decided this device must go NOW.
|
||||
{ "forceSelfDestruct", "true" },
|
||||
{ "gracePeriodSeconds", "0" },
|
||||
{ "origin", "kill_signal" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(response.AdminAction) && !_isTerminalEventSeen())
|
||||
{
|
||||
// Admin marked the session terminal from the portal (Mark-Failed / Mark-Succeeded).
|
||||
// Soft shutdown: config decides whether to self-destruct — but in Prod
|
||||
// SelfDestructOnComplete=true, so no zombies.
|
||||
synthesized = new List<ServerAction>
|
||||
{
|
||||
new ServerAction
|
||||
{
|
||||
Type = ServerActionTypes.TerminateSession,
|
||||
Reason = $"Admin marked session as {response.AdminAction}",
|
||||
QueuedAt = DateTime.UtcNow,
|
||||
Params = new Dictionary<string, string>
|
||||
{
|
||||
{ "adminOutcome", response.AdminAction },
|
||||
{ "gracePeriodSeconds", "0" },
|
||||
{ "origin", "admin_action" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Merge synthesized signals with real server actions, then dispatch once.
|
||||
// Order: synthesized first (they're the highest-priority stop signals).
|
||||
if (synthesized != null || (response.Actions != null && response.Actions.Count > 0))
|
||||
{
|
||||
var batch = synthesized ?? new List<ServerAction>();
|
||||
if (response.Actions != null) batch.AddRange(response.Actions);
|
||||
|
||||
if (_serverActionDispatcher != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _serverActionDispatcher.DispatchAsync(batch);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"ServerActionDispatcher.DispatchAsync threw (batch aborted)", ex);
|
||||
}
|
||||
}
|
||||
else if (synthesized != null)
|
||||
{
|
||||
// Defensive: if a synthesized kill/admin signal arrives before the dispatcher
|
||||
// was wired, fall back to a bare-minimum shutdown so we don't leave a zombie.
|
||||
_logger.Error("Kill/Admin signal received but no dispatcher wired — forcing exit");
|
||||
StopTimers();
|
||||
_configuration.SelfDestructOnComplete = true;
|
||||
_cleanupService.ExecuteSelfDestruct();
|
||||
ExitProcess(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
_spool.RemoveEvents(events);
|
||||
_logger.Info($"Uploaded {response.EventsProcessed} events");
|
||||
|
||||
if (_consecutiveAuthFailures > 0 || _consecutiveUploadFailures > 0)
|
||||
_logger.Info($"Upload success — resetting failure counters (auth={_consecutiveAuthFailures}, upload={_consecutiveUploadFailures})");
|
||||
_consecutiveAuthFailures = 0;
|
||||
_firstAuthFailureTime = null;
|
||||
_consecutiveUploadFailures = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Warning($"Upload failed: {response.Message}");
|
||||
}
|
||||
}
|
||||
catch (BackendAuthException ex)
|
||||
{
|
||||
_logger.Error($"Upload authentication failed: {ex.Message}");
|
||||
HandleAuthFailure(ex.StatusCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_consecutiveUploadFailures++;
|
||||
_logger.Error("Error uploading events", ex);
|
||||
|
||||
_logger.Info($"Upload failure #{_consecutiveUploadFailures}: {ex.Message}");
|
||||
|
||||
if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold)
|
||||
{
|
||||
_ = _emergencyReporter.TrySendAsync(
|
||||
AgentErrorType.IngestFailed,
|
||||
ex.Message,
|
||||
httpStatusCode: null,
|
||||
sequenceNumber: 0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_uploadSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles an authentication failure by incrementing the counter and shutting down
|
||||
/// the agent if the configured threshold is reached. Called from upload path and
|
||||
/// session registration path.
|
||||
/// </summary>
|
||||
public void HandleAuthFailure(int httpStatusCode = 0)
|
||||
{
|
||||
_consecutiveAuthFailures++;
|
||||
|
||||
if (_firstAuthFailureTime == null)
|
||||
_firstAuthFailureTime = DateTime.UtcNow;
|
||||
|
||||
if (_consecutiveAuthFailures == 1 && _distressReporter != null)
|
||||
{
|
||||
var distressType = httpStatusCode == 401
|
||||
? DistressErrorType.AuthCertificateRejected
|
||||
: DistressErrorType.DeviceNotRegistered;
|
||||
|
||||
_ = _distressReporter.TrySendAsync(distressType,
|
||||
$"Backend returned {httpStatusCode} during event upload",
|
||||
httpStatusCode: httpStatusCode);
|
||||
}
|
||||
|
||||
_logger.Warning($"Authentication failure {_consecutiveAuthFailures}" +
|
||||
(_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") +
|
||||
$" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})");
|
||||
|
||||
if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures)
|
||||
{
|
||||
_logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " +
|
||||
"The device is not authorized to send data to Autopilot Monitor. " +
|
||||
"Check client certificate and Autopilot device validation in your tenant configuration. ===");
|
||||
|
||||
_emitShutdownEvent(
|
||||
"auth_failure",
|
||||
$"Agent terminated: {_consecutiveAuthFailures} consecutive authentication failures exceeded threshold ({_configuration.MaxAuthFailures})",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "consecutiveFailures", _consecutiveAuthFailures },
|
||||
{ "shutdownTrigger", "max_attempts" },
|
||||
{ "maxAuthFailures", _configuration.MaxAuthFailures }
|
||||
});
|
||||
ExitProcess(1);
|
||||
}
|
||||
|
||||
if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value;
|
||||
if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes)
|
||||
{
|
||||
_logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " +
|
||||
$"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " +
|
||||
"The device is not authorized to send data to Autopilot Monitor. " +
|
||||
"Check client certificate and Autopilot device validation in your tenant configuration. ===");
|
||||
|
||||
_emitShutdownEvent(
|
||||
"auth_failure",
|
||||
$"Agent terminated: authentication failures persisted for {elapsed.TotalMinutes:F0} minutes (timeout: {_configuration.AuthFailureTimeoutMinutes} min)",
|
||||
new Dictionary<string, object>
|
||||
{
|
||||
{ "consecutiveFailures", _consecutiveAuthFailures },
|
||||
{ "shutdownTrigger", "timeout" },
|
||||
{ "authFailureTimeoutMinutes", _configuration.AuthFailureTimeoutMinutes },
|
||||
{ "elapsedMinutes", Math.Round(elapsed.TotalMinutes, 1) }
|
||||
});
|
||||
ExitProcess(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal virtual void ExitProcess(int code) => Environment.Exit(code);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_uploadTimer?.Dispose();
|
||||
_debounceTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
public class GeoLocationResult
|
||||
{
|
||||
public string Country { get; set; }
|
||||
public string Region { get; set; }
|
||||
public string City { get; set; }
|
||||
public string Loc { get; set; }
|
||||
public string Timezone { get; set; }
|
||||
public string Source { get; set; }
|
||||
|
||||
public Dictionary<string, object> ToDictionary()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "country", Country },
|
||||
{ "region", Region },
|
||||
{ "city", City },
|
||||
{ "loc", Loc },
|
||||
{ "timezone", Timezone },
|
||||
{ "source", Source }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class GeoLocationAttemptResult
|
||||
{
|
||||
public GeoLocationResult Location { get; set; }
|
||||
public string PrimaryError { get; set; }
|
||||
public string PrimaryRetryError { get; set; }
|
||||
public string FallbackError { get; set; }
|
||||
}
|
||||
|
||||
public static class GeoLocationService
|
||||
{
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Static HttpClient to avoid socket exhaustion from repeated short-lived instances.
|
||||
// HttpClient is designed to be reused across requests.
|
||||
private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient();
|
||||
|
||||
private static HttpClient CreateSharedHttpClient()
|
||||
{
|
||||
var client = new HttpClient { Timeout = RequestTimeout };
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
return client;
|
||||
}
|
||||
|
||||
public static async Task<GeoLocationAttemptResult> GetLocationAsync(AgentLogger logger)
|
||||
{
|
||||
var attempt = new GeoLocationAttemptResult();
|
||||
|
||||
// Try ipinfo.io first
|
||||
var (result, error) = await TryIpInfo(logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
return attempt;
|
||||
}
|
||||
attempt.PrimaryError = error;
|
||||
|
||||
// Retry ipinfo.io once after a short delay (network may still be initializing during Autopilot)
|
||||
logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s...");
|
||||
await Task.Delay(RetryDelay);
|
||||
|
||||
(result, error) = await TryIpInfo(logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
return attempt;
|
||||
}
|
||||
attempt.PrimaryRetryError = error;
|
||||
|
||||
// Fallback to ifconfig.co
|
||||
(result, error) = await TryIfConfigCo(logger);
|
||||
if (result != null)
|
||||
{
|
||||
attempt.Location = result;
|
||||
return attempt;
|
||||
}
|
||||
attempt.FallbackError = error;
|
||||
|
||||
logger?.Warning("GeoLocation: All providers failed, skipping location event");
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger?.Info("GeoLocation: Querying ipinfo.io...");
|
||||
|
||||
using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json"))
|
||||
{
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
var response = await httpResponse.Content.ReadAsStringAsync();
|
||||
var json = JObject.Parse(response);
|
||||
|
||||
var result = new GeoLocationResult
|
||||
{
|
||||
Country = json.Value<string>("country"),
|
||||
Region = json.Value<string>("region"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = json.Value<string>("loc"),
|
||||
Timezone = json.Value<string>("timezone"),
|
||||
Source = "ipinfo"
|
||||
};
|
||||
|
||||
logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}");
|
||||
return (result, null);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
var error = "Timeout (5s)";
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = ex.Message;
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger?.Info("GeoLocation: Falling back to ifconfig.co...");
|
||||
|
||||
using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json"))
|
||||
{
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})";
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
|
||||
var response = await httpResponse.Content.ReadAsStringAsync();
|
||||
var json = JObject.Parse(response);
|
||||
|
||||
var latitude = json.Value<string>("latitude") ?? "";
|
||||
var longitude = json.Value<string>("longitude") ?? "";
|
||||
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
|
||||
? $"{latitude},{longitude}"
|
||||
: "";
|
||||
|
||||
var result = new GeoLocationResult
|
||||
{
|
||||
Country = json.Value<string>("country_iso"),
|
||||
Region = json.Value<string>("region_name"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = loc,
|
||||
Timezone = json.Value<string>("time_zone"),
|
||||
Source = "ifconfig.co"
|
||||
};
|
||||
|
||||
logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}");
|
||||
return (result, null);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
var error = "Timeout (5s)";
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var error = ex.Message;
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {error}");
|
||||
return (null, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe counters for HTTP request/response tracking.
|
||||
/// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector.
|
||||
/// All operations are lock-free (Interlocked).
|
||||
/// </summary>
|
||||
public class NetworkMetrics
|
||||
{
|
||||
private long _requestCount;
|
||||
private long _failureCount;
|
||||
private long _totalBytesUp;
|
||||
private long _totalBytesDown;
|
||||
private long _totalLatencyMs;
|
||||
|
||||
public void RecordRequest(long bytesUp, long bytesDown, long latencyMs, bool failed)
|
||||
{
|
||||
Interlocked.Increment(ref _requestCount);
|
||||
Interlocked.Add(ref _totalBytesUp, bytesUp);
|
||||
Interlocked.Add(ref _totalBytesDown, bytesDown);
|
||||
Interlocked.Add(ref _totalLatencyMs, latencyMs);
|
||||
if (failed)
|
||||
Interlocked.Increment(ref _failureCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an immutable snapshot of all counters for delta calculation.
|
||||
/// </summary>
|
||||
public NetworkMetricsSnapshot GetSnapshot()
|
||||
{
|
||||
return new NetworkMetricsSnapshot(
|
||||
Interlocked.Read(ref _requestCount),
|
||||
Interlocked.Read(ref _failureCount),
|
||||
Interlocked.Read(ref _totalBytesUp),
|
||||
Interlocked.Read(ref _totalBytesDown),
|
||||
Interlocked.Read(ref _totalLatencyMs)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of network counters at a point in time.
|
||||
/// Used for delta calculation between two collection intervals.
|
||||
/// </summary>
|
||||
public class NetworkMetricsSnapshot
|
||||
{
|
||||
public long RequestCount { get; }
|
||||
public long FailureCount { get; }
|
||||
public long TotalBytesUp { get; }
|
||||
public long TotalBytesDown { get; }
|
||||
public long TotalLatencyMs { get; }
|
||||
|
||||
public NetworkMetricsSnapshot(long requestCount, long failureCount,
|
||||
long totalBytesUp, long totalBytesDown, long totalLatencyMs)
|
||||
{
|
||||
RequestCount = requestCount;
|
||||
FailureCount = failureCount;
|
||||
TotalBytesUp = totalBytesUp;
|
||||
TotalBytesDown = totalBytesDown;
|
||||
TotalLatencyMs = totalLatencyMs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the delta between this snapshot and a previous one.
|
||||
/// </summary>
|
||||
public NetworkMetricsDelta DeltaFrom(NetworkMetricsSnapshot previous)
|
||||
{
|
||||
var requests = RequestCount - previous.RequestCount;
|
||||
var failures = FailureCount - previous.FailureCount;
|
||||
var bytesUp = TotalBytesUp - previous.TotalBytesUp;
|
||||
var bytesDown = TotalBytesDown - previous.TotalBytesDown;
|
||||
var latencyDelta = TotalLatencyMs - previous.TotalLatencyMs;
|
||||
var avgLatency = requests > 0 ? (double)latencyDelta / requests : 0;
|
||||
|
||||
return new NetworkMetricsDelta(requests, failures, bytesUp, bytesDown, avgLatency);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computed delta values between two snapshots — ready for emission as event data.
|
||||
/// </summary>
|
||||
public class NetworkMetricsDelta
|
||||
{
|
||||
public long Requests { get; }
|
||||
public long Failures { get; }
|
||||
public long BytesUp { get; }
|
||||
public long BytesDown { get; }
|
||||
public double AvgLatencyMs { get; }
|
||||
|
||||
public NetworkMetricsDelta(long requests, long failures,
|
||||
long bytesUp, long bytesDown, double avgLatencyMs)
|
||||
{
|
||||
Requests = requests;
|
||||
Failures = failures;
|
||||
BytesUp = bytesUp;
|
||||
BytesDown = bytesDown;
|
||||
AvgLatencyMs = avgLatencyMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
public class NtpCheckResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public double OffsetSeconds { get; set; }
|
||||
public string NtpServer { get; set; }
|
||||
public string Error { get; set; }
|
||||
public DateTime? NtpTime { get; set; }
|
||||
public DateTime? LocalTime { get; set; }
|
||||
}
|
||||
|
||||
public static class NtpTimeCheckService
|
||||
{
|
||||
private static readonly int ReceiveTimeoutMs = 3000;
|
||||
|
||||
/// <summary>
|
||||
/// Queries an NTP server and returns the time offset between local clock and NTP time.
|
||||
/// Based on NTP v3 client mode (LI=0, VN=3, Mode=3).
|
||||
/// </summary>
|
||||
public static NtpCheckResult CheckTime(string ntpServer, AgentLogger logger)
|
||||
{
|
||||
var result = new NtpCheckResult { NtpServer = ntpServer };
|
||||
|
||||
try
|
||||
{
|
||||
logger.Info($"NTP time check: querying {ntpServer}");
|
||||
|
||||
// NTP request packet: 48 bytes, first byte = 0x1B (LI=0, VN=3, Mode=3)
|
||||
var ntpData = new byte[48];
|
||||
ntpData[0] = 0x1B;
|
||||
|
||||
var addresses = Dns.GetHostEntry(ntpServer).AddressList;
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
result.Error = $"DNS resolution returned no addresses for {ntpServer}";
|
||||
logger.Warning($"NTP time check failed: {result.Error}");
|
||||
return result;
|
||||
}
|
||||
|
||||
var ipEndPoint = new IPEndPoint(addresses[0], 123);
|
||||
using (var socket = new Socket(addresses[0].AddressFamily, SocketType.Dgram, ProtocolType.Udp))
|
||||
{
|
||||
socket.ReceiveTimeout = ReceiveTimeoutMs;
|
||||
socket.SendTimeout = ReceiveTimeoutMs;
|
||||
socket.Connect(ipEndPoint);
|
||||
socket.Send(ntpData);
|
||||
var received = socket.Receive(ntpData);
|
||||
|
||||
// Validate NTP response: must be at least 48 bytes
|
||||
if (received < 48)
|
||||
{
|
||||
result.Error = $"Invalid NTP response: received {received} bytes (expected 48)";
|
||||
logger.Warning($"NTP time check failed: {result.Error}");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Check Leap Indicator (bits 6-7 of first byte): value 3 means unsynchronized
|
||||
var leapIndicator = (ntpData[0] >> 6) & 3;
|
||||
if (leapIndicator == 3)
|
||||
{
|
||||
result.Error = "NTP server is unsynchronized (Leap Indicator = 3)";
|
||||
logger.Warning($"NTP time check failed: {result.Error}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse transmit timestamp from bytes 40-47 (NTP epoch: 1900-01-01)
|
||||
ulong intPart = (ulong)ntpData[40] << 24
|
||||
| (ulong)ntpData[41] << 16
|
||||
| (ulong)ntpData[42] << 8
|
||||
| (ulong)ntpData[43];
|
||||
|
||||
ulong fractPart = (ulong)ntpData[44] << 24
|
||||
| (ulong)ntpData[45] << 16
|
||||
| (ulong)ntpData[46] << 8
|
||||
| (ulong)ntpData[47];
|
||||
|
||||
var milliseconds = (intPart * 1000) + ((fractPart * 1000) / 0x100000000L);
|
||||
var ntpTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(milliseconds);
|
||||
var localTime = DateTime.UtcNow;
|
||||
var offset = ntpTime.Subtract(localTime);
|
||||
|
||||
result.Success = true;
|
||||
result.NtpTime = ntpTime;
|
||||
result.LocalTime = localTime;
|
||||
result.OffsetSeconds = offset.TotalSeconds;
|
||||
|
||||
logger.Info($"NTP time check: offset={offset.TotalSeconds:F2}s (NTP={ntpTime:o}, Local={localTime:o})");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = ex.Message;
|
||||
logger.Warning($"NTP time check failed for {ntpServer}: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
public class TimezoneSetResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string IanaTimezone { get; set; }
|
||||
public string WindowsTimezoneId { get; set; }
|
||||
public string PreviousTimezone { get; set; }
|
||||
public string Error { get; set; }
|
||||
}
|
||||
|
||||
public static class TimezoneService
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to set the Windows timezone based on an IANA timezone identifier.
|
||||
/// Uses tzutil /s to apply the timezone (works from SYSTEM context).
|
||||
/// </summary>
|
||||
public static TimezoneSetResult TrySetTimezone(string ianaTimezone, AgentLogger logger)
|
||||
{
|
||||
var result = new TimezoneSetResult { IanaTimezone = ianaTimezone };
|
||||
|
||||
try
|
||||
{
|
||||
// Map IANA timezone to Windows timezone ID
|
||||
if (!IanaToWindowsMap.TryGetValue(ianaTimezone, out var windowsId))
|
||||
{
|
||||
result.Error = $"No Windows timezone mapping found for IANA timezone '{ianaTimezone}'";
|
||||
logger.Warning($"Timezone auto-set: {result.Error}");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.WindowsTimezoneId = windowsId;
|
||||
result.PreviousTimezone = TimeZoneInfo.Local.Id;
|
||||
|
||||
// Skip if already set to the target timezone
|
||||
if (string.Equals(result.PreviousTimezone, windowsId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Success = true;
|
||||
logger.Info($"Timezone auto-set: already set to {windowsId} (IANA: {ianaTimezone})");
|
||||
return result;
|
||||
}
|
||||
|
||||
// Execute tzutil /s to set the timezone
|
||||
logger.Info($"Timezone auto-set: setting timezone to {windowsId} (IANA: {ianaTimezone}, previous: {result.PreviousTimezone})");
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = SystemPaths.TzUtil,
|
||||
Arguments = $"/s \"{windowsId}\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
var stderr = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(10000); // 10s timeout
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
result.Error = $"tzutil exited with code {process.ExitCode}: {stderr.Trim()}";
|
||||
logger.Warning($"Timezone auto-set failed: {result.Error}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
result.Success = true;
|
||||
logger.Info($"Timezone auto-set: successfully set to {windowsId}");
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = ex.Message;
|
||||
logger.Warning($"Timezone auto-set failed: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IANA timezone to Windows timezone ID mapping.
|
||||
/// Sourced from Unicode CLDR / IANA timezone database.
|
||||
/// Covers all major regions and practical timezones.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> IanaToWindowsMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// UTC
|
||||
{ "Etc/UTC", "UTC" },
|
||||
{ "Etc/GMT", "UTC" },
|
||||
{ "UTC", "UTC" },
|
||||
|
||||
// Europe
|
||||
{ "Europe/London", "GMT Standard Time" },
|
||||
{ "Europe/Dublin", "GMT Standard Time" },
|
||||
{ "Europe/Lisbon", "GMT Standard Time" },
|
||||
{ "Europe/Berlin", "W. Europe Standard Time" },
|
||||
{ "Europe/Amsterdam", "W. Europe Standard Time" },
|
||||
{ "Europe/Zurich", "W. Europe Standard Time" },
|
||||
{ "Europe/Vienna", "W. Europe Standard Time" },
|
||||
{ "Europe/Rome", "W. Europe Standard Time" },
|
||||
{ "Europe/Stockholm", "W. Europe Standard Time" },
|
||||
{ "Europe/Oslo", "W. Europe Standard Time" },
|
||||
{ "Europe/Copenhagen", "Romance Standard Time" },
|
||||
{ "Europe/Paris", "Romance Standard Time" },
|
||||
{ "Europe/Brussels", "Romance Standard Time" },
|
||||
{ "Europe/Madrid", "Romance Standard Time" },
|
||||
{ "Europe/Warsaw", "Central European Standard Time" },
|
||||
{ "Europe/Prague", "Central Europe Standard Time" },
|
||||
{ "Europe/Budapest", "Central Europe Standard Time" },
|
||||
{ "Europe/Belgrade", "Central Europe Standard Time" },
|
||||
{ "Europe/Bratislava", "Central Europe Standard Time" },
|
||||
{ "Europe/Ljubljana", "Central Europe Standard Time" },
|
||||
{ "Europe/Zagreb", "Central Europe Standard Time" },
|
||||
{ "Europe/Athens", "GTB Standard Time" },
|
||||
{ "Europe/Bucharest", "GTB Standard Time" },
|
||||
{ "Europe/Helsinki", "FLE Standard Time" },
|
||||
{ "Europe/Kiev", "FLE Standard Time" },
|
||||
{ "Europe/Kyiv", "FLE Standard Time" },
|
||||
{ "Europe/Riga", "FLE Standard Time" },
|
||||
{ "Europe/Sofia", "FLE Standard Time" },
|
||||
{ "Europe/Tallinn", "FLE Standard Time" },
|
||||
{ "Europe/Vilnius", "FLE Standard Time" },
|
||||
{ "Europe/Istanbul", "Turkey Standard Time" },
|
||||
{ "Europe/Moscow", "Russian Standard Time" },
|
||||
{ "Europe/Minsk", "Belarus Standard Time" },
|
||||
{ "Europe/Samara", "Russia Time Zone 3" },
|
||||
|
||||
// Americas
|
||||
{ "America/New_York", "Eastern Standard Time" },
|
||||
{ "America/Detroit", "Eastern Standard Time" },
|
||||
{ "America/Toronto", "Eastern Standard Time" },
|
||||
{ "America/Chicago", "Central Standard Time" },
|
||||
{ "America/Winnipeg", "Central Standard Time" },
|
||||
{ "America/Denver", "Mountain Standard Time" },
|
||||
{ "America/Edmonton", "Mountain Standard Time" },
|
||||
{ "America/Phoenix", "US Mountain Standard Time" },
|
||||
{ "America/Los_Angeles", "Pacific Standard Time" },
|
||||
{ "America/Vancouver", "Pacific Standard Time" },
|
||||
{ "America/Anchorage", "Alaskan Standard Time" },
|
||||
{ "Pacific/Honolulu", "Hawaiian Standard Time" },
|
||||
{ "America/Halifax", "Atlantic Standard Time" },
|
||||
{ "America/St_Johns", "Newfoundland Standard Time" },
|
||||
{ "America/Mexico_City", "Central Standard Time (Mexico)" },
|
||||
{ "America/Tijuana", "Pacific Standard Time (Mexico)" },
|
||||
{ "America/Bogota", "SA Pacific Standard Time" },
|
||||
{ "America/Lima", "SA Pacific Standard Time" },
|
||||
{ "America/Santiago", "Pacific SA Standard Time" },
|
||||
{ "America/Buenos_Aires", "Argentina Standard Time" },
|
||||
{ "America/Argentina/Buenos_Aires", "Argentina Standard Time" },
|
||||
{ "America/Sao_Paulo", "E. South America Standard Time" },
|
||||
{ "America/Caracas", "Venezuela Standard Time" },
|
||||
|
||||
// Asia
|
||||
{ "Asia/Tokyo", "Tokyo Standard Time" },
|
||||
{ "Asia/Seoul", "Korea Standard Time" },
|
||||
{ "Asia/Shanghai", "China Standard Time" },
|
||||
{ "Asia/Hong_Kong", "China Standard Time" },
|
||||
{ "Asia/Taipei", "Taipei Standard Time" },
|
||||
{ "Asia/Singapore", "Singapore Standard Time" },
|
||||
{ "Asia/Kuala_Lumpur", "Singapore Standard Time" },
|
||||
{ "Asia/Bangkok", "SE Asia Standard Time" },
|
||||
{ "Asia/Jakarta", "SE Asia Standard Time" },
|
||||
{ "Asia/Ho_Chi_Minh", "SE Asia Standard Time" },
|
||||
{ "Asia/Kolkata", "India Standard Time" },
|
||||
{ "Asia/Calcutta", "India Standard Time" },
|
||||
{ "Asia/Colombo", "Sri Lanka Standard Time" },
|
||||
{ "Asia/Karachi", "Pakistan Standard Time" },
|
||||
{ "Asia/Dhaka", "Bangladesh Standard Time" },
|
||||
{ "Asia/Kathmandu", "Nepal Standard Time" },
|
||||
{ "Asia/Almaty", "Central Asia Standard Time" },
|
||||
{ "Asia/Tashkent", "West Asia Standard Time" },
|
||||
{ "Asia/Dubai", "Arabian Standard Time" },
|
||||
{ "Asia/Muscat", "Arabian Standard Time" },
|
||||
{ "Asia/Riyadh", "Arab Standard Time" },
|
||||
{ "Asia/Kuwait", "Arab Standard Time" },
|
||||
{ "Asia/Baghdad", "Arabic Standard Time" },
|
||||
{ "Asia/Tehran", "Iran Standard Time" },
|
||||
{ "Asia/Kabul", "Afghanistan Standard Time" },
|
||||
{ "Asia/Baku", "Azerbaijan Standard Time" },
|
||||
{ "Asia/Tbilisi", "Georgian Standard Time" },
|
||||
{ "Asia/Yerevan", "Caucasus Standard Time" },
|
||||
{ "Asia/Jerusalem", "Israel Standard Time" },
|
||||
{ "Asia/Beirut", "Middle East Standard Time" },
|
||||
{ "Asia/Yangon", "Myanmar Standard Time" },
|
||||
{ "Asia/Vladivostok", "Vladivostok Standard Time" },
|
||||
{ "Asia/Yakutsk", "Yakutsk Standard Time" },
|
||||
{ "Asia/Krasnoyarsk", "North Asia Standard Time" },
|
||||
{ "Asia/Novosibirsk", "N. Central Asia Standard Time" },
|
||||
{ "Asia/Irkutsk", "North Asia East Standard Time" },
|
||||
|
||||
// Oceania
|
||||
{ "Australia/Sydney", "AUS Eastern Standard Time" },
|
||||
{ "Australia/Melbourne", "AUS Eastern Standard Time" },
|
||||
{ "Australia/Brisbane", "E. Australia Standard Time" },
|
||||
{ "Australia/Perth", "W. Australia Standard Time" },
|
||||
{ "Australia/Adelaide", "Cen. Australia Standard Time" },
|
||||
{ "Australia/Darwin", "AUS Central Standard Time" },
|
||||
{ "Australia/Hobart", "Tasmania Standard Time" },
|
||||
{ "Pacific/Auckland", "New Zealand Standard Time" },
|
||||
{ "Pacific/Fiji", "Fiji Standard Time" },
|
||||
{ "Pacific/Guam", "West Pacific Standard Time" },
|
||||
{ "Pacific/Port_Moresby", "West Pacific Standard Time" },
|
||||
|
||||
// Africa
|
||||
{ "Africa/Cairo", "Egypt Standard Time" },
|
||||
{ "Africa/Johannesburg", "South Africa Standard Time" },
|
||||
{ "Africa/Lagos", "W. Central Africa Standard Time" },
|
||||
{ "Africa/Nairobi", "E. Africa Standard Time" },
|
||||
{ "Africa/Casablanca", "Morocco Standard Time" },
|
||||
{ "Africa/Windhoek", "Namibia Standard Time" },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# AutopilotMonitor.Agent.V2.Core
|
||||
|
||||
**Owner:** V2-Agent clean-slate library. Plan [REFACTOR_AGENT_V2.md](../../../plans/REFACTOR_AGENT_V2.md) §2.16.
|
||||
|
||||
## Strict Separation Rule (plan §2.16 — merge block if violated)
|
||||
|
||||
This project references **only**:
|
||||
- `AutopilotMonitor.Shared`
|
||||
- `AutopilotMonitor.DecisionCore`
|
||||
|
||||
It must **never** reference `AutopilotMonitor.Agent.Core` or `AutopilotMonitor.Agent`.
|
||||
Required legacy code is **copied**, not referenced — see copy list below. Once copied,
|
||||
a file belongs to V2.Core and is maintained here independently.
|
||||
|
||||
Verification (CI gate):
|
||||
```
|
||||
grep -r "AutopilotMonitor\.Agent\.Core" src/Agent/AutopilotMonitor.Agent.V2*
|
||||
```
|
||||
Must return zero matches.
|
||||
|
||||
## Guiding Principle — V2 is Kernel-Only
|
||||
|
||||
The V2 refactor replaces **only** the decision/completion kernel. Every other capability
|
||||
(gather rules, analyzers, diagnostics upload, config polling, timezone, server-driven actions,
|
||||
summary dialog launch, env-var resolution, version checks, registry-based signal tracking,
|
||||
network / DO / performance telemetry, self-update, bootstrap→cert-auth handover, self-destruct
|
||||
cleanup) is preserved verbatim. See user feedback memory
|
||||
`feedback_v2_kernel_only_refactor.md` for the policy.
|
||||
|
||||
SKIP list is closed to plan §2.11 items:
|
||||
- `CompletionStateMachine` + partials
|
||||
- `CompletionGuards`
|
||||
- `EnrollmentTracker.CompletionLogic`
|
||||
- `IEnrollmentFlowHandler` + `ClassicAutopilotFlow` / `DevicePreparationFlow` / `EnrollmentFlowFactory`
|
||||
- `WhiteGloveClassifier` (scoring ported to `DecisionCore.Classifiers.WhiteGloveSealingClassifier` in M3)
|
||||
- `WhiteGloveSignals`
|
||||
- `EnrollmentStatePersistence`
|
||||
- Shadow infrastructure (`ShadowProcessTrigger`, `EmitShadowDiscrepancyEvent`, `shadow_discrepancy`)
|
||||
- `CheckSignalCorrelatedWhiteGlove()` as direct completion invoker
|
||||
- `TryEmitEnrollmentComplete` / related direct completion paths
|
||||
- EnrollmentType string checks in guard code
|
||||
- Event emissions: `decision_process_completion`, `completion_check`, `shadow_discrepancy`,
|
||||
decision-trace parts of `agent_trace`
|
||||
|
||||
Everything else from `Agent.Core` → **COPY** into V2.Core (this project).
|
||||
|
||||
## Copy List (source of truth: [tasks/m2-copy-list.md](../../../tasks/m2-copy-list.md))
|
||||
|
||||
After M2 copy phase: this README is updated with the full per-file inventory + adjustments
|
||||
applied (namespace renames, V2-specific path constants, etc.). Until then,
|
||||
`tasks/m2-copy-list.md` is the working document.
|
||||
|
||||
## Folder Structure
|
||||
|
||||
Target layout mirrors plan §7 R7 guidance:
|
||||
|
||||
```
|
||||
Signals/ — V2-specific signal adapters (wrap copied collectors; M4)
|
||||
State/ — V2 local state glue (not the pure DecisionState — that lives in DecisionCore)
|
||||
Engine/ — (empty here; decision engine lives in AutopilotMonitor.DecisionCore)
|
||||
Classifiers/ — (empty here; classifiers live in AutopilotMonitor.DecisionCore)
|
||||
Transport/ — TelemetrySpool, TelemetryUploadOrchestrator, BackendApiClient (M4)
|
||||
Persistence/ — SignalLogWriter, JournalWriter, SnapshotPersistence (M4)
|
||||
SignalAdapters/ — one adapter per collector (M4)
|
||||
Orchestration/ — V2-Orchestrator (replaces legacy MonitoringService in M4)
|
||||
Collectors/ — Copies of the 8 decision-relevant + 4 telemetry-only collectors
|
||||
Infrastructure/ — Logger, Config, Security, BackendApiClient baseline, CleanupService, …
|
||||
```
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// H-2 mitigation: removes the persisted bootstrap token (<c>bootstrap-config.json</c>)
|
||||
/// once the agent has proven it can authenticate with its MDM client certificate.
|
||||
///
|
||||
/// The bootstrap token is only needed during OOBE (before the MDM cert is issued). Leaving
|
||||
/// it on disk after enrollment creates an unnecessary secret-at-rest window: <c>%ProgramData%</c>
|
||||
/// grants <c>BUILTIN\Users</c> read access by default, so any post-OOBE interactive user
|
||||
/// could exfiltrate the token and replay it against the bootstrap endpoints until expiry.
|
||||
///
|
||||
/// The cleanup runs a single-shot, cert-authenticated probe against <c>/api/agent/config</c>
|
||||
/// using a dedicated <see cref="HttpClient"/>. If the probe succeeds, the file is deleted.
|
||||
/// Any failure (cert missing, network error, non-2xx response) is logged and the file is
|
||||
/// left in place — the next run will try again. This path is strictly additive: a failed
|
||||
/// probe never disrupts the in-progress bootstrap session.
|
||||
/// </summary>
|
||||
public static class BootstrapConfigCleanup
|
||||
{
|
||||
private const string BootstrapConfigFileName = "bootstrap-config.json";
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to remove the persisted bootstrap-config.json after verifying the MDM client
|
||||
/// certificate authenticates successfully against the backend. Safe no-op if the agent
|
||||
/// is not in bootstrap-token mode, the file is absent, the cert is missing, or the
|
||||
/// probe call fails.
|
||||
/// </summary>
|
||||
public static async Task TryDeleteIfCertReadyAsync(
|
||||
AgentConfiguration configuration,
|
||||
AgentLogger logger,
|
||||
string agentVersion)
|
||||
{
|
||||
if (configuration == null || !configuration.UseBootstrapTokenAuth)
|
||||
return;
|
||||
|
||||
var bootstrapPath = Path.Combine(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory),
|
||||
BootstrapConfigFileName);
|
||||
|
||||
if (!File.Exists(bootstrapPath))
|
||||
return;
|
||||
|
||||
X509Certificate2 cert;
|
||||
try
|
||||
{
|
||||
cert = CertificateHelper.FindMdmCertificate(logger: logger);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"Bootstrap cleanup: MDM cert lookup threw ({ex.Message}), keeping {BootstrapConfigFileName}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cert == null)
|
||||
{
|
||||
logger?.Info($"Bootstrap cleanup: MDM cert not yet available, keeping {BootstrapConfigFileName}");
|
||||
return;
|
||||
}
|
||||
|
||||
var handler = new HttpClientHandler();
|
||||
handler.ClientCertificates.Add(cert);
|
||||
|
||||
using (handler)
|
||||
using (var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) })
|
||||
{
|
||||
var request = BuildProbeRequest(configuration, agentVersion);
|
||||
await ProbeAndDeleteCoreAsync(bootstrapPath, http, request, logger).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the probe request: GET /api/agent/config with the standard agent security
|
||||
/// headers, but <b>without</b> the X-Bootstrap-Token header — authentication must come
|
||||
/// from the TLS-layer client certificate alone.
|
||||
/// </summary>
|
||||
private static HttpRequestMessage BuildProbeRequest(AgentConfiguration configuration, string agentVersion)
|
||||
{
|
||||
var url = $"{configuration.ApiBaseUrl.TrimEnd('/')}{Constants.ApiEndpoints.GetAgentConfig}";
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
if (!string.IsNullOrEmpty(configuration.TenantId))
|
||||
request.Headers.Add("X-Tenant-Id", configuration.TenantId);
|
||||
|
||||
var hw = HardwareInfo.GetHardwareInfo(null);
|
||||
if (!string.IsNullOrEmpty(hw.Manufacturer))
|
||||
request.Headers.Add("X-Device-Manufacturer", hw.Manufacturer);
|
||||
if (!string.IsNullOrEmpty(hw.Model))
|
||||
request.Headers.Add("X-Device-Model", hw.Model);
|
||||
if (!string.IsNullOrEmpty(hw.SerialNumber))
|
||||
request.Headers.Add("X-Device-SerialNumber", hw.SerialNumber);
|
||||
if (!string.IsNullOrEmpty(agentVersion))
|
||||
request.Headers.Add("X-Agent-Version", agentVersion);
|
||||
|
||||
var userAgent = string.IsNullOrEmpty(agentVersion)
|
||||
? "AutopilotMonitor.Agent"
|
||||
: $"AutopilotMonitor.Agent/{agentVersion}";
|
||||
request.Headers.UserAgent.ParseAdd(userAgent);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-IO core: sends the probe, deletes the file on 2xx, logs + keeps on anything
|
||||
/// else. Exposed <c>internal</c> for unit tests that inject a fake
|
||||
/// <see cref="HttpMessageHandler"/>.
|
||||
/// </summary>
|
||||
internal static async Task ProbeAndDeleteCoreAsync(
|
||||
string bootstrapConfigPath,
|
||||
HttpClient httpClient,
|
||||
HttpRequestMessage request,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (request)
|
||||
using (var response = await httpClient.SendAsync(request).ConfigureAwait(false))
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(bootstrapConfigPath))
|
||||
File.Delete(bootstrapConfigPath);
|
||||
logger?.Info("Bootstrap cleanup: cert probe succeeded — bootstrap-config.json removed, cert auth is now operational");
|
||||
}
|
||||
catch (Exception delEx)
|
||||
{
|
||||
logger?.Warning($"Bootstrap cleanup: cert probe succeeded but delete failed ({delEx.Message}); will retry on next run");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.Warning($"Bootstrap cleanup: cert probe returned HTTP {(int)response.StatusCode}, keeping {BootstrapConfigFileName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"Bootstrap cleanup: cert probe failed ({ex.Message}), keeping {BootstrapConfigFileName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper for finding and validating MDM client certificates
|
||||
/// </summary>
|
||||
public static class CertificateHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the MDM device certificate for client authentication
|
||||
/// </summary>
|
||||
/// <param name="thumbprint">Optional specific thumbprint to search for</param>
|
||||
/// <param name="logger">Logger for diagnostic output</param>
|
||||
/// <returns>The MDM certificate, or null if not found</returns>
|
||||
public static X509Certificate2 FindMdmCertificate(string thumbprint = null, AgentLogger logger = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search locations for MDM certificate
|
||||
var stores = new[]
|
||||
{
|
||||
new { Location = StoreLocation.LocalMachine, Name = StoreName.My },
|
||||
new { Location = StoreLocation.CurrentUser, Name = StoreName.My }
|
||||
};
|
||||
|
||||
foreach (var storeInfo in stores)
|
||||
{
|
||||
using (var store = new X509Store(storeInfo.Name, storeInfo.Location))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
logger?.Debug($"Searching for MDM certificate in {storeInfo.Location}\\{storeInfo.Name}");
|
||||
|
||||
// If thumbprint specified, search by thumbprint
|
||||
if (!string.IsNullOrEmpty(thumbprint))
|
||||
{
|
||||
var cert = store.Certificates
|
||||
.Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false)
|
||||
.OfType<X509Certificate2>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (cert != null)
|
||||
{
|
||||
logger?.Info($"Found certificate by thumbprint: {thumbprint}");
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Auto-detect MDM certificate by issuer and EKU
|
||||
var mdmCerts = store.Certificates
|
||||
.OfType<X509Certificate2>()
|
||||
.Where(c =>
|
||||
{
|
||||
// Check issuer (Microsoft Intune MDM Device CA or similar)
|
||||
var issuerContainsMdm = c.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) ||
|
||||
c.Issuer.Contains("Microsoft Device CA", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
|
||||
var hasClientAuth = c.Extensions
|
||||
.OfType<X509EnhancedKeyUsageExtension>()
|
||||
.Any(ext => ext.EnhancedKeyUsages
|
||||
.OfType<Oid>()
|
||||
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"));
|
||||
|
||||
return issuerContainsMdm && hasClientAuth;
|
||||
})
|
||||
.OrderByDescending(c => c.NotAfter) // Prefer certs with longer validity
|
||||
.ToList();
|
||||
|
||||
if (mdmCerts.Any())
|
||||
{
|
||||
var cert = mdmCerts.First();
|
||||
logger?.Info($"Found MDM certificate: Issuer={cert.Issuer}, Subject={cert.Subject}, Thumbprint={cert.Thumbprint}");
|
||||
return cert;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Warning("No MDM certificate found");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Error("Error finding MDM certificate", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that a certificate is still valid
|
||||
/// </summary>
|
||||
public static bool IsCertificateValid(X509Certificate2 certificate)
|
||||
{
|
||||
if (certificate == null)
|
||||
return false;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
return now >= certificate.NotBefore && now <= certificate.NotAfter;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Waits for an Intune MDM certificate to appear in the certificate store.
|
||||
/// Used in await-enrollment mode when the agent is deployed before MDM enrollment completes.
|
||||
/// Polls CertificateHelper.FindMdmCertificate() at a fixed interval (Timer-based, no busy-wait).
|
||||
/// </summary>
|
||||
public static class EnrollmentAwaiter
|
||||
{
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan LogInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Polls the certificate store until a valid MDM certificate is found or the timeout expires.
|
||||
/// </summary>
|
||||
public static async Task<X509Certificate2> WaitForMdmCertificateAsync(
|
||||
string thumbprint = null,
|
||||
int timeoutMinutes = 480,
|
||||
AgentLogger logger = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeoutDisplay = timeoutMinutes > 0 ? $"{timeoutMinutes}min" : "unlimited";
|
||||
logger?.Info($"Await-enrollment: Waiting for MDM certificate (timeout: {timeoutDisplay}, poll interval: {PollInterval.TotalSeconds}s)");
|
||||
|
||||
var startTime = DateTime.UtcNow;
|
||||
var timeout = timeoutMinutes > 0 ? TimeSpan.FromMinutes(timeoutMinutes) : TimeSpan.MaxValue;
|
||||
var lastLogTime = DateTime.MinValue;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var cert = CertificateHelper.FindMdmCertificate(thumbprint, logger);
|
||||
|
||||
if (cert != null)
|
||||
{
|
||||
// No IsCertificateValid check here: freshly provisioned SCEP certs can have
|
||||
// a NotBefore slightly in the future due to clock skew between device and CA.
|
||||
// The cert existing in the store with correct issuer+EKU is sufficient.
|
||||
var elapsed = DateTime.UtcNow - startTime;
|
||||
logger?.Info($"Await-enrollment: MDM certificate found after {FormatElapsed(elapsed)} — " +
|
||||
$"Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " +
|
||||
$"Valid={cert.NotBefore:yyyy-MM-dd HH:mm:ss}..{cert.NotAfter:yyyy-MM-dd HH:mm:ss}");
|
||||
return cert;
|
||||
}
|
||||
|
||||
var totalElapsed = DateTime.UtcNow - startTime;
|
||||
|
||||
if (totalElapsed >= timeout)
|
||||
{
|
||||
logger?.Warning($"Await-enrollment: Timeout after {FormatElapsed(totalElapsed)} — no MDM certificate found");
|
||||
LogCertificateStoreDetails(logger);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Periodic status with cert counts (every 1 minute)
|
||||
if (DateTime.UtcNow - lastLogTime >= LogInterval)
|
||||
{
|
||||
var remaining = timeout - totalElapsed;
|
||||
var storeSummary = GetCertificateStoreSummary();
|
||||
logger?.Info($"Await-enrollment: Waiting for MDM certificate... " +
|
||||
$"(elapsed: {FormatElapsed(totalElapsed)}, remaining: {FormatElapsed(remaining)}, {storeSummary})");
|
||||
lastLogTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(PollInterval, cancellationToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Info("Await-enrollment: Cancelled");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a short summary of cert counts per store, e.g. "LocalMachine=2, CurrentUser=0".
|
||||
/// </summary>
|
||||
private static string GetCertificateStoreSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
int lmCount = 0, cuCount = 0;
|
||||
|
||||
using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
lmCount = store.Certificates.Count;
|
||||
}
|
||||
|
||||
using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
cuCount = store.Certificates.Count;
|
||||
}
|
||||
|
||||
return $"certs in store: LM={lmCount}, CU={cuCount}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "certs in store: unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full cert store dump — only on timeout to help diagnose why no MDM cert was matched.
|
||||
/// </summary>
|
||||
private static void LogCertificateStoreDetails(AgentLogger logger)
|
||||
{
|
||||
if (logger == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
var stores = new[]
|
||||
{
|
||||
new { Location = StoreLocation.LocalMachine, Name = StoreName.My },
|
||||
new { Location = StoreLocation.CurrentUser, Name = StoreName.My }
|
||||
};
|
||||
|
||||
foreach (var storeInfo in stores)
|
||||
{
|
||||
using (var store = new X509Store(storeInfo.Name, storeInfo.Location))
|
||||
{
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
var certs = store.Certificates.OfType<X509Certificate2>().ToList();
|
||||
|
||||
logger.Info($"Await-enrollment: {storeInfo.Location}\\{storeInfo.Name} — {certs.Count} certificate(s):");
|
||||
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
var hasClientAuth = cert.Extensions
|
||||
.OfType<X509EnhancedKeyUsageExtension>()
|
||||
.Any(ext => ext.EnhancedKeyUsages
|
||||
.OfType<Oid>()
|
||||
.Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2"));
|
||||
|
||||
logger.Info($" - Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " +
|
||||
$"ClientAuth={hasClientAuth}, Valid={cert.NotBefore:yyyy-MM-dd}..{cert.NotAfter:yyyy-MM-dd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"Await-enrollment: Could not read certificate store: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatElapsed(TimeSpan elapsed)
|
||||
{
|
||||
if (elapsed.TotalHours >= 1)
|
||||
return $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2}m";
|
||||
return $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds:D2}s";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper for retrieving hardware information
|
||||
/// </summary>
|
||||
public static class HardwareInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the device manufacturer and model from WMI
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger for diagnostic output</param>
|
||||
/// <returns>Tuple of (Manufacturer, Model, SerialNumber)</returns>
|
||||
public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo(AgentLogger logger = null)
|
||||
{
|
||||
string manufacturer = "Unknown";
|
||||
string model = "Unknown";
|
||||
string serialNumber = "Unknown";
|
||||
|
||||
try
|
||||
{
|
||||
// Get Manufacturer and Model from Win32_ComputerSystem
|
||||
// Lenovo reports the marketing model name in Win32_ComputerSystemProduct.Version
|
||||
// instead of Win32_ComputerSystem.Model (which contains a generic platform string)
|
||||
using (var searcher = new ManagementObjectSearcher("SELECT Manufacturer, Model FROM Win32_ComputerSystem"))
|
||||
using (var collection = searcher.Get())
|
||||
{
|
||||
foreach (var obj in collection)
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
manufacturer = obj["Manufacturer"]?.ToString() ?? "Unknown";
|
||||
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
|
||||
using (var lenovoCollection = lenovoSearcher.Get())
|
||||
{
|
||||
foreach (var lenovoObj in lenovoCollection)
|
||||
{
|
||||
using (lenovoObj)
|
||||
{
|
||||
model = lenovoObj["Version"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
model = obj["Model"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Serial Number from Win32_BIOS
|
||||
using (var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
|
||||
using (var collection = searcher.Get())
|
||||
{
|
||||
foreach (var obj in collection)
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
serialNumber = obj["SerialNumber"]?.ToString() ?? "Unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger?.Debug($"Hardware detected: Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}");
|
||||
return (manufacturer, model, serialNumber);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Error("Error retrieving hardware information", ex);
|
||||
return ("Unknown", "Unknown", "Unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolved absolute paths to Windows system binaries.
|
||||
///
|
||||
/// Rationale: launching processes by bare name (e.g. "cmd.exe", "tzutil") lets
|
||||
/// Windows walk the PATH to find them. As SYSTEM this is a DLL/binary-hijacking
|
||||
/// vector — an attacker able to plant a file in any PATH entry ahead of System32
|
||||
/// gets code execution with SYSTEM privileges. Always use these resolved paths.
|
||||
///
|
||||
/// <see cref="Environment.SystemDirectory"/> respects WOW64 redirection
|
||||
/// automatically:
|
||||
/// - 64-bit process on any Windows → C:\Windows\System32
|
||||
/// - 32-bit process on 64-bit Windows → C:\Windows\SysWOW64
|
||||
/// The agent ships as AnyCPU/net48 and runs 64-bit on 64-bit Windows, so this
|
||||
/// resolves to the real System32. It also honors non-default %SystemRoot%
|
||||
/// (rare, but seen in custom OEM images on drives other than C:).
|
||||
///
|
||||
/// All listed binaries are in-box on every supported Windows version
|
||||
/// (Windows 7 / 8.1 / 10 / 11 / Server 2012R2 – 2025).
|
||||
/// </summary>
|
||||
public static class SystemPaths
|
||||
{
|
||||
private static readonly string System32 = Environment.SystemDirectory;
|
||||
|
||||
/// <summary>C:\Windows\System32\cmd.exe</summary>
|
||||
public static readonly string Cmd = Path.Combine(System32, "cmd.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\shutdown.exe</summary>
|
||||
public static readonly string Shutdown = Path.Combine(System32, "shutdown.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\tzutil.exe</summary>
|
||||
public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe");
|
||||
|
||||
/// <summary>C:\Windows\System32\netsh.exe</summary>
|
||||
public static readonly string Netsh = Path.Combine(System32, "netsh.exe");
|
||||
|
||||
/// <summary>
|
||||
/// C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
|
||||
/// Note: the "v1.0" folder is historical — it hosts every in-box PowerShell
|
||||
/// version including 5.1 on Windows 10/11. Do not "fix" it to v5.1.
|
||||
/// </summary>
|
||||
public static readonly string PowerShell = Path.Combine(System32, "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user