From c91fd1a43ed7df3e005b2ac1483d7c975154def5 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 6 Mar 2026 03:02:19 +0100 Subject: [PATCH] massive re-org and re-structuring --- .../DeviceInfoCollector.NetworkAndSecurity.cs | 701 +++++++++ .../Collectors/DeviceInfoCollector.cs | 680 +-------- .../EspAndHelloTracker.HelloTracking.cs | 554 +++++++ .../EspAndHelloTracker.ShellCoreTracking.cs | 347 +++++ .../Collectors/EspAndHelloTracker.cs | 874 +---------- .../GatherRuleExecutor.Executors.cs | 716 +++++++++ .../Collectors/GatherRuleExecutor.cs | 693 +-------- .../MonitoringService.CollectorLifecycle.cs | 641 ++++++++ .../Core/MonitoringService.EventUpload.cs | 565 +++++++ .../Monitoring/Core/MonitoringService.cs | 1155 +------------- .../EnrollmentTracker.CompletionLogic.cs | 589 ++++++++ .../Tracking/EnrollmentTracker.Diagnostics.cs | 327 ++++ .../EnrollmentTracker.EventHandlers.cs | 467 ++++++ .../Monitoring/Tracking/EnrollmentTracker.cs | 1342 +---------------- .../Tracking/ImeLogTracker.Handlers.cs | 580 +++++++ .../Tracking/ImeLogTracker.LogProcessing.cs | 298 ++++ .../Monitoring/Tracking/ImeLogTracker.cs | 839 +---------- .../Program.Configuration.cs | 368 +++++ .../Program.GatherRulesMode.cs | 271 ++++ .../Program.InstallMode.cs | 208 +++ src/Agent/AutopilotMonitor.Agent/Program.cs | 810 +--------- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- ...utopilotDeviceValidationConsentFunction.cs | 2 +- .../Admin/DailyMaintenanceFunction.cs | 2 +- .../Functions/Admin/DeviceBlockFunction.cs | 2 +- .../Admin/GetAllBlockedDevicesFunction.cs | 2 +- .../Functions/Admin/GetAuditLogsFunction.cs | 2 +- .../Admin/GetGalacticAuditLogsFunction.cs | 2 +- .../Admin/ReseedFromGitHubFunction.cs | 2 +- .../Admin/TenantAdminManagementFunction.cs | 2 +- .../Functions/Admin/TenantOffboardFunction.cs | 2 +- .../Admin/TriggerMaintenanceFunction.cs | 2 +- .../BootstrapGetAgentConfigFunction.cs | 3 +- .../BootstrapIngestEventsFunction.cs | 3 +- .../BootstrapRegisterSessionFunction.cs | 3 +- .../BootstrapReportAgentErrorFunction.cs | 3 +- .../CreateBootstrapSessionFunction.cs | 2 +- .../ListBootstrapSessionsFunction.cs | 2 +- .../RevokeBootstrapSessionFunction.cs | 2 +- .../ValidateBootstrapCodeFunction.cs | 2 +- .../Config/GetAdminConfigurationFunction.cs | 2 +- .../Config/GetAgentConfigFunction.cs | 2 +- .../GetAllTenantConfigurationsFunction.cs | 2 +- .../Config/GetTenantConfigurationFunction.cs | 2 +- .../UpdateAdminConfigurationFunction.cs | 2 +- .../UpdateTenantConfigurationFunction.cs | 2 +- .../UpdateTenantSecurityBypassFunction.cs | 2 +- .../DiagnosticsDownloadFunction.cs | 2 +- .../GetDiagnosticsUploadUrlFunction.cs | 2 +- .../Functions/Infrastructure/AuthFunction.cs | 2 +- .../Infrastructure/HealthCheckFunction.cs | 2 +- .../SignalRAddToGroupFunction.cs | 2 +- .../SignalRNegotiateFunction.cs | 2 +- .../SignalRRemoveFromGroupFunction.cs | 2 +- .../Ingest/IngestEventsFunction.Helpers.cs | 303 ++++ .../Ingest/IngestEventsFunction.Processing.cs | 539 +++++++ .../Functions/Ingest/IngestEventsFunction.cs | 854 +---------- .../Ingest/ReportAgentErrorFunction.cs | 2 +- .../Metrics/GetAppMetricsFunction.cs | 2 +- .../Metrics/GetGalacticAppMetricsFunction.cs | 2 +- .../GetGalacticGeographicMetricsFunction.cs | 2 +- .../GetGalacticPlatformMetricsFunction.cs | 2 +- .../Metrics/GetGeographicMetricsFunction.cs | 2 +- .../Metrics/GetPlatformStatsFunction.cs | 2 +- .../Metrics/PlatformUsageMetricsFunction.cs | 2 +- .../Functions/Metrics/UsageMetricsFunction.cs | 2 +- .../GetSessionReportDownloadUrlFunction.cs | 2 +- .../Reports/GetSessionReportsFunction.cs | 2 +- .../Reports/SubmitSessionReportFunction.cs | 2 +- .../UpdateSessionReportNoteFunction.cs | 2 +- .../Functions/Rules/AnalyzeRulesFunction.cs | 2 +- .../Functions/Rules/GatherRulesFunction.cs | 2 +- .../Functions/Rules/GetRuleResultsFunction.cs | 2 +- .../Functions/Rules/ImeLogPatternsFunction.cs | 2 +- .../Rules/PreviewWhitelistFunction.cs | 2 +- .../Sessions/DeleteSessionFunction.cs | 2 +- .../Sessions/GetAllSessionsFunction.cs | 2 +- .../Sessions/GetSessionEventsFunction.cs | 2 +- .../Functions/Sessions/GetSessionFunction.cs | 2 +- .../Functions/Sessions/GetSessionsFunction.cs | 2 +- .../Sessions/MarkSessionFailedFunction.cs | 2 +- .../Sessions/RegisterSessionFunction.cs | 2 +- .../AutopilotMonitor.Functions/Program.cs | 11 +- .../MaintenanceService.Aggregation.cs | 396 +++++ .../Services/MaintenanceService.cs | 375 +---- .../RuleEngine.ConditionEvaluators.cs | 534 +++++++ .../Services/RuleEngine.cs | 521 +------ .../components/AnalyzeRuleCard.tsx | 275 ++++ .../components/AnalyzeRuleFormFields.tsx | 247 +++ .../app/analyze-rules/page.tsx | 925 ++---------- .../app/analyze-rules/types.ts | 158 ++ .../components/ConfirmationModals.tsx | 104 ++ .../app/dashboard/components/SessionTable.tsx | 385 +++++ .../app/dashboard/components/SettingsMenu.tsx | 313 ++++ .../app/dashboard/components/StatsCards.tsx | 47 + .../app/dashboard/components/UserMenu.tsx | 91 ++ .../dashboard/components/WelcomeMessage.tsx | 90 ++ .../app/dashboard/page.tsx | 1037 +------------ .../app/dashboard/types.ts | 16 + .../components/DiagnosisBadges.tsx | 60 + .../app/diagnosis/[sessionId]/page.tsx | 59 +- .../fleet-health/components/FleetStatCard.tsx | 41 + .../app/fleet-health/page.tsx | 48 +- .../components/GatherRuleCard.tsx | 398 +++++ .../components/GatherRuleFormFields.tsx | 344 +++++ .../app/gather-rules/page.tsx | 1273 +++------------- .../app/gather-rules/types.ts | 121 ++ .../components/ImePatternCard.tsx | 450 ++++++ .../app/ime-log-patterns/page.tsx | 721 ++------- .../app/ime-log-patterns/types.ts | 73 + .../components/DeviceDetailsCard.tsx | 389 +++++ .../app/sessions/[sessionId]/page.tsx | 418 +---- src/Web/autopilot-monitor-web/hooks/index.ts | 12 + .../hooks/useAuthenticatedFetch.ts | 119 ++ .../hooks/useNotificationMessages.ts | 94 ++ .../tsconfig.tsbuildinfo | 2 +- 116 files changed, 12789 insertions(+), 12199 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.HelloTracking.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.Handlers.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.LogProcessing.cs create mode 100644 src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs create mode 100644 src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs create mode 100644 src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs create mode 100644 src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx create mode 100644 src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx create mode 100644 src/Web/autopilot-monitor-web/app/analyze-rules/types.ts create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/ConfirmationModals.tsx create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/SettingsMenu.tsx create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/StatsCards.tsx create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/UserMenu.tsx create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/components/WelcomeMessage.tsx create mode 100644 src/Web/autopilot-monitor-web/app/dashboard/types.ts create mode 100644 src/Web/autopilot-monitor-web/app/diagnosis/[sessionId]/components/DiagnosisBadges.tsx create mode 100644 src/Web/autopilot-monitor-web/app/fleet-health/components/FleetStatCard.tsx create mode 100644 src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleCard.tsx create mode 100644 src/Web/autopilot-monitor-web/app/gather-rules/components/GatherRuleFormFields.tsx create mode 100644 src/Web/autopilot-monitor-web/app/gather-rules/types.ts create mode 100644 src/Web/autopilot-monitor-web/app/ime-log-patterns/components/ImePatternCard.tsx create mode 100644 src/Web/autopilot-monitor-web/app/ime-log-patterns/types.ts create mode 100644 src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/DeviceDetailsCard.tsx create mode 100644 src/Web/autopilot-monitor-web/hooks/index.ts create mode 100644 src/Web/autopilot-monitor-web/hooks/useAuthenticatedFetch.ts create mode 100644 src/Web/autopilot-monitor-web/hooks/useNotificationMessages.ts diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs new file mode 100644 index 00000000..a5f3225f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.NetworkAndSecurity.cs @@ -0,0 +1,701 @@ +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; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Partial: Network, proxy, Autopilot profile, security (SecureBoot, BitLocker, TPM, AAD join), + /// ESP configuration, WiFi signal, and helper methods. + /// + public partial class DeviceInfoCollector + { + 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) CollectAutopilotProfile() + { + var detectedType = "v1"; + var isHybridJoin = false; + + 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)) + { + string modeLabel; + switch (autopilotMode) + { + case 0: modeLabel = "User Driven"; break; + case 1: modeLabel = "Self Deploying"; break; + case 2: modeLabel = "Pre-Provisioning (White Glove)"; break; + default: modeLabel = $"Unknown ({autopilotMode})"; break; + } + data["autopilotModeLabel"] = modeLabel; + _logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})"); + } + + // --- Interpret CloudAssignedDomainJoinMethod --- + // 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join + var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod") + ? data["CloudAssignedDomainJoinMethod"]?.ToString() + : null; + 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 } } + }); + + _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}"); + } + catch (Exception ex) + { + _logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}"); + } + + return (detectedType, isHybridJoin); + } + + 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"; + } + } + + EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}", 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(); + + using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo")) + { + if (joinInfoKey != null) + { + var subKeyNames = joinInfoKey.GetSubKeyNames(); + if (subKeyNames.Length > 0) + { + using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0])) + { + if (subKey != null) + { + data["tenantId"] = subKey.GetValue("TenantId")?.ToString(); + data["userEmail"] = subKey.GetValue("UserEmail")?.ToString(); + data["joinType"] = "Azure AD Joined"; + data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint + } + } + } + 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 = "netsh.exe", + 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/Collectors/DeviceInfoCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs index 415c18d7..52283a7f 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/DeviceInfoCollector.cs @@ -18,7 +18,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors /// proxy_configuration, autopilot_profile, enrollment_type_detected, secureboot_status, /// bitlocker_status, aad_join_status) via the provided emitEvent action. /// - public class DeviceInfoCollector + public partial class DeviceInfoCollector { private readonly string _sessionId; private readonly string _tenantId; @@ -206,683 +206,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors /// consolidated to halve the WMI round-trips (each creates a new COM connection). /// Emits two events (network_adapters, dns_configuration) for backward compatibility. /// - 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) CollectAutopilotProfile() - { - var detectedType = "v1"; - var isHybridJoin = false; - - 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)) - { - string modeLabel; - switch (autopilotMode) - { - case 0: modeLabel = "User Driven"; break; - case 1: modeLabel = "Self Deploying"; break; - case 2: modeLabel = "Pre-Provisioning (White Glove)"; break; - default: modeLabel = $"Unknown ({autopilotMode})"; break; - } - data["autopilotModeLabel"] = modeLabel; - _logger.Info($"EnrollmentTracker: AutopilotMode={autopilotMode} ({modeLabel})"); - } - - // --- Interpret CloudAssignedDomainJoinMethod --- - // 0 = Entra Join (Azure AD Join), 1 = Hybrid Azure AD Join - var domainJoinMethodStr = data.ContainsKey("CloudAssignedDomainJoinMethod") - ? data["CloudAssignedDomainJoinMethod"]?.ToString() - : null; - 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 } } - }); - - _logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}"); - } - catch (Exception ex) - { - _logger.Warning($"EnrollmentTracker: failed to collect autopilot profile: {ex.Message}"); - } - - return (detectedType, isHybridJoin); - } - - 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"; - } - } - - EmitDeviceInfoEvent("secureboot_status", $"SecureBoot: {data["uefiSecureBootEnabled"]}", 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(); - - using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo")) - { - if (joinInfoKey != null) - { - var subKeyNames = joinInfoKey.GetSubKeyNames(); - if (subKeyNames.Length > 0) - { - using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0])) - { - if (subKey != null) - { - data["tenantId"] = subKey.GetValue("TenantId")?.ToString(); - data["userEmail"] = subKey.GetValue("UserEmail")?.ToString(); - data["joinType"] = "Azure AD Joined"; - data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint - } - } - } - 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 = "netsh.exe", - 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/Collectors/EspAndHelloTracker.HelloTracking.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.HelloTracking.cs new file mode 100644 index 00000000..3aa4a4e2 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.HelloTracking.cs @@ -0,0 +1,554 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Partial: Hello policy detection, User Device Registration event handling, and Hello timers. + /// + public partial class EspAndHelloTracker + { + private void CheckHelloPolicy() + { + try + { + var (isEnabled, source) = DetectHelloPolicy(); + + lock (_stateLock) + { + // If policy was not configured before but is now, update state and emit event + 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})"); + + // Stop periodic policy check - we found what we were looking for + if (_policyCheckTimer != null) + { + _policyCheckTimer.Dispose(); + _policyCheckTimer = null; + _logger.Debug("Stopped periodic Hello policy check - policy has been detected"); + } + } + else if (!_isPolicyConfigured && !isEnabled.HasValue) + { + // Policy still not found - this is normal during early enrollment (Debug level to keep UI clean) + _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() + { + // 1. Check CSP/Intune-delivered policy (per-tenant paths) + // Policy can be device-scoped: {tenantId}\Device\Policies + // or user-scoped: {tenantId}\{userSid}\Policies + 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) + { + // Check all subkeys (Device or user SIDs like S-1-5-...) + 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 { } + + // 2. Check GPO-delivered policy + 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); + } + + private void StartEventLogWatcher() + { + try + { + // Watch for specific WHfB-related event IDs + var query = new EventLogQuery( + EventLogChannel, + PathType.LogName, + "*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]" + ); + + _watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query); + _watcher.EventRecordWritten += OnEventRecordWritten; + _watcher.Enabled = true; + + _logger.Info($"Started watching: {EventLogChannel}"); + } + catch (EventLogNotFoundException) + { + _logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)"); + } + catch (Exception ex) + { + _logger.Error($"Failed to start Hello event log watcher", ex); + } + } + + private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e) + { + if (e.EventRecord == null) + return; + + try + { + var record = e.EventRecord; + var eventId = record.Id; + if (!TrackedEventIds.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); + } + } + + private 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; + } + + // We only need the event ID and provider for tracking purposes. + // The full description can contain sensitive data (e.g. 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 + }); + + _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; + } + } + + /// + /// Called when the Hello wait timer expires after ESP exit. + /// If Hello wizard hasn't started by now, we assume it's not configured or was skipped. + /// Mark Hello as completed so enrollment can proceed. + /// + private void OnHelloWaitTimeout(object state) + { + lock (_stateLock) + { + if (_helloWizardStarted) + { + // Hello wizard already started, this timeout is obsolete + _logger.Debug("Hello wait timeout fired but wizard already started - ignoring"); + return; + } + + if (_isHelloCompleted) + { + // Hello already completed via events 300/301 - timeout is obsolete + _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 Hello policy is disabled/unknown, assume Hello is not configured and proceed. + 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", _espExitDetected }, + { "helloPolicyEnabled", true }, + { "action", "extended_wait" } + } + }); + + // Arm the long completion timer so we don't wait forever + StartHelloCompletionTimerLocked(); + return; + } + + // Hello policy disabled or unknown — assume Hello is not configured + _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(); + + // Emit event for tracking + _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", _espExitDetected }, + { "helloPolicyEnabled", false }, + { "action", "enrollment_complete" } + } + }); + + // Trigger HelloCompleted so enrollment can proceed + try + { + HelloCompleted?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.Error("Error invoking HelloCompleted from timeout", ex); + } + } + } + + /// + /// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits. + /// The timer waits for Hello wizard to start (event 62404) within the configured timeout. + /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed. + /// + public void StartHelloWaitTimer() + { + lock (_stateLock) + { + // Don't start if Hello already started or completed + 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; + } + + // Don't start if timer already running + 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) // One-shot timer + ); + } + } + + 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 } + } + }); + + try + { + HelloCompleted?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + _logger.Error("Error invoking HelloCompleted from completion timeout", ex); + } + } + } + + private void BackfillRecentTerminalHelloEvents() + { + try + { + var lookbackMs = BackfillLookbackMinutes * 60 * 1000; + var query = new EventLogQuery( + EventLogChannel, + 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}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs new file mode 100644 index 00000000..7bddd9ec --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.ShellCoreTracking.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Eventing.Reader; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Partial: Shell-Core event log handling — ESP exit detection, ESP failure detection, + /// WhiteGlove detection, Hello wizard start detection, and ESP backfill. + /// + public partial class EspAndHelloTracker + { + private void StartShellCoreEventLogWatcher() + { + try + { + // Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log + var query = new EventLogQuery( + ShellCoreEventLogChannel, + PathType.LogName, + "*[System[(EventID=62404 or EventID=62407)]]" + ); + + _shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query); + _shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten; + _shellCoreWatcher.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); + } + } + + private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e) + { + if (e.EventRecord == null) + return; + + try + { + var record = e.EventRecord; + var eventId = record.Id; + + if (!TrackedShellCoreEventIds.Contains(eventId)) + return; + + var description = record.FormatDescription() ?? $"Event ID {eventId}"; + + string eventType; + EventSeverity severity = EventSeverity.Info; + string message; + bool triggerFinalizingSetup = false; + string finalizingSetupReason = null; + + switch (eventId) + { + case EventId_ShellCore_WebAppStarted: // 62404 + // Check if this is AADHello or NGC (Hello wizard started) + if (description.Contains("AADHello") || description.Contains("'NGC'")) + { + eventType = "hello_wizard_started"; + message = "Windows Hello wizard started (CloudExperienceHost)"; + triggerFinalizingSetup = true; + finalizingSetupReason = "hello_wizard_started"; + + lock (_stateLock) + { + _helloWizardStarted = true; + + // Stop the hello wait timer if running (Hello wizard appeared within timeout) + if (_helloWaitTimer != null) + { + _helloWaitTimer.Dispose(); + _helloWaitTimer = null; + _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer"); + } + + StartHelloCompletionTimerLocked(); + } + + _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404"); + } + else + { + // Not Hello-related, ignore + return; + } + break; + + //{ + //"windowsEventId": 62407, + //"providerName": "Microsoft-Windows-Shell-Core", + //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.", + //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" + //} + + //{ + //"windowsEventId": 62407, + //"providerName": "Microsoft-Windows-Shell-Core", + //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.", + //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" + //} + + //{ + //"windowsEventId": 62407, + //"providerName": "Microsoft-Windows-Shell-Core", + //"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.", + //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" + //} + + //{ + //"windowsEventId": 62407, + //"providerName": "Microsoft-Windows-Shell-Core", + //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.", + //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" + //} + + case EventId_ShellCore_WebAppEvent: // 62407 + // WhiteGlove check FIRST — its description also contains "Exiting" + // which would match the generic OOBE_ESP.*Exiting regex below. + if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase)) + { + // Guard: event 62407 can fire multiple times; only process once + lock (_stateLock) + { + if (_whiteGloveDetected) return; + _whiteGloveDetected = true; + } + + eventType = "whiteglove_complete"; + message = "WhiteGlove (Pre-Provisioning) completed successfully"; + // Do NOT set triggerFinalizingSetup — WhiteGlove terminates the + // pre-provisioning phase entirely, it does not transition to FinalizingSetup. + + _logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407"); + } + // Check if this is ESP exit event + // Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting + // Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description. + // Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above. + else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) + || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) + || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) + || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) + || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) + || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase)) + { + var failureType = ExtractEspFailureType(description); + eventType = "esp_failure"; + severity = EventSeverity.Error; + message = $"ESP (Enrollment Status Page) reported a failure: {failureType}"; + _logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}"); + _detectedEspFailureType = failureType; + } + else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + 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 + } + + _logger.Info("ESP phase exit detected - detected via Shell-Core event 62407"); + } + else + { + // Not a tracked ESP event, ignore + return; + } + break; + + default: + return; + } + + var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(); + + var eventData = new Dictionary + { + { "windowsEventId", eventId }, + { "providerName", record.ProviderName ?? "" }, + { "description", description }, + { "eventLogChannel", ShellCoreEventLogChannel }, + { "eventTime", eventTimestamp.ToString("o") } + }; + + if (eventType == "esp_failure" && _detectedEspFailureType != null) + { + eventData["failureType"] = _detectedEspFailureType; + } + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = eventTimestamp, + EventType = eventType, + Severity = severity, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase + Message = message, + Data = eventData + }); + + _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})"); + + // Trigger FinalizingSetup phase transition + if (triggerFinalizingSetup) + { + try + { + FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); + } + catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); } + } + + // Fire WhiteGloveCompleted if this was a WhiteGlove success event. + // Must happen AFTER the event has been emitted above 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 if this was an ESP failure event. + // Must happen AFTER the event has been emitted above so the + // esp_failure event is in the spool before the agent potentially shuts down. + if (eventType == "esp_failure" && _detectedEspFailureType != null) + { + var failureType = _detectedEspFailureType; + _detectedEspFailureType = null; + try + { + EspFailureDetected?.Invoke(this, failureType); + } + catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); } + } + } + catch (Exception ex) + { + _logger.Error("Error processing Shell-Core event record", ex); + } + } + + /// + /// Extracts a structured failure type from the Shell-Core event description. + /// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed". + /// + private static string ExtractEspFailureType(string description) + { + // Known failure type keywords to search for in order of specificity + string[] knownTypes = { + "ESPProgress_Failure", + "ESPProgress_Failed", + "ESPProgress_Timeout", + "ESPProgress_Abort", + "WhiteGlove_Failed", + "WhiteGlove_Failure" + }; + + foreach (var type in knownTypes) + { + if (description.Contains(type, StringComparison.OrdinalIgnoreCase)) + return type; + } + + return "Unknown_ESP_Failure"; + } + + /// + /// 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() ?? ""; + + // Check for ESP exit + if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) + { + lock (_stateLock) + { + if (!_espExitDetected) + { + _espExitDetected = true; + _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); } + } + } + } + + // Check for ESP failures + if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) + || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) + || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) + || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) + || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) + || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase)) + { + 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); } + } + } + } + } + } + catch (Exception ex) + { + _logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs index 94df7dfe..cdc4d064 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/EspAndHelloTracker.cs @@ -24,7 +24,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors /// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit /// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove (Pre-Provisioning) complete /// - public class EspAndHelloTracker : IDisposable + public partial class EspAndHelloTracker : IDisposable { private readonly AgentLogger _logger; private readonly string _sessionId; @@ -259,878 +259,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors } } - private void CheckHelloPolicy() - { - try - { - var (isEnabled, source) = DetectHelloPolicy(); - - lock (_stateLock) - { - // If policy was not configured before but is now, update state and emit event - 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})"); - - // Stop periodic policy check - we found what we were looking for - if (_policyCheckTimer != null) - { - _policyCheckTimer.Dispose(); - _policyCheckTimer = null; - _logger.Debug("Stopped periodic Hello policy check - policy has been detected"); - } - } - else if (!_isPolicyConfigured && !isEnabled.HasValue) - { - // Policy still not found - this is normal during early enrollment (Debug level to keep UI clean) - _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() - { - // 1. Check CSP/Intune-delivered policy (per-tenant paths) - // Policy can be device-scoped: {tenantId}\Device\Policies - // or user-scoped: {tenantId}\{userSid}\Policies - 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) - { - // Check all subkeys (Device or user SIDs like S-1-5-...) - 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 { } - - // 2. Check GPO-delivered policy - 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); - } - - private void StartEventLogWatcher() - { - try - { - // Watch for specific WHfB-related event IDs - var query = new EventLogQuery( - EventLogChannel, - PathType.LogName, - "*[System[(EventID=300 or EventID=301 or EventID=358 or EventID=360 or EventID=362 or EventID=376)]]" - ); - - _watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query); - _watcher.EventRecordWritten += OnEventRecordWritten; - _watcher.Enabled = true; - - _logger.Info($"Started watching: {EventLogChannel}"); - } - catch (EventLogNotFoundException) - { - _logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)"); - } - catch (Exception ex) - { - _logger.Error($"Failed to start Hello event log watcher", ex); - } - } - - private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e) - { - if (e.EventRecord == null) - return; - - try - { - var record = e.EventRecord; - var eventId = record.Id; - if (!TrackedEventIds.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); - } - } - - private 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; - } - - // We only need the event ID and provider for tracking purposes. - // The full description can contain sensitive data (e.g. 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 - }); - - _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 StartShellCoreEventLogWatcher() - { - try - { - // Watch for ESP exit and Hello wizard start events in Shell-Core/Operational log - var query = new EventLogQuery( - ShellCoreEventLogChannel, - PathType.LogName, - "*[System[(EventID=62404 or EventID=62407)]]" - ); - - _shellCoreWatcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query); - _shellCoreWatcher.EventRecordWritten += OnShellCoreEventRecordWritten; - _shellCoreWatcher.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); - } - } - - private void OnShellCoreEventRecordWritten(object sender, EventRecordWrittenEventArgs e) - { - if (e.EventRecord == null) - return; - - try - { - var record = e.EventRecord; - var eventId = record.Id; - - if (!TrackedShellCoreEventIds.Contains(eventId)) - return; - - var description = record.FormatDescription() ?? $"Event ID {eventId}"; - - string eventType; - EventSeverity severity = EventSeverity.Info; - string message; - bool triggerFinalizingSetup = false; - string finalizingSetupReason = null; - - switch (eventId) - { - case EventId_ShellCore_WebAppStarted: // 62404 - // Check if this is AADHello or NGC (Hello wizard started) - if (description.Contains("AADHello") || description.Contains("'NGC'")) - { - eventType = "hello_wizard_started"; - message = "Windows Hello wizard started (CloudExperienceHost)"; - triggerFinalizingSetup = true; - finalizingSetupReason = "hello_wizard_started"; - - lock (_stateLock) - { - _helloWizardStarted = true; - - // Stop the hello wait timer if running (Hello wizard appeared within timeout) - if (_helloWaitTimer != null) - { - _helloWaitTimer.Dispose(); - _helloWaitTimer = null; - _logger.Info($"Hello wizard started within {_helloWaitTimeoutSeconds}s timeout - stopping wait timer"); - } - - StartHelloCompletionTimerLocked(); - } - - _logger.Info("Windows Hello wizard started - detected via Shell-Core event 62404"); - } - else - { - // Not Hello-related, ignore - return; - } - break; - - //{ - //"windowsEventId": 62407, - //"providerName": "Microsoft-Windows-Shell-Core", - //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESP_ExitPage', Value: '{\"message\":\"BootstrapStatus: Clearing ESP cache on page exit\",\"errorCode\":0}'.", - //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" - //} - - //{ - //"windowsEventId": 62407, - //"providerName": "Microsoft-Windows-Shell-Core", - //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Page_Exiting', Value: '{\"message\":\"BootstrapStatus: Exiting page normally.\",\"errorCode\":0}'.", - //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" - //} - - //{ - //"windowsEventId": 62407, - //"providerName": "Microsoft-Windows-Shell-Core", - //"description": "CloudExperienceHost-Web-App-Ereignis 2. Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success', Wert: '{\"message\":\"BootstrapStatus: Exiting page due to White Glove success.\",\"errorCode\":0}'.", - //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" - //} - - //{ - //"windowsEventId": 62407, - //"providerName": "Microsoft-Windows-Shell-Core", - //"description": "CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_ESPProgress_Failure', Value: '{\"message\":\"BootstrapStatus: ...\",\"errorCode\":...}'.", - //"eventLogChannel": "Microsoft-Windows-Shell-Core/Operational" - //} - - case EventId_ShellCore_WebAppEvent: // 62407 - // WhiteGlove check FIRST — its description also contains "Exiting" - // which would match the generic OOBE_ESP.*Exiting regex below. - if (description.Contains("WhiteGlove_Success", StringComparison.OrdinalIgnoreCase)) - { - // Guard: event 62407 can fire multiple times; only process once - lock (_stateLock) - { - if (_whiteGloveDetected) return; - _whiteGloveDetected = true; - } - - eventType = "whiteglove_complete"; - message = "WhiteGlove (Pre-Provisioning) completed successfully"; - // Do NOT set triggerFinalizingSetup — WhiteGlove terminates the - // pre-provisioning phase entirely, it does not transition to FinalizingSetup. - - _logger.Info("WhiteGlove (Pre-Provisioning) success detected via Shell-Core event 62407"); - } - // Check if this is ESP exit event - // Use robust pattern: OOBE_ESP*Exiting* instead of full string CommercialOOBE_ESPProgress_Page_Exiting - // Fix 26.02.26 - RegEx was not preceisely matching as we used Exit instead of Exiting, which is the actual value in the event description. - // Updated to check for Exiting to reliably detect ESP exit events. Compare with event samples from real devices listed above. - else if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) - || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) - || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) - || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) - || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) - || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase)) - { - var failureType = ExtractEspFailureType(description); - eventType = "esp_failure"; - severity = EventSeverity.Error; - message = $"ESP (Enrollment Status Page) reported a failure: {failureType}"; - _logger.Warning($"ESP failure detected via Shell-Core event 62407: {failureType}"); - _detectedEspFailureType = failureType; - } - else if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - 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 - } - - _logger.Info("ESP phase exit detected - detected via Shell-Core event 62407"); - } - else - { - // Not a tracked ESP event, ignore - return; - } - break; - - default: - return; - } - - var eventTimestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime(); - - var eventData = new Dictionary - { - { "windowsEventId", eventId }, - { "providerName", record.ProviderName ?? "" }, - { "description", description }, - { "eventLogChannel", ShellCoreEventLogChannel }, - { "eventTime", eventTimestamp.ToString("o") } - }; - - if (eventType == "esp_failure" && _detectedEspFailureType != null) - { - eventData["failureType"] = _detectedEspFailureType; - } - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - Timestamp = eventTimestamp, - EventType = eventType, - Severity = severity, - Source = "EspAndHelloTracker", - Phase = EnrollmentPhase.Unknown, // Let EnrollmentTracker decide phase - Message = message, - Data = eventData - }); - - _logger.Info($"Shell-Core event detected: {eventType} (EventID {eventId})"); - - // Trigger FinalizingSetup phase transition - if (triggerFinalizingSetup) - { - try - { - FinalizingSetupPhaseTriggered?.Invoke(this, finalizingSetupReason); - } - catch (Exception ex) { _logger.Error("FinalizingSetupPhaseTriggered handler failed", ex); } - } - - // Fire WhiteGloveCompleted if this was a WhiteGlove success event. - // Must happen AFTER the event has been emitted above 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 if this was an ESP failure event. - // Must happen AFTER the event has been emitted above so the - // esp_failure event is in the spool before the agent potentially shuts down. - if (eventType == "esp_failure" && _detectedEspFailureType != null) - { - var failureType = _detectedEspFailureType; - _detectedEspFailureType = null; - try - { - EspFailureDetected?.Invoke(this, failureType); - } - catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); } - } - } - catch (Exception ex) - { - _logger.Error("Error processing Shell-Core event record", ex); - } - } - - /// - /// Called when the Hello wait timer expires after ESP exit. - /// If Hello wizard hasn't started by now, we assume it's not configured or was skipped. - /// Mark Hello as completed so enrollment can proceed. - /// - private void OnHelloWaitTimeout(object state) - { - lock (_stateLock) - { - if (_helloWizardStarted) - { - // Hello wizard already started, this timeout is obsolete - _logger.Debug("Hello wait timeout fired but wizard already started - ignoring"); - return; - } - - if (_isHelloCompleted) - { - // Hello already completed via events 300/301 - timeout is obsolete - _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 Hello policy is disabled/unknown, assume Hello is not configured and proceed. - 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", _espExitDetected }, - { "helloPolicyEnabled", true }, - { "action", "extended_wait" } - } - }); - - // Arm the long completion timer so we don't wait forever - StartHelloCompletionTimerLocked(); - return; - } - - // Hello policy disabled or unknown — assume Hello is not configured - _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(); - - // Emit event for tracking - _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", _espExitDetected }, - { "helloPolicyEnabled", false }, - { "action", "enrollment_complete" } - } - }); - - // Trigger HelloCompleted so enrollment can proceed - try - { - HelloCompleted?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - _logger.Error("Error invoking HelloCompleted from timeout", ex); - } - } - } - - /// - /// Starts the Hello wait timer. Should be called by EnrollmentTracker when AccountSetup phase exits. - /// The timer waits for Hello wizard to start (event 62404) within the configured timeout. - /// If timeout expires without Hello wizard, marks Hello as completed so enrollment can proceed. - /// - public void StartHelloWaitTimer() - { - lock (_stateLock) - { - // Don't start if Hello already started or completed - 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; - } - - // Don't start if timer already running - 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) // One-shot timer - ); - } - } - - 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 } - } - }); - - try - { - HelloCompleted?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - _logger.Error("Error invoking HelloCompleted from completion timeout", ex); - } - } - } - - /// - /// Extracts a structured failure type from the Shell-Core event description. - /// E.g. "ESPProgress_Failure", "ESPProgress_Timeout", "WhiteGlove_Failed". - /// - private static string ExtractEspFailureType(string description) - { - // Known failure type keywords to search for in order of specificity - string[] knownTypes = { - "ESPProgress_Failure", - "ESPProgress_Failed", - "ESPProgress_Timeout", - "ESPProgress_Abort", - "WhiteGlove_Failed", - "WhiteGlove_Failure" - }; - - foreach (var type in knownTypes) - { - if (description.Contains(type, StringComparison.OrdinalIgnoreCase)) - return type; - } - - return "Unknown_ESP_Failure"; - } - - /// - /// 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() ?? ""; - - // Check for ESP exit - if (System.Text.RegularExpressions.Regex.IsMatch(description, @"OOBE_ESP.*Exiting", System.Text.RegularExpressions.RegexOptions.IgnoreCase)) - { - lock (_stateLock) - { - if (!_espExitDetected) - { - _espExitDetected = true; - _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); } - } - } - } - - // Check for ESP failures - if (description.Contains("ESPProgress_Failure", StringComparison.OrdinalIgnoreCase) - || description.Contains("ESPProgress_Failed", StringComparison.OrdinalIgnoreCase) - || description.Contains("ESPProgress_Timeout", StringComparison.OrdinalIgnoreCase) - || description.Contains("ESPProgress_Abort", StringComparison.OrdinalIgnoreCase) - || description.Contains("WhiteGlove_Failed", StringComparison.OrdinalIgnoreCase) - || description.Contains("WhiteGlove_Failure", StringComparison.OrdinalIgnoreCase)) - { - 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); } - } - } - } - } - } - catch (Exception ex) - { - _logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}"); - } - } - - private void BackfillRecentTerminalHelloEvents() - { - try - { - var lookbackMs = BackfillLookbackMinutes * 60 * 1000; - var query = new EventLogQuery( - EventLogChannel, - 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}"); - } - } - public void Dispose() { Stop(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs new file mode 100644 index 00000000..eda929c9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.Executors.cs @@ -0,0 +1,716 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Eventing.Reader; +using System.IO; +using System.Linq; +using System.Management; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Tracking; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Partial: Individual rule executor implementations (registry, WMI, command, file, eventlog, logparser). + /// + public partial class GatherRuleExecutor + { + private Dictionary ExecuteRegistryRule(GatherRule rule) + { + 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)) + return EmitSecurityWarning(rule, "registry", path); + + // Determine explicit valueName from parameters + string explicitValueName = null; + rule.Parameters?.TryGetValue("valueName", out explicitValueName); + + 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)) + { + 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; + + // Read specific value if specified in parameters + if (!string.IsNullOrEmpty(explicitValueName)) + { + 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; + } + + return data; + } + + private Dictionary ExecuteWmiRule(GatherRule rule) + { + 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)) + return 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; + } + + private Dictionary ExecuteCommandRule(GatherRule rule) + { + var data = new Dictionary(); + var command = rule.Target; + + if (string.IsNullOrEmpty(command)) + return data; + + // SECURITY: Check command against allowlist + if (!GatherRuleGuards.IsCommandAllowed(command)) + { + _logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})"); + data["blocked"] = true; + data["reason"] = "Command not on allowlist"; + data["command"] = command; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _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 = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + } + else + { + psi = new ProcessStartInfo + { + FileName = "cmd.exe", + 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; + } + + private Dictionary ExecuteFileRule(GatherRule rule) + { + var data = new Dictionary(); + var filePath = rule.Target; + + if (string.IsNullOrEmpty(filePath)) + return data; + + // Expand environment variables + filePath = Environment.ExpandEnvironmentVariables(filePath); + + // Guard: only allow enrollment-relevant file paths + if (!GatherRuleGuards.IsFilePathAllowed(filePath)) + return EmitSecurityWarning(rule, "file", filePath); + + 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; + } + + return data; + } + + private Dictionary ExecuteEventLogRule(GatherRule rule) + { + var data = new Dictionary(); + var logName = rule.Target; + + if (string.IsNullOrEmpty(logName)) + return data; + + 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}"); + } + } + } + + data["entries"] = string.Join("\n", entries); + data["entries_returned"] = entries.Count; + } + catch (Exception ex) + { + data["error"] = ex.Message; + } + + return data; + } + + private static string EscapeXPath(string value) + { + return value.Replace("'", "'"); + } + + private void ExecuteLogParserRule(GatherRule rule) + { + var filePath = rule.Target; + if (string.IsNullOrEmpty(filePath)) + return; + + filePath = Environment.ExpandEnvironmentVariables(filePath); + + // Apply IME log path override if set + if (!string.IsNullOrEmpty(_imeLogPathOverride)) + { + var fileName = Path.GetFileName(filePath); + filePath = Path.Combine(_imeLogPathOverride, fileName); + } + + // Get parameters + string patternStr; + if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) || + string.IsNullOrEmpty(patternStr)) + { + _logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter"); + return; + } + + 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); + + Regex pattern; + try + { + pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + } + catch (Exception ex) + { + _logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}"); + return; + } + + if (!File.Exists(filePath)) + { + _logger.Debug($"LogParser rule {rule.RuleId}: file not found: {filePath}"); + return; + } + + try + { + var fileInfo = new FileInfo(filePath); + long startPosition = trackPosition + ? _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++; + + CmTraceLogEntry entry; + if (!CmTraceLogParser.TryParseLine(line, out entry)) + continue; + + Match match; + try + { + match = pattern.Match(entry.Message); + } + catch (RegexMatchTimeoutException) + { + continue; + } + if (!match.Success) + continue; + + // Build data dictionary from named capture groups + var data = new Dictionary(); + foreach (var groupName in pattern.GetGroupNames()) + { + if (groupName == "0") continue; // Skip full match group + var group = match.Groups[groupName]; + if (group.Success) + data[groupName] = group.Value; + } + + // Add CMTrace metadata + data["logTimestamp"] = entry.Timestamp.ToString("o"); + data["logComponent"] = entry.Component; + data["logType"] = entry.Type; + data["logMessage"] = TruncateMessage(entry.Message, 500); + 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); + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _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) + _filePositionTracker.SetPosition(filePath, endPosition); + + if (matchCount > 0) + _logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}"); + } + catch (Exception ex) + { + _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) + "..."; + } + + /// + /// Emits a security_warning event and returns an empty result dictionary. + /// Called when a gather rule targets a path/query not on the allowlist. + /// + private 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(); + } + + private 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; + } + } + + 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/Collectors/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs index 3a0adffb..715c5e59 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs @@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors /// Executes gather rules received from the backend API /// Supports registry, eventlog, wmi, file, command_allowlisted, and logparser collector types /// - public class GatherRuleExecutor : IDisposable + public partial class GatherRuleExecutor : IDisposable { private readonly AgentLogger _logger; private readonly string _sessionId; @@ -197,696 +197,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors } } - private Dictionary ExecuteRegistryRule(GatherRule rule) - { - 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)) - return EmitSecurityWarning(rule, "registry", path); - - // Determine explicit valueName from parameters - string explicitValueName = null; - rule.Parameters?.TryGetValue("valueName", out explicitValueName); - - 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)) - { - 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; - - // Read specific value if specified in parameters - if (!string.IsNullOrEmpty(explicitValueName)) - { - 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; - } - - return data; - } - - private Dictionary ExecuteWmiRule(GatherRule rule) - { - 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)) - return 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; - } - - private Dictionary ExecuteCommandRule(GatherRule rule) - { - var data = new Dictionary(); - var command = rule.Target; - - if (string.IsNullOrEmpty(command)) - return data; - - // SECURITY: Check command against allowlist - if (!GatherRuleGuards.IsCommandAllowed(command)) - { - _logger.Warning($"SECURITY: Command blocked by allowlist: {command} (Rule: {rule.RuleId})"); - data["blocked"] = true; - data["reason"] = "Command not on allowlist"; - data["command"] = command; - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _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 = "powershell.exe", - Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}", - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - } - else - { - psi = new ProcessStartInfo - { - FileName = "cmd.exe", - 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; - } - - private Dictionary ExecuteFileRule(GatherRule rule) - { - var data = new Dictionary(); - var filePath = rule.Target; - - if (string.IsNullOrEmpty(filePath)) - return data; - - // Expand environment variables - filePath = Environment.ExpandEnvironmentVariables(filePath); - - // Guard: only allow enrollment-relevant file paths - if (!GatherRuleGuards.IsFilePathAllowed(filePath)) - return EmitSecurityWarning(rule, "file", filePath); - - 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; - } - - return data; - } - - private Dictionary ExecuteEventLogRule(GatherRule rule) - { - var data = new Dictionary(); - var logName = rule.Target; - - if (string.IsNullOrEmpty(logName)) - return data; - - 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}"); - } - } - } - - data["entries"] = string.Join("\n", entries); - data["entries_returned"] = entries.Count; - } - catch (Exception ex) - { - data["error"] = ex.Message; - } - - return data; - } - - private static string EscapeXPath(string value) - { - return value.Replace("'", "'"); - } - - private void ExecuteLogParserRule(GatherRule rule) - { - var filePath = rule.Target; - if (string.IsNullOrEmpty(filePath)) - return; - - filePath = Environment.ExpandEnvironmentVariables(filePath); - - // Apply IME log path override if set - if (!string.IsNullOrEmpty(_imeLogPathOverride)) - { - var fileName = Path.GetFileName(filePath); - filePath = Path.Combine(_imeLogPathOverride, fileName); - } - - // Get parameters - string patternStr; - if (rule.Parameters == null || !rule.Parameters.TryGetValue("pattern", out patternStr) || - string.IsNullOrEmpty(patternStr)) - { - _logger.Warning($"LogParser rule {rule.RuleId} has no 'pattern' parameter"); - return; - } - - 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); - - Regex pattern; - try - { - pattern = new Regex(patternStr, RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - } - catch (Exception ex) - { - _logger.Warning($"LogParser rule {rule.RuleId} has invalid regex: {ex.Message}"); - return; - } - - if (!File.Exists(filePath)) - { - _logger.Debug($"LogParser rule {rule.RuleId}: file not found: {filePath}"); - return; - } - - try - { - var fileInfo = new FileInfo(filePath); - long startPosition = trackPosition - ? _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++; - - CmTraceLogEntry entry; - if (!CmTraceLogParser.TryParseLine(line, out entry)) - continue; - - Match match; - try - { - match = pattern.Match(entry.Message); - } - catch (RegexMatchTimeoutException) - { - continue; - } - if (!match.Success) - continue; - - // Build data dictionary from named capture groups - var data = new Dictionary(); - foreach (var groupName in pattern.GetGroupNames()) - { - if (groupName == "0") continue; // Skip full match group - var group = match.Groups[groupName]; - if (group.Success) - data[groupName] = group.Value; - } - - // Add CMTrace metadata - data["logTimestamp"] = entry.Timestamp.ToString("o"); - data["logComponent"] = entry.Component; - data["logType"] = entry.Type; - data["logMessage"] = TruncateMessage(entry.Message, 500); - 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); - - _onEventCollected(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _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) - _filePositionTracker.SetPosition(filePath, endPosition); - - if (matchCount > 0) - _logger.Debug($"LogParser rule {rule.RuleId}: {matchCount} matches from {linesRead} lines in {Path.GetFileName(filePath)}"); - } - catch (Exception ex) - { - _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) + "..."; - } - - /// - /// Emits a security_warning event and returns an empty result dictionary. - /// Called when a gather rule targets a path/query not on the allowlist. - /// - private 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(); - } - - private 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; - } - } - - 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/Core/MonitoringService.CollectorLifecycle.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs new file mode 100644 index 00000000..9584e225 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs @@ -0,0 +1,641 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.Core.Configuration; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Analyzers; +using AutopilotMonitor.Agent.Core.Monitoring.Collectors; +using AutopilotMonitor.Agent.Core.Monitoring.Network; +using AutopilotMonitor.Agent.Core.Monitoring.Replay; +using AutopilotMonitor.Agent.Core.Monitoring.Tracking; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Core +{ + /// + /// Partial: Collector lifecycle management (start/stop optional collectors, idle timeout, + /// max lifetime timer, gather rules, analyzers, session registration). + /// + public partial class MonitoringService + { + private void StartOptionalCollectors() + { + var config = _remoteConfigService?.CurrentConfig; + var collectors = config?.Collectors; + + _logger.Info("Starting collectors based on remote config"); + + try + { + // PerformanceCollector is always on (feeds UI chart) + var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60; + _performanceCollector = new PerformanceCollector( + _configuration.SessionId, + _configuration.TenantId, + EmitEvent, + _logger, + perfInterval + ); + _performanceCollector.Start(); + + // AgentSelfMetricsCollector: measures the agent's own CPU, memory, and network footprint + if (collectors?.EnableAgentSelfMetrics != false) + { + var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60; + _agentSelfMetricsCollector = new AgentSelfMetricsCollector( + _configuration.SessionId, + _configuration.TenantId, + EmitEvent, + _apiClient.NetworkMetrics, + _logger, + _agentVersion, + selfMetricsInterval + ); + _agentSelfMetricsCollector.Start(); + } + + // Collector idle timeout — stop periodic collectors when enrollment goes quiet + _collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15; + _lastRealEventTime = DateTime.UtcNow; + _collectorsIdleStopped = false; + if (_collectorIdleTimeoutMinutes > 0) + { + _idleCheckTimer = new Timer( + IdleCheckCallback, + null, + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(60) + ); + _logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min"); + } + else + { + _logger.Info("Collector idle timeout disabled (0)"); + } + + // EnrollmentTracker: smart enrollment tracking with IME log parsing + // (replaces DownloadProgressCollector + EspUiStateCollector) + // Skip when log replay is active - LogReplayService starts its own EnrollmentTracker + 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 + ); + _enrollmentTracker.Start(); + } + + // Desktop arrival detector — detects real user desktop for no-ESP completion + _desktopArrivalDetector = new DesktopArrivalDetector(_logger); + _desktopArrivalDetector.DesktopArrived += OnDesktopArrived; + _desktopArrivalDetector.Start(); + + // Agent max lifetime safety net — prevents zombie agents + 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); + } + } + + /// + /// Stops all optional collectors + /// + private void StopOptionalCollectors() + { + _idleCheckTimer?.Dispose(); + _idleCheckTimer = null; + + _maxLifetimeTimer?.Dispose(); + _maxLifetimeTimer = null; + + if (_desktopArrivalDetector != null) + { + _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived; + _desktopArrivalDetector.Dispose(); + _desktopArrivalDetector = null; + } + + _performanceCollector?.Stop(); + _performanceCollector?.Dispose(); + _performanceCollector = null; + + _agentSelfMetricsCollector?.Stop(); + _agentSelfMetricsCollector?.Dispose(); + _agentSelfMetricsCollector = null; + + _enrollmentTracker?.Stop(); + _enrollmentTracker?.Dispose(); + _enrollmentTracker = null; + } + + /// + /// Fires when the agent max lifetime expires. Emits enrollment_failed with failureType "agent_timeout". + /// The normal shutdown path (EmitEvent → enrollment_failed) handles everything from here. + /// + private void MaxLifetimeTimerCallback(object state) + { + if (_enrollmentTerminalEventSeen) + { + _logger.Debug("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) } + } + }); + } + + /// + /// Fires when DesktopArrivalDetector detects explorer.exe under a real user. + /// Emits desktop_arrived event for the timeline and notifies EnrollmentTracker. + /// + private void OnDesktopArrived(object sender, EventArgs e) + { + if (_enrollmentTerminalEventSeen) + { + _logger.Debug("Desktop arrived but terminal event already seen — ignoring"); + return; + } + + // Emit informational event for the timeline (shows when user desktop appeared) + 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)" + }); + + // Notify EnrollmentTracker — it decides whether this triggers completion + _enrollmentTracker?.NotifyDesktopArrived(); + } + + /// + /// Returns true for events generated by periodic timers (not real enrollment activity) + /// + private static bool IsPeriodicEvent(string eventType) + { + return eventType == "performance_snapshot" || + eventType == "agent_metrics_snapshot" || + eventType == "performance_collector_stopped" || + eventType == "agent_metrics_collector_stopped"; + } + + /// + /// Periodic check (every 60s) whether enrollment has gone idle. + /// When no real event has arrived within the configured timeout window, + /// periodic collectors are stopped to prevent session bloat. + /// + private void IdleCheckCallback(object state) + { + if (_collectorsIdleStopped) + return; + + var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes; + if (idleMinutes < _collectorIdleTimeoutMinutes) + return; + + _logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors"); + + // Stop PerformanceCollector + 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) } + } + }); + } + + // Stop AgentSelfMetricsCollector + 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; + + // Stop the idle check timer — it will be restarted if collectors resume + _idleCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// + /// Restarts periodic collectors after they were stopped due to idle timeout. + /// Called when new real enrollment activity is detected. + /// + 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, + _logger, + _agentVersion, + selfMetricsInterval + ); + _agentSelfMetricsCollector.Start(); + } + + // Reset idle tracking and restart the check timer + _lastRealEventTime = DateTime.UtcNow; + _collectorsIdleStopped = false; + if (_collectorIdleTimeoutMinutes > 0) + { + _idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); + } + } + catch (Exception ex) + { + _logger.Error("Error restarting periodic collectors", ex); + } + } + + /// + /// Starts the gather rule executor with rules from remote config + /// + private 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.UpdateRules(config.GatherRules); + } + catch (Exception ex) + { + _logger.Error("Error starting gather rule executor", ex); + } + } + + /// + /// Initializes agent analyzers from remote config. + /// Called once during Start(), after all collectors are started. + /// + private void InitializeAnalyzers() + { + _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"); + } + + _logger.Info($"Analyzers initialized: {_analyzers.Count} active"); + } + + /// + /// Runs all registered analyzers at agent startup — asynchronously on the ThreadPool + /// so they do not block or delay the agent start sequence. + /// Emitted events will be picked up by the spool watcher and uploaded normally. + /// + private void RunStartupAnalyzers() + { + if (_analyzers.Count == 0) + return; + + // Capture the list snapshot — _analyzers is not modified after Start() + 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); + } + } + }); + } + + /// + /// Runs all registered analyzers at shutdown / enrollment completion. + /// Emits end-state findings for delta detection against the startup results. + /// + private void RunShutdownAnalyzers() + { + if (_analyzers.Count == 0) + return; + + _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s)"); + + foreach (var analyzer in _analyzers) + { + try + { + analyzer.AnalyzeAtShutdown(); + } + catch (Exception ex) + { + _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex); + } + } + } + + /// + /// Registers the session with the backend. Retries with exponential backoff + /// to handle network not being ready during OOBE boot (WhiteGlove Part 2). + /// + 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, + OsBuild = Environment.OSVersion.Version.ToString(), + 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}"); + return; + } + + _logger.Warning($"Session registration failed: {response.Message}"); + } + catch (BackendAuthException ex) + { + _logger.Error($"Session registration authentication failed: {ex.Message}"); + HandleAuthFailure(); + return; // Auth failure is not retryable + } + catch (Exception ex) + { + _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex); + + if (attempt == maxAttempts) + { + // Report to emergency channel on final failure + _ = _emergencyReporter.TrySendAsync( + AgentErrorType.RegisterSessionFailed, + ex.Message); + return; + } + } + + // Exponential backoff: 2s, 4s, 8s, 16s + var delaySeconds = (int)Math.Pow(2, attempt); + _logger.Info($"Retrying session registration in {delaySeconds}s"); + await Task.Delay(delaySeconds * 1000); + } + } + + /// + /// Stops the monitoring service + /// + public void Stop() + { + _logger.Info("Stopping monitoring service"); + + // Stop FileSystemWatcher + _spool.StopWatching(); + + // Stop event collectors + StopEventCollectors(); + + // Run shutdown analyzers before final event (captures end-state for delta detection) + RunShutdownAnalyzers(); + + // Emit final event + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + EventType = "agent_stopped", + Severity = EventSeverity.Info, + Source = "Agent", + Phase = _lastPhase ?? EnrollmentPhase.Unknown, + Message = "Autopilot Monitor Agent stopped" + }); + + // Final upload attempt + UploadEventsAsync().Wait(TimeSpan.FromSeconds(10)); + + _logger.Info("Monitoring service stopped"); + + // Unblock WaitForCompletion() in background/SYSTEM mode + _completionEvent.Set(); + } + + /// + /// Blocks the calling thread until the service stops (used when running as Scheduled Task + /// under SYSTEM where there is no interactive Console.ReadLine() to keep the process alive). + /// + public void WaitForCompletion() + { + _completionEvent.Wait(); + } + + /// + /// Triggers cleanup without running enrollment monitoring. + /// Used when enrollment complete marker is detected on startup (cleanup retry). + /// + 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"); + } + } + + /// + /// Stops all event collection components + /// + private void StopEventCollectors() + { + _logger.Info("Stopping event collectors"); + + try + { + _espAndHelloTracker?.Stop(); + _espAndHelloTracker?.Dispose(); + + _logReplay?.Stop(); + _logReplay?.Dispose(); + + // Stop optional collectors + StopOptionalCollectors(); + + // Stop gather rule executor + _gatherRuleExecutor?.Dispose(); + _gatherRuleExecutor = null; + + _logger.Info("Event collectors stopped"); + } + catch (Exception ex) + { + _logger.Error("Error stopping event collectors", ex); + } + } + + /// + /// Emits an event (adds to spool) + /// + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs new file mode 100644 index 00000000..22076c96 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs @@ -0,0 +1,565 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.Core.Configuration; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Collectors; +using AutopilotMonitor.Agent.Core.Monitoring.Network; +using AutopilotMonitor.Agent.Core.Monitoring.Tracking; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Core +{ + /// + /// Partial: Event emission, upload orchestration, auth failure handling, + /// enrollment/WhiteGlove completion, and diagnostics upload. + /// + public partial class MonitoringService + { + public void EmitEvent(EnrollmentEvent evt) + { + evt.Sequence = Interlocked.Increment(ref _eventSequence); + _spool.Add(evt); + _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}"); + + // Track real enrollment activity for idle timeout + if (!string.IsNullOrEmpty(evt.EventType) && !IsPeriodicEvent(evt.EventType)) + { + _lastRealEventTime = DateTime.UtcNow; + + // Restart periodic collectors if they were stopped due to idle + if (_collectorsIdleStopped) + { + RestartPeriodicCollectors(); + } + } + + // 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; + _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite); + } + + // Check if this is a phase transition + bool isPhaseTransition = false; + if (evt.Phase != _lastPhase) + { + _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}"); + _lastPhase = evt.Phase; + isPhaseTransition = true; + + // Notify gather rule executor of phase change + try { _gatherRuleExecutor?.OnPhaseChanged(evt.Phase); } catch { } + } + + // Notify gather rule executor of event type (for on_event triggers) + if (!string.IsNullOrEmpty(evt.EventType)) + { + try { _gatherRuleExecutor?.OnEvent(evt.EventType); } catch { } + } + + // 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."); + + // Stop performance collection immediately (no value while device is off) + _performanceCollector?.Stop(); + _performanceCollector?.Dispose(); + _performanceCollector = null; + + // Stop all event collectors and the spool watcher — no new events after this point + StopEventCollectors(); + _spool.StopWatching(); + + // Stop upload timers — we drive uploads manually from here + _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + Task.Run(() => HandleWhiteGloveComplete()); + + return; // Don't continue with normal event processing + } + + // 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}"); + + // Delete session ID so a new session will be created on next enrollment + DeleteSessionId(); + + // Stop ALL collectors to minimize system impact + _logger.Info("Stopping all collectors after enrollment completion..."); + StopEventCollectors(); + + // Stop upload timers - no more periodic uploads needed + _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + // Final upload to flush any remaining events + Task.Run(async () => + { + try + { + await UploadEventsAsync(); + _logger.Info("Final event upload after enrollment completion done"); + } + catch (Exception ex) + { + _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}"); + } + }); + + // Trigger self-destruct or reboot if configured + if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete) + { + Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded)); + return; // Don't continue with normal event processing + } + + // No self-destruct/reboot — still upload diagnostics if configured + Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded)); + } + + // Immediate upload for: + // 1. Critical events (errors) - for troubleshooting + // 2. Phase transitions (start/end) - for real-time phase tracking in UI + // 3. Events with "phase" in EventType - explicit phase-related events + // 4. App download/install events - for real-time download progress UI updates + var isAppEvent = evt.EventType?.StartsWith("app_", StringComparison.OrdinalIgnoreCase) == true; + + if (evt.Severity >= EventSeverity.Error || + isPhaseTransition || + evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true || + isAppEvent) + { + _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)"); + Task.Run(() => UploadEventsAsync()); + } + } + + /// + /// Called when new events are available in the spool (FileSystemWatcher detected new files) + /// Uses debouncing to batch events before uploading + /// + private void OnEventsAvailable(object sender, EventArgs e) + { + _logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer"); + + // Reset debounce timer - wait for batch window before uploading + // This allows multiple events to accumulate, reducing API calls + lock (_timerLock) + { + _debounceTimer.Change( + TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds), + Timeout.InfiniteTimeSpan + ); + } + } + + /// + /// Called when debounce timer expires - uploads batched events + /// + private void DebounceTimerCallback(object state) + { + _logger.Debug("Debounce timer expired, uploading batched events"); + Task.Run(() => UploadEventsAsync()); + } + + /// + /// Fallback timer callback in case FileSystemWatcher misses events + /// + private void UploadTimerCallback(object state) + { + Task.Run(() => UploadEventsAsync()); + } + + private async Task UploadEventsAsync() + { + // Prevent concurrent uploads — if one is already in flight, skip this call. + // The events will be picked up by the next trigger or debounce timer. + if (!_uploadSemaphore.Wait(0)) + 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); + + if (response.DeviceKillSignal) + { + _logger.Warning("=== REMOTE KILL SIGNAL received from administrator. Initiating self-destruct... ==="); + + // Stop all timers — no further uploads + _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + // Force self-destruct regardless of current config, respecting KeepLogFile from tenant config + _configuration.SelfDestructOnComplete = true; + _cleanupService.ExecuteSelfDestruct(); + Environment.Exit(0); + return; + } + + 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. ==="); + + // Stop upload timers — no further uploads for the duration of this run. + // The block is temporary; the next agent run (after reboot / re-enrollment) will check again. + _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); + return; + } + + if (response.Success) + { + _spool.RemoveEvents(events); + _logger.Info($"Uploaded {response.EventsProcessed} events"); + + // Reset failure counters on success + _consecutiveAuthFailures = 0; + _firstAuthFailureTime = null; + _consecutiveUploadFailures = 0; + } + else + { + _logger.Warning($"Upload failed: {response.Message}"); + } + } + catch (BackendAuthException ex) + { + _logger.Error($"Upload authentication failed: {ex.Message}"); + HandleAuthFailure(); + } + catch (Exception ex) + { + _consecutiveUploadFailures++; + _logger.Error("Error uploading events", ex); + + // After N consecutive non-auth failures, signal the emergency channel once. + // The EmergencyReporter deduplicates further calls for the same error category. + if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold) + { + _ = _emergencyReporter.TrySendAsync( + AgentErrorType.IngestFailed, + ex.Message, + httpStatusCode: null, + sequenceNumber: _eventSequence); + } + } + finally + { + _uploadSemaphore.Release(); + } + } + + /// + /// Handles an authentication failure by incrementing the counter and shutting down + /// the agent if the configured threshold is reached. + /// + private void HandleAuthFailure() + { + _consecutiveAuthFailures++; + + if (_firstAuthFailureTime == null) + _firstAuthFailureTime = DateTime.UtcNow; + + _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" + + (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") + + $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})"); + + // Check max attempts (0 = disabled) + 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. ==="); + Environment.Exit(1); + } + + // Check timeout (0 = disabled) + 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. ==="); + Environment.Exit(1); + } + } + } + + /// + /// Deletes the persisted session ID and sequence files. + /// Should be called when enrollment is complete/failed. + /// + private void DeleteSessionId() + { + _logger.Info("Deleting persisted session ID and sequence..."); + try + { + _sessionPersistence.DeleteSession(); // Deletes session.id AND session.seq + _logger.Info("Session ID and sequence deleted successfully"); + } + catch (Exception ex) + { + _logger.Warning($"Failed to delete session ID: {ex.Message}"); + } + } + + /// + /// Uploads diagnostics package with timeline events (start + result). + /// Emits events, uploads them, then performs the actual diagnostics upload. + /// + 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; + + // Emit "collecting" event + 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(); + + // Perform the actual ZIP + upload + var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix); + var success = uploadResult?.Success == true; + + // Emit result event (includes blobName so backend can store it on the session) + var resultData = new Dictionary + { + { "uploadMode", mode } + }; + if (uploadResult?.BlobName != null) + resultData["blobName"] = uploadResult.BlobName; + if (uploadResult?.SasUrlPrefix != null) + resultData["sasUrl"] = uploadResult.SasUrlPrefix; + if (uploadResult?.ErrorCode != null) + resultData["errorCode"] = uploadResult.ErrorCode; + + EmitEvent(new EnrollmentEvent + { + SessionId = _configuration.SessionId, + TenantId = _configuration.TenantId, + Timestamp = DateTime.UtcNow, + EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed", + Severity = success ? EventSeverity.Info : EventSeverity.Warning, + Source = "MonitoringService", + Phase = EnrollmentPhase.Complete, + Message = success + ? $"Diagnostics package uploaded: {uploadResult.BlobName}" + : $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}", + Data = resultData + }); + await UploadEventsAsync(); + } + + /// + /// Handles enrollment completion and triggers self-destruct sequence + /// + private async Task HandleEnrollmentComplete(bool enrollmentSucceeded) + { + try + { + _logger.Info("===== ENROLLMENT COMPLETE - Starting Self-Destruct Sequence ====="); + + // Step 1: Stop all event collectors + _logger.Info("Stopping event collectors..."); + StopEventCollectors(); + _spool.StopWatching(); + + // Step 1.5: Run shutdown analyzers to capture end-state (delta from startup) + RunShutdownAnalyzers(); + + // Step 2: Upload all remaining events (includes analyzer findings) + _logger.Info("Uploading final events..."); + await UploadEventsAsync(); + + // Give a moment for final upload to complete + await Task.Delay(2000); + + // Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot) + await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded); + + // Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only + if (_configuration.SelfDestructOnComplete) + { + _cleanupService.ExecuteSelfDestruct(); + } + else if (_configuration.RebootOnComplete) + { + _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s"); + + // Emit reboot event into the timeline before shutdown + 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 } + } + }); + + // Final upload to ensure the reboot event is sent + await UploadEventsAsync(); + + var psi = new ProcessStartInfo + { + FileName = "shutdown.exe", + Arguments = $"/r /t {_configuration.RebootDelaySeconds} /c \"Autopilot enrollment completed - rebooting\"", + UseShellExecute = false, + CreateNoWindow = true + }; + Process.Start(psi); + } + + _logger.Info("Self-destruct sequence initiated. Agent will now exit."); + + // Exit the application + Environment.Exit(0); + } + catch (Exception ex) + { + _logger.Error("Error during self-destruct sequence", ex); + Environment.Exit(1); + } + } + + /// + /// Graceful shutdown sequence after WhiteGlove (Pre-Provisioning) completes. + /// Mirrors HandleEnrollmentComplete but does NOT delete the session ID or write the + /// enrollment-complete marker — the session must survive for Part 2 (user enrollment). + /// Uploads a "preprov" diagnostics package if diagnostics are configured. + /// + private async Task HandleWhiteGloveComplete() + { + try + { + _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence ====="); + + // Step 1: Drain the entire spool — whiteglove_complete may be beyond the first + // MaxBatchSize (100) events, so loop until empty. + _logger.Info("Uploading final events (draining spool)..."); + int maxIterations = 20; // safety cap against persistent upload failures + while (_spool.GetCount() > 0 && maxIterations-- > 0) + { + await UploadEventsAsync(); + } + + // Step 2: Small delay to ensure final network transmission completes + await Task.Delay(2000); + + // Step 3: Upload pre-provisioning diagnostics package if configured. + // Pass enrollmentSucceeded=true (pre-prov succeeded) and suffix "preprov" + // so the blob is named AgentDiagnostics-{sessionId}-{ts}-preprov.zip, + // distinguishable from the later full-enrollment package. + await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov"); + + // Step 4: Persist final sequence for Boot 2 continuation + _sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence)); + _logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot"); + + // Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2) + // can signal the backend to transition Pending → InProgress. + _sessionPersistence.SaveWhiteGloveComplete(); + _logger.Info("WhiteGlove marker persisted for Part 2 detection"); + + // Step 6: Exit — session.id + session.seq + whiteglove.complete survive so + // the agent resumes on next boot. + // No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct. + // The Windows Autopilot shutdown will power off the device; + // the Scheduled Task restarts the agent on the user's first boot. + _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting."); + Environment.Exit(0); + } + catch (Exception ex) + { + _logger.Error("Error during WhiteGlove shutdown sequence", ex); + Environment.Exit(0); // Still exit — don't leave a zombie process + } + } + + public void Dispose() + { + _uploadTimer?.Dispose(); + _debounceTimer?.Dispose(); + _idleCheckTimer?.Dispose(); + _apiClient?.Dispose(); + _spool?.Dispose(); + _espAndHelloTracker?.Dispose(); + _logReplay?.Dispose(); + _performanceCollector?.Dispose(); + _agentSelfMetricsCollector?.Dispose(); + _enrollmentTracker?.Dispose(); + _gatherRuleExecutor?.Dispose(); + _remoteConfigService?.Dispose(); + _completionEvent.Dispose(); + } + } +} + diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 7f02a0bf..383cf11d 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -19,7 +19,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core /// /// Main monitoring service that collects and uploads telemetry /// - public class MonitoringService : IDisposable + public partial class MonitoringService : IDisposable { private readonly AgentConfiguration _configuration; private readonly AgentLogger _logger; @@ -339,1158 +339,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core /// /// Starts optional collectors based on remote configuration /// - private void StartOptionalCollectors() - { - var config = _remoteConfigService?.CurrentConfig; - var collectors = config?.Collectors; - - _logger.Info("Starting collectors based on remote config"); - - try - { - // PerformanceCollector is always on (feeds UI chart) - var perfInterval = collectors?.PerformanceIntervalSeconds ?? 60; - _performanceCollector = new PerformanceCollector( - _configuration.SessionId, - _configuration.TenantId, - EmitEvent, - _logger, - perfInterval - ); - _performanceCollector.Start(); - - // AgentSelfMetricsCollector: measures the agent's own CPU, memory, and network footprint - if (collectors?.EnableAgentSelfMetrics != false) - { - var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60; - _agentSelfMetricsCollector = new AgentSelfMetricsCollector( - _configuration.SessionId, - _configuration.TenantId, - EmitEvent, - _apiClient.NetworkMetrics, - _logger, - _agentVersion, - selfMetricsInterval - ); - _agentSelfMetricsCollector.Start(); - } - - // Collector idle timeout — stop periodic collectors when enrollment goes quiet - _collectorIdleTimeoutMinutes = collectors?.CollectorIdleTimeoutMinutes ?? 15; - _lastRealEventTime = DateTime.UtcNow; - _collectorsIdleStopped = false; - if (_collectorIdleTimeoutMinutes > 0) - { - _idleCheckTimer = new Timer( - IdleCheckCallback, - null, - TimeSpan.FromSeconds(60), - TimeSpan.FromSeconds(60) - ); - _logger.Info($"Collector idle timeout enabled: {_collectorIdleTimeoutMinutes} min"); - } - else - { - _logger.Info("Collector idle timeout disabled (0)"); - } - - // EnrollmentTracker: smart enrollment tracking with IME log parsing - // (replaces DownloadProgressCollector + EspUiStateCollector) - // Skip when log replay is active - LogReplayService starts its own EnrollmentTracker - 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 - ); - _enrollmentTracker.Start(); - } - - // Desktop arrival detector — detects real user desktop for no-ESP completion - _desktopArrivalDetector = new DesktopArrivalDetector(_logger); - _desktopArrivalDetector.DesktopArrived += OnDesktopArrived; - _desktopArrivalDetector.Start(); - - // Agent max lifetime safety net — prevents zombie agents - 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); - } - } - - /// - /// Stops all optional collectors - /// - private void StopOptionalCollectors() - { - _idleCheckTimer?.Dispose(); - _idleCheckTimer = null; - - _maxLifetimeTimer?.Dispose(); - _maxLifetimeTimer = null; - - if (_desktopArrivalDetector != null) - { - _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived; - _desktopArrivalDetector.Dispose(); - _desktopArrivalDetector = null; - } - - _performanceCollector?.Stop(); - _performanceCollector?.Dispose(); - _performanceCollector = null; - - _agentSelfMetricsCollector?.Stop(); - _agentSelfMetricsCollector?.Dispose(); - _agentSelfMetricsCollector = null; - - _enrollmentTracker?.Stop(); - _enrollmentTracker?.Dispose(); - _enrollmentTracker = null; - } - - /// - /// Fires when the agent max lifetime expires. Emits enrollment_failed with failureType "agent_timeout". - /// The normal shutdown path (EmitEvent → enrollment_failed) handles everything from here. - /// - private void MaxLifetimeTimerCallback(object state) - { - if (_enrollmentTerminalEventSeen) - { - _logger.Debug("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) } - } - }); - } - - /// - /// Fires when DesktopArrivalDetector detects explorer.exe under a real user. - /// Emits desktop_arrived event for the timeline and notifies EnrollmentTracker. - /// - private void OnDesktopArrived(object sender, EventArgs e) - { - if (_enrollmentTerminalEventSeen) - { - _logger.Debug("Desktop arrived but terminal event already seen — ignoring"); - return; - } - - // Emit informational event for the timeline (shows when user desktop appeared) - 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)" - }); - - // Notify EnrollmentTracker — it decides whether this triggers completion - _enrollmentTracker?.NotifyDesktopArrived(); - } - - /// - /// Returns true for events generated by periodic timers (not real enrollment activity) - /// - private static bool IsPeriodicEvent(string eventType) - { - return eventType == "performance_snapshot" || - eventType == "agent_metrics_snapshot" || - eventType == "performance_collector_stopped" || - eventType == "agent_metrics_collector_stopped"; - } - - /// - /// Periodic check (every 60s) whether enrollment has gone idle. - /// When no real event has arrived within the configured timeout window, - /// periodic collectors are stopped to prevent session bloat. - /// - private void IdleCheckCallback(object state) - { - if (_collectorsIdleStopped) - return; - - var idleMinutes = (DateTime.UtcNow - _lastRealEventTime).TotalMinutes; - if (idleMinutes < _collectorIdleTimeoutMinutes) - return; - - _logger.Info($"Collector idle timeout reached ({_collectorIdleTimeoutMinutes} min, idle for {idleMinutes:F0} min) — stopping periodic collectors"); - - // Stop PerformanceCollector - 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) } - } - }); - } - - // Stop AgentSelfMetricsCollector - 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; - - // Stop the idle check timer — it will be restarted if collectors resume - _idleCheckTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - - /// - /// Restarts periodic collectors after they were stopped due to idle timeout. - /// Called when new real enrollment activity is detected. - /// - 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, - _logger, - _agentVersion, - selfMetricsInterval - ); - _agentSelfMetricsCollector.Start(); - } - - // Reset idle tracking and restart the check timer - _lastRealEventTime = DateTime.UtcNow; - _collectorsIdleStopped = false; - if (_collectorIdleTimeoutMinutes > 0) - { - _idleCheckTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); - } - } - catch (Exception ex) - { - _logger.Error("Error restarting periodic collectors", ex); - } - } - - /// - /// Starts the gather rule executor with rules from remote config - /// - private 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.UpdateRules(config.GatherRules); - } - catch (Exception ex) - { - _logger.Error("Error starting gather rule executor", ex); - } - } - - /// - /// Initializes agent analyzers from remote config. - /// Called once during Start(), after all collectors are started. - /// - private void InitializeAnalyzers() - { - _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"); - } - - _logger.Info($"Analyzers initialized: {_analyzers.Count} active"); - } - - /// - /// Runs all registered analyzers at agent startup — asynchronously on the ThreadPool - /// so they do not block or delay the agent start sequence. - /// Emitted events will be picked up by the spool watcher and uploaded normally. - /// - private void RunStartupAnalyzers() - { - if (_analyzers.Count == 0) - return; - - // Capture the list snapshot — _analyzers is not modified after Start() - 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); - } - } - }); - } - - /// - /// Runs all registered analyzers at shutdown / enrollment completion. - /// Emits end-state findings for delta detection against the startup results. - /// - private void RunShutdownAnalyzers() - { - if (_analyzers.Count == 0) - return; - - _logger.Info($"Running {_analyzers.Count} shutdown analyzer(s)"); - - foreach (var analyzer in _analyzers) - { - try - { - analyzer.AnalyzeAtShutdown(); - } - catch (Exception ex) - { - _logger.Error($"Analyzer {analyzer.Name} threw during shutdown", ex); - } - } - } - - /// - /// Registers the session with the backend. Retries with exponential backoff - /// to handle network not being ready during OOBE boot (WhiteGlove Part 2). - /// - 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, - OsBuild = Environment.OSVersion.Version.ToString(), - 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}"); - return; - } - - _logger.Warning($"Session registration failed: {response.Message}"); - } - catch (BackendAuthException ex) - { - _logger.Error($"Session registration authentication failed: {ex.Message}"); - HandleAuthFailure(); - return; // Auth failure is not retryable - } - catch (Exception ex) - { - _logger.Error($"Failed to register session (attempt {attempt}/{maxAttempts})", ex); - - if (attempt == maxAttempts) - { - // Report to emergency channel on final failure - _ = _emergencyReporter.TrySendAsync( - AgentErrorType.RegisterSessionFailed, - ex.Message); - return; - } - } - - // Exponential backoff: 2s, 4s, 8s, 16s - var delaySeconds = (int)Math.Pow(2, attempt); - _logger.Info($"Retrying session registration in {delaySeconds}s"); - await Task.Delay(delaySeconds * 1000); - } - } - - /// - /// Stops the monitoring service - /// - public void Stop() - { - _logger.Info("Stopping monitoring service"); - - // Stop FileSystemWatcher - _spool.StopWatching(); - - // Stop event collectors - StopEventCollectors(); - - // Run shutdown analyzers before final event (captures end-state for delta detection) - RunShutdownAnalyzers(); - - // Emit final event - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - EventType = "agent_stopped", - Severity = EventSeverity.Info, - Source = "Agent", - Phase = _lastPhase ?? EnrollmentPhase.Unknown, - Message = "Autopilot Monitor Agent stopped" - }); - - // Final upload attempt - UploadEventsAsync().Wait(TimeSpan.FromSeconds(10)); - - _logger.Info("Monitoring service stopped"); - - // Unblock WaitForCompletion() in background/SYSTEM mode - _completionEvent.Set(); - } - - /// - /// Blocks the calling thread until the service stops (used when running as Scheduled Task - /// under SYSTEM where there is no interactive Console.ReadLine() to keep the process alive). - /// - public void WaitForCompletion() - { - _completionEvent.Wait(); - } - - /// - /// Triggers cleanup without running enrollment monitoring. - /// Used when enrollment complete marker is detected on startup (cleanup retry). - /// - 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"); - } - } - - /// - /// Stops all event collection components - /// - private void StopEventCollectors() - { - _logger.Info("Stopping event collectors"); - - try - { - _espAndHelloTracker?.Stop(); - _espAndHelloTracker?.Dispose(); - - _logReplay?.Stop(); - _logReplay?.Dispose(); - - // Stop optional collectors - StopOptionalCollectors(); - - // Stop gather rule executor - _gatherRuleExecutor?.Dispose(); - _gatherRuleExecutor = null; - - _logger.Info("Event collectors stopped"); - } - catch (Exception ex) - { - _logger.Error("Error stopping event collectors", ex); - } - } - - /// - /// Emits an event (adds to spool) - /// - public void EmitEvent(EnrollmentEvent evt) - { - evt.Sequence = Interlocked.Increment(ref _eventSequence); - _spool.Add(evt); - _logger.Info($"Event emitted: {evt.EventType} - {evt.Message}"); - - // Track real enrollment activity for idle timeout - if (!string.IsNullOrEmpty(evt.EventType) && !IsPeriodicEvent(evt.EventType)) - { - _lastRealEventTime = DateTime.UtcNow; - - // Restart periodic collectors if they were stopped due to idle - if (_collectorsIdleStopped) - { - RestartPeriodicCollectors(); - } - } - - // 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; - _maxLifetimeTimer?.Change(Timeout.Infinite, Timeout.Infinite); - } - - // Check if this is a phase transition - bool isPhaseTransition = false; - if (evt.Phase != _lastPhase) - { - _logger.Info($"Phase transition: {_lastPhase?.ToString() ?? "null"} -> {evt.Phase}"); - _lastPhase = evt.Phase; - isPhaseTransition = true; - - // Notify gather rule executor of phase change - try { _gatherRuleExecutor?.OnPhaseChanged(evt.Phase); } catch { } - } - - // Notify gather rule executor of event type (for on_event triggers) - if (!string.IsNullOrEmpty(evt.EventType)) - { - try { _gatherRuleExecutor?.OnEvent(evt.EventType); } catch { } - } - - // 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."); - - // Stop performance collection immediately (no value while device is off) - _performanceCollector?.Stop(); - _performanceCollector?.Dispose(); - _performanceCollector = null; - - // Stop all event collectors and the spool watcher — no new events after this point - StopEventCollectors(); - _spool.StopWatching(); - - // Stop upload timers — we drive uploads manually from here - _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); - - Task.Run(() => HandleWhiteGloveComplete()); - - return; // Don't continue with normal event processing - } - - // 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}"); - - // Delete session ID so a new session will be created on next enrollment - DeleteSessionId(); - - // Stop ALL collectors to minimize system impact - _logger.Info("Stopping all collectors after enrollment completion..."); - StopEventCollectors(); - - // Stop upload timers - no more periodic uploads needed - _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); - - // Final upload to flush any remaining events - Task.Run(async () => - { - try - { - await UploadEventsAsync(); - _logger.Info("Final event upload after enrollment completion done"); - } - catch (Exception ex) - { - _logger.Warning($"Final upload after enrollment completion failed: {ex.Message}"); - } - }); - - // Trigger self-destruct or reboot if configured - if (_configuration.SelfDestructOnComplete || _configuration.RebootOnComplete) - { - Task.Run(() => HandleEnrollmentComplete(enrollmentSucceeded)); - return; // Don't continue with normal event processing - } - - // No self-destruct/reboot — still upload diagnostics if configured - Task.Run(() => UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded)); - } - - // Immediate upload for: - // 1. Critical events (errors) - for troubleshooting - // 2. Phase transitions (start/end) - for real-time phase tracking in UI - // 3. Events with "phase" in EventType - explicit phase-related events - // 4. App download/install events - for real-time download progress UI updates - var isAppEvent = evt.EventType?.StartsWith("app_", StringComparison.OrdinalIgnoreCase) == true; - - if (evt.Severity >= EventSeverity.Error || - isPhaseTransition || - evt.EventType?.Contains("phase", StringComparison.OrdinalIgnoreCase) == true || - isAppEvent) - { - _logger.Info($"Triggering immediate upload for {evt.EventType} (bypassing debounce)"); - Task.Run(() => UploadEventsAsync()); - } - } - - /// - /// Called when new events are available in the spool (FileSystemWatcher detected new files) - /// Uses debouncing to batch events before uploading - /// - private void OnEventsAvailable(object sender, EventArgs e) - { - _logger.Debug("FileSystemWatcher detected new events, starting/resetting debounce timer"); - - // Reset debounce timer - wait for batch window before uploading - // This allows multiple events to accumulate, reducing API calls - lock (_timerLock) - { - _debounceTimer.Change( - TimeSpan.FromSeconds(_configuration.UploadIntervalSeconds), - Timeout.InfiniteTimeSpan - ); - } - } - - /// - /// Called when debounce timer expires - uploads batched events - /// - private void DebounceTimerCallback(object state) - { - _logger.Debug("Debounce timer expired, uploading batched events"); - Task.Run(() => UploadEventsAsync()); - } - - /// - /// Fallback timer callback in case FileSystemWatcher misses events - /// - private void UploadTimerCallback(object state) - { - Task.Run(() => UploadEventsAsync()); - } - - private async Task UploadEventsAsync() - { - // Prevent concurrent uploads — if one is already in flight, skip this call. - // The events will be picked up by the next trigger or debounce timer. - if (!_uploadSemaphore.Wait(0)) - 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); - - if (response.DeviceKillSignal) - { - _logger.Warning("=== REMOTE KILL SIGNAL received from administrator. Initiating self-destruct... ==="); - - // Stop all timers — no further uploads - _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); - - // Force self-destruct regardless of current config, respecting KeepLogFile from tenant config - _configuration.SelfDestructOnComplete = true; - _cleanupService.ExecuteSelfDestruct(); - Environment.Exit(0); - return; - } - - 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. ==="); - - // Stop upload timers — no further uploads for the duration of this run. - // The block is temporary; the next agent run (after reboot / re-enrollment) will check again. - _uploadTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debounceTimer?.Change(Timeout.Infinite, Timeout.Infinite); - return; - } - - if (response.Success) - { - _spool.RemoveEvents(events); - _logger.Info($"Uploaded {response.EventsProcessed} events"); - - // Reset failure counters on success - _consecutiveAuthFailures = 0; - _firstAuthFailureTime = null; - _consecutiveUploadFailures = 0; - } - else - { - _logger.Warning($"Upload failed: {response.Message}"); - } - } - catch (BackendAuthException ex) - { - _logger.Error($"Upload authentication failed: {ex.Message}"); - HandleAuthFailure(); - } - catch (Exception ex) - { - _consecutiveUploadFailures++; - _logger.Error("Error uploading events", ex); - - // After N consecutive non-auth failures, signal the emergency channel once. - // The EmergencyReporter deduplicates further calls for the same error category. - if (_consecutiveUploadFailures == EmergencyReporter.ConsecutiveFailureThreshold) - { - _ = _emergencyReporter.TrySendAsync( - AgentErrorType.IngestFailed, - ex.Message, - httpStatusCode: null, - sequenceNumber: _eventSequence); - } - } - finally - { - _uploadSemaphore.Release(); - } - } - - /// - /// Handles an authentication failure by incrementing the counter and shutting down - /// the agent if the configured threshold is reached. - /// - private void HandleAuthFailure() - { - _consecutiveAuthFailures++; - - if (_firstAuthFailureTime == null) - _firstAuthFailureTime = DateTime.UtcNow; - - _logger.Warning($"Authentication failure {_consecutiveAuthFailures}" + - (_configuration.MaxAuthFailures > 0 ? $"/{_configuration.MaxAuthFailures}" : "") + - $" (first failure at {_firstAuthFailureTime.Value:HH:mm:ss})"); - - // Check max attempts (0 = disabled) - 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. ==="); - Environment.Exit(1); - } - - // Check timeout (0 = disabled) - 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. ==="); - Environment.Exit(1); - } - } - } - - /// - /// Deletes the persisted session ID and sequence files. - /// Should be called when enrollment is complete/failed. - /// - private void DeleteSessionId() - { - _logger.Info("Deleting persisted session ID and sequence..."); - try - { - _sessionPersistence.DeleteSession(); // Deletes session.id AND session.seq - _logger.Info("Session ID and sequence deleted successfully"); - } - catch (Exception ex) - { - _logger.Warning($"Failed to delete session ID: {ex.Message}"); - } - } - - /// - /// Uploads diagnostics package with timeline events (start + result). - /// Emits events, uploads them, then performs the actual diagnostics upload. - /// - 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; - - // Emit "collecting" event - 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(); - - // Perform the actual ZIP + upload - var uploadResult = await _diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded, fileNameSuffix); - var success = uploadResult?.Success == true; - - // Emit result event (includes blobName so backend can store it on the session) - var resultData = new Dictionary - { - { "uploadMode", mode } - }; - if (uploadResult?.BlobName != null) - resultData["blobName"] = uploadResult.BlobName; - if (uploadResult?.SasUrlPrefix != null) - resultData["sasUrl"] = uploadResult.SasUrlPrefix; - if (uploadResult?.ErrorCode != null) - resultData["errorCode"] = uploadResult.ErrorCode; - - EmitEvent(new EnrollmentEvent - { - SessionId = _configuration.SessionId, - TenantId = _configuration.TenantId, - Timestamp = DateTime.UtcNow, - EventType = success ? "diagnostics_uploaded" : "diagnostics_upload_failed", - Severity = success ? EventSeverity.Info : EventSeverity.Warning, - Source = "MonitoringService", - Phase = EnrollmentPhase.Complete, - Message = success - ? $"Diagnostics package uploaded: {uploadResult.BlobName}" - : $"Diagnostics package upload failed{(uploadResult?.ErrorCode != null ? $": {uploadResult.ErrorCode}" : "")}", - Data = resultData - }); - await UploadEventsAsync(); - } - - /// - /// Handles enrollment completion and triggers self-destruct sequence - /// - private async Task HandleEnrollmentComplete(bool enrollmentSucceeded) - { - try - { - _logger.Info("===== ENROLLMENT COMPLETE - Starting Self-Destruct Sequence ====="); - - // Step 1: Stop all event collectors - _logger.Info("Stopping event collectors..."); - StopEventCollectors(); - _spool.StopWatching(); - - // Step 1.5: Run shutdown analyzers to capture end-state (delta from startup) - RunShutdownAnalyzers(); - - // Step 2: Upload all remaining events (includes analyzer findings) - _logger.Info("Uploading final events..."); - await UploadEventsAsync(); - - // Give a moment for final upload to complete - await Task.Delay(2000); - - // Step 2.5: Upload diagnostics package (must complete before self-destruct/reboot) - await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded); - - // Step 3: Self-destruct (removes Scheduled Task + files) or reboot-only - if (_configuration.SelfDestructOnComplete) - { - _cleanupService.ExecuteSelfDestruct(); - } - else if (_configuration.RebootOnComplete) - { - _logger.Info($"RebootOnComplete enabled - initiating reboot in {_configuration.RebootDelaySeconds}s"); - - // Emit reboot event into the timeline before shutdown - 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 } - } - }); - - // Final upload to ensure the reboot event is sent - await UploadEventsAsync(); - - var psi = new ProcessStartInfo - { - FileName = "shutdown.exe", - Arguments = $"/r /t {_configuration.RebootDelaySeconds} /c \"Autopilot enrollment completed - rebooting\"", - UseShellExecute = false, - CreateNoWindow = true - }; - Process.Start(psi); - } - - _logger.Info("Self-destruct sequence initiated. Agent will now exit."); - - // Exit the application - Environment.Exit(0); - } - catch (Exception ex) - { - _logger.Error("Error during self-destruct sequence", ex); - Environment.Exit(1); - } - } - - /// - /// Graceful shutdown sequence after WhiteGlove (Pre-Provisioning) completes. - /// Mirrors HandleEnrollmentComplete but does NOT delete the session ID or write the - /// enrollment-complete marker — the session must survive for Part 2 (user enrollment). - /// Uploads a "preprov" diagnostics package if diagnostics are configured. - /// - private async Task HandleWhiteGloveComplete() - { - try - { - _logger.Info("===== WHITEGLOVE COMPLETE - Starting graceful shutdown sequence ====="); - - // Step 1: Drain the entire spool — whiteglove_complete may be beyond the first - // MaxBatchSize (100) events, so loop until empty. - _logger.Info("Uploading final events (draining spool)..."); - int maxIterations = 20; // safety cap against persistent upload failures - while (_spool.GetCount() > 0 && maxIterations-- > 0) - { - await UploadEventsAsync(); - } - - // Step 2: Small delay to ensure final network transmission completes - await Task.Delay(2000); - - // Step 3: Upload pre-provisioning diagnostics package if configured. - // Pass enrollmentSucceeded=true (pre-prov succeeded) and suffix "preprov" - // so the blob is named AgentDiagnostics-{sessionId}-{ts}-preprov.zip, - // distinguishable from the later full-enrollment package. - await UploadDiagnosticsWithTimelineEvents(enrollmentSucceeded: true, fileNameSuffix: "preprov"); - - // Step 4: Persist final sequence for Boot 2 continuation - _sessionPersistence.SaveSequence(Interlocked.Read(ref _eventSequence)); - _logger.Info($"Sequence counter persisted at {Interlocked.Read(ref _eventSequence)} for next boot"); - - // Step 5: Mark WhiteGlove Part 1 as complete so the next boot (Part 2) - // can signal the backend to transition Pending → InProgress. - _sessionPersistence.SaveWhiteGloveComplete(); - _logger.Info("WhiteGlove marker persisted for Part 2 detection"); - - // Step 6: Exit — session.id + session.seq + whiteglove.complete survive so - // the agent resumes on next boot. - // No DeleteSessionId, no WriteEnrollmentCompleteMarker, no self-destruct. - // The Windows Autopilot shutdown will power off the device; - // the Scheduled Task restarts the agent on the user's first boot. - _logger.Info("WhiteGlove graceful shutdown complete. Agent exiting."); - Environment.Exit(0); - } - catch (Exception ex) - { - _logger.Error("Error during WhiteGlove shutdown sequence", ex); - Environment.Exit(0); // Still exit — don't leave a zombie process - } - } - - public void Dispose() - { - _uploadTimer?.Dispose(); - _debounceTimer?.Dispose(); - _idleCheckTimer?.Dispose(); - _apiClient?.Dispose(); - _spool?.Dispose(); - _espAndHelloTracker?.Dispose(); - _logReplay?.Dispose(); - _performanceCollector?.Dispose(); - _agentSelfMetricsCollector?.Dispose(); - _enrollmentTracker?.Dispose(); - _gatherRuleExecutor?.Dispose(); - _remoteConfigService?.Dispose(); - _completionEvent.Dispose(); - } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs new file mode 100644 index 00000000..5fe59395 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs @@ -0,0 +1,589 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// 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"); + _stateData.HelloResolvedUtc = DateTime.UtcNow; + RecordSignal("hello_resolved"); + + // Cancel safety timer — Hello resolved normally + _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); + + if (_isWaitingForHello) + { + _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now"); + _isWaitingForHello = false; + TryEmitEnrollmentComplete("ime_hello"); + } + else if (_espFinalExitSeen) + { + _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion"); + TryEmitEnrollmentComplete("esp_hello_composite"); + } + else if (_desktopArrived) + { + _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"); + } + } + + /// + /// Called when WhiteGlove (Pre-Provisioning) completes successfully. + /// Emits the whiteglove_complete event. The MonitoringService handles + /// the actual agent shutdown upon seeing this event type. + /// + private void OnWhiteGloveCompleted(object sender, EventArgs e) + { + _logger.Info("EnrollmentTracker: WhiteGlove pre-provisioning completed"); + + // 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" + }); + + // 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. + } + + /// + /// 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. + /// + 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"); + _pendingEspFailureType = failureType; + + // Cancel existing timer if any (e.g. second timeout event) + _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"); + } + } + + /// + /// Called when the ESP failure grace period expires without recovery. + /// Emits enrollment_failed. + /// + private void OnEspFailureGracePeriodExpired(object state) + { + var failureType = _pendingEspFailureType ?? "unknown"; + _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed"); + _pendingEspFailureType = null; + + EmitEnrollmentFailed(failureType, "esp_failure_grace_expired"); + } + + /// + /// Cancels any pending ESP failure grace period timer (called when recovery is detected). + /// + private void CancelPendingEspFailure() + { + if (_espFailureTimer != null) + { + _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{_pendingEspFailureType}'"); + _espFailureTimer.Dispose(); + _espFailureTimer = null; + _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) + { + if (!_isWaitingForHello || _enrollmentCompleteEmitted) + return; + + _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion"); + _isWaitingForHello = false; + + // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes + if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) + { + _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout"); + } + + TryEmitEnrollmentComplete("ime_hello_safety_timeout"); + } + + /// + /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete. + /// + private void EmitEnrollmentFailed(string failureType, string failureSource) + { + // 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", _stateData.SignalsSeen }, + { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds } + } + }); + + // 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") + { + _espEverSeen = true; + + if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) || _desktopArrived) + { + // Final ESP exit: either AccountSetup phase detected OR desktop arrived (backup) + var phaseInfo = string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) + ? "AccountSetup" + : $"{_lastEspPhase ?? "unknown"} (desktop arrival backup)"; + _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer"); + + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + 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", _lastEspPhase ?? "unknown" }, + { "desktopArrivedBackup", _desktopArrived && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) } + } + }); + + CollectDeviceInfoAtFinalizingSetup(reason); + + // 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 (_skipUserStatusPage == true) + { + // Registry definitively says no AccountSetup expected → immediate device-only classification + _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)"); + + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + 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", _lastEspPhase ?? "unknown" }, + { "skipUserStatusPage", true } + } + }); + + CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry"); + _espAndHelloTracker?.StartHelloWaitTimer(); + TryEmitEnrollmentComplete("device_only_esp_registry"); + } + else + { + // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection + var fallbackReason = _skipUserStatusPage == null ? "registry keys not found" : "SkipUserStatusPage=false"; + _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "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 } + } + }); + + CollectDeviceInfoAtFinalizingSetup(reason); + } + } + + // ===== State Persistence ===== + + private void LoadState() + { + var loaded = _statePersistence.Load(); + if (loaded == null) + return; + + _espEverSeen = loaded.EspEverSeen; + _espFinalExitSeen = loaded.EspFinalExitSeen; + _desktopArrived = loaded.DesktopArrived; + _lastEspPhase = loaded.LastEspPhase; + _isWaitingForHello = loaded.IsWaitingForHello; + _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted; + _enrollmentType = loaded.EnrollmentType ?? _enrollmentType; + _skipUserStatusPage = loaded.SkipUserStatusPage; + _skipDeviceStatusPage = loaded.SkipDeviceStatusPage; + _stateData = loaded; + + _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 + if (_isWaitingForHello && !_enrollmentCompleteEmitted) + { + _logger.Info("EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery"); + _waitingForHelloSafetyTimer?.Dispose(); + _waitingForHelloSafetyTimer = new Timer( + OnWaitingForHelloSafetyTimeout, + null, + TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds), + TimeSpan.FromMilliseconds(-1)); + } + } + + private void RecordSignal(string signal) + { + if (!_stateData.SignalsSeen.Contains(signal)) + _stateData.SignalsSeen.Add(signal); + _stateDirty = true; + } + + // ===== 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 (_enrollmentCompleteEmitted) + { + _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping"); + return; + } + + if (string.IsNullOrEmpty(source)) + return; + + // Hello-Check: Hello must be resolved before we can complete + bool helloResolved = _espAndHelloTracker == null + || _espAndHelloTracker.IsHelloCompleted + || !_espAndHelloTracker.IsPolicyConfigured; + + if (!helloResolved) + { + _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet"); + EmitCompletionCheck(source, "hello_pending", "hello_not_resolved"); + return; + } + + // Desktop-Arrival Gate: Block desktop-based completion when ESP is still actively running. + // Both "desktop_arrival" (direct) and "desktop_hello" (Hello resolved after desktop arrival) + // must be gated — otherwise Hello timeout during active AccountSetup triggers premature completion. + // WDP v2 has no ESP — skip the gate entirely. + if ((source == "desktop_arrival" || source == "desktop_hello") && _enrollmentType != "v2" && _espEverSeen && !_espFinalExitSeen) + { + _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active"); + EmitCompletionCheck(source, "blocked", "esp_active"); + return; + } + + _enrollmentCompleteEmitted = true; + _stateData.EnrollmentCompleteEmitted = true; + + // Stop timers + _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _summaryTimerActive = false; + _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debugStateTimerActive = false; + + var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown"; + + var 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"); + + _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 }, + { "signalsSeen", _stateData.SignalsSeen }, + { "signalTimestamps", signalTimestamps }, + { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds } + } + }); + + // 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; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "completion_check", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Completion source '{source}' evaluated — {result}", + Data = 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" } + } + }); + } + + /// + /// 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() + { + if (_desktopArrived) + return; + + _desktopArrived = true; + _stateData.DesktopArrived = true; + _stateData.DesktopArrivedUtc = DateTime.UtcNow; + RecordSignal("desktop_arrived"); + _logger.Info("EnrollmentTracker: Desktop arrival notified"); + + // Phase correction: If ESP was seen but AccountSetup was never detected by IME log, + // correct the phase and emit an event for the timeline + if (_espEverSeen && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) + { + var previousPhase = _lastEspPhase ?? "unknown"; + _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 } + } + }); + } + + // 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. + if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted + && (!_espEverSeen || _espFinalExitSeen)) + { + _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer"); + _espAndHelloTracker.StartHelloWaitTimer(); + } + + TryEmitEnrollmentComplete("desktop_arrival"); + } + + /// + /// 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) + { + // AccountSetup started meanwhile? Timer is obsolete + if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring"); + return; + } + + if (_desktopArrived) + { + _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active"); + _espFinalExitSeen = true; + _stateData.EspFinalExitSeen = true; + _stateData.EspFinalExitUtc = DateTime.UtcNow; + 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"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs new file mode 100644 index 00000000..fe6e4486 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.Diagnostics.cs @@ -0,0 +1,327 @@ +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; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// 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() + { + 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) + { + if (_finalDeviceInfoCollected) + { + _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})"); + return; + } + + _finalDeviceInfoCollected = true; + _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(); + + // Periodic state save (piggybacks on the existing 30s timer) + if (_stateDirty) + { + _stateDirty = false; + _statePersistence.Save(_stateData); + } + } + + /// + /// 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 + { + 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: {_lastEspPhase ?? "none"}, espExit: {_espFinalExitSeen}, desktop: {_desktopArrived}"); + } + 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); + if (hash == _lastEmittedSummaryHash) return; + + _lastEmittedSummaryHash = hash; + + _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() + }); + } + + /// + /// Emits app_tracking_summary unconditionally (for final summary on completion). + /// + private void EmitAppTrackingSummary() + { + var states = _imeLogTracker?.PackageStates; + if (states == null || states.CountAll == 0) return; + + _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() + }); + } + + 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; + } + + _espFailureTimer?.Dispose(); + _deviceOnlyEspTimer?.Dispose(); + _waitingForHelloSafetyTimer?.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"); + + // 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", _stateData.SignalsSeen ?? new List() }, + { "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/Tracking/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs new file mode 100644 index 00000000..5dd461d7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// Partial: IME log event handlers (ESP phase, app state, script completion, etc.). + /// + public partial class EnrollmentTracker + { + private void HandleEspPhaseChanged(string phase) + { + // WDP (v2) has no ESP - skip ESP phase handling entirely + if (_enrollmentType == "v2") + { + _logger.Debug($"EnrollmentTracker: skipping ESP phase event in WDP enrollment (phase: {phase})"); + return; + } + + // Only emit event if the phase has actually changed + if (string.Equals(phase, _lastEspPhase, StringComparison.OrdinalIgnoreCase)) + { + _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event"); + return; + } + + _logger.Info($"EnrollmentTracker: ESP phase changed from '{_lastEspPhase ?? "null"}' to '{phase}'"); + _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; + 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; + + _emitEvent(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 } } + }); + + // Re-collect enrollment-dependent device info (AAD join, profile, ESP config) + // that may have been empty at startup (bootstrap / pre-enrollment scenario). + CollectDeviceInfoAtEnrollmentStart(); + + // 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)); + } + } + + private void HandleImeAgentVersion(string version) + { + _emitEvent(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) + { + // 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 + if (!_hasAutoSwitchedToAppsPhase && + (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) && + oldState < AppInstallationState.Downloading) + { + if (_lastEspPhase != null) + { + if (string.Equals(_lastEspPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase)) + { + // Switch from DeviceSetup to AppsDevice + _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase"); + _hasAutoSwitchedToAppsPhase = true; + _emitEvent(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 } } + }); + } + else if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) + { + // Switch from AccountSetup to AppsUser + _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase"); + _hasAutoSwitchedToAppsPhase = true; + _emitEvent(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 } } + }); + } + } + } + + // 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 + { + _emitEvent(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) + _emitEvent(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) + _emitEvent(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_failed"; + 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}"; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = eventType, + Severity = severity, + Source = "ImeLogTracker", + Phase = phase, + Message = message, + Data = app.ToEventData() + }); + + // 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. + if (_lastEspPhase != null) + { + _logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'"); + _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 + _emitEvent(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() + }); + + // Also emit download_progress so the UI picks up DO stats + _emitEvent(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() + }); + } + + private void HandleScriptCompleted(ScriptExecutionState script) + { + if (script == null || string.IsNullOrEmpty(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; + + _emitEvent(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 HandleUserSessionCompleted() + { + _logger.Info("EnrollmentTracker: User session completed (detected from IME log)"); + _stateData.ImePatternSeenUtc = DateTime.UtcNow; + RecordSignal("ime_pattern"); + + // 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; + + // 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."); + + _emitEvent(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" + }); + + // Set flag so we know we're waiting + _isWaitingForHello = true; + _stateData.IsWaitingForHello = true; + _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)); + + return; + } + } + + TryEmitEnrollmentComplete("ime_pattern"); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs index 7a9ad40b..4ece92e1 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs @@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking /// Collects consolidated device info events at startup and manages ImeLogTracker /// for smart app installation tracking with strategic event emission. /// - public class EnrollmentTracker : IDisposable + public partial class EnrollmentTracker : IDisposable { private readonly string _sessionId; private readonly string _tenantId; @@ -267,1345 +267,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking private void CollectDeviceInfoAtEnd() => _deviceInfoCollector.CollectAtEnd(); - // ===== ImeLogTracker Callbacks -> Strategic Events ===== - - private void HandleEspPhaseChanged(string phase) - { - // WDP (v2) has no ESP - skip ESP phase handling entirely - if (_enrollmentType == "v2") - { - _logger.Debug($"EnrollmentTracker: skipping ESP phase event in WDP enrollment (phase: {phase})"); - return; - } - - // Only emit event if the phase has actually changed - if (string.Equals(phase, _lastEspPhase, StringComparison.OrdinalIgnoreCase)) - { - _logger.Debug($"EnrollmentTracker: ESP phase unchanged ({phase}), skipping event"); - return; - } - - _logger.Info($"EnrollmentTracker: ESP phase changed from '{_lastEspPhase ?? "null"}' to '{phase}'"); - _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; - 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; - - _emitEvent(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 } } - }); - - // Re-collect enrollment-dependent device info (AAD join, profile, ESP config) - // that may have been empty at startup (bootstrap / pre-enrollment scenario). - CollectDeviceInfoAtEnrollmentStart(); - - // 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)); - } - } - - private void HandleImeAgentVersion(string version) - { - _emitEvent(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) - { - // 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 - if (!_hasAutoSwitchedToAppsPhase && - (newState == AppInstallationState.Downloading || newState == AppInstallationState.Installing) && - oldState < AppInstallationState.Downloading) - { - if (_lastEspPhase != null) - { - if (string.Equals(_lastEspPhase, "DeviceSetup", StringComparison.OrdinalIgnoreCase)) - { - // Switch from DeviceSetup to AppsDevice - _logger.Info($"EnrollmentTracker: First app activity detected during DeviceSetup, switching to AppsDevice phase"); - _hasAutoSwitchedToAppsPhase = true; - _emitEvent(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 } } - }); - } - else if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - // Switch from AccountSetup to AppsUser - _logger.Info($"EnrollmentTracker: First app activity detected during AccountSetup, switching to AppsUser phase"); - _hasAutoSwitchedToAppsPhase = true; - _emitEvent(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 } } - }); - } - } - } - - // 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 - { - _emitEvent(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) - _emitEvent(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) - _emitEvent(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_failed"; - 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}"; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = eventType, - Severity = severity, - Source = "ImeLogTracker", - Phase = phase, - Message = message, - Data = app.ToEventData() - }); - - // 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. - if (_lastEspPhase != null) - { - _logger.Info($"EnrollmentTracker: All apps completed while in phase '{_lastEspPhase}'"); - _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 - _emitEvent(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() - }); - - // Also emit download_progress so the UI picks up DO stats - _emitEvent(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() - }); - } - - private void HandleScriptCompleted(ScriptExecutionState script) - { - if (script == null || string.IsNullOrEmpty(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; - - _emitEvent(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 HandleUserSessionCompleted() - { - _logger.Info("EnrollmentTracker: User session completed (detected from IME log)"); - _stateData.ImePatternSeenUtc = DateTime.UtcNow; - RecordSignal("ime_pattern"); - - // 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; - - // 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."); - - _emitEvent(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" - }); - - // Set flag so we know we're waiting - _isWaitingForHello = true; - _stateData.IsWaitingForHello = true; - _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)); - - return; - } - } - - TryEmitEnrollmentComplete("ime_pattern"); - } - - /// - /// Called when Windows Hello provisioning completes (via EspAndHelloTracker event). - /// Multiple completion paths: IME pattern was waiting for Hello, ESP final exit + Hello composite, - /// or Desktop arrival + Hello. - /// - private void OnHelloCompleted(object sender, EventArgs e) - { - _logger.Info("EnrollmentTracker: Received HelloCompleted event from EspAndHelloTracker"); - _stateData.HelloResolvedUtc = DateTime.UtcNow; - RecordSignal("hello_resolved"); - - // Cancel safety timer — Hello resolved normally - _waitingForHelloSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); - - if (_isWaitingForHello) - { - _logger.Info("EnrollmentTracker: Hello provisioning completed while waiting (IME path) - marking enrollment as complete now"); - _isWaitingForHello = false; - TryEmitEnrollmentComplete("ime_hello"); - } - else if (_espFinalExitSeen) - { - _logger.Info("EnrollmentTracker: Hello completed + ESP final exit seen — composite completion"); - TryEmitEnrollmentComplete("esp_hello_composite"); - } - else if (_desktopArrived) - { - _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"); - } - } - - /// - /// Called when WhiteGlove (Pre-Provisioning) completes successfully. - /// Emits the whiteglove_complete event. The MonitoringService handles - /// the actual agent shutdown upon seeing this event type. - /// - private void OnWhiteGloveCompleted(object sender, EventArgs e) - { - _logger.Info("EnrollmentTracker: WhiteGlove pre-provisioning completed"); - - // 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" - }); - - // 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. - } - - /// - /// 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. - /// - 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"); - _pendingEspFailureType = failureType; - - // Cancel existing timer if any (e.g. second timeout event) - _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"); - } - } - - /// - /// Called when the ESP failure grace period expires without recovery. - /// Emits enrollment_failed. - /// - private void OnEspFailureGracePeriodExpired(object state) - { - var failureType = _pendingEspFailureType ?? "unknown"; - _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed"); - _pendingEspFailureType = null; - - EmitEnrollmentFailed(failureType, "esp_failure_grace_expired"); - } - - /// - /// Cancels any pending ESP failure grace period timer (called when recovery is detected). - /// - private void CancelPendingEspFailure() - { - if (_espFailureTimer != null) - { - _logger.Info($"EnrollmentTracker: ESP recovery detected — cancelling pending failure for '{_pendingEspFailureType}'"); - _espFailureTimer.Dispose(); - _espFailureTimer = null; - _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) - { - if (!_isWaitingForHello || _enrollmentCompleteEmitted) - return; - - _logger.Warning($"EnrollmentTracker: waiting_for_hello safety timeout ({WaitingForHelloSafetyTimeoutSeconds}s) expired — forcing completion"); - _isWaitingForHello = false; - - // Force-resolve Hello so TryEmitEnrollmentComplete's hello check passes - if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted) - { - _espAndHelloTracker.ForceMarkHelloCompleted("safety_timeout"); - } - - TryEmitEnrollmentComplete("ime_hello_safety_timeout"); - } - - /// - /// Emits an enrollment_failed event. The MonitoringService handles shutdown identically to enrollment_complete. - /// - private void EmitEnrollmentFailed(string failureType, string failureSource) - { - // 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", _stateData.SignalsSeen }, - { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds } - } - }); - - // 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") - { - _espEverSeen = true; - - if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) || _desktopArrived) - { - // Final ESP exit: either AccountSetup phase detected OR desktop arrived (backup) - var phaseInfo = string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) - ? "AccountSetup" - : $"{_lastEspPhase ?? "unknown"} (desktop arrival backup)"; - _logger.Info($"EnrollmentTracker: ESP final exit from {phaseInfo} — marking _espFinalExitSeen, starting Hello wait timer"); - - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - 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", _lastEspPhase ?? "unknown" }, - { "desktopArrivedBackup", _desktopArrived && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) } - } - }); - - CollectDeviceInfoAtFinalizingSetup(reason); - - // 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 (_skipUserStatusPage == true) - { - // Registry definitively says no AccountSetup expected → immediate device-only classification - _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "unknown"}' — SkipUserStatusPage=true, classified as device-only ESP (registry)"); - - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - 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", _lastEspPhase ?? "unknown" }, - { "skipUserStatusPage", true } - } - }); - - CollectDeviceInfoAtFinalizingSetup("device_only_esp_registry"); - _espAndHelloTracker?.StartHelloWaitTimer(); - TryEmitEnrollmentComplete("device_only_esp_registry"); - } - else - { - // Registry keys unknown or SkipUserStatusPage=false → fallback to timer-based detection - var fallbackReason = _skipUserStatusPage == null ? "registry keys not found" : "SkipUserStatusPage=false"; - _logger.Info($"EnrollmentTracker: ESP phase exiting from '{_lastEspPhase ?? "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 } - } - }); - - CollectDeviceInfoAtFinalizingSetup(reason); - } - } - - // ===== State Persistence ===== - - private void LoadState() - { - var loaded = _statePersistence.Load(); - if (loaded == null) - return; - - _espEverSeen = loaded.EspEverSeen; - _espFinalExitSeen = loaded.EspFinalExitSeen; - _desktopArrived = loaded.DesktopArrived; - _lastEspPhase = loaded.LastEspPhase; - _isWaitingForHello = loaded.IsWaitingForHello; - _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted; - _enrollmentType = loaded.EnrollmentType ?? _enrollmentType; - _skipUserStatusPage = loaded.SkipUserStatusPage; - _skipDeviceStatusPage = loaded.SkipDeviceStatusPage; - _stateData = loaded; - - _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 - if (_isWaitingForHello && !_enrollmentCompleteEmitted) - { - _logger.Info("EnrollmentTracker: restarting waiting_for_hello safety timer after state recovery"); - _waitingForHelloSafetyTimer?.Dispose(); - _waitingForHelloSafetyTimer = new Timer( - OnWaitingForHelloSafetyTimeout, - null, - TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds), - TimeSpan.FromMilliseconds(-1)); - } - } - - private void RecordSignal(string signal) - { - if (!_stateData.SignalsSeen.Contains(signal)) - _stateData.SignalsSeen.Add(signal); - _stateDirty = true; - } - - // ===== 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 (_enrollmentCompleteEmitted) - { - _logger.Debug($"EnrollmentTracker: TryEmitEnrollmentComplete('{source}') — already emitted, skipping"); - return; - } - - if (string.IsNullOrEmpty(source)) - return; - - // Hello-Check: Hello must be resolved before we can complete - bool helloResolved = _espAndHelloTracker == null - || _espAndHelloTracker.IsHelloCompleted - || !_espAndHelloTracker.IsPolicyConfigured; - - if (!helloResolved) - { - _logger.Info($"EnrollmentTracker: Completion source '{source}' ready but Hello still pending — not completing yet"); - EmitCompletionCheck(source, "hello_pending", "hello_not_resolved"); - return; - } - - // Desktop-Arrival Gate: Block desktop-based completion when ESP is still actively running. - // Both "desktop_arrival" (direct) and "desktop_hello" (Hello resolved after desktop arrival) - // must be gated — otherwise Hello timeout during active AccountSetup triggers premature completion. - // WDP v2 has no ESP — skip the gate entirely. - if ((source == "desktop_arrival" || source == "desktop_hello") && _enrollmentType != "v2" && _espEverSeen && !_espFinalExitSeen) - { - _logger.Info($"EnrollmentTracker: Completion source '{source}' blocked — ESP still active"); - EmitCompletionCheck(source, "blocked", "esp_active"); - return; - } - - _enrollmentCompleteEmitted = true; - _stateData.EnrollmentCompleteEmitted = true; - - // Stop timers - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _summaryTimerActive = false; - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimerActive = false; - - var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown"; - - var 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"); - - _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 }, - { "signalsSeen", _stateData.SignalsSeen }, - { "signalTimestamps", signalTimestamps }, - { "agentUptimeSeconds", (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds } - } - }); - - // 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; - - _emitEvent(new EnrollmentEvent - { - SessionId = _sessionId, - TenantId = _tenantId, - EventType = "completion_check", - Severity = EventSeverity.Info, - Source = "EnrollmentTracker", - Phase = EnrollmentPhase.Unknown, - Message = $"Completion source '{source}' evaluated — {result}", - Data = 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" } - } - }); - } - - /// - /// 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() - { - if (_desktopArrived) - return; - - _desktopArrived = true; - _stateData.DesktopArrived = true; - _stateData.DesktopArrivedUtc = DateTime.UtcNow; - RecordSignal("desktop_arrived"); - _logger.Info("EnrollmentTracker: Desktop arrival notified"); - - // Phase correction: If ESP was seen but AccountSetup was never detected by IME log, - // correct the phase and emit an event for the timeline - if (_espEverSeen && !string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - var previousPhase = _lastEspPhase ?? "unknown"; - _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 } - } - }); - } - - // 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. - if (_espAndHelloTracker != null && !_espAndHelloTracker.IsHelloCompleted - && (!_espEverSeen || _espFinalExitSeen)) - { - _logger.Info("EnrollmentTracker: Desktop arrived with Hello pending (no active ESP) — starting Hello wait timer"); - _espAndHelloTracker.StartHelloWaitTimer(); - } - - TryEmitEnrollmentComplete("desktop_arrival"); - } - - /// - /// 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) - { - // AccountSetup started meanwhile? Timer is obsolete - if (string.Equals(_lastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) - { - _logger.Debug("EnrollmentTracker: Device-only ESP timer expired but AccountSetup detected — ignoring"); - return; - } - - if (_desktopArrived) - { - _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min — classified as device-only ESP, desktop is active"); - _espFinalExitSeen = true; - _stateData.EspFinalExitSeen = true; - _stateData.EspFinalExitUtc = DateTime.UtcNow; - 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"); - } - } - - /// - /// Re-collects enrollment-dependent device info (AAD join, Autopilot profile, ESP config) - /// that were likely empty when the agent started before enrollment (bootstrap scenario). - /// Only runs in bootstrap mode — in normal mode CollectAll() already has complete data. - /// Called once when the first ESP phase is detected. - /// - private void CollectDeviceInfoAtEnrollmentStart() - { - 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) - { - if (_finalDeviceInfoCollected) - { - _logger.Debug($"EnrollmentTracker: final device info already collected, skipping (trigger: {triggerReason})"); - return; - } - - _finalDeviceInfoCollected = true; - _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(); - - // Periodic state save (piggybacks on the existing 30s timer) - if (_stateDirty) - { - _stateDirty = false; - _statePersistence.Save(_stateData); - } - } - - /// - /// 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 - { - 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: {_lastEspPhase ?? "none"}, espExit: {_espFinalExitSeen}, desktop: {_desktopArrived}"); - } - 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); - if (hash == _lastEmittedSummaryHash) return; - - _lastEmittedSummaryHash = hash; - - _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() - }); - } - - /// - /// Emits app_tracking_summary unconditionally (for final summary on completion). - /// - private void EmitAppTrackingSummary() - { - var states = _imeLogTracker?.PackageStates; - if (states == null || states.CountAll == 0) return; - - _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() - }); - } - - 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; - } - - _espFailureTimer?.Dispose(); - _deviceOnlyEspTimer?.Dispose(); - _waitingForHelloSafetyTimer?.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"); - - // 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", _stateData.SignalsSeen ?? new List() }, - { "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/Tracking/ImeLogTracker.Handlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.Handlers.cs new file mode 100644 index 00000000..10773482 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.Handlers.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared.Models; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// 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. + /// + private 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.Debug($"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.Debug($"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) + { + 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); + + 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/Tracking/ImeLogTracker.LogProcessing.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.LogProcessing.cs new file mode 100644 index 00000000..b2126968 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.LogProcessing.cs @@ -0,0 +1,298 @@ +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.Tracking +{ + /// + /// 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) continue; + + 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)) + { + string line; + while ((line = await reader.ReadLineAsync()) != null) + { + if (token.IsCancellationRequested) break; + + // Parse CMTrace format to get the message content + 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); + } + } + catch (RegexMatchTimeoutException) + { + // Skip this pattern match to prevent ReDoS + } + } + } + } + + _positionTracker.SetPosition(filePath, stream.Position); + _stateDirty = true; + } + } + catch (FileNotFoundException) { } + catch (IOException ex) + { + _logger.Debug($"ImeLogTracker: IO error reading {Path.GetFileName(filePath)}: {ex.Message}"); + } + } + } + + private void HandlePatternMatch(CompiledPattern pattern, Match match, string message) + { + 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": + _logger.Debug($"IME session change: {match.Groups["change"]?.Value}"); + 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": + 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); + } + else if (!string.IsNullOrEmpty(id)) + { + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message); + } + 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/Tracking/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs index eddd3d35..825b7e45 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs @@ -18,12 +18,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking /// /// Parses IME (Intune Management Extension) log files using regex patterns from the backend. /// Tracks app installation state transitions and emits strategic events. - /// Adapted from EspOverlay's ImeLogFileParser + RjLogFileParser. + /// 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 class ImeLogTracker : IDisposable + public partial class ImeLogTracker : IDisposable { private readonly string _logFolder; private readonly AgentLogger _logger; @@ -414,840 +414,5 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking #endregion - 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) continue; - - 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)) - { - string line; - while ((line = await reader.ReadLineAsync()) != null) - { - if (token.IsCancellationRequested) break; - - // Parse CMTrace format to get the message content - 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); - } - } - catch (RegexMatchTimeoutException) - { - // Skip this pattern match to prevent ReDoS - } - } - } - } - - _positionTracker.SetPosition(filePath, stream.Position); - _stateDirty = true; - } - } - catch (FileNotFoundException) { } - catch (IOException ex) - { - _logger.Debug($"ImeLogTracker: IO error reading {Path.GetFileName(filePath)}: {ex.Message}"); - } - } - } - - private void HandlePatternMatch(CompiledPattern pattern, Match match, string message) - { - 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": - _logger.Debug($"IME session change: {match.Groups["change"]?.Value}"); - 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": - 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); - } - else if (!string.IsNullOrEmpty(id)) - { - UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message); - } - 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} - /// - 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. - /// - private 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.Debug($"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.Debug($"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) - { - 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); - - 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/Program.Configuration.cs b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs new file mode 100644 index 00000000..2d76fce8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent/Program.Configuration.cs @@ -0,0 +1,368 @@ +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.Core; +using AutopilotMonitor.Shared; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent +{ + partial class Program + { + static string GetTenantIdFromRegistry() + { + try + { + const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; + + using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath)) + { + if (enrollmentsKey == null) + return null; + + foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames()) + { + using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid)) + { + if (enrollmentKey == null) continue; + + var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); + if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6) + { + var tenantId = enrollmentKey.GetValue("AADTenantID"); + if (tenantId != null) + return tenantId.ToString(); + } + } + } + } + } + catch { } + + return null; + } + + static AgentConfiguration LoadConfiguration(string[] args = null) + { + var noAuth = args?.Contains("--no-auth") ?? false; + 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; + + string certThumbprint = null; + string tenantIdOverride = null; + string apiBaseUrlOverride = null; + string imeLogPathOverride = null; + string imeMatchLogPath = null; + string replayLogDir = null; + string bootstrapToken = null; + double replaySpeedFactor = 50; + + if (args != null) + { + var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint"); + if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length) + certThumbprint = args[thumbprintIndex + 1]; + + var tenantIdIndex = Array.IndexOf(args, "--tenant-id"); + if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length) + tenantIdOverride = args[tenantIdIndex + 1]; + + // 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]; + } + + // Defaults + string apiBaseUrl = Constants.ApiBaseUrl; + int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds; + bool selfDestructOnComplete = false; + bool rebootOnCompleteConfig = false; + bool enableGeoLocationConfig = true; + bool useClientCertAuthConfig = true; + + // Environment variable overrides built-in default + apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl; + // CLI override wins over environment variable/default + if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride)) + apiBaseUrl = apiBaseUrlOverride; + + if (noCleanup) + { + selfDestructOnComplete = false; + } + + // Determine TenantId: command-line arg > registry > fallback + string tenantId; + if (!string.IsNullOrEmpty(tenantIdOverride)) + { + tenantId = tenantIdOverride; + } + else + { + tenantId = GetTenantIdFromRegistry(); + } + + 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(tenantIdOverride) && !string.IsNullOrEmpty(bootstrapConfig.TenantId)) + { + tenantId = bootstrapConfig.TenantId; + } + // Also set noAuth so cert search is skipped + noAuth = true; + } + } + 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 = !noAuth && !useBootstrapToken && useClientCertAuthConfig, + ClientCertThumbprint = certThumbprint, + SelfDestructOnComplete = selfDestructOnComplete, + RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig, + EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig, + ImeLogPathOverride = imeLogPathOverride, + ImeMatchLogPath = imeMatchLogPath, + ReplayLogDir = replayLogDir, + ReplaySpeedFactor = replaySpeedFactor, + KeepLogFile = keepLogFile, + BootstrapToken = bootstrapToken, + UseBootstrapTokenAuth = useBootstrapToken + }; + } + + 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) + { + 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 new file mode 100644 index 00000000..fd6efa25 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent/Program.GatherRulesMode.cs @@ -0,0 +1,271 @@ +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.Collectors; +using AutopilotMonitor.Agent.Core.Monitoring.Core; +using AutopilotMonitor.Agent.Core.Monitoring.Network; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +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)) + 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, + OsBuild = Environment.OSVersion.Version.ToString(), + 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.InstallMode.cs b/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs new file mode 100644 index 00000000..e261dbd7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent/Program.InstallMode.cs @@ -0,0 +1,208 @@ +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; + +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"); + } + + 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"); + + 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 index f1b87147..f5abbd55 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -1,22 +1,15 @@ using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; -using System.Reflection; -using System.Threading; using AutopilotMonitor.Agent.Core.Configuration; using AutopilotMonitor.Agent.Core.Logging; -using AutopilotMonitor.Agent.Core.Monitoring.Collectors; using AutopilotMonitor.Agent.Core.Monitoring.Core; -using AutopilotMonitor.Agent.Core.Monitoring.Network; using AutopilotMonitor.Shared; -using AutopilotMonitor.Shared.Models; -using Microsoft.Win32; namespace AutopilotMonitor.Agent { - class Program + partial class Program { static void Main(string[] args) { @@ -41,357 +34,6 @@ namespace AutopilotMonitor.Agent RunAgent(args); } - 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"); - } - - 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"); - - 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); - } - } - - /// - /// 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)) - 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, - OsBuild = Environment.OSVersion.Version.ToString(), - 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); - } - } - } - static void RunAgent(string[] args) { var consoleMode = args.Contains("--console") || Environment.UserInteractive; @@ -510,301 +152,6 @@ namespace AutopilotMonitor.Agent } } - static string GetTenantIdFromRegistry() - { - try - { - const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments"; - - using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath)) - { - if (enrollmentsKey == null) - return null; - - foreach (var enrollmentGuid in enrollmentsKey.GetSubKeyNames()) - { - using (var enrollmentKey = enrollmentsKey.OpenSubKey(enrollmentGuid)) - { - if (enrollmentKey == null) continue; - - var enrollmentType = enrollmentKey.GetValue("EnrollmentType"); - if (enrollmentType != null && Convert.ToInt32(enrollmentType) == 6) - { - var tenantId = enrollmentKey.GetValue("AADTenantID"); - if (tenantId != null) - return tenantId.ToString(); - } - } - } - } - } - catch { } - - return null; - } - - static AgentConfiguration LoadConfiguration(string[] args = null) - { - var noAuth = args?.Contains("--no-auth") ?? false; - 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; - - string certThumbprint = null; - string tenantIdOverride = null; - string apiBaseUrlOverride = null; - string imeLogPathOverride = null; - string imeMatchLogPath = null; - string replayLogDir = null; - string bootstrapToken = null; - double replaySpeedFactor = 50; - - if (args != null) - { - var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint"); - if (thumbprintIndex >= 0 && thumbprintIndex + 1 < args.Length) - certThumbprint = args[thumbprintIndex + 1]; - - var tenantIdIndex = Array.IndexOf(args, "--tenant-id"); - if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length) - tenantIdOverride = args[tenantIdIndex + 1]; - - // 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]; - } - - // Defaults - string apiBaseUrl = Constants.ApiBaseUrl; - int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds; - bool selfDestructOnComplete = false; - bool rebootOnCompleteConfig = false; - bool enableGeoLocationConfig = true; - bool useClientCertAuthConfig = true; - - // Environment variable overrides built-in default - apiBaseUrl = Environment.GetEnvironmentVariable("AUTOPILOT_MONITOR_API") ?? apiBaseUrl; - // CLI override wins over environment variable/default - if (!string.IsNullOrWhiteSpace(apiBaseUrlOverride)) - apiBaseUrl = apiBaseUrlOverride; - - if (noCleanup) - { - selfDestructOnComplete = false; - } - - // Determine TenantId: command-line arg > registry > fallback - string tenantId; - if (!string.IsNullOrEmpty(tenantIdOverride)) - { - tenantId = tenantIdOverride; - } - else - { - tenantId = GetTenantIdFromRegistry(); - } - - 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(tenantIdOverride) && !string.IsNullOrEmpty(bootstrapConfig.TenantId)) - { - tenantId = bootstrapConfig.TenantId; - } - // Also set noAuth so cert search is skipped - noAuth = true; - } - } - 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 = !noAuth && !useBootstrapToken && useClientCertAuthConfig, - ClientCertThumbprint = certThumbprint, - SelfDestructOnComplete = selfDestructOnComplete, - RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig, - EnableGeoLocation = !disableGeoLoc && enableGeoLocationConfig, - ImeLogPathOverride = imeLogPathOverride, - ImeMatchLogPath = imeMatchLogPath, - ReplayLogDir = replayLogDir, - ReplaySpeedFactor = replaySpeedFactor, - KeepLogFile = keepLogFile, - BootstrapToken = bootstrapToken, - UseBootstrapTokenAuth = useBootstrapToken - }; - } - - 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; - } - - 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}'"); - } - /// /// Checks if another AutopilotMonitor.Agent.exe process is already running. /// @@ -824,161 +171,6 @@ namespace AutopilotMonitor.Agent return false; } } - - /// - /// 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) - { - 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/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 77f83230..67f3f23e 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -243 +270 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs index 206d229f..5802c571 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/AutopilotDeviceValidationConsentFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions; +namespace AutopilotMonitor.Functions.Functions.Admin; public class AutopilotDeviceValidationConsentFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs index 859a393c..81cf7659 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DailyMaintenanceFunction.cs @@ -3,7 +3,7 @@ using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { /// /// Timer-triggered Azure Function that runs daily maintenance. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs index b2591b84..619ce527 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { public class DeviceBlockFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs index 654536e4..ead851e2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAllBlockedDevicesFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { public class GetAllBlockedDevicesFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs index 00acb555..277d5a65 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { public class GetAuditLogsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs index 05ca3fe6..088a3fc8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGalacticAuditLogsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { public class GetGalacticAuditLogsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs index 14ef9aae..4f1a70db 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { /// /// Central reseed endpoint that fetches all rule types from GitHub and writes them to Table Storage. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs index 27affe6e..e4a2705b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions; +namespace AutopilotMonitor.Functions.Functions.Admin; /// /// Tenant Admin Management endpoints diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs index 1f594137..2a54aad5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs @@ -11,7 +11,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions; +namespace AutopilotMonitor.Functions.Functions.Admin; /// /// Tenant Offboarding endpoint diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs index db01ef3c..2ffad423 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs @@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Admin { /// /// Allows Galactic Admins to manually trigger maintenance tasks diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs index ea28f037..ba94daab 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs @@ -1,12 +1,13 @@ using System; using System.Net; +using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// GET /api/bootstrap/config — cert-free agent config for pre-enrollment agents. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs index b54b576c..f87d1bee 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapIngestEventsFunction.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using Microsoft.Azure.Functions.Worker; @@ -7,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// POST /api/bootstrap/ingest — cert-free ingest for pre-enrollment agents. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs index 057fddf0..0ca031ac 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs @@ -1,5 +1,6 @@ using System; using System.Net; +using AutopilotMonitor.Functions.Functions.Sessions; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; @@ -9,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// POST /api/bootstrap/register-session — cert-free session registration for pre-enrollment agents. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs index 79389570..b3d35280 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs @@ -1,12 +1,13 @@ 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.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// POST /api/bootstrap/error — cert-free error reporting for pre-enrollment agents. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs index 576fcd19..e8bc5c94 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs @@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// POST /api/bootstrap/sessions — Create a new bootstrap session for OOBE agent deployment. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs index b66896de..9b96f357 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ListBootstrapSessionsFunction.cs @@ -9,7 +9,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// GET /api/bootstrap/sessions?tenantId={tenantId} — List bootstrap sessions for a tenant. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs index f99cccac..cf6e4b67 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// DELETE /api/bootstrap/sessions/{code}?tenantId={tenantId} — Revoke a bootstrap session. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs index 04b8bbb1..d46b97f7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/ValidateBootstrapCodeFunction.cs @@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Bootstrap { /// /// GET /api/bootstrap/validate/{code} — Validate a bootstrap code and return the token. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs index 47b9d4dd..78c8667f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAdminConfigurationFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { public class GetAdminConfigurationFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index cbb3f04c..1832da8b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { /// /// Returns agent configuration including collector toggles and active gather rules diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs index 45e92677..daf0a32b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAllTenantConfigurationsFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { public class GetAllTenantConfigurationsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs index 9e0b2a2d..3dca215f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantConfigurationFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { public class GetTenantConfigurationFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs index bfe1367c..27ed68dd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs @@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { public class UpdateAdminConfigurationFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 1cb3bf8b..00200bd8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { public class UpdateTenantConfigurationFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs index 340064f7..11725009 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantSecurityBypassFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Config { public class UpdateTenantSecurityBypassFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs index b9f0b7ea..335ef51f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/DiagnosticsDownloadFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Diagnostics { /// /// Returns a download URL for a diagnostics package stored in the tenant's Blob Storage. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs index c8438772..c164af17 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Diagnostics { /// /// Returns a short-lived SAS URL for diagnostics package upload. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 4a2e83ba..d4538826 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions; +namespace AutopilotMonitor.Functions.Functions.Infrastructure; /// /// Authentication and authorization endpoints diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs index 1d5e7802..9e22b7af 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/HealthCheckFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Infrastructure { public class HealthCheckFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs index b3094331..9190224d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Infrastructure { public class SignalRAddToGroupFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs index c3e1a179..15922efd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRNegotiateFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Infrastructure { public class SignalRNegotiateFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs index 19e3274c..553fb225 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Infrastructure { public class SignalRRemoveFromGroupFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs new file mode 100644 index 00000000..02064089 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Helpers.cs @@ -0,0 +1,303 @@ +using System.IO.Compression; +using System.Linq; +using System; +using System.Net; +using System.Text; +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; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Functions.Ingest +{ + /// + /// Partial: Request parsing, event normalization, app install aggregation helpers. + /// + public partial class IngestEventsFunction + { + private async Task ParseNdjsonGzipRequest(Stream body, string? tenantId = null) + { + // Get configuration for payload size limit (use default if tenantId not available yet) + var config = !string.IsNullOrEmpty(tenantId) + ? await _configService.GetConfigurationAsync(tenantId) + : null; + + var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024; + + // Decompress gzip with size limit protection + using var decompressed = new MemoryStream(); + using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true)) + { + // Copy with size limit to prevent memory exhaustion attacks + var buffer = new byte[8192]; + int bytesRead; + long totalBytesRead = 0; + + while ((bytesRead = await gzip.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + totalBytesRead += bytesRead; + + // Check if we've exceeded the maximum payload size + if (totalBytesRead > maxPayloadSizeBytes) + { + throw new InvalidOperationException( + $"NDJSON payload size exceeds maximum allowed size of {config?.MaxNdjsonPayloadSizeMB ?? 5} MB (decompressed). " + + $"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB" + ); + } + + await decompressed.WriteAsync(buffer, 0, bytesRead); + } + + _logger.LogDebug($"NDJSON payload decompressed: {totalBytesRead / 1024.0:F2} KB (limit: {maxPayloadSizeBytes / 1024.0 / 1024.0} MB)"); + } + + decompressed.Position = 0; + var ndjson = await new StreamReader(decompressed, Encoding.UTF8).ReadToEndAsync(); + + // Parse NDJSON (newline-delimited JSON) + var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries); + + if (lines.Length < 1) + { + throw new InvalidOperationException("NDJSON must contain at least metadata line"); + } + + // First line: metadata + var metadata = JsonConvert.DeserializeObject(lines[0]); + if (metadata == null) + { + throw new InvalidOperationException("Failed to parse NDJSON metadata"); + } + + // Subsequent lines: events + var events = new List(); + for (int i = 1; i < lines.Length; i++) + { + var evt = JsonConvert.DeserializeObject(lines[i]); + if (evt != null) + { + NormalizeEventData(evt); + events.Add(evt); + } + } + + return new IngestEventsRequest + { + SessionId = metadata.SessionId, + TenantId = metadata.TenantId, + Events = events + }; + } + + /// + /// Normalizes event Data dictionary by converting Newtonsoft JToken objects to native .NET types. + /// Required because Newtonsoft.Json deserializes nested objects as JObject/JArray, which + /// System.Text.Json (used by SignalR) cannot serialize correctly - producing [[[]]] instead of real values. + /// + private static void NormalizeEventData(EnrollmentEvent evt) + { + if (evt.Data == null || evt.Data.Count == 0) return; + var normalized = new Dictionary(); + foreach (var kvp in evt.Data) + normalized[kvp.Key] = ConvertJTokenToNative(kvp.Value); + evt.Data = normalized; + } + + private static object ConvertJTokenToNative(object value) + { + if (value is JArray jArray) + return jArray.Select(item => ConvertJTokenToNative(item)).ToList(); + if (value is JObject jObject) + { + var dict = new Dictionary(); + foreach (var prop in jObject.Properties()) + dict[prop.Name] = ConvertJTokenToNative(prop.Value); + return dict; + } + if (value is JValue jValue) + return jValue.Value ?? string.Empty; + return value; + } + + /// + /// 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() : 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; + + 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, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds); + break; + + case "app_install_failed": + summary.Status = "Failed"; + summary.CompletedAt = evt.Timestamp; + if (summary.StartedAt != DateTime.MinValue) + summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds); + // 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 + if (summary.Status == "InProgress") + 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); + 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; + } + break; + } + + RecalculateAppDurations(state); + } + + private 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 = (int)(state.InstallStartedAt.Value - state.DownloadStartedAt.Value).TotalSeconds; + } + + // Full duration: from effective start to completion/failure. + if (summary.CompletedAt.HasValue && summary.StartedAt != DateTime.MinValue && + summary.CompletedAt.Value >= summary.StartedAt) + { + summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds; + } + } + + 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 new file mode 100644 index 00000000..2d7eb04f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs @@ -0,0 +1,539 @@ +using System.Linq; +using System.Net; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Models; +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. + var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber") + ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() + : null; + + if (!string.IsNullOrEmpty(serialNumberHeader)) + { + var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader); + 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() + }; + } + } + + // --- Parse NDJSON+gzip request body (only after security is cleared) --- + var request = await ParseNdjsonGzipRequest(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})"); + + // Store events in Azure Table Storage (batch write for efficiency) + var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events); + int processedCount = storedEvents.Count; + + // Classify events for downstream processing + var classification = ClassifyEvents(storedEvents); + + // Store app install summaries + foreach (var summary in classification.AppInstallUpdates.Values) + { + await _storageService.StoreAppInstallSummaryAsync(summary.Summary); + } + + // Extract geo-location data and merge into session row + if (classification.DeviceLocationEvent?.Data != null) + { + var geoData = classification.DeviceLocationEvent.Data; + await _storageService.UpdateSessionGeoAsync( + request.TenantId, + request.SessionId, + 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 + ); + } + + // Update session status based on events + var (statusTransitioned, whiteGloveStatusTransitioned, failureReason) = await UpdateSessionStatusAsync( + request, sessionPrefix, classification); + + // Always increment event count when events were stored + if (processedCount > 0) + { + await _storageService.IncrementSessionEventCountAsync( + request.TenantId, + request.SessionId, + processedCount, + classification.EarliestEventTimestamp, + classification.LatestEventTimestamp, + currentPhase: classification.LastPhaseChangeEvent?.Phase + ); + } + + // Run full analysis when enrollment ends (cost-efficient: one pass over all events) + var newRuleResults = new List(); + if (classification.CompletionEvent != null || classification.FailureEvent != null) + { + try + { + var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); + newRuleResults = await ruleEngine.AnalyzeSessionAsync(request.TenantId, request.SessionId); + + foreach (var result in newRuleResults) + { + await _storageService.StoreRuleResultAsync(result); + } + + if (newRuleResults.Count > 0) + { + _logger.LogInformation($"{sessionPrefix} Enrollment-end analysis: {newRuleResults.Count} issue(s) detected"); + } + } + catch (Exception ruleEx) + { + _logger.LogWarning(ruleEx, $"{sessionPrefix} Enrollment-end analysis failed (non-fatal)"); + } + } + + // Increment platform stats (fire-and-forget, non-blocking) + _ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + if (newRuleResults.Count > 0) + _ = _storageService.IncrementPlatformStatAsync("IssuesDetected", newRuleResults.Count) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + if (classification.CompletionEvent != null) + _ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments") + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); + + // Store diagnostics blob name on session (if agent uploaded a diagnostics package) + if (classification.DiagnosticsUploadedEvent != null) + { + var blobName = classification.DiagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true + ? classification.DiagnosticsUploadedEvent.Data["blobName"]?.ToString() + : null; + if (!string.IsNullOrEmpty(blobName)) + { + await _storageService.UpdateSessionDiagnosticsBlobAsync( + request.TenantId, request.SessionId, blobName); + } + } + + // Retrieve updated session data to include in SignalR messages + var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); + + // Session age warning: log if session >4h old and still InProgress (observability only) + if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress) + { + var sessionAge = DateTime.UtcNow - updatedSession.StartedAt; + if (sessionAge.TotalHours > 4) + { + _logger.LogWarning("Session {SessionId} (tenant {TenantId}) still InProgress after {Hours:F1}h — may be stuck", + request.SessionId, request.TenantId, sessionAge.TotalHours); + } + } + + // 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 Teams notifications (fire-and-forget, non-fatal) + await SendTeamsNotificationsAsync( + request, sessionPrefix, classification, updatedSession, + statusTransitioned, whiteGloveStatusTransitioned, failureReason); + + // 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 + }); + + 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": + 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 "esp_failure": + classification.EspFailureEvent = evt; + break; + case "device_location": + classification.DeviceLocationEvent = evt; + break; + } + + AggregateAppInstallEvent(evt, storedEvents[0].TenantId, storedEvents[0].SessionId, classification.AppInstallUpdates); + } + + return classification; + } + + /// + /// 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 _storageService.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 _storageService.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 _storageService.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 _storageService.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) + { + whiteGloveStatusTransitioned = await _storageService.UpdateSessionStatusAsync( + request.TenantId, request.SessionId, SessionStatus.Pending, c.WhiteGloveEvent.Phase, + 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 _storageService.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 _storageService.GetSessionAsync(request.TenantId, request.SessionId); + if (currentSession?.Status == SessionStatus.Pending) + { + await _storageService.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); + } + } + + return (statusTransitioned, whiteGloveStatusTransitioned, failureReason); + } + + /// + /// Sends Teams notifications for enrollment completion, WhiteGlove, and ESP failure events. + /// + private async Task SendTeamsNotificationsAsync( + IngestEventsRequest request, string sessionPrefix, EventClassification c, + SessionSummary? updatedSession, bool statusTransitioned, bool whiteGloveStatusTransitioned, string? failureReason) + { + // Send Teams notification on enrollment completion (fire-and-forget, non-fatal) + // Only send when statusTransitioned=true to prevent duplicates on retry/double-upload + if (statusTransitioned && (c.CompletionEvent != null || c.FailureEvent != null)) + { + var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); + if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl)) + { + var notifySuccess = c.CompletionEvent != null && tenantConfig.TeamsNotifyOnSuccess; + var notifyFailure = c.FailureEvent != null && tenantConfig.TeamsNotifyOnFailure; + 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; + } + + _ = _teamsNotificationService.SendEnrollmentNotificationAsync( + tenantConfig.TeamsWebhookUrl, + updatedSession?.DeviceName, + updatedSession?.SerialNumber, + updatedSession?.Manufacturer, + updatedSession?.Model, + success: c.CompletionEvent != null, + failureReason: failureReason, + duration: duration); + } + } + } + + // Send Teams notification on WhiteGlove pre-provisioning completion + if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null) + { + var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); + if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnSuccess) + { + var duration = updatedSession?.DurationSeconds != null + ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) + : (TimeSpan?)null; + _ = _teamsNotificationService.SendWhiteGloveNotificationAsync( + tenantConfig.TeamsWebhookUrl, + updatedSession?.DeviceName, + updatedSession?.SerialNumber, + updatedSession?.Manufacturer, + updatedSession?.Model, + success: true, + duration: duration); + } + } + + // Send Teams notification on WhiteGlove pre-provisioning failure via esp_failure + if (c.EspFailureEvent != null && updatedSession?.IsPreProvisioned == true) + { + var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); + if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnFailure) + { + var duration = updatedSession?.DurationSeconds != null + ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) + : (TimeSpan?)null; + _ = _teamsNotificationService.SendWhiteGloveNotificationAsync( + tenantConfig.TeamsWebhookUrl, + updatedSession?.DeviceName, + updatedSession?.SerialNumber, + updatedSession?.Manufacturer, + updatedSession?.Model, + success: false, + duration: duration); + } + } + } + + /// + /// 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 }; + } + } + + /// + /// 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? WhiteGloveResumedEvent { get; set; } + public EnrollmentEvent? EspFailureEvent { get; set; } + public EnrollmentEvent? DeviceLocationEvent { get; set; } + public DateTime? EarliestEventTimestamp { get; set; } + public DateTime? LatestEventTimestamp { get; set; } + public Dictionary AppInstallUpdates { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs index cce26cf2..9b37a681 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs @@ -1,7 +1,4 @@ -using System.IO.Compression; -using System.Linq; using System.Net; -using System.Text; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.Models; @@ -9,12 +6,10 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Ingest { - public class IngestEventsFunction + public partial class IngestEventsFunction { private readonly ILogger _logger; private readonly TableStorageService _storageService; @@ -100,851 +95,6 @@ namespace AutopilotMonitor.Functions.Functions return await CreateErrorResponse(req, HttpStatusCode.InternalServerError, "Internal server error"); } } - - /// - /// 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. - var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber") - ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() - : null; - - if (!string.IsNullOrEmpty(serialNumberHeader)) - { - var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader); - 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() - }; - } - } - - // --- Parse NDJSON+gzip request body (only after security is cleared) --- - var request = await ParseNdjsonGzipRequest(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})"); - - // Store events in Azure Table Storage (batch write for efficiency) - var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events); - int processedCount = storedEvents.Count; - - EnrollmentEvent? lastPhaseChangeEvent = null; - EnrollmentEvent? completionEvent = null; - EnrollmentEvent? failureEvent = null; - EnrollmentEvent? gatherCompletionEvent = null; - EnrollmentEvent? diagnosticsUploadedEvent = null; - EnrollmentEvent? whiteGloveEvent = null; - EnrollmentEvent? whiteGloveResumedEvent = null; - EnrollmentEvent? espFailureEvent = null; - EnrollmentEvent? deviceLocationEvent = null; - string? failureReason = null; - DateTime? earliestEventTimestamp = null; - DateTime? latestEventTimestamp = null; - - // Track app install events for AppInstallSummary aggregation - var appInstallUpdates = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var evt in storedEvents) - { - // Track earliest event timestamp for accurate session StartedAt - if (!earliestEventTimestamp.HasValue || evt.Timestamp < earliestEventTimestamp.Value) - { - earliestEventTimestamp = evt.Timestamp; - } - - // Track latest event timestamp for excessive data sender detection - if (!latestEventTimestamp.HasValue || evt.Timestamp > latestEventTimestamp.Value) - { - latestEventTimestamp = evt.Timestamp; - } - - // Track special events for session status updates - if (evt.EventType == "phase_changed" || evt.EventType == "esp_phase_changed") - { - lastPhaseChangeEvent = evt; - } - else if (evt.EventType == "enrollment_complete") - { - completionEvent = evt; - } - else if (evt.EventType == "gather_rules_collection_completed") - { - gatherCompletionEvent = evt; - } - else if (evt.EventType == "enrollment_failed") - { - failureEvent = evt; - } - else if (evt.EventType == "diagnostics_uploaded") - { - diagnosticsUploadedEvent = evt; - } - else if (evt.EventType == "whiteglove_complete") - { - whiteGloveEvent = evt; - } - else if (evt.EventType == "whiteglove_resumed") - { - whiteGloveResumedEvent = evt; - } - else if (evt.EventType == "esp_failure") - { - espFailureEvent = evt; - } - else if (evt.EventType == "device_location") - { - deviceLocationEvent = evt; - } - - // Track app install events for per-app metrics - AggregateAppInstallEvent(evt, request.TenantId, request.SessionId, appInstallUpdates); - } - - // Store app install summaries - foreach (var summary in appInstallUpdates.Values) - { - await _storageService.StoreAppInstallSummaryAsync(summary.Summary); - } - - // Extract geo-location data and merge into session row - if (deviceLocationEvent?.Data != null) - { - var geoData = deviceLocationEvent.Data; - await _storageService.UpdateSessionGeoAsync( - request.TenantId, - request.SessionId, - 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 - ); - } - - // Update session status based on events - // statusTransitioned = true only when this is the FIRST time the session reaches Succeeded/Failed - // (UpdateSessionStatusAsync returns false if already in a terminal state — idempotency guard) - bool statusTransitioned = false; - bool whiteGloveStatusTransitioned = false; - if (completionEvent != null) - { - // Enrollment completed successfully - use event timestamp for accurate duration - statusTransitioned = await _storageService.UpdateSessionStatusAsync( - request.TenantId, - request.SessionId, - SessionStatus.Succeeded, - completionEvent.Phase, - completedAt: completionEvent.Timestamp, - earliestEventTimestamp: earliestEventTimestamp, - latestEventTimestamp: latestEventTimestamp - ); - _logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned); - } - else if (failureEvent != null) - { - // Enrollment failed - use event timestamp for accurate duration - failureReason = failureEvent.Data?.ContainsKey("errorCode") == true - ? $"{failureEvent.Message} ({failureEvent.Data["errorCode"]})" - : failureEvent.Message; - - statusTransitioned = await _storageService.UpdateSessionStatusAsync( - request.TenantId, - request.SessionId, - SessionStatus.Failed, - failureEvent.Phase, - failureReason, - completedAt: failureEvent.Timestamp, - earliestEventTimestamp: earliestEventTimestamp, - latestEventTimestamp: latestEventTimestamp - ); - _logger.LogWarning("{SessionPrefix} Status: Failed - {FailureReason} (transitioned={Transitioned})", sessionPrefix, failureReason, statusTransitioned); - } - else if (espFailureEvent != null) - { - // Fallback: esp_failure arrived without enrollment_failed — - // agent-side handler likely threw an exception (catch {} swallowed it). - failureReason = espFailureEvent.Message ?? "ESP failure (backend fallback)"; - statusTransitioned = await _storageService.UpdateSessionStatusAsync( - request.TenantId, - request.SessionId, - SessionStatus.Failed, - espFailureEvent.Phase, - failureReason, - completedAt: espFailureEvent.Timestamp, - earliestEventTimestamp: earliestEventTimestamp, - latestEventTimestamp: latestEventTimestamp - ); - _logger.LogWarning("{SessionPrefix} Status: Failed via esp_failure fallback - {FailureReason} (transitioned={Transitioned})", - sessionPrefix, failureReason, statusTransitioned); - } - else if (gatherCompletionEvent != null) - { - // Gather rules collection completed — mark session as Succeeded. - // Does not trigger enrollment stats or Teams notifications (not a real enrollment). - await _storageService.UpdateSessionStatusAsync( - request.TenantId, - request.SessionId, - SessionStatus.Succeeded, - gatherCompletionEvent.Phase, - completedAt: gatherCompletionEvent.Timestamp, - earliestEventTimestamp: earliestEventTimestamp, - latestEventTimestamp: latestEventTimestamp - ); - _logger.LogInformation("{SessionPrefix} Status: Succeeded (gather_rules)", sessionPrefix); - } - else if (whiteGloveEvent != null) - { - // WhiteGlove pre-provisioning completed — transition to Pending - // IsPreProvisioned is set atomically in the same merge write to prevent - // the race condition where status update fails but IsPreProvisioned succeeds. - whiteGloveStatusTransitioned = await _storageService.UpdateSessionStatusAsync( - request.TenantId, - request.SessionId, - SessionStatus.Pending, - whiteGloveEvent.Phase, - earliestEventTimestamp: earliestEventTimestamp, - latestEventTimestamp: latestEventTimestamp, - isPreProvisioned: true, - isUserDriven: false - ); - - // Fallback: if the optimistic-concurrency merge failed despite internal retries - // (5 ETag retries + force-write), use an unconditional write to persist both - // IsPreProvisioned and Status=Pending. UpdateSessionStatusAsync only retries - // on ETag conflicts (412); any other transient error (429/503/timeout) causes - // an immediate return false — this fallback bypasses that limitation. - if (!whiteGloveStatusTransitioned) - { - _logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix); - try - { - await _storageService.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 (whiteGloveResumedEvent != null) - { - // WhiteGlove Part 2: agent signals that user enrollment started after - // pre-provisioning. Transition Pending → InProgress only if the session - // is actually in Pending state — prevents overwriting Succeeded/Failed - // if the event arrives late. - var currentSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); - if (currentSession?.Status == SessionStatus.Pending) - { - await _storageService.UpdateSessionStatusAsync( - request.TenantId, - request.SessionId, - SessionStatus.InProgress, - whiteGloveResumedEvent.Phase, - earliestEventTimestamp: earliestEventTimestamp, - latestEventTimestamp: latestEventTimestamp, - isUserDriven: true, - resumedAt: 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); - } - } - - // Always increment event count when events were stored, regardless of which - // status branch was taken above. UpdateSessionStatusAsync uses Merge-mode and - // does not touch EventCount, so this separate call is needed to keep the count - // accurate. Both methods have ETag retry logic; the second write retries on conflict. - if (processedCount > 0) - { - await _storageService.IncrementSessionEventCountAsync( - request.TenantId, - request.SessionId, - processedCount, - earliestEventTimestamp, - latestEventTimestamp, - currentPhase: lastPhaseChangeEvent?.Phase - ); - } - - // Run full analysis when enrollment ends (cost-efficient: one pass over all events) - var newRuleResults = new List(); - if (completionEvent != null || failureEvent != null) - { - try - { - var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); - newRuleResults = await ruleEngine.AnalyzeSessionAsync(request.TenantId, request.SessionId); - - foreach (var result in newRuleResults) - { - await _storageService.StoreRuleResultAsync(result); - } - - if (newRuleResults.Count > 0) - { - _logger.LogInformation($"{sessionPrefix} Enrollment-end analysis: {newRuleResults.Count} issue(s) detected"); - } - } - catch (Exception ruleEx) - { - _logger.LogWarning(ruleEx, $"{sessionPrefix} Enrollment-end analysis failed (non-fatal)"); - } - } - - // Increment platform stats (fire-and-forget, non-blocking) - _ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - if (newRuleResults.Count > 0) - _ = _storageService.IncrementPlatformStatAsync("IssuesDetected", newRuleResults.Count) - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - if (completionEvent != null) - _ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments") - .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); - - // Store diagnostics blob name on session (if agent uploaded a diagnostics package) - if (diagnosticsUploadedEvent != null) - { - var blobName = diagnosticsUploadedEvent.Data?.ContainsKey("blobName") == true - ? diagnosticsUploadedEvent.Data["blobName"]?.ToString() - : null; - if (!string.IsNullOrEmpty(blobName)) - { - await _storageService.UpdateSessionDiagnosticsBlobAsync( - request.TenantId, request.SessionId, blobName); - } - } - - // Retrieve updated session data to include in SignalR messages - var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); - - // Session age warning: log if session >4h old and still InProgress (observability only) - if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress) - { - var sessionAge = DateTime.UtcNow - updatedSession.StartedAt; - if (sessionAge.TotalHours > 4) - { - _logger.LogWarning("Session {SessionId} (tenant {TenantId}) still InProgress after {Hours:F1}h — may be stuck", - request.SessionId, request.TenantId, sessionAge.TotalHours); - } - } - - // Log warning if WhiteGlove status update was not persisted despite retries and fallback. - // Events are already stored (idempotent UpsertReplace). Do NOT return 500 here: - // the agent retries the same batch containing whiteglove_complete, hitting the same - // failing update path, creating a self-reinforcing 500 loop that blocks spool drain - // and causes data loss on the monitoring session. Returning 200 lets the agent - // drain its spool; the session may need manual correction in the worst case. - if (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 Teams notification on enrollment completion (fire-and-forget, non-fatal) - // Only send when statusTransitioned=true to prevent duplicates on retry/double-upload - if (statusTransitioned && (completionEvent != null || failureEvent != null)) - { - var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); - if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl)) - { - var notifySuccess = completionEvent != null && tenantConfig.TeamsNotifyOnSuccess; - var notifyFailure = failureEvent != null && tenantConfig.TeamsNotifyOnFailure; - 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) - // instead of the full wall-clock time which includes the pre-provisioning pause. - if (updatedSession?.IsPreProvisioned == true && updatedSession?.ResumedAt != null) - { - var completionTime = completionEvent?.Timestamp ?? failureEvent?.Timestamp; - if (completionTime.HasValue) - duration = completionTime.Value - updatedSession.ResumedAt.Value; - } - - _ = _teamsNotificationService.SendEnrollmentNotificationAsync( - tenantConfig.TeamsWebhookUrl, - updatedSession?.DeviceName, - updatedSession?.SerialNumber, - updatedSession?.Manufacturer, - updatedSession?.Model, - success: completionEvent != null, - failureReason: failureReason, - duration: duration); - } - } - } - - // Send Teams notification on WhiteGlove pre-provisioning completion (fire-and-forget, non-fatal) - // Only send when whiteGloveStatusTransitioned=true to prevent duplicates on retry/double-upload - if (whiteGloveStatusTransitioned && whiteGloveEvent != null) - { - var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); - if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnSuccess) - { - var duration = updatedSession?.DurationSeconds != null - ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) - : (TimeSpan?)null; - _ = _teamsNotificationService.SendWhiteGloveNotificationAsync( - tenantConfig.TeamsWebhookUrl, - updatedSession?.DeviceName, - updatedSession?.SerialNumber, - updatedSession?.Manufacturer, - updatedSession?.Model, - success: true, - duration: duration); - } - } - - // Send Teams notification on WhiteGlove pre-provisioning failure via esp_failure (fire-and-forget, non-fatal) - // Only notify if the session is already pre-provisioned (IsPreProvisioned=true), meaning we are in a - // WhiteGlove scenario. For normal enrollments, esp_failure is handled via enrollment_failed. - if (espFailureEvent != null && updatedSession?.IsPreProvisioned == true) - { - var tenantConfig = await _configService.GetConfigurationAsync(request.TenantId); - if (!string.IsNullOrEmpty(tenantConfig.TeamsWebhookUrl) && tenantConfig.TeamsNotifyOnFailure) - { - var duration = updatedSession?.DurationSeconds != null - ? TimeSpan.FromSeconds(updatedSession.DurationSeconds.Value) - : (TimeSpan?)null; - _ = _teamsNotificationService.SendWhiteGloveNotificationAsync( - tenantConfig.TeamsWebhookUrl, - updatedSession?.DeviceName, - updatedSession?.SerialNumber, - updatedSession?.Manufacturer, - updatedSession?.Model, - success: false, - duration: duration); - } - } - - var response = req.CreateResponse(HttpStatusCode.OK); - var responseData = new IngestEventsResponse - { - Success = true, - EventsReceived = request.Events.Count, - EventsProcessed = processedCount, - Message = $"Successfully stored {processedCount} of {request.Events.Count} events", - ProcessedAt = DateTime.UtcNow - }; - - await response.WriteAsJsonAsync(responseData); - - // Send SignalR notifications using Groups for multi-tenancy and cost optimization - // Include session data so frontend doesn't need to fetch it (cost optimization) - - // 1. Summary notification for session list updates (tenant-specific only) - // Only sent to clients in the tenant group - prevents cross-tenant data leaks - // Galactic Admins only receive "newSession" events from RegisterSessionFunction, - // not every event batch, to avoid flooding them with updates - // Note: newRuleResults intentionally omitted - list views don't use them - // Send only mutable fields as delta update (static fields like - // serialNumber, deviceName, manufacturer, model, startedAt, - // enrollmentType never change after registration) - 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) - // Only sent to clients viewing this specific session - cost-efficient signal pattern: - // frontend fetches full events from Table Storage on receipt, ensuring canonical truth - // and eliminating SignalR gap issues. Events payload omitted intentionally. - 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; - - // Signal-only pattern: no full session object — the detail page already receives - // the session delta via the "newevents" message (tenant group) and fetches fresh - // events from Table Storage on receipt. Sending the full session here was redundant. - 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 IngestEventsOutput - { - HttpResponse = response, - SignalRMessages = new[] { summaryMessage, eventsMessage } - }; - } - - /// - /// Parses NDJSON + gzip compressed request - /// Format: First line is metadata (sessionId, tenantId), subsequent lines are events - /// - private async Task ParseNdjsonGzipRequest(Stream body, string? tenantId = null) - { - // Get configuration for payload size limit (use default if tenantId not available yet) - var config = !string.IsNullOrEmpty(tenantId) - ? await _configService.GetConfigurationAsync(tenantId) - : null; - - var maxPayloadSizeBytes = (config?.MaxNdjsonPayloadSizeMB ?? 5) * 1024 * 1024; - - // Decompress gzip with size limit protection - using var decompressed = new MemoryStream(); - using (var gzip = new GZipStream(body, CompressionMode.Decompress, leaveOpen: true)) - { - // Copy with size limit to prevent memory exhaustion attacks - var buffer = new byte[8192]; - int bytesRead; - long totalBytesRead = 0; - - while ((bytesRead = await gzip.ReadAsync(buffer, 0, buffer.Length)) > 0) - { - totalBytesRead += bytesRead; - - // Check if we've exceeded the maximum payload size - if (totalBytesRead > maxPayloadSizeBytes) - { - throw new InvalidOperationException( - $"NDJSON payload size exceeds maximum allowed size of {config?.MaxNdjsonPayloadSizeMB ?? 5} MB (decompressed). " + - $"Current size: {totalBytesRead / 1024.0 / 1024.0:F2} MB" - ); - } - - await decompressed.WriteAsync(buffer, 0, bytesRead); - } - - _logger.LogDebug($"NDJSON payload decompressed: {totalBytesRead / 1024.0:F2} KB (limit: {maxPayloadSizeBytes / 1024.0 / 1024.0} MB)"); - } - - decompressed.Position = 0; - var ndjson = await new StreamReader(decompressed, Encoding.UTF8).ReadToEndAsync(); - - // Parse NDJSON (newline-delimited JSON) - var lines = ndjson.Split('\n', StringSplitOptions.RemoveEmptyEntries); - - if (lines.Length < 1) - { - throw new InvalidOperationException("NDJSON must contain at least metadata line"); - } - - // First line: metadata - var metadata = JsonConvert.DeserializeObject(lines[0]); - if (metadata == null) - { - throw new InvalidOperationException("Failed to parse NDJSON metadata"); - } - - // Subsequent lines: events - var events = new List(); - for (int i = 1; i < lines.Length; i++) - { - var evt = JsonConvert.DeserializeObject(lines[i]); - if (evt != null) - { - NormalizeEventData(evt); - events.Add(evt); - } - } - - return new IngestEventsRequest - { - SessionId = metadata.SessionId, - TenantId = metadata.TenantId, - Events = events - }; - } - - /// - /// Normalizes event Data dictionary by converting Newtonsoft JToken objects to native .NET types. - /// Required because Newtonsoft.Json deserializes nested objects as JObject/JArray, which - /// System.Text.Json (used by SignalR) cannot serialize correctly - producing [[[]]] instead of real values. - /// - private static void NormalizeEventData(EnrollmentEvent evt) - { - if (evt.Data == null || evt.Data.Count == 0) return; - var normalized = new Dictionary(); - foreach (var kvp in evt.Data) - normalized[kvp.Key] = ConvertJTokenToNative(kvp.Value); - evt.Data = normalized; - } - - private static object ConvertJTokenToNative(object value) - { - if (value is JArray jArray) - return jArray.Select(item => ConvertJTokenToNative(item)).ToList(); - if (value is JObject jObject) - { - var dict = new Dictionary(); - foreach (var prop in jObject.Properties()) - dict[prop.Name] = ConvertJTokenToNative(prop.Value); - return dict; - } - if (value is JValue jValue) - return jValue.Value ?? string.Empty; - return value; - } - - /// - /// 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() : 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; - - 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, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds); - break; - - case "app_install_failed": - summary.Status = "Failed"; - summary.CompletedAt = evt.Timestamp; - if (summary.StartedAt != DateTime.MinValue) - summary.DurationSeconds = Math.Max(1, (int)(evt.Timestamp - summary.StartedAt).TotalSeconds); - // 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 - if (summary.Status == "InProgress") - 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); - 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; - } - break; - } - - RecalculateAppDurations(state); - } - - private 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 = (int)(state.InstallStartedAt.Value - state.DownloadStartedAt.Value).TotalSeconds; - } - - // Full duration: from effective start to completion/failure. - if (summary.CompletedAt.HasValue && summary.StartedAt != DateTime.MinValue && - summary.CompletedAt.Value >= summary.StartedAt) - { - summary.DurationSeconds = (int)(summary.CompletedAt.Value - summary.StartedAt).TotalSeconds; - } - } - - 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() }; - } } public class IngestEventsOutput diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs index cba1b3e3..74c1092c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs @@ -10,7 +10,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Ingest { /// /// Emergency channel endpoint: receives critical agent-side error reports when the diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs index d44e502e..ff7bc790 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { public class GetAppMetricsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticAppMetricsFunction.cs index bb4bff00..a112005f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticAppMetricsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { public class GetGalacticAppMetricsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticGeographicMetricsFunction.cs index 43a0db31..0325bffe 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticGeographicMetricsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { public class GetGalacticGeographicMetricsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticPlatformMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticPlatformMetricsFunction.cs index 21fb1350..b4f1980a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticPlatformMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGalacticPlatformMetricsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { /// /// Function for retrieving platform agent metrics (Galactic Admin only). diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs index ee86841a..64bff5e4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { public class GetGeographicMetricsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs index ef5171a4..97817870 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs @@ -4,7 +4,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { /// /// Public endpoint - no authentication required. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs index a2e7b02d..963088ee 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { /// /// Function for retrieving platform usage metrics (Galactic Admin only) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs index 5c1cfac4..c8f9a1fa 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Metrics { /// /// Function for retrieving tenant-specific usage metrics (Tenant Admin) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportDownloadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportDownloadUrlFunction.cs index 74e62a22..fbfd8ddd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportDownloadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportDownloadUrlFunction.cs @@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Reports { /// /// Returns a short-lived SAS download URL for a session report blob stored in central blob storage. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs index d652c112..73d7843a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Reports { public class GetSessionReportsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs index fd3d0f70..bbccadb8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Reports { public class SubmitSessionReportFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/UpdateSessionReportNoteFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/UpdateSessionReportNoteFunction.cs index 0a077c09..64fd637c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/UpdateSessionReportNoteFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/UpdateSessionReportNoteFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Reports { /// /// Allows Galactic Admins to add or update an admin note on a submitted session report. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/AnalyzeRulesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/AnalyzeRulesFunction.cs index 1f6f1426..5cec6b4e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/AnalyzeRulesFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/AnalyzeRulesFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Rules { /// /// CRUD API for managing analyze rules (portal-facing, JWT auth) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs index 8e765da0..188054a8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Rules { /// /// CRUD API for managing gather rules (portal-facing, JWT auth) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs index fe7f09c9..54757f97 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Rules { /// /// Returns analysis results (rule evaluations) for a session. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/ImeLogPatternsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/ImeLogPatternsFunction.cs index e97606d8..184d89a4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/ImeLogPatternsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/ImeLogPatternsFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Rules { /// /// API for managing IME log patterns (portal-facing, JWT auth). diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs index 4cf62cf9..2df8e8b6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions; +namespace AutopilotMonitor.Functions.Functions.Rules; /// /// CRUD endpoints for managing the Private Preview tenant whitelist. diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs index f3fe9de2..5f837308 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class DeleteSessionFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs index 21fa917d..ad0a9363 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class GetAllSessionsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs index 7ab0948c..31ec407c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class GetSessionEventsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs index 405ea522..077b85ba 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs @@ -6,7 +6,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class GetSessionFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs index a22f8779..2b7c513a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs @@ -5,7 +5,7 @@ using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class GetSessionsFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs index fd9a29f9..279db936 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs @@ -7,7 +7,7 @@ using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class MarkSessionFailedFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index ec2cce98..bf6e75f6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -8,7 +8,7 @@ using Microsoft.Azure.Functions.Worker.Extensions.SignalRService; using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace AutopilotMonitor.Functions.Functions +namespace AutopilotMonitor.Functions.Functions.Sessions { public class RegisterSessionFunction { diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index e5fe4576..cbb82f3e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -1,5 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; +using AutopilotMonitor.Functions.Functions.Config; +using AutopilotMonitor.Functions.Functions.Ingest; +using AutopilotMonitor.Functions.Functions.Sessions; using AutopilotMonitor.Functions.Middleware; using AutopilotMonitor.Functions.Services; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -113,10 +116,10 @@ builder.Services.AddSingleton(); 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(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs new file mode 100644 index 00000000..e0caae3f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AutopilotMonitor.Shared.Models; +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Partial: Metrics aggregation, data cleanup, and platform stats recomputation. + /// + public partial class MaintenanceService + { + private async Task AggregateMetricsWithCatchUpAsync() + { + const int maxCatchUpDays = 7; + var today = DateTime.UtcNow.Date; + var aggregatedCount = 0; + + for (int daysBack = maxCatchUpDays; daysBack >= 1; daysBack--) + { + var date = today.AddDays(-daysBack); + var dateStr = date.ToString("yyyy-MM-dd"); + + try + { + if (await _storageService.HasUsageMetricsSnapshotAsync(dateStr)) + continue; + + _logger.LogInformation($"Catch-up: Aggregating metrics for missed date {dateStr}"); + await AggregateMetricsForDateAsync(date); + aggregatedCount++; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to aggregate metrics for {dateStr} during catch-up"); + } + } + + if (aggregatedCount > 0) + _logger.LogInformation($"Catch-up completed: aggregated {aggregatedCount} missed day(s)"); + else + _logger.LogInformation("No missed days to catch up on"); + } + + /// + /// Aggregates metrics for a specific date and saves them as historical snapshots + /// + private async Task AggregateMetricsForDateAsync(DateTime targetDate) + { + _logger.LogInformation($"Aggregating metrics for {targetDate:yyyy-MM-dd}..."); + var aggregateStart = Stopwatch.StartNew(); + + try + { + var targetDateStr = targetDate.ToString("yyyy-MM-dd"); + + var targetDateSessions = await _storageService.GetSessionsByDateRangeAsync(targetDate, targetDate.AddDays(1)); + + if (targetDateSessions.Count == 0) + { + _logger.LogInformation($"No sessions found for {targetDateStr}"); + return; + } + + var globalMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, "global", targetDateSessions); + await _storageService.SaveUsageMetricsSnapshotAsync(globalMetrics); + + var tenantGroups = targetDateSessions.GroupBy(s => s.TenantId); + foreach (var tenantGroup in tenantGroups) + { + var tenantMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, tenantGroup.Key, tenantGroup.ToList()); + await _storageService.SaveUsageMetricsSnapshotAsync(tenantMetrics); + } + + aggregateStart.Stop(); + _logger.LogInformation($"Aggregated metrics for {targetDateSessions.Count} sessions from {targetDateStr} in {aggregateStart.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to aggregate metrics for {targetDate:yyyy-MM-dd}"); + throw; + } + } + + /// + /// Computes historical metrics for a specific date and tenant + /// + private async Task ComputeUsageMetricsSnapshotAsync(string date, string tenantId, List sessions) + { + var computeStart = Stopwatch.StartNew(); + + var completed = sessions.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed).ToList(); + var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded); + var successRate = completed.Count > 0 ? Math.Round((succeeded / (double)completed.Count) * 100, 1) : 0; + + var completedWithDuration = sessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList(); + double avgDuration = 0, medianDuration = 0, p95Duration = 0, p99Duration = 0; + + if (completedWithDuration.Any()) + { + var durations = completedWithDuration.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList(); + avgDuration = Math.Round(durations.Average(), 1); + medianDuration = CalculatePercentile(durations, 50); + p95Duration = CalculatePercentile(durations, 95); + p99Duration = CalculatePercentile(durations, 99); + } + + var manufacturers = sessions + .GroupBy(s => s.Manufacturer) + .Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) }) + .OrderByDescending(x => x.Count) + .Take(5) + .ToList(); + + var models = sessions + .GroupBy(s => s.Model) + .Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) }) + .OrderByDescending(x => x.Count) + .Take(5) + .ToList(); + + var targetDate = DateTime.ParseExact(date, "yyyy-MM-dd", null); + var (uniqueUsers, loginCount) = await _storageService.GetUserActivityForDateAsync( + tenantId == "global" ? null : tenantId, targetDate); + + computeStart.Stop(); + + return new UsageMetricsSnapshot + { + Date = date, + TenantId = tenantId, + ComputedAt = DateTime.UtcNow, + ComputeDurationMs = (int)computeStart.ElapsedMilliseconds, + SessionsTotal = sessions.Count, + SessionsSucceeded = succeeded, + SessionsFailed = sessions.Count(s => s.Status == SessionStatus.Failed), + SessionsInProgress = sessions.Count(s => s.Status == SessionStatus.InProgress), + SessionsSuccessRate = successRate, + AvgDurationMinutes = avgDuration, + MedianDurationMinutes = medianDuration, + P95DurationMinutes = p95Duration, + P99DurationMinutes = p99Duration, + UniqueTenants = tenantId == "global" ? sessions.Select(s => s.TenantId).Distinct().Count() : 0, + UserDrivenSessions = sessions.Count(s => s.IsUserDriven), + WhiteGloveSessions = sessions.Count(s => s.IsPreProvisioned), + UniqueUsers = uniqueUsers, + LoginCount = loginCount, + TopManufacturers = JsonConvert.SerializeObject(manufacturers), + TopModels = JsonConvert.SerializeObject(models) + }; + } + + /// + /// Cleans up old sessions and events based on tenant retention policies + /// + private async Task CleanupOldDataAsync() + { + _logger.LogInformation("Starting data retention cleanup..."); + var cleanupStart = Stopwatch.StartNew(); + + try + { + var tenantIds = await _storageService.GetAllTenantIdsAsync(); + int totalSessionsDeleted = 0; + int totalEventsDeleted = 0; + + foreach (var tenantId in tenantIds) + { + try + { + var config = await _tenantConfigService.GetConfigurationAsync(tenantId); + var retentionDays = config?.DataRetentionDays ?? 90; + var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); + + var oldSessions = await _storageService.GetSessionsOlderThanAsync(tenantId, cutoffDate); + + if (oldSessions.Count == 0) + { + _logger.LogInformation($"Tenant {tenantId}: No sessions older than {retentionDays} days"); + continue; + } + + int sessionCount = 0; + int eventCount = 0; + int ruleResultCount = 0; + int appSummaryCount = 0; + + foreach (var session in oldSessions) + { + var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId); + eventCount += deletedEvents; + + var deletedRuleResults = await _storageService.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId); + ruleResultCount += deletedRuleResults; + + var deletedAppSummaries = await _storageService.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId); + appSummaryCount += deletedAppSummaries; + + await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId); + sessionCount++; + } + + totalSessionsDeleted += sessionCount; + totalEventsDeleted += eventCount; + + await _storageService.LogAuditEntryAsync( + tenantId, + "DataRetentionCleanup", + "Session", + $"{sessionCount} sessions", + "System.DailyMaintenance", + new Dictionary + { + { "SessionsDeleted", sessionCount.ToString() }, + { "EventsDeleted", eventCount.ToString() }, + { "RuleResultsDeleted", ruleResultCount.ToString() }, + { "AppSummariesDeleted", appSummaryCount.ToString() }, + { "RetentionDays", retentionDays.ToString() }, + { "CutoffDate", cutoffDate.ToString("yyyy-MM-dd") } + }); + + _logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions, {eventCount} events, {ruleResultCount} rule results, {appSummaryCount} app summaries (retention: {retentionDays} days)"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to cleanup data for tenant {tenantId}"); + } + } + + cleanupStart.Stop(); + _logger.LogInformation($"Data retention cleanup completed: {totalSessionsDeleted} sessions and {totalEventsDeleted} events deleted in {cleanupStart.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to cleanup old data"); + } + } + + /// + /// Recomputes platform-wide stats from all tables. + /// Used on the public landing page (no auth required). + /// + private async Task RecomputePlatformStatsAsync() + { + _logger.LogInformation("Recomputing platform stats..."); + var sw = Stopwatch.StartNew(); + + try + { + var tenantIds = await _storageService.GetAllTenantIdsAsync(); + var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync(); + long totalEnrollments = 0; + long successfulEnrollments = 0; + long totalEvents = 0; + long totalUsers = 0; + var uniqueModels = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tid in tenantIds) + { + var sessions = await _storageService.GetSessionsAsync(tid, maxResults: 10000); + totalEnrollments += sessions.Count; + successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded); + + foreach (var s in sessions) + { + var modelKey = $"{s.Manufacturer} {s.Model}".Trim(); + if (!string.IsNullOrEmpty(modelKey)) + uniqueModels.Add(modelKey); + totalEvents += s.EventCount; + } + + var userMetrics = await _storageService.GetUserActivityMetricsAsync(tid); + totalUsers += userMetrics.TotalUniqueUsers; + } + + var existingStats = await _storageService.GetPlatformStatsAsync(); + + var stats = new PlatformStats + { + TotalEnrollments = totalEnrollments, + TotalUsers = totalUsers, + TotalTenants = tenantIds.Count, + TotalSignedUpTenants = allConfigs.Count, + UniqueDeviceModels = uniqueModels.Count, + TotalEventsProcessed = totalEvents, + SuccessfulEnrollments = successfulEnrollments, + IssuesDetected = existingStats?.IssuesDetected ?? 0, + LastFullCompute = DateTime.UtcNow, + LastUpdated = DateTime.UtcNow + }; + + await _storageService.SavePlatformStatsAsync(stats); + await TryPublishPlatformStatsJsonAsync(stats); + + sw.Stop(); + _logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " + + $"{totalEnrollments} enrollments, {totalUsers} users, {tenantIds.Count} tenants, {uniqueModels.Count} models"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to recompute platform stats"); + } + } + + /// + /// Publishes versioned platform stats JSON + alias manifest to Blob Storage. + /// This must never fail maintenance execution. + /// + private async Task TryPublishPlatformStatsJsonAsync(PlatformStats stats) + { + try + { + var adminConfig = await _adminConfigurationService.GetConfigurationAsync(); + var containerSasUrl = adminConfig.PlatformStatsBlobSasUrl?.Trim(); + + if (string.IsNullOrWhiteSpace(containerSasUrl)) + { + _logger.LogInformation("Skipping platform stats JSON publish: PlatformStatsBlobSasUrl is not configured."); + return; + } + + var containerClient = new BlobContainerClient(new Uri(containerSasUrl)); + var generatedAtUtc = DateTime.UtcNow; + var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json"; + + var versionedPayload = new + { + totalEnrollments = stats.TotalEnrollments, + totalUsers = stats.TotalUsers, + totalTenants = stats.TotalTenants, + totalSignedUpTenants = stats.TotalSignedUpTenants, + uniqueDeviceModels = stats.UniqueDeviceModels, + totalEventsProcessed = stats.TotalEventsProcessed, + successfulEnrollments = stats.SuccessfulEnrollments, + issuesDetected = stats.IssuesDetected, + lastFullCompute = stats.LastFullCompute, + lastUpdated = stats.LastUpdated + }; + + var aliasPayload = new + { + latest = versionedFileName, + generatedAtUtc = generatedAtUtc.ToString("o") + }; + + await UploadJsonBlobAsync(containerClient, versionedFileName, versionedPayload, PlatformStatsVersionedCacheControl); + await UploadJsonBlobAsync(containerClient, PlatformStatsAliasFileName, aliasPayload, PlatformStatsAliasCacheControl); + + _logger.LogInformation( + "Published platform stats JSON blobs: versioned={VersionedFile} and alias={AliasFile}", + versionedFileName, + PlatformStatsAliasFileName); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to publish platform stats JSON to Blob Storage. Maintenance continues."); + } + } + + private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl) + { + var blobClient = containerClient.GetBlobClient(blobName); + var json = JsonConvert.SerializeObject(payload); + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); + + await blobClient.UploadAsync(stream, overwrite: true); + await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders + { + ContentType = "application/json; charset=utf-8", + CacheControl = cacheControl + }); + await blobClient.SetAccessTierAsync(AccessTier.Hot); + } + + /// + /// Calculates percentile value from sorted list + /// + private double CalculatePercentile(List sortedValues, int percentile) + { + if (sortedValues.Count == 0) return 0; + + var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1; + index = Math.Max(0, Math.Min(index, sortedValues.Count - 1)); + return Math.Round(sortedValues[index], 1); + } + } +} + diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 2b3692d1..f79aa6ef 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -38,7 +38,7 @@ namespace AutopilotMonitor.Functions.Services /// 3. Deletes old sessions and events based on tenant retention policies /// 4. Recomputes platform-wide stats for the landing page /// - public class MaintenanceService + public partial class MaintenanceService { private readonly TableStorageService _storageService; private readonly TenantConfigurationService _tenantConfigService; @@ -318,378 +318,5 @@ namespace AutopilotMonitor.Functions.Services /// Aggregates metrics for any missed days in the last 7 days, plus yesterday. /// Checks the UsageMetrics table for existing snapshots to avoid re-aggregation. /// - private async Task AggregateMetricsWithCatchUpAsync() - { - const int maxCatchUpDays = 7; - var today = DateTime.UtcNow.Date; - var aggregatedCount = 0; - - for (int daysBack = maxCatchUpDays; daysBack >= 1; daysBack--) - { - var date = today.AddDays(-daysBack); - var dateStr = date.ToString("yyyy-MM-dd"); - - try - { - if (await _storageService.HasUsageMetricsSnapshotAsync(dateStr)) - continue; - - _logger.LogInformation($"Catch-up: Aggregating metrics for missed date {dateStr}"); - await AggregateMetricsForDateAsync(date); - aggregatedCount++; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to aggregate metrics for {dateStr} during catch-up"); - } - } - - if (aggregatedCount > 0) - _logger.LogInformation($"Catch-up completed: aggregated {aggregatedCount} missed day(s)"); - else - _logger.LogInformation("No missed days to catch up on"); - } - - /// - /// Aggregates metrics for a specific date and saves them as historical snapshots - /// - private async Task AggregateMetricsForDateAsync(DateTime targetDate) - { - _logger.LogInformation($"Aggregating metrics for {targetDate:yyyy-MM-dd}..."); - var aggregateStart = Stopwatch.StartNew(); - - try - { - var targetDateStr = targetDate.ToString("yyyy-MM-dd"); - - var targetDateSessions = await _storageService.GetSessionsByDateRangeAsync(targetDate, targetDate.AddDays(1)); - - if (targetDateSessions.Count == 0) - { - _logger.LogInformation($"No sessions found for {targetDateStr}"); - return; - } - - var globalMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, "global", targetDateSessions); - await _storageService.SaveUsageMetricsSnapshotAsync(globalMetrics); - - var tenantGroups = targetDateSessions.GroupBy(s => s.TenantId); - foreach (var tenantGroup in tenantGroups) - { - var tenantMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, tenantGroup.Key, tenantGroup.ToList()); - await _storageService.SaveUsageMetricsSnapshotAsync(tenantMetrics); - } - - aggregateStart.Stop(); - _logger.LogInformation($"Aggregated metrics for {targetDateSessions.Count} sessions from {targetDateStr} in {aggregateStart.ElapsedMilliseconds}ms"); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to aggregate metrics for {targetDate:yyyy-MM-dd}"); - throw; - } - } - - /// - /// Computes historical metrics for a specific date and tenant - /// - private async Task ComputeUsageMetricsSnapshotAsync(string date, string tenantId, List sessions) - { - var computeStart = Stopwatch.StartNew(); - - var completed = sessions.Where(s => s.Status == SessionStatus.Succeeded || s.Status == SessionStatus.Failed).ToList(); - var succeeded = sessions.Count(s => s.Status == SessionStatus.Succeeded); - var successRate = completed.Count > 0 ? Math.Round((succeeded / (double)completed.Count) * 100, 1) : 0; - - var completedWithDuration = sessions.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0).ToList(); - double avgDuration = 0, medianDuration = 0, p95Duration = 0, p99Duration = 0; - - if (completedWithDuration.Any()) - { - var durations = completedWithDuration.Select(s => s.DurationSeconds!.Value / 60.0).OrderBy(d => d).ToList(); - avgDuration = Math.Round(durations.Average(), 1); - medianDuration = CalculatePercentile(durations, 50); - p95Duration = CalculatePercentile(durations, 95); - p99Duration = CalculatePercentile(durations, 99); - } - - var manufacturers = sessions - .GroupBy(s => s.Manufacturer) - .Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) }) - .OrderByDescending(x => x.Count) - .Take(5) - .ToList(); - - var models = sessions - .GroupBy(s => s.Model) - .Select(g => new { Name = g.Key, Count = g.Count(), Percentage = Math.Round((g.Count() / (double)sessions.Count) * 100, 1) }) - .OrderByDescending(x => x.Count) - .Take(5) - .ToList(); - - var targetDate = DateTime.ParseExact(date, "yyyy-MM-dd", null); - var (uniqueUsers, loginCount) = await _storageService.GetUserActivityForDateAsync( - tenantId == "global" ? null : tenantId, targetDate); - - computeStart.Stop(); - - return new UsageMetricsSnapshot - { - Date = date, - TenantId = tenantId, - ComputedAt = DateTime.UtcNow, - ComputeDurationMs = (int)computeStart.ElapsedMilliseconds, - SessionsTotal = sessions.Count, - SessionsSucceeded = succeeded, - SessionsFailed = sessions.Count(s => s.Status == SessionStatus.Failed), - SessionsInProgress = sessions.Count(s => s.Status == SessionStatus.InProgress), - SessionsSuccessRate = successRate, - AvgDurationMinutes = avgDuration, - MedianDurationMinutes = medianDuration, - P95DurationMinutes = p95Duration, - P99DurationMinutes = p99Duration, - UniqueTenants = tenantId == "global" ? sessions.Select(s => s.TenantId).Distinct().Count() : 0, - UserDrivenSessions = sessions.Count(s => s.IsUserDriven), - WhiteGloveSessions = sessions.Count(s => s.IsPreProvisioned), - UniqueUsers = uniqueUsers, - LoginCount = loginCount, - TopManufacturers = JsonConvert.SerializeObject(manufacturers), - TopModels = JsonConvert.SerializeObject(models) - }; - } - - /// - /// Cleans up old sessions and events based on tenant retention policies - /// - private async Task CleanupOldDataAsync() - { - _logger.LogInformation("Starting data retention cleanup..."); - var cleanupStart = Stopwatch.StartNew(); - - try - { - var tenantIds = await _storageService.GetAllTenantIdsAsync(); - int totalSessionsDeleted = 0; - int totalEventsDeleted = 0; - - foreach (var tenantId in tenantIds) - { - try - { - var config = await _tenantConfigService.GetConfigurationAsync(tenantId); - var retentionDays = config?.DataRetentionDays ?? 90; - var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); - - var oldSessions = await _storageService.GetSessionsOlderThanAsync(tenantId, cutoffDate); - - if (oldSessions.Count == 0) - { - _logger.LogInformation($"Tenant {tenantId}: No sessions older than {retentionDays} days"); - continue; - } - - int sessionCount = 0; - int eventCount = 0; - int ruleResultCount = 0; - int appSummaryCount = 0; - - foreach (var session in oldSessions) - { - var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId); - eventCount += deletedEvents; - - var deletedRuleResults = await _storageService.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId); - ruleResultCount += deletedRuleResults; - - var deletedAppSummaries = await _storageService.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId); - appSummaryCount += deletedAppSummaries; - - await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId); - sessionCount++; - } - - totalSessionsDeleted += sessionCount; - totalEventsDeleted += eventCount; - - await _storageService.LogAuditEntryAsync( - tenantId, - "DataRetentionCleanup", - "Session", - $"{sessionCount} sessions", - "System.DailyMaintenance", - new Dictionary - { - { "SessionsDeleted", sessionCount.ToString() }, - { "EventsDeleted", eventCount.ToString() }, - { "RuleResultsDeleted", ruleResultCount.ToString() }, - { "AppSummariesDeleted", appSummaryCount.ToString() }, - { "RetentionDays", retentionDays.ToString() }, - { "CutoffDate", cutoffDate.ToString("yyyy-MM-dd") } - }); - - _logger.LogInformation($"Tenant {tenantId}: Deleted {sessionCount} sessions, {eventCount} events, {ruleResultCount} rule results, {appSummaryCount} app summaries (retention: {retentionDays} days)"); - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to cleanup data for tenant {tenantId}"); - } - } - - cleanupStart.Stop(); - _logger.LogInformation($"Data retention cleanup completed: {totalSessionsDeleted} sessions and {totalEventsDeleted} events deleted in {cleanupStart.ElapsedMilliseconds}ms"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to cleanup old data"); - } - } - - /// - /// Recomputes platform-wide stats from all tables. - /// Used on the public landing page (no auth required). - /// - private async Task RecomputePlatformStatsAsync() - { - _logger.LogInformation("Recomputing platform stats..."); - var sw = Stopwatch.StartNew(); - - try - { - var tenantIds = await _storageService.GetAllTenantIdsAsync(); - var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync(); - long totalEnrollments = 0; - long successfulEnrollments = 0; - long totalEvents = 0; - long totalUsers = 0; - var uniqueModels = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var tid in tenantIds) - { - var sessions = await _storageService.GetSessionsAsync(tid, maxResults: 10000); - totalEnrollments += sessions.Count; - successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded); - - foreach (var s in sessions) - { - var modelKey = $"{s.Manufacturer} {s.Model}".Trim(); - if (!string.IsNullOrEmpty(modelKey)) - uniqueModels.Add(modelKey); - totalEvents += s.EventCount; - } - - var userMetrics = await _storageService.GetUserActivityMetricsAsync(tid); - totalUsers += userMetrics.TotalUniqueUsers; - } - - var existingStats = await _storageService.GetPlatformStatsAsync(); - - var stats = new PlatformStats - { - TotalEnrollments = totalEnrollments, - TotalUsers = totalUsers, - TotalTenants = tenantIds.Count, - TotalSignedUpTenants = allConfigs.Count, - UniqueDeviceModels = uniqueModels.Count, - TotalEventsProcessed = totalEvents, - SuccessfulEnrollments = successfulEnrollments, - IssuesDetected = existingStats?.IssuesDetected ?? 0, - LastFullCompute = DateTime.UtcNow, - LastUpdated = DateTime.UtcNow - }; - - await _storageService.SavePlatformStatsAsync(stats); - await TryPublishPlatformStatsJsonAsync(stats); - - sw.Stop(); - _logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " + - $"{totalEnrollments} enrollments, {totalUsers} users, {tenantIds.Count} tenants, {uniqueModels.Count} models"); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to recompute platform stats"); - } - } - - /// - /// Publishes versioned platform stats JSON + alias manifest to Blob Storage. - /// This must never fail maintenance execution. - /// - private async Task TryPublishPlatformStatsJsonAsync(PlatformStats stats) - { - try - { - var adminConfig = await _adminConfigurationService.GetConfigurationAsync(); - var containerSasUrl = adminConfig.PlatformStatsBlobSasUrl?.Trim(); - - if (string.IsNullOrWhiteSpace(containerSasUrl)) - { - _logger.LogInformation("Skipping platform stats JSON publish: PlatformStatsBlobSasUrl is not configured."); - return; - } - - var containerClient = new BlobContainerClient(new Uri(containerSasUrl)); - var generatedAtUtc = DateTime.UtcNow; - var versionedFileName = $"platform-stats.{generatedAtUtc:yyyy-MM-dd}.json"; - - var versionedPayload = new - { - totalEnrollments = stats.TotalEnrollments, - totalUsers = stats.TotalUsers, - totalTenants = stats.TotalTenants, - totalSignedUpTenants = stats.TotalSignedUpTenants, - uniqueDeviceModels = stats.UniqueDeviceModels, - totalEventsProcessed = stats.TotalEventsProcessed, - successfulEnrollments = stats.SuccessfulEnrollments, - issuesDetected = stats.IssuesDetected, - lastFullCompute = stats.LastFullCompute, - lastUpdated = stats.LastUpdated - }; - - var aliasPayload = new - { - latest = versionedFileName, - generatedAtUtc = generatedAtUtc.ToString("o") - }; - - await UploadJsonBlobAsync(containerClient, versionedFileName, versionedPayload, PlatformStatsVersionedCacheControl); - await UploadJsonBlobAsync(containerClient, PlatformStatsAliasFileName, aliasPayload, PlatformStatsAliasCacheControl); - - _logger.LogInformation( - "Published platform stats JSON blobs: versioned={VersionedFile} and alias={AliasFile}", - versionedFileName, - PlatformStatsAliasFileName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to publish platform stats JSON to Blob Storage. Maintenance continues."); - } - } - - private async Task UploadJsonBlobAsync(BlobContainerClient containerClient, string blobName, object payload, string cacheControl) - { - var blobClient = containerClient.GetBlobClient(blobName); - var json = JsonConvert.SerializeObject(payload); - await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)); - - await blobClient.UploadAsync(stream, overwrite: true); - await blobClient.SetHttpHeadersAsync(new BlobHttpHeaders - { - ContentType = "application/json; charset=utf-8", - CacheControl = cacheControl - }); - await blobClient.SetAccessTierAsync(AccessTier.Hot); - } - - /// - /// Calculates percentile value from sorted list - /// - private double CalculatePercentile(List sortedValues, int percentile) - { - if (sortedValues.Count == 0) return 0; - - var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1; - index = Math.Max(0, Math.Min(index, sortedValues.Count - 1)); - return Math.Round(sortedValues[index], 1); - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs new file mode 100644 index 00000000..6bc267cd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs @@ -0,0 +1,534 @@ +using System.Text.RegularExpressions; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services +{ + /// + /// Partial: Individual condition evaluators (event type, event data, event count, + /// phase duration, app install duration, event correlation, confidence factors). + /// + public partial class RuleEngine + { + private (bool matched, object evidence) EvaluateCondition(RuleCondition condition, List events) + { + switch (condition.Source) + { + case "event_type": + return EvaluateEventTypeCondition(condition, events); + + case "event_data": + return EvaluateEventDataCondition(condition, events); + + case "event_count": + return EvaluateEventCountCondition(condition, events); + + case "phase_duration": + return EvaluatePhaseDurationCondition(condition, events); + + case "app_install_duration": + return EvaluateAppInstallDurationCondition(condition, events); + + case "event_correlation": + return EvaluateEventCorrelationCondition(condition, events); + + default: + return (false, "unknown source"); + } + } + + private (bool matched, object evidence) EvaluateEventTypeCondition(RuleCondition condition, List events) + { + var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); + + if (!matchingEvents.Any()) + return (false, "no matching events"); + + // If DataField is specified, check data within matching events + if (!string.IsNullOrEmpty(condition.DataField)) + { + foreach (var evt in matchingEvents) + { + var fieldValue = GetDataFieldValue(evt, condition.DataField); + if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value)) + { + return (true, new Dictionary + { + ["eventId"] = evt.EventId, + ["sequence"] = evt.Sequence, + ["timestamp"] = evt.Timestamp, + ["eventType"] = evt.EventType, + ["field"] = condition.DataField, + ["value"] = fieldValue + }); + } + } + return (false, "data field not matched"); + } + + // Just check if event type exists — return first matching event for reference + var first = matchingEvents[0]; + return (true, new Dictionary + { + ["eventId"] = first.EventId, + ["sequence"] = first.Sequence, + ["timestamp"] = first.Timestamp, + ["eventType"] = condition.EventType, + ["count"] = matchingEvents.Count + }); + } + + private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List events) + { + var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); + + foreach (var evt in matchingEvents) + { + var fieldValue = GetDataFieldValue(evt, condition.DataField); + if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value)) + { + return (true, new Dictionary + { + ["eventId"] = evt.EventId, + ["sequence"] = evt.Sequence, + ["timestamp"] = evt.Timestamp, + ["field"] = condition.DataField, + ["value"] = fieldValue + }); + } + } + + return (false, "no matching data"); + } + + private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List events) + { + var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); + + // count_per_group_gte: group by DataField value (e.g. appId), fire if any group >= threshold + if (condition.Operator == "count_per_group_gte" && !string.IsNullOrEmpty(condition.DataField) && int.TryParse(condition.Value, out var groupThreshold)) + { + var groups = matchingEvents + .Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.DataField) }) + .Where(x => !string.IsNullOrEmpty(x.Key)) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() >= groupThreshold) + .ToList(); + + if (groups.Any()) + { + var worst = groups.OrderByDescending(g => g.Count()).First(); + var firstEvent = worst.First().Event; + string groupKey = worst.Key ?? string.Empty; + string appName = GetDataFieldValue(firstEvent, "appName") ?? groupKey; + return (true, new Dictionary + { + ["eventId"] = firstEvent.EventId ?? string.Empty, + ["sequence"] = firstEvent.Sequence, + ["timestamp"] = firstEvent.Timestamp, + ["groupKey"] = groupKey, + ["appName"] = appName, + ["count"] = worst.Count(), + ["threshold"] = groupThreshold + }); + } + + var distinctApps = matchingEvents + .Select(e => GetDataFieldValue(e, condition.DataField)) + .Where(k => k != null) + .Cast() + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + return (false, new Dictionary { ["totalEvents"] = matchingEvents.Count, ["distinctApps"] = distinctApps }); + } + + // count_gte: global count across all matching events + var count = matchingEvents.Count; + if (condition.Operator == "count_gte" && int.TryParse(condition.Value, out var threshold)) + { + if (count >= threshold) + { + var first = matchingEvents[0]; + return (true, new Dictionary + { + ["eventId"] = first.EventId, + ["sequence"] = first.Sequence, + ["timestamp"] = first.Timestamp, + ["count"] = count, + ["threshold"] = threshold + }); + } + } + + return (false, new Dictionary { ["count"] = count }); + } + + private static (bool matched, object evidence) EvaluatePhaseDurationCondition(RuleCondition condition, List events) + { + // Find phase change events to calculate phase duration + var phaseEvents = events + .Where(e => e.EventType == "esp_phase_changed") + .OrderBy(e => e.Timestamp) + .ToList(); + + if (!phaseEvents.Any()) + return (false, "no phase events"); + + // condition.DataField = field to look up (e.g. "espPhase"), condition.Value = target phase name (e.g. "DeviceSetup") + var targetPhase = condition.Value; + var lookupField = string.IsNullOrEmpty(condition.DataField) ? "espPhase" : condition.DataField; + + for (int i = 0; i < phaseEvents.Count; i++) + { + var evt = phaseEvents[i]; + var currentPhase = evt.Data?.ContainsKey(lookupField) == true + ? evt.Data[lookupField]?.ToString() + : null; + + if (!string.Equals(currentPhase, targetPhase, StringComparison.OrdinalIgnoreCase)) + continue; + + // Calculate how long this phase lasted + DateTime phaseEnd; + string? phaseEndEventId = null; + if (i + 1 < phaseEvents.Count) + { + phaseEnd = phaseEvents[i + 1].Timestamp; + phaseEndEventId = phaseEvents[i + 1].EventId; + } + else + { + phaseEnd = DateTime.UtcNow; // Phase is still active + } + + var durationSeconds = (phaseEnd - evt.Timestamp).TotalSeconds; + + return (true, new Dictionary + { + ["eventId"] = evt.EventId, + ["sequence"] = evt.Sequence, + ["phaseStartTimestamp"] = evt.Timestamp, + ["phaseEndEventId"] = phaseEndEventId ?? "(still active)", + ["phase"] = targetPhase, + ["durationSeconds"] = durationSeconds, + ["durationFormatted"] = FormatDuration(durationSeconds) + }); + } + + return (false, "phase not found"); + } + + private static string FormatDuration(double totalSeconds) + { + var ts = TimeSpan.FromSeconds(totalSeconds); + if (ts.TotalHours >= 1) + return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s"; + if (ts.TotalMinutes >= 1) + return $"{ts.Minutes}m {ts.Seconds}s"; + return $"{ts.Seconds}s"; + } + + private bool EvaluateConfidenceFactor(ConfidenceFactor factor, List events, Dictionary matchedConditions) + { + if (factor.Condition.StartsWith("phase_duration >")) + { + if (int.TryParse(factor.Condition.Replace("phase_duration >", "").Trim(), out var threshold)) + { + foreach (var mc in matchedConditions.Values) + { + if (mc is Dictionary dict && dict.TryGetValue("durationSeconds", out var rawDuration)) + { + var duration = Convert.ToDouble(rawDuration); + return duration > threshold; + } + } + } + } + else if (factor.Condition == "exists") + { + return matchedConditions.ContainsKey(factor.Signal); + } + else if (factor.Condition.StartsWith("count >=")) + { + if (int.TryParse(factor.Condition.Replace("count >=", "").Trim(), out var threshold)) + { + var count = events.Count(e => e.EventType == factor.Signal); + return count >= threshold; + } + } + + return false; + } + + // ===== HELPERS ===== + + private bool MatchesEventType(EnrollmentEvent evt, string eventType) + { + if (string.IsNullOrEmpty(eventType)) + return false; + + return string.Equals(evt.EventType, eventType, StringComparison.OrdinalIgnoreCase); + } + + private string? GetDataFieldValue(EnrollmentEvent evt, string dataField) + { + if (evt.Data == null || string.IsNullOrEmpty(dataField)) + return null; + + // Support checking message field directly + if (dataField.Equals("message", StringComparison.OrdinalIgnoreCase)) + return evt.Message; + + // Check in Data dictionary + if (evt.Data.TryGetValue(dataField, out var value)) + return value?.ToString(); + + // Try case-insensitive lookup + var key = evt.Data.Keys.FirstOrDefault(k => k.Equals(dataField, StringComparison.OrdinalIgnoreCase)); + if (key != null) + return evt.Data[key]?.ToString(); + + return null; + } + + private bool MatchesOperator(string fieldValue, string op, string compareValue) + { + switch (op?.ToLower()) + { + case "equals": + return string.Equals(fieldValue, compareValue, StringComparison.OrdinalIgnoreCase); + + case "contains": + return fieldValue.IndexOf(compareValue, StringComparison.OrdinalIgnoreCase) >= 0; + + case "regex": + try + { + return Regex.IsMatch(fieldValue, compareValue, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + } + catch + { + return false; + } + + case "gt": + return double.TryParse(fieldValue, out var gt1) && double.TryParse(compareValue, out var gt2) && gt1 > gt2; + + case "lt": + return double.TryParse(fieldValue, out var lt1) && double.TryParse(compareValue, out var lt2) && lt1 < lt2; + + case "gte": + return double.TryParse(fieldValue, out var gte1) && double.TryParse(compareValue, out var gte2) && gte1 >= gte2; + + case "lte": + return double.TryParse(fieldValue, out var lte1) && double.TryParse(compareValue, out var lte2) && lte1 <= lte2; + + case "exists": + return !string.IsNullOrEmpty(fieldValue); + + default: + return false; + } + } + + private (bool matched, object evidence) EvaluateAppInstallDurationCondition(RuleCondition condition, List events) + { + var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); + + var completionEventTypes = string.IsNullOrWhiteSpace(condition.EventType) + ? new HashSet(new[] { "app_install_completed", "app_install_failed" }, StringComparer.OrdinalIgnoreCase) + : new HashSet(new[] { condition.EventType }, StringComparer.OrdinalIgnoreCase); + + var completionEvents = sortedEvents + .Where(e => completionEventTypes.Contains(e.EventType ?? string.Empty)) + .ToList(); + + foreach (var completionEvent in completionEvents) + { + var appId = GetDataFieldValue(completionEvent, "appId"); + var appName = GetDataFieldValue(completionEvent, "appName"); + var appKey = !string.IsNullOrWhiteSpace(appId) ? appId : appName; + + if (string.IsNullOrWhiteSpace(appKey)) + continue; + + var startEvent = sortedEvents.LastOrDefault(e => + (string.Equals(e.EventType, "app_install_started", StringComparison.OrdinalIgnoreCase) || + string.Equals(e.EventType, "app_install_start", StringComparison.OrdinalIgnoreCase)) && + e.Timestamp <= completionEvent.Timestamp && + string.Equals(GetDataFieldValue(e, "appId") ?? GetDataFieldValue(e, "appName"), appKey, StringComparison.OrdinalIgnoreCase)); + + if (startEvent == null) + continue; + + var durationSeconds = Math.Max(0, (completionEvent.Timestamp - startEvent.Timestamp).TotalSeconds); + + if (!MatchesOperator(durationSeconds.ToString(), condition.Operator, condition.Value)) + continue; + + return (true, new Dictionary + { + ["eventId"] = completionEvent.EventId, + ["sequence"] = completionEvent.Sequence, + ["startEventId"] = startEvent.EventId, + ["startTimestamp"] = startEvent.Timestamp, + ["endTimestamp"] = completionEvent.Timestamp, + ["eventType"] = completionEvent.EventType, + ["appId"] = appId ?? string.Empty, + ["appName"] = appName ?? appKey, + ["durationSeconds"] = durationSeconds, + ["durationFormatted"] = FormatDuration(durationSeconds) + }); + } + + return (false, "no app install duration matched"); + } + + /// + /// Generic event correlation: finds pairs of events (A before B) sharing + /// the same value in a join field, with optional filters on each event + /// and optional time window constraint. + /// + /// Condition fields used: + /// EventType = Event A type (required) + /// CorrelateEventType = Event B type (required) + /// JoinField = field that must match in both events (required, e.g. "appId") + /// TimeWindowSeconds = max seconds between A and B (optional, 0/null = unlimited) + /// DataField/Operator/Value = optional filter on Event B + /// EventAFilterField/Operator/Value = optional filter on Event A + /// + private (bool matched, object evidence) EvaluateEventCorrelationCondition(RuleCondition condition, List events) + { + if (string.IsNullOrEmpty(condition.EventType) || + string.IsNullOrEmpty(condition.CorrelateEventType) || + string.IsNullOrEmpty(condition.JoinField)) + { + _logger.LogWarning($"event_correlation condition '{condition.Signal}' missing required fields (EventType, CorrelateEventType, JoinField)"); + return (false, "event_correlation requires EventType, CorrelateEventType, and JoinField"); + } + + var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); + + // Collect Event A candidates with optional filter + var eventAList = sortedEvents + .Where(e => MatchesEventType(e, condition.EventType)) + .Where(e => + { + if (string.IsNullOrEmpty(condition.EventAFilterField)) + return true; + var val = GetDataFieldValue(e, condition.EventAFilterField); + return val != null && MatchesOperator(val, condition.EventAFilterOperator ?? "exists", condition.EventAFilterValue ?? ""); + }) + .ToList(); + + // Collect Event B candidates with optional DataField/Operator/Value filter + var eventBList = sortedEvents + .Where(e => MatchesEventType(e, condition.CorrelateEventType)) + .Where(e => + { + if (string.IsNullOrEmpty(condition.DataField)) + return true; + var val = GetDataFieldValue(e, condition.DataField); + return val != null && MatchesOperator(val, condition.Operator ?? "exists", condition.Value ?? ""); + }) + .ToList(); + + if (!eventAList.Any() || !eventBList.Any()) + return (false, "no matching event pairs found"); + + // Index Event A by join field value for O(1) lookup + var eventAByJoinKey = eventAList + .Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.JoinField) }) + .Where(x => !string.IsNullOrEmpty(x.Key)) + .GroupBy(x => x.Key!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.Select(x => x.Event).ToList(), StringComparer.OrdinalIgnoreCase); + + // For each Event B, find a matching Event A (same join key, A before B, within time window) + var matchedPairs = new List>(); + var matchedJoinKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var eventB in eventBList) + { + var bKey = GetDataFieldValue(eventB, condition.JoinField); + if (string.IsNullOrEmpty(bKey) || !eventAByJoinKey.ContainsKey(bKey)) + continue; + + // Find the latest Event A that occurred before Event B + var matchingA = eventAByJoinKey[bKey] + .Where(a => a.Timestamp < eventB.Timestamp || + (a.Timestamp == eventB.Timestamp && a.Sequence < eventB.Sequence)) + .Where(a => + { + if (condition.TimeWindowSeconds == null || condition.TimeWindowSeconds <= 0) + return true; + return (eventB.Timestamp - a.Timestamp).TotalSeconds <= condition.TimeWindowSeconds.Value; + }) + .OrderByDescending(a => a.Timestamp) + .ThenByDescending(a => a.Sequence) + .FirstOrDefault(); + + if (matchingA == null) + continue; + + // One match per join key to avoid duplicates + if (matchedJoinKeys.Contains(bKey)) + continue; + matchedJoinKeys.Add(bKey); + + // Build evidence for this correlated pair + var pair = new Dictionary + { + ["joinField"] = condition.JoinField, + ["joinValue"] = bKey, + ["eventA_eventId"] = matchingA.EventId ?? string.Empty, + ["eventA_eventType"] = matchingA.EventType ?? string.Empty, + ["eventA_timestamp"] = matchingA.Timestamp, + ["eventA_sequence"] = matchingA.Sequence, + ["eventA_message"] = matchingA.Message ?? string.Empty, + ["eventB_eventId"] = eventB.EventId ?? string.Empty, + ["eventB_eventType"] = eventB.EventType ?? string.Empty, + ["eventB_timestamp"] = eventB.Timestamp, + ["eventB_sequence"] = eventB.Sequence, + ["eventB_message"] = eventB.Message ?? string.Empty, + ["timeDeltaSeconds"] = (eventB.Timestamp - matchingA.Timestamp).TotalSeconds + }; + + // Add well-known data fields from both events + AddDataFieldsToEvidence(pair, matchingA, "eventA_", condition.JoinField); + AddDataFieldsToEvidence(pair, eventB, "eventB_", condition.JoinField); + + matchedPairs.Add(pair); + } + + if (!matchedPairs.Any()) + return (false, "no correlated event pairs found"); + + // Single match: flat dictionary; multiple: first as primary + allMatches list + var primary = matchedPairs[0]; + primary["totalMatches"] = matchedPairs.Count; + if (matchedPairs.Count > 1) + { + primary["allMatches"] = matchedPairs; + } + return (true, primary); + } + + /// + /// Adds well-known data fields from an event to the evidence dictionary with a prefix. + /// + private void AddDataFieldsToEvidence(Dictionary evidence, EnrollmentEvent evt, string prefix, string joinField) + { + if (evt.Data == null) return; + + var knownFields = new[] { "appId", "appName", "errorPatternId", "errorDetail", "errorCode", "status" }; + + foreach (var field in knownFields) + { + var val = GetDataFieldValue(evt, field); + if (val != null) + evidence[$"{prefix}{field}"] = val; + } + } + } +} + diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index 7a46059c..83b400ef 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -9,7 +9,7 @@ namespace AutopilotMonitor.Functions.Services /// Runs once at enrollment end or on-demand via "Analyze Now" button. /// All rules (single + correlation) are evaluated in a single pass over all events. /// - public class RuleEngine + public partial class RuleEngine { private readonly AnalyzeRuleService _ruleService; private readonly TableStorageService _storageService; @@ -152,524 +152,5 @@ namespace AutopilotMonitor.Functions.Services /// /// Evaluates a single condition against the event stream /// - private (bool matched, object evidence) EvaluateCondition(RuleCondition condition, List events) - { - switch (condition.Source) - { - case "event_type": - return EvaluateEventTypeCondition(condition, events); - - case "event_data": - return EvaluateEventDataCondition(condition, events); - - case "event_count": - return EvaluateEventCountCondition(condition, events); - - case "phase_duration": - return EvaluatePhaseDurationCondition(condition, events); - - case "app_install_duration": - return EvaluateAppInstallDurationCondition(condition, events); - - case "event_correlation": - return EvaluateEventCorrelationCondition(condition, events); - - default: - return (false, "unknown source"); - } - } - - private (bool matched, object evidence) EvaluateEventTypeCondition(RuleCondition condition, List events) - { - var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); - - if (!matchingEvents.Any()) - return (false, "no matching events"); - - // If DataField is specified, check data within matching events - if (!string.IsNullOrEmpty(condition.DataField)) - { - foreach (var evt in matchingEvents) - { - var fieldValue = GetDataFieldValue(evt, condition.DataField); - if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value)) - { - return (true, new Dictionary - { - ["eventId"] = evt.EventId, - ["sequence"] = evt.Sequence, - ["timestamp"] = evt.Timestamp, - ["eventType"] = evt.EventType, - ["field"] = condition.DataField, - ["value"] = fieldValue - }); - } - } - return (false, "data field not matched"); - } - - // Just check if event type exists — return first matching event for reference - var first = matchingEvents[0]; - return (true, new Dictionary - { - ["eventId"] = first.EventId, - ["sequence"] = first.Sequence, - ["timestamp"] = first.Timestamp, - ["eventType"] = condition.EventType, - ["count"] = matchingEvents.Count - }); - } - - private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List events) - { - var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); - - foreach (var evt in matchingEvents) - { - var fieldValue = GetDataFieldValue(evt, condition.DataField); - if (fieldValue != null && MatchesOperator(fieldValue, condition.Operator, condition.Value)) - { - return (true, new Dictionary - { - ["eventId"] = evt.EventId, - ["sequence"] = evt.Sequence, - ["timestamp"] = evt.Timestamp, - ["field"] = condition.DataField, - ["value"] = fieldValue - }); - } - } - - return (false, "no matching data"); - } - - private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List events) - { - var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList(); - - // count_per_group_gte: group by DataField value (e.g. appId), fire if any group >= threshold - if (condition.Operator == "count_per_group_gte" && !string.IsNullOrEmpty(condition.DataField) && int.TryParse(condition.Value, out var groupThreshold)) - { - var groups = matchingEvents - .Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.DataField) }) - .Where(x => !string.IsNullOrEmpty(x.Key)) - .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) - .Where(g => g.Count() >= groupThreshold) - .ToList(); - - if (groups.Any()) - { - var worst = groups.OrderByDescending(g => g.Count()).First(); - var firstEvent = worst.First().Event; - string groupKey = worst.Key ?? string.Empty; - string appName = GetDataFieldValue(firstEvent, "appName") ?? groupKey; - return (true, new Dictionary - { - ["eventId"] = firstEvent.EventId ?? string.Empty, - ["sequence"] = firstEvent.Sequence, - ["timestamp"] = firstEvent.Timestamp, - ["groupKey"] = groupKey, - ["appName"] = appName, - ["count"] = worst.Count(), - ["threshold"] = groupThreshold - }); - } - - var distinctApps = matchingEvents - .Select(e => GetDataFieldValue(e, condition.DataField)) - .Where(k => k != null) - .Cast() - .Distinct(StringComparer.OrdinalIgnoreCase) - .Count(); - return (false, new Dictionary { ["totalEvents"] = matchingEvents.Count, ["distinctApps"] = distinctApps }); - } - - // count_gte: global count across all matching events - var count = matchingEvents.Count; - if (condition.Operator == "count_gte" && int.TryParse(condition.Value, out var threshold)) - { - if (count >= threshold) - { - var first = matchingEvents[0]; - return (true, new Dictionary - { - ["eventId"] = first.EventId, - ["sequence"] = first.Sequence, - ["timestamp"] = first.Timestamp, - ["count"] = count, - ["threshold"] = threshold - }); - } - } - - return (false, new Dictionary { ["count"] = count }); - } - - private static (bool matched, object evidence) EvaluatePhaseDurationCondition(RuleCondition condition, List events) - { - // Find phase change events to calculate phase duration - var phaseEvents = events - .Where(e => e.EventType == "esp_phase_changed") - .OrderBy(e => e.Timestamp) - .ToList(); - - if (!phaseEvents.Any()) - return (false, "no phase events"); - - // condition.DataField = field to look up (e.g. "espPhase"), condition.Value = target phase name (e.g. "DeviceSetup") - var targetPhase = condition.Value; - var lookupField = string.IsNullOrEmpty(condition.DataField) ? "espPhase" : condition.DataField; - - for (int i = 0; i < phaseEvents.Count; i++) - { - var evt = phaseEvents[i]; - var currentPhase = evt.Data?.ContainsKey(lookupField) == true - ? evt.Data[lookupField]?.ToString() - : null; - - if (!string.Equals(currentPhase, targetPhase, StringComparison.OrdinalIgnoreCase)) - continue; - - // Calculate how long this phase lasted - DateTime phaseEnd; - string? phaseEndEventId = null; - if (i + 1 < phaseEvents.Count) - { - phaseEnd = phaseEvents[i + 1].Timestamp; - phaseEndEventId = phaseEvents[i + 1].EventId; - } - else - { - phaseEnd = DateTime.UtcNow; // Phase is still active - } - - var durationSeconds = (phaseEnd - evt.Timestamp).TotalSeconds; - - return (true, new Dictionary - { - ["eventId"] = evt.EventId, - ["sequence"] = evt.Sequence, - ["phaseStartTimestamp"] = evt.Timestamp, - ["phaseEndEventId"] = phaseEndEventId ?? "(still active)", - ["phase"] = targetPhase, - ["durationSeconds"] = durationSeconds, - ["durationFormatted"] = FormatDuration(durationSeconds) - }); - } - - return (false, "phase not found"); - } - - private static string FormatDuration(double totalSeconds) - { - var ts = TimeSpan.FromSeconds(totalSeconds); - if (ts.TotalHours >= 1) - return $"{(int)ts.TotalHours}h {ts.Minutes}m {ts.Seconds}s"; - if (ts.TotalMinutes >= 1) - return $"{ts.Minutes}m {ts.Seconds}s"; - return $"{ts.Seconds}s"; - } - - private bool EvaluateConfidenceFactor(ConfidenceFactor factor, List events, Dictionary matchedConditions) - { - if (factor.Condition.StartsWith("phase_duration >")) - { - if (int.TryParse(factor.Condition.Replace("phase_duration >", "").Trim(), out var threshold)) - { - foreach (var mc in matchedConditions.Values) - { - if (mc is Dictionary dict && dict.TryGetValue("durationSeconds", out var rawDuration)) - { - var duration = Convert.ToDouble(rawDuration); - return duration > threshold; - } - } - } - } - else if (factor.Condition == "exists") - { - return matchedConditions.ContainsKey(factor.Signal); - } - else if (factor.Condition.StartsWith("count >=")) - { - if (int.TryParse(factor.Condition.Replace("count >=", "").Trim(), out var threshold)) - { - var count = events.Count(e => e.EventType == factor.Signal); - return count >= threshold; - } - } - - return false; - } - - // ===== HELPERS ===== - - private bool MatchesEventType(EnrollmentEvent evt, string eventType) - { - if (string.IsNullOrEmpty(eventType)) - return false; - - return string.Equals(evt.EventType, eventType, StringComparison.OrdinalIgnoreCase); - } - - private string? GetDataFieldValue(EnrollmentEvent evt, string dataField) - { - if (evt.Data == null || string.IsNullOrEmpty(dataField)) - return null; - - // Support checking message field directly - if (dataField.Equals("message", StringComparison.OrdinalIgnoreCase)) - return evt.Message; - - // Check in Data dictionary - if (evt.Data.TryGetValue(dataField, out var value)) - return value?.ToString(); - - // Try case-insensitive lookup - var key = evt.Data.Keys.FirstOrDefault(k => k.Equals(dataField, StringComparison.OrdinalIgnoreCase)); - if (key != null) - return evt.Data[key]?.ToString(); - - return null; - } - - private bool MatchesOperator(string fieldValue, string op, string compareValue) - { - switch (op?.ToLower()) - { - case "equals": - return string.Equals(fieldValue, compareValue, StringComparison.OrdinalIgnoreCase); - - case "contains": - return fieldValue.IndexOf(compareValue, StringComparison.OrdinalIgnoreCase) >= 0; - - case "regex": - try - { - return Regex.IsMatch(fieldValue, compareValue, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); - } - catch - { - return false; - } - - case "gt": - return double.TryParse(fieldValue, out var gt1) && double.TryParse(compareValue, out var gt2) && gt1 > gt2; - - case "lt": - return double.TryParse(fieldValue, out var lt1) && double.TryParse(compareValue, out var lt2) && lt1 < lt2; - - case "gte": - return double.TryParse(fieldValue, out var gte1) && double.TryParse(compareValue, out var gte2) && gte1 >= gte2; - - case "lte": - return double.TryParse(fieldValue, out var lte1) && double.TryParse(compareValue, out var lte2) && lte1 <= lte2; - - case "exists": - return !string.IsNullOrEmpty(fieldValue); - - default: - return false; - } - } - - private (bool matched, object evidence) EvaluateAppInstallDurationCondition(RuleCondition condition, List events) - { - var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); - - var completionEventTypes = string.IsNullOrWhiteSpace(condition.EventType) - ? new HashSet(new[] { "app_install_completed", "app_install_failed" }, StringComparer.OrdinalIgnoreCase) - : new HashSet(new[] { condition.EventType }, StringComparer.OrdinalIgnoreCase); - - var completionEvents = sortedEvents - .Where(e => completionEventTypes.Contains(e.EventType ?? string.Empty)) - .ToList(); - - foreach (var completionEvent in completionEvents) - { - var appId = GetDataFieldValue(completionEvent, "appId"); - var appName = GetDataFieldValue(completionEvent, "appName"); - var appKey = !string.IsNullOrWhiteSpace(appId) ? appId : appName; - - if (string.IsNullOrWhiteSpace(appKey)) - continue; - - var startEvent = sortedEvents.LastOrDefault(e => - (string.Equals(e.EventType, "app_install_started", StringComparison.OrdinalIgnoreCase) || - string.Equals(e.EventType, "app_install_start", StringComparison.OrdinalIgnoreCase)) && - e.Timestamp <= completionEvent.Timestamp && - string.Equals(GetDataFieldValue(e, "appId") ?? GetDataFieldValue(e, "appName"), appKey, StringComparison.OrdinalIgnoreCase)); - - if (startEvent == null) - continue; - - var durationSeconds = Math.Max(0, (completionEvent.Timestamp - startEvent.Timestamp).TotalSeconds); - - if (!MatchesOperator(durationSeconds.ToString(), condition.Operator, condition.Value)) - continue; - - return (true, new Dictionary - { - ["eventId"] = completionEvent.EventId, - ["sequence"] = completionEvent.Sequence, - ["startEventId"] = startEvent.EventId, - ["startTimestamp"] = startEvent.Timestamp, - ["endTimestamp"] = completionEvent.Timestamp, - ["eventType"] = completionEvent.EventType, - ["appId"] = appId ?? string.Empty, - ["appName"] = appName ?? appKey, - ["durationSeconds"] = durationSeconds, - ["durationFormatted"] = FormatDuration(durationSeconds) - }); - } - - return (false, "no app install duration matched"); - } - - /// - /// Generic event correlation: finds pairs of events (A before B) sharing - /// the same value in a join field, with optional filters on each event - /// and optional time window constraint. - /// - /// Condition fields used: - /// EventType = Event A type (required) - /// CorrelateEventType = Event B type (required) - /// JoinField = field that must match in both events (required, e.g. "appId") - /// TimeWindowSeconds = max seconds between A and B (optional, 0/null = unlimited) - /// DataField/Operator/Value = optional filter on Event B - /// EventAFilterField/Operator/Value = optional filter on Event A - /// - private (bool matched, object evidence) EvaluateEventCorrelationCondition(RuleCondition condition, List events) - { - if (string.IsNullOrEmpty(condition.EventType) || - string.IsNullOrEmpty(condition.CorrelateEventType) || - string.IsNullOrEmpty(condition.JoinField)) - { - _logger.LogWarning($"event_correlation condition '{condition.Signal}' missing required fields (EventType, CorrelateEventType, JoinField)"); - return (false, "event_correlation requires EventType, CorrelateEventType, and JoinField"); - } - - var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); - - // Collect Event A candidates with optional filter - var eventAList = sortedEvents - .Where(e => MatchesEventType(e, condition.EventType)) - .Where(e => - { - if (string.IsNullOrEmpty(condition.EventAFilterField)) - return true; - var val = GetDataFieldValue(e, condition.EventAFilterField); - return val != null && MatchesOperator(val, condition.EventAFilterOperator ?? "exists", condition.EventAFilterValue ?? ""); - }) - .ToList(); - - // Collect Event B candidates with optional DataField/Operator/Value filter - var eventBList = sortedEvents - .Where(e => MatchesEventType(e, condition.CorrelateEventType)) - .Where(e => - { - if (string.IsNullOrEmpty(condition.DataField)) - return true; - var val = GetDataFieldValue(e, condition.DataField); - return val != null && MatchesOperator(val, condition.Operator ?? "exists", condition.Value ?? ""); - }) - .ToList(); - - if (!eventAList.Any() || !eventBList.Any()) - return (false, "no matching event pairs found"); - - // Index Event A by join field value for O(1) lookup - var eventAByJoinKey = eventAList - .Select(e => new { Event = e, Key = GetDataFieldValue(e, condition.JoinField) }) - .Where(x => !string.IsNullOrEmpty(x.Key)) - .GroupBy(x => x.Key!, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.Select(x => x.Event).ToList(), StringComparer.OrdinalIgnoreCase); - - // For each Event B, find a matching Event A (same join key, A before B, within time window) - var matchedPairs = new List>(); - var matchedJoinKeys = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var eventB in eventBList) - { - var bKey = GetDataFieldValue(eventB, condition.JoinField); - if (string.IsNullOrEmpty(bKey) || !eventAByJoinKey.ContainsKey(bKey)) - continue; - - // Find the latest Event A that occurred before Event B - var matchingA = eventAByJoinKey[bKey] - .Where(a => a.Timestamp < eventB.Timestamp || - (a.Timestamp == eventB.Timestamp && a.Sequence < eventB.Sequence)) - .Where(a => - { - if (condition.TimeWindowSeconds == null || condition.TimeWindowSeconds <= 0) - return true; - return (eventB.Timestamp - a.Timestamp).TotalSeconds <= condition.TimeWindowSeconds.Value; - }) - .OrderByDescending(a => a.Timestamp) - .ThenByDescending(a => a.Sequence) - .FirstOrDefault(); - - if (matchingA == null) - continue; - - // One match per join key to avoid duplicates - if (matchedJoinKeys.Contains(bKey)) - continue; - matchedJoinKeys.Add(bKey); - - // Build evidence for this correlated pair - var pair = new Dictionary - { - ["joinField"] = condition.JoinField, - ["joinValue"] = bKey, - ["eventA_eventId"] = matchingA.EventId ?? string.Empty, - ["eventA_eventType"] = matchingA.EventType ?? string.Empty, - ["eventA_timestamp"] = matchingA.Timestamp, - ["eventA_sequence"] = matchingA.Sequence, - ["eventA_message"] = matchingA.Message ?? string.Empty, - ["eventB_eventId"] = eventB.EventId ?? string.Empty, - ["eventB_eventType"] = eventB.EventType ?? string.Empty, - ["eventB_timestamp"] = eventB.Timestamp, - ["eventB_sequence"] = eventB.Sequence, - ["eventB_message"] = eventB.Message ?? string.Empty, - ["timeDeltaSeconds"] = (eventB.Timestamp - matchingA.Timestamp).TotalSeconds - }; - - // Add well-known data fields from both events - AddDataFieldsToEvidence(pair, matchingA, "eventA_", condition.JoinField); - AddDataFieldsToEvidence(pair, eventB, "eventB_", condition.JoinField); - - matchedPairs.Add(pair); - } - - if (!matchedPairs.Any()) - return (false, "no correlated event pairs found"); - - // Single match: flat dictionary; multiple: first as primary + allMatches list - var primary = matchedPairs[0]; - primary["totalMatches"] = matchedPairs.Count; - if (matchedPairs.Count > 1) - { - primary["allMatches"] = matchedPairs; - } - return (true, primary); - } - - /// - /// Adds well-known data fields from an event to the evidence dictionary with a prefix. - /// - private void AddDataFieldsToEvidence(Dictionary evidence, EnrollmentEvent evt, string prefix, string joinField) - { - if (evt.Data == null) return; - - var knownFields = new[] { "appId", "appName", "errorPatternId", "errorDetail", "errorCode", "status" }; - - foreach (var field in knownFields) - { - var val = GetDataFieldValue(evt, field); - if (val != null) - evidence[$"{prefix}{field}"] = val; - } - } } } diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx new file mode 100644 index 00000000..14d8921f --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { AnalyzeRule, RuleForm, getSeverityColor, getCategoryColor } from "../types"; +import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJsonToggle"; +import AnalyzeRuleFormFields from "./AnalyzeRuleFormFields"; + +interface AnalyzeRuleCardProps { + rule: AnalyzeRule; + isExpanded: boolean; + isEditing: boolean; + editForm: RuleForm; + setEditForm: (f: RuleForm) => void; + saving: boolean; + togglingRuleId: string | null; + deletingRuleId: string | null; + jsonModeEdit: boolean; + jsonText: string; + jsonError: string | null; + onToggle: () => void; + onToggleEnabled: (rule: AnalyzeRule) => void; + onStartEditing: (rule: AnalyzeRule) => void; + onSaveEdit: (rule: AnalyzeRule, formOverride?: RuleForm) => void; + onCancelEdit: () => void; + onDelete: (rule: AnalyzeRule) => void; + onExport: (rule: AnalyzeRule) => void; + onSetJsonModeEdit: (mode: boolean) => void; + onSetJsonText: (text: string) => void; + onSetJsonError: (error: string | null) => void; +} + +export default function AnalyzeRuleCard({ + rule, isExpanded, isEditing, editForm, setEditForm, saving, + togglingRuleId, deletingRuleId, + jsonModeEdit, jsonText, jsonError, + onToggle, onToggleEnabled, onStartEditing, onSaveEdit, onCancelEdit, + onDelete, onExport, + onSetJsonModeEdit, onSetJsonText, onSetJsonError, +}: AnalyzeRuleCardProps) { + const sevColor = getSeverityColor(rule.severity); + const catColor = getCategoryColor(rule.category); + const canEdit = !rule.isBuiltIn && !rule.isCommunity; + + return ( +
+ {/* Collapsed Header */} +
{ if (isEditing) return; onToggle(); }}> +
+ + + + {rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)} + + {rule.ruleId} +
+

{rule.title}

+
+ + {rule.category.charAt(0).toUpperCase() + rule.category.slice(1)} + + + {rule.isBuiltIn ? "Built-in" : rule.isCommunity ? "Community" : "Custom"} + + Threshold: {rule.confidenceThreshold}% + +
+
+ + {/* Expanded Details (read-only) */} + {isExpanded && !isEditing && ( +
+
+ Version: {rule.version} + Author: {rule.author} + Trigger: {(rule.trigger || "single").charAt(0).toUpperCase() + (rule.trigger || "single").slice(1)} + Created: {new Date(rule.createdAt).toLocaleDateString()} + Updated: {new Date(rule.updatedAt).toLocaleDateString()} + {rule.ruleId} +
+ + {rule.tags && rule.tags.length > 0 && ( +
+ {rule.tags.map((tag, idx) => (#{tag}))} +
+ )} + +
+

Description

+

{rule.description}

+
+ + {rule.conditions.length > 0 && ( +
+

Conditions ({rule.conditions.length})

+
+ {rule.conditions.map((condition, idx) => ( +
+
+ {condition.signal} + {condition.required && (Required)} +
+
+

+ Source: {condition.source} + {condition.eventType && ( | Event Type: {condition.eventType})} + {condition.dataField && ( | Field: {condition.dataField})} +

+

+ Operator: {condition.operator}{" "} + Value: {condition.value} +

+
+
+ ))} +
+
+ )} + +
+

Confidence Scoring

+
+
+ Base Confidence: {rule.baseConfidence}% + Threshold: {rule.confidenceThreshold}% +
+ {rule.confidenceFactors.length > 0 && ( +
+

Confidence Factors

+
+ {rule.confidenceFactors.map((factor, idx) => ( +
+
{factor.signal}-{factor.condition}
+ 0 ? "text-green-600" : "text-red-600"}`}>{factor.weight > 0 ? "+" : ""}{factor.weight}% +
+ ))} +
+
+ )} +
+
+ + {rule.explanation && ( +
+

Explanation

+

{rule.explanation}

+
+ )} + + {rule.remediation.length > 0 && ( +
+

Remediation Steps

+
+ {rule.remediation.map((rem, idx) => ( +
+
{rem.title}
+
    + {rem.steps.map((step, sIdx) => (
  1. {step}
  2. ))} +
+
+ ))} +
+
+ )} + + {rule.relatedDocs.length > 0 && ( +
+

Related Documentation

+
+ {rule.relatedDocs.map((doc, idx) => ( + + + {doc.title} + + ))} +
+
+ )} + + {/* Actions */} +
+ + {canEdit && ( + + )} + {!rule.isBuiltIn && !rule.isCommunity && ( + + )} +
+
+ )} + + {/* Edit Form */} + {isExpanded && isEditing && ( +
+
+
+ +

+ Editing: {rule.ruleId} +

+
+ {/* JSON Mode Toggle */} + { + if (mode) { onSetJsonText(JSON.stringify(editForm, null, 2)); } + onSetJsonModeEdit(mode); + onSetJsonError(null); + }} + /> +
+ + { + if (mode) { onSetJsonText(JSON.stringify(editForm, null, 2)); } + onSetJsonModeEdit(mode); + onSetJsonError(null); + }} + jsonText={jsonText} + onJsonTextChange={(text) => { onSetJsonText(text); onSetJsonError(null); }} + jsonError={jsonError} + onApplyJson={() => { + try { + const parsed = JSON.parse(jsonText) as RuleForm; + if (!parsed.title) throw new Error("JSON must include title"); + setEditForm({ ...editForm, ...parsed }); + onSetJsonModeEdit(false); + onSetJsonError(null); + } catch (e) { + onSetJsonError(e instanceof Error ? e.message : "Invalid JSON"); + } + }} + textareaRows={30} + description='Edit the rule as JSON. All fields are supported including event_correlation condition properties.' + > + + + +
+ + +
+
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx new file mode 100644 index 00000000..98a6a898 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleFormFields.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { + RuleForm, RuleCondition, + CATEGORIES, SEVERITIES, TRIGGERS, OPERATORS, SOURCES, + EMPTY_CONDITION, EMPTY_FACTOR, +} from "../types"; + +interface AnalyzeRuleFormFieldsProps { + form: RuleForm; + setForm: (f: RuleForm) => void; + showRuleId: boolean; +} + +export default function AnalyzeRuleFormFields({ form, setForm, showRuleId }: AnalyzeRuleFormFieldsProps) { + return ( +
+ {/* Basic Fields */} +
+ {showRuleId && ( +
+ + setForm({ ...form, ruleId: e.target.value })} placeholder="e.g., ANALYZE-CUSTOM-001" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" /> +
+ )} +
+ + setForm({ ...form, title: e.target.value })} placeholder="e.g., Proxy Authentication Failure" autoComplete="off" className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" /> +
+
+ +
+ +