From 4802668e38e842e1f22b2918e7d6148ff7aecc55 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 15 Feb 2026 17:00:18 +0100 Subject: [PATCH] Re-architecture, removal of dead code, security hardening, Rules re-work, same caching strategy everywhere used --- .../Configuration/AgentConfiguration.cs | 3 +- .../Collectors/GatherRuleExecutor.cs | 20 +- .../Monitoring/Collectors/GatherRuleGuards.cs | 34 +- .../Monitoring/Core/CleanupService.cs | 193 ++++++++++ .../Monitoring/Core/DeviceInfoProvider.cs | 86 +++++ .../Monitoring/Core/MonitoringService.cs | 266 +------------- .../Monitoring/Tracking/AppPackageState.cs | 25 +- .../Monitoring/Tracking/EnrollmentTracker.cs | 33 +- .../Monitoring/Tracking/ImeLogTracker.cs | 281 ++++++-------- .../Tracking/ImeTrackerStatePersistence.cs | 132 +++++++ src/Agent/AutopilotMonitor.Agent/Program.cs | 35 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Functions/AuthFunction.cs | 14 +- .../Functions/IngestEventsFunction.cs | 49 +-- .../Functions/RegisterSessionFunction.cs | 3 +- .../Services/AdminConfigurationService.cs | 38 +- .../Services/BuiltInAnalyzeRules.cs | 346 ++++++++++++++---- .../Services/TableStorageService.cs | 4 - .../Services/TenantConfigurationService.cs | 69 +--- .../AutopilotMonitor.Shared/Constants.cs | 151 ++++---- .../Models/AdminConfiguration.cs | 7 - .../Models/AnalyzeRule.cs | 5 - .../Models/EnrollmentEvent.cs | 1 + .../Models/GatherRule.cs | 5 - .../Models/TenantConfiguration.cs | 7 - 25 files changed, 1032 insertions(+), 777 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CleanupService.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DeviceInfoProvider.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeTrackerStatePersistence.cs diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs index 8e450b47..cfa357ee 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Configuration/AgentConfiguration.cs @@ -1,4 +1,5 @@ using System; +using AutopilotMonitor.Shared; namespace AutopilotMonitor.Agent.Core.Configuration { @@ -85,7 +86,7 @@ namespace AutopilotMonitor.Agent.Core.Configuration /// /// Name of the Scheduled Task to remove during self-destruct /// - public string ScheduledTaskName { get; set; } = "AutopilotMonitor-Agent"; + public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName; /// /// Whether to reboot the device after enrollment completes diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs index daca5d1c..d4ebe979 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleExecutor.cs @@ -17,8 +17,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 - /// NOTE: "command" is accepted as a legacy alias for "command_allowlisted" - both enforce the allowlist - /// + /// public class GatherRuleExecutor : IDisposable { private readonly AgentLogger _logger; @@ -427,10 +426,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors 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 Bypass -Command \"{command}\"", + Arguments = $"-NoProfile -ExecutionPolicy RemoteSigned -EncodedCommand {encodedCommand}", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -510,8 +511,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors if (rule.Parameters != null && rule.Parameters.TryGetValue("readContent", out readContent) && readContent == "true" && info.Length < 50000) // Max 50KB { - var content = File.ReadAllText(filePath); - data["content"] = content.Length > 4000 ? content.Substring(0, 4000) + "... (truncated)" : content; + // Read only up to 4000 chars to avoid loading entire file into memory + var buffer = new char[4000]; + int charsRead; + using (var reader = new StreamReader(filePath)) + { + charsRead = reader.Read(buffer, 0, buffer.Length); + } + var content = new string(buffer, 0, charsRead); + data["content"] = charsRead == 4000 && info.Length > 4000 + ? content + "... (truncated)" + : content; } } else if (Directory.Exists(filePath)) diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleGuards.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleGuards.cs index 211edc82..5472b2d7 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleGuards.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/GatherRuleGuards.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors @@ -12,8 +13,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors /// outside these lists are blocked and reported as security_warning events. /// /// To allow additional paths: add a prefix entry to the appropriate list. - /// Prefix matching is case-insensitive. Registry paths are expected without - /// the hive prefix (e.g. "SOFTWARE\\Microsoft\\..." not "HKLM\\..."). + /// Segment-bounded matching is used: the path must match the prefix exactly + /// up to a path separator or end of string, preventing prefix spoofing. + /// Registry paths are expected without the hive prefix (e.g. "SOFTWARE\\Microsoft\\..." not "HKLM\\..."). /// File paths should be expanded (no environment variables). /// public static class GatherRuleGuards @@ -120,7 +122,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors // ----------------------------------------------------------------------- /// - /// Returns true if the registry subPath (hive stripped) starts with an allowed prefix. + /// Returns true if the registry subPath (hive stripped) matches an allowed prefix + /// with segment-bounded matching (next char must be '\' or end of string). /// public static bool IsRegistryPathAllowed(string subPath) { @@ -128,23 +131,37 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors return false; return AllowedRegistryPrefixes.Any(prefix => - subPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + subPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (subPath.Length == prefix.Length || subPath[prefix.Length] == '\\')); } /// - /// Returns true if the expanded file path starts with an allowed prefix. + /// Returns true if the expanded file path matches an allowed prefix + /// with segment-bounded matching and path normalization to prevent traversal. /// public static bool IsFilePathAllowed(string expandedPath) { if (string.IsNullOrEmpty(expandedPath)) return false; + string normalizedPath; + try + { + normalizedPath = Path.GetFullPath(expandedPath); + } + catch + { + return false; + } + return AllowedFilePrefixes.Any(prefix => - expandedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + normalizedPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (normalizedPath.Length == prefix.Length || normalizedPath[prefix.Length] == '\\')); } /// - /// Returns true if the WMI query (trimmed) starts with an allowed prefix. + /// Returns true if the WMI query (trimmed) matches an allowed prefix + /// with boundary matching (next char must be whitespace or end of string). /// public static bool IsWmiQueryAllowed(string query) { @@ -153,7 +170,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors var trimmed = query.Trim(); return AllowedWmiQueryPrefixes.Any(prefix => - trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length]))); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CleanupService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CleanupService.cs new file mode 100644 index 00000000..450429f7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CleanupService.cs @@ -0,0 +1,193 @@ +using System; +using System.Diagnostics; +using System.IO; +using AutopilotMonitor.Agent.Core.Configuration; +using AutopilotMonitor.Agent.Core.Logging; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Core +{ + /// + /// Handles agent self-destruct and cleanup operations (PowerShell script generation and launch). + /// + public class CleanupService + { + private readonly AgentConfiguration _configuration; + private readonly AgentLogger _logger; + + public CleanupService(AgentConfiguration configuration, AgentLogger logger) + { + _configuration = configuration; + _logger = logger; + } + + /// + /// Executes full self-destruct: removes scheduled task and deletes all files + /// + public void ExecuteSelfDestruct() + { + try + { + _logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); + + var currentProcessId = Process.GetCurrentProcess().Id; + var agentBasePath = Path.GetFullPath(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "AutopilotMonitor" + )); + + var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs")); + var keepLogs = _configuration.KeepLogFile; + + // Create a self-deleting PowerShell script + var cleanupScript = $@" +$scriptPath = $MyInvocation.MyCommand.Path + +# Wait for agent process to exit +Start-Sleep -Seconds 2 +Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30 +Start-Sleep -Seconds 1 + +# Remove Scheduled Task +try {{ + Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue + Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue +}} catch {{ }} + +{(keepLogs ? $@" +# Delete everything except Logs directory +Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{ + $dest = $_.FullName + '.del' + try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }} + Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue +}} +" : $@" +# Rename folder then delete. Retry a few times to let the OS release handles. +$renamedPath = '{agentBasePath}.del' +$renamed = $false +for ($i = 1; $i -le 10; $i++) {{ + try {{ + Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop + $renamed = $true + break + }} catch {{ + Start-Sleep -Seconds 2 + }} +}} +if ($renamed) {{ + Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue +}} else {{ + Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction SilentlyContinue +}} +")} +{(_configuration.RebootOnComplete ? @" +Restart-Computer -Force -Delay 10 -Comment 'Autopilot enrollment completed - Autopilot Monitor is rebooting' +" : "")} +Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue +"; + + // Write cleanup script to temp location (outside of agent folder) + var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1"); + File.WriteAllText(tempScriptPath, cleanupScript); + _logger.Info($"Cleanup script written to {tempScriptPath}"); + + // Change CWD to temp so this process no longer holds a reference into + // the AutopilotMonitor folder tree - Windows won't allow renaming a + // directory that any process has as its current working directory. + try { Directory.SetCurrentDirectory(Path.GetTempPath()); } catch { } + + // Launch via cmd /c start so the powershell process is created outside the + // current Job Object (Scheduled Task job). cmd's 'start' command always + // creates a new process group that breaks job inheritance, even under SYSTEM. + var psi = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"\" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetTempPath() + }; + + Process.Start(psi); + _logger.Info("Cleanup script launched. Agent will now exit."); + } + catch (Exception ex) + { + _logger.Error("Error executing self-destruct", ex); + } + } + + /// + /// Executes cleanup only (files) without removing scheduled task + /// + public void ExecuteCleanup() + { + try + { + _logger.Info($"Executing CLEANUP (Files only, keeping Scheduled Task{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); + + var currentProcessId = Process.GetCurrentProcess().Id; + var agentBasePath = Path.GetFullPath(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "AutopilotMonitor" + )); + + var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs")); + var keepLogs = _configuration.KeepLogFile; + + // Create a self-deleting cleanup PowerShell script + var cleanupScript = $@" +$scriptPath = $MyInvocation.MyCommand.Path + +# Wait for agent process to exit. Wait-Process may fail if the process is already gone - that is fine. +Start-Sleep -Seconds 2 +Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30 +Start-Sleep -Seconds 1 + +{(keepLogs ? $@" +# Delete everything except Logs directory. +# Rename subdirs/files first so locked EXE bytes are released, then remove. +Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{ + $dest = $_.FullName + '.del' + try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }} + Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue +}} +" : $@" +# Rename the folder first (works even while EXE bytes are still mapped), +# then delete the renamed copy - by then all handles are closed. +$renamedPath = '{agentBasePath}.del' +try {{ Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop }} catch {{ $renamedPath = '{agentBasePath}' }} +Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue +")} +{(_configuration.RebootOnComplete ? @" +Restart-Computer -Force +" : "")} +# Delete this script +Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue +"; + + // Write cleanup script to temp location + var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1"); + File.WriteAllText(tempScriptPath, cleanupScript); + _logger.Info($"Cleanup script written to {tempScriptPath}"); + + // Launch via cmd /c start so the powershell process is created outside the + // current Job Object (Scheduled Task job). cmd's 'start' command always + // creates a new process group that breaks job inheritance, even under SYSTEM. + var psi = new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/c start \"\" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true + }; + + Process.Start(psi); + _logger.Info("Cleanup script launched. Agent will now exit."); + } + catch (Exception ex) + { + _logger.Error("Error executing cleanup", ex); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DeviceInfoProvider.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DeviceInfoProvider.cs new file mode 100644 index 00000000..52db0987 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/DeviceInfoProvider.cs @@ -0,0 +1,86 @@ +using System; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Core +{ + /// + /// Provides device hardware and OS information via WMI and registry queries. + /// + public static class DeviceInfoProvider + { + public static string GetSerialNumber() + { + try + { + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) + { + foreach (var obj in searcher.Get()) + { + return obj["SerialNumber"]?.ToString() ?? "Unknown"; + } + } + } + catch { } + return "Unknown"; + } + + public static string GetManufacturer() + { + try + { + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem")) + { + foreach (var obj in searcher.Get()) + { + return obj["Manufacturer"]?.ToString() ?? "Unknown"; + } + } + } + catch { } + return "Unknown"; + } + + public static string GetModel() + { + try + { + var manufacturer = GetManufacturer(); + if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) + { + // Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9") + // in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model + // which only contains the internal type number (e.g. "20Y3S0FP00") + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) + { + foreach (var obj in searcher.Get()) + { + return obj["Version"]?.ToString() ?? "Unknown"; + } + } + } + + using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem")) + { + foreach (var obj in searcher.Get()) + { + return obj["Model"]?.ToString() ?? "Unknown"; + } + } + } + catch { } + return "Unknown"; + } + + public static string GetOsEdition() + { + try + { + using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) + { + return key?.GetValue("EditionID")?.ToString() ?? "Unknown"; + } + } + catch { } + return "Unknown"; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 6ba99e6c..f5b4e17c 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -45,6 +45,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core private RemoteConfigService _remoteConfigService; private GatherRuleExecutor _gatherRuleExecutor; + // Cleanup/self-destruct + private readonly CleanupService _cleanupService; + public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion) { _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); @@ -58,6 +61,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _spool = new EventSpool(_configuration.SpoolDirectory); _apiClient = new BackendApiClient(_configuration.ApiBaseUrl, _configuration, _logger); + _cleanupService = new CleanupService(_configuration, _logger); // Subscribe to spool events for batched upload when new events arrive _spool.EventsAvailable += OnEventsAvailable; @@ -350,12 +354,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core { SessionId = _configuration.SessionId, TenantId = _configuration.TenantId, - SerialNumber = GetSerialNumber(), - Manufacturer = GetManufacturer(), - Model = GetModel(), + SerialNumber = DeviceInfoProvider.GetSerialNumber(), + Manufacturer = DeviceInfoProvider.GetManufacturer(), + Model = DeviceInfoProvider.GetModel(), DeviceName = Environment.MachineName, OsBuild = Environment.OSVersion.Version.ToString(), - OsEdition = GetOsEdition(), + OsEdition = DeviceInfoProvider.GetOsEdition(), OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name, StartedAt = DateTime.UtcNow, AgentVersion = "1.0.0", @@ -379,82 +383,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } } - private string GetSerialNumber() - { - try - { - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS")) - { - foreach (var obj in searcher.Get()) - { - return obj["SerialNumber"]?.ToString() ?? "Unknown"; - } - } - } - catch { } - return "Unknown"; - } - - private string GetManufacturer() - { - try - { - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem")) - { - foreach (var obj in searcher.Get()) - { - return obj["Manufacturer"]?.ToString() ?? "Unknown"; - } - } - } - catch { } - return "Unknown"; - } - - private string GetModel() - { - try - { - var manufacturer = GetManufacturer(); - if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0) - { - // Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9") - // in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model - // which only contains the internal type number (e.g. "20Y3S0FP00") - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct")) - { - foreach (var obj in searcher.Get()) - { - return obj["Version"]?.ToString() ?? "Unknown"; - } - } - } - - using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem")) - { - foreach (var obj in searcher.Get()) - { - return obj["Model"]?.ToString() ?? "Unknown"; - } - } - } - catch { } - return "Unknown"; - } - - private string GetOsEdition() - { - try - { - using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion")) - { - return key?.GetValue("EditionID")?.ToString() ?? "Unknown"; - } - } - catch { } - return "Unknown"; - } - /// /// Stops the monitoring service /// @@ -508,11 +436,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core if (_configuration.SelfDestructOnComplete) { - ExecuteSelfDestruct(); + _cleanupService.ExecuteSelfDestruct(); } else if (_configuration.CleanupOnExit) { - ExecuteCleanup(); + _cleanupService.ExecuteCleanup(); } else { @@ -751,11 +679,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Step 3: Execute self-destruct or cleanup if (_configuration.SelfDestructOnComplete) { - ExecuteSelfDestruct(); + _cleanupService.ExecuteSelfDestruct(); } else if (_configuration.CleanupOnExit) { - ExecuteCleanup(); + _cleanupService.ExecuteCleanup(); } else if (_configuration.RebootOnComplete) { @@ -782,176 +710,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core } } - /// - /// Executes full self-destruct: removes scheduled task and deletes all files - /// - private void ExecuteSelfDestruct() - { - try - { - _logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); - - var currentProcessId = Process.GetCurrentProcess().Id; - var agentBasePath = Path.GetFullPath(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "AutopilotMonitor" - )); - - var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs")); - var keepLogs = _configuration.KeepLogFile; - - // Create a self-deleting PowerShell script - var cleanupScript = $@" -$scriptPath = $MyInvocation.MyCommand.Path - -# Wait for agent process to exit -Start-Sleep -Seconds 2 -Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30 -Start-Sleep -Seconds 1 - -# Remove Scheduled Task -try {{ - Stop-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -ErrorAction SilentlyContinue - Unregister-ScheduledTask -TaskName '{_configuration.ScheduledTaskName}' -Confirm:$false -ErrorAction SilentlyContinue -}} catch {{ }} - -{(keepLogs ? $@" -# Delete everything except Logs directory -Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{ - $dest = $_.FullName + '.del' - try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }} - Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue -}} -" : $@" -# Rename folder then delete. Retry a few times to let the OS release handles. -$renamedPath = '{agentBasePath}.del' -$renamed = $false -for ($i = 1; $i -le 10; $i++) {{ - try {{ - Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop - $renamed = $true - break - }} catch {{ - Start-Sleep -Seconds 2 - }} -}} -if ($renamed) {{ - Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue -}} else {{ - Remove-Item -Path '{agentBasePath}' -Recurse -Force -ErrorAction SilentlyContinue -}} -")} -{(_configuration.RebootOnComplete ? @" -Restart-Computer -Force -Delay 10 -Comment 'Autopilot enrollment completed - Autopilot Monitor is rebooting' -" : "")} -Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue -"; - - // Write cleanup script to temp location (outside of agent folder) - var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1"); - File.WriteAllText(tempScriptPath, cleanupScript); - _logger.Info($"Cleanup script written to {tempScriptPath}"); - - // Change CWD to temp so this process no longer holds a reference into - // the AutopilotMonitor folder tree - Windows won't allow renaming a - // directory that any process has as its current working directory. - try { Directory.SetCurrentDirectory(Path.GetTempPath()); } catch { } - - // Launch via cmd /c start so the powershell process is created outside the - // current Job Object (Scheduled Task job). cmd's 'start' command always - // creates a new process group that breaks job inheritance, even under SYSTEM. - var psi = new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/c start \"\" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"", - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = Path.GetTempPath() - }; - - Process.Start(psi); - _logger.Info("Cleanup script launched. Agent will now exit."); - } - catch (Exception ex) - { - _logger.Error("Error executing self-destruct", ex); - } - } - - /// - /// Executes cleanup only (files) without removing scheduled task - /// - private void ExecuteCleanup() - { - try - { - _logger.Info($"Executing CLEANUP (Files only, keeping Scheduled Task{(_configuration.RebootOnComplete ? " + Reboot" : "")})"); - - var currentProcessId = Process.GetCurrentProcess().Id; - var agentBasePath = Path.GetFullPath(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), - "AutopilotMonitor" - )); - - var logDir = Path.GetFullPath(Path.Combine(agentBasePath, "Logs")); - var keepLogs = _configuration.KeepLogFile; - - // Create a self-deleting cleanup PowerShell script - var cleanupScript = $@" -$scriptPath = $MyInvocation.MyCommand.Path - -# Wait for agent process to exit. Wait-Process may fail if the process is already gone - that is fine. -Start-Sleep -Seconds 2 -Wait-Process -Id {currentProcessId} -ErrorAction SilentlyContinue -Timeout 30 -Start-Sleep -Seconds 1 - -{(keepLogs ? $@" -# Delete everything except Logs directory. -# Rename subdirs/files first so locked EXE bytes are released, then remove. -Get-ChildItem -Path '{agentBasePath}' -Exclude 'Logs' | ForEach-Object {{ - $dest = $_.FullName + '.del' - try {{ Rename-Item -Path $_.FullName -NewName $dest -Force -ErrorAction SilentlyContinue }} catch {{ }} - Remove-Item -Path $dest -Recurse -Force -ErrorAction SilentlyContinue -}} -" : $@" -# Rename the folder first (works even while EXE bytes are still mapped), -# then delete the renamed copy - by then all handles are closed. -$renamedPath = '{agentBasePath}.del' -try {{ Rename-Item -Path '{agentBasePath}' -NewName $renamedPath -Force -ErrorAction Stop }} catch {{ $renamedPath = '{agentBasePath}' }} -Remove-Item -Path $renamedPath -Recurse -Force -ErrorAction SilentlyContinue -")} -{(_configuration.RebootOnComplete ? @" -Restart-Computer -Force -" : "")} -# Delete this script -Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue -"; - - // Write cleanup script to temp location - var tempScriptPath = Path.Combine(Path.GetTempPath(), $"AutopilotMonitor-Cleanup-{Guid.NewGuid()}.ps1"); - File.WriteAllText(tempScriptPath, cleanupScript); - _logger.Info($"Cleanup script written to {tempScriptPath}"); - - // Launch via cmd /c start so the powershell process is created outside the - // current Job Object (Scheduled Task job). cmd's 'start' command always - // creates a new process group that breaks job inheritance, even under SYSTEM. - var psi = new ProcessStartInfo - { - FileName = "cmd.exe", - Arguments = $"/c start \"\" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{tempScriptPath}\"", - UseShellExecute = false, - CreateNoWindow = true - }; - - Process.Start(psi); - _logger.Info("Cleanup script launched. Agent will now exit."); - } - catch (Exception ex) - { - _logger.Error("Error executing cleanup", ex); - } - } - public void Dispose() { _uploadTimer?.Dispose(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageState.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageState.cs index 58f0ec3e..37ead45f 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageState.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/AppPackageState.cs @@ -88,6 +88,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking public int? ProgressPercent { get; private set; } = null; public long BytesDownloaded { get; private set; } = 0; public long BytesTotal { get; private set; } = 0; + public string ErrorPatternId { get; private set; } + public string ErrorDetail { get; private set; } private static readonly Dictionary Win32StateMap = new Dictionary @@ -120,7 +122,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking AppRunAs runAs, AppIntent intent, AppTargeted targeted, HashSet dependsOn, AppInstallationState installationState, bool downloadingOrInstallingSeen, - int? progressPercent, long bytesDownloaded, long bytesTotal) + int? progressPercent, long bytesDownloaded, long bytesTotal, + string errorPatternId = null, string errorDetail = null) { var pkg = new AppPackageState(id, listPos) { @@ -134,6 +137,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking ProgressPercent = progressPercent, BytesDownloaded = bytesDownloaded, BytesTotal = bytesTotal, + ErrorPatternId = errorPatternId, + ErrorDetail = errorDetail, InstallationStateLastChangedTicks = Stopwatch.GetTimestamp() }; return pkg; @@ -196,6 +201,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking return true; } + /// + /// Sets the error context for the app (called when an error pattern is matched). + /// + public void SetErrorContext(string patternId, string detail) + { + ErrorPatternId = patternId; + ErrorDetail = detail; + } + /// /// Updates the installation state with transition protection. /// Returns true if the state actually changed. @@ -330,7 +344,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking /// public Dictionary ToEventData() { - return new Dictionary + var data = new Dictionary { { "appId", Id }, { "name", Name ?? Id }, @@ -345,6 +359,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { "isError", IsError }, { "isCompleted", IsCompleted } }; + + if (!string.IsNullOrEmpty(ErrorPatternId)) + data["errorPatternId"] = ErrorPatternId; + if (!string.IsNullOrEmpty(ErrorDetail)) + data["errorDetail"] = ErrorDetail; + + return data; } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs index eab9d82c..88d49644 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs @@ -480,32 +480,6 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking } } - // LEGACY: Old implementation reading from Diagnostics\AutoPilot (kept for reference, can be removed later) - /* - using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\Diagnostics\AutoPilot")) - { - if (key != null) - { - data["cloudAssignedTenantId"] = key.GetValue("CloudAssignedTenantId")?.ToString(); - data["cloudAssignedTenantDomain"] = key.GetValue("CloudAssignedTenantDomain")?.ToString(); - data["cloudAssignedOobeConfig"] = key.GetValue("CloudAssignedOobeConfig")?.ToString(); - data["deploymentProfileName"] = key.GetValue("DeploymentProfileName")?.ToString(); - - // Read WDP detection values - var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString(); - var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString(); - data["cloudAssignedDeviceRegistration"] = deviceReg; - data["cloudAssignedEspEnabled"] = espEnabled; - - // Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled - if (deviceReg == "2" || espEnabled == "0") - _enrollmentType = "v2"; - else - _enrollmentType = "v1"; - } - } - */ - // Include enrollment type in autopilot_profile event data data["enrollmentType"] = _enrollmentType; @@ -884,6 +858,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking 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, @@ -892,7 +871,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking Severity = severity, Source = "ImeLogTracker", Phase = phase, - Message = $"{app.Name ?? app.Id}: {newState}", + Message = message, Data = app.ToEventData() }); } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs index 9a2b03f5..66e649dc 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeLogTracker.cs @@ -70,8 +70,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // State persistence: saves tracker state to disk so agent restart continues // from the exact log position without re-parsing or re-building ignore lists. - private readonly string _stateDirectory; - private readonly string _stateFilePath; + private readonly ImeTrackerStatePersistence _statePersistence; private bool _stateDirty; // Standard GUID capture pattern used as {GUID} placeholder in patterns @@ -112,8 +111,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // State persistence setup if (!string.IsNullOrEmpty(stateDirectory)) { - _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); - _stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json"); + _statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger); } if (!string.IsNullOrEmpty(_matchLogPath)) @@ -246,127 +244,73 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking #region State Persistence - /// - /// DTO for JSON serialization of tracker state. - /// - private class ImeTrackerStateData - { - public int CurrentPhaseOrder { get; set; } - public string LastEspPhaseDetected { get; set; } - public bool AllAppsCompletedFired { get; set; } - public bool LogPhaseIsCurrentPhase { get; set; } - public List SeenAppIds { get; set; } - public List IgnoreList { get; set; } - public string CurrentPackageId { get; set; } - public List Packages { get; set; } - public Dictionary FilePositions { get; set; } - } - - private class PackageStateData - { - public string Id { get; set; } - public int ListPos { get; set; } - public string Name { get; set; } - public int RunAs { get; set; } - public int Intent { get; set; } - public int Targeted { get; set; } - public List DependsOn { get; set; } - public int InstallationState { get; set; } - public bool DownloadingOrInstallingSeen { get; set; } - public int? ProgressPercent { get; set; } - public long BytesDownloaded { get; set; } - public long BytesTotal { get; set; } - } - - private class FilePositionData - { - public long Position { get; set; } - public long LastKnownSize { get; set; } - } - /// /// Loads persisted state from disk. Called on Start() to restore tracker state /// after an agent restart, so parsing continues exactly where it left off. /// private void LoadState() { - if (_stateFilePath == null || !File.Exists(_stateFilePath)) + if (_statePersistence == null) return; + + var state = _statePersistence.Load(); + if (state == null) return; + + // Restore phase tracking + _currentPhaseOrder = state.CurrentPhaseOrder; + _lastEspPhaseDetected = state.LastEspPhaseDetected; + _allAppsCompletedFired = state.AllAppsCompletedFired; + _logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase; + + // Restore seen app IDs + _seenAppIds.Clear(); + if (state.SeenAppIds != null) { - _logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)"); - return; + foreach (var id in state.SeenAppIds) + _seenAppIds.Add(id); } - try + // Restore ignore list + if (state.IgnoreList != null) { - var json = File.ReadAllText(_stateFilePath); - var state = JsonConvert.DeserializeObject(json); - if (state == null) - { - _logger.Warning("ImeLogTracker: persisted state file was empty or invalid"); - return; - } - - // Restore phase tracking - _currentPhaseOrder = state.CurrentPhaseOrder; - _lastEspPhaseDetected = state.LastEspPhaseDetected; - _allAppsCompletedFired = state.AllAppsCompletedFired; - _logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase; - - // Restore seen app IDs - _seenAppIds.Clear(); - if (state.SeenAppIds != null) - { - foreach (var id in state.SeenAppIds) - _seenAppIds.Add(id); - } - - // Restore ignore list - if (state.IgnoreList != null) - { - foreach (var id in state.IgnoreList) - _packageStates.AddToIgnoreList(id); - } - - // Restore current package ID - _packageStates.CurrentPackageId = state.CurrentPackageId; - - // Restore package states - if (state.Packages != null) - { - _packageStates.Clear(); - foreach (var p in state.Packages) - { - var pkg = AppPackageState.Restore( - p.Id, p.ListPos, p.Name, - (AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted, - p.DependsOn != null ? new HashSet(p.DependsOn) : new HashSet(), - (AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen, - p.ProgressPercent, p.BytesDownloaded, p.BytesTotal); - _packageStates.Add(pkg); - } - } - - // Restore file positions - if (state.FilePositions != null) - { - foreach (var kvp in state.FilePositions) - { - var fullPath = Path.Combine(_logFolder, kvp.Key); - _positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize); - } - } - - // Re-activate patterns based on restored phase state - ActivatePatterns(_logPhaseIsCurrentPhase, force: true); - - _logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " + - $"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " + - $"file positions: {state.FilePositions?.Count ?? 0}"); + foreach (var id in state.IgnoreList) + _packageStates.AddToIgnoreList(id); } - catch (Exception ex) + + // Restore current package ID + _packageStates.CurrentPackageId = state.CurrentPackageId; + + // Restore package states + if (state.Packages != null) { - _logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}"); + _packageStates.Clear(); + foreach (var p in state.Packages) + { + var pkg = AppPackageState.Restore( + p.Id, p.ListPos, p.Name, + (AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted, + p.DependsOn != null ? new HashSet(p.DependsOn) : new HashSet(), + (AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen, + p.ProgressPercent, p.BytesDownloaded, p.BytesTotal); + _packageStates.Add(pkg); + } } + + // Restore file positions + if (state.FilePositions != null) + { + foreach (var kvp in state.FilePositions) + { + var fullPath = Path.Combine(_logFolder, kvp.Key); + _positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize); + } + } + + // Re-activate patterns based on restored phase state + ActivatePatterns(_logPhaseIsCurrentPhase, force: true); + + _logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " + + $"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " + + $"file positions: {state.FilePositions?.Count ?? 0}"); } /// @@ -375,60 +319,48 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking /// private void SaveState() { - if (_stateFilePath == null) return; + if (_statePersistence == null) return; - try + // Build state DTO + var state = new ImeTrackerStateData { - // Build state DTO - var state = new ImeTrackerStateData + CurrentPhaseOrder = _currentPhaseOrder, + LastEspPhaseDetected = _lastEspPhaseDetected, + AllAppsCompletedFired = _allAppsCompletedFired, + LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase, + SeenAppIds = _seenAppIds.ToList(), + IgnoreList = _packageStates.IgnoreList.ToList(), + CurrentPackageId = _packageStates.CurrentPackageId, + Packages = _packageStates.Select(p => new PackageStateData { - CurrentPhaseOrder = _currentPhaseOrder, - LastEspPhaseDetected = _lastEspPhaseDetected, - AllAppsCompletedFired = _allAppsCompletedFired, - LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase, - SeenAppIds = _seenAppIds.ToList(), - IgnoreList = _packageStates.IgnoreList.ToList(), - CurrentPackageId = _packageStates.CurrentPackageId, - Packages = _packageStates.Select(p => new PackageStateData - { - Id = p.Id, - ListPos = p.ListPos, - Name = p.Name, - RunAs = (int)p.RunAs, - Intent = (int)p.Intent, - Targeted = (int)p.Targeted, - DependsOn = p.DependsOn?.ToList() ?? new List(), - InstallationState = (int)p.InstallationState, - DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen, - ProgressPercent = p.ProgressPercent, - BytesDownloaded = p.BytesDownloaded, - BytesTotal = p.BytesTotal - }).ToList(), - FilePositions = new Dictionary() + Id = p.Id, + ListPos = p.ListPos, + Name = p.Name, + RunAs = (int)p.RunAs, + Intent = (int)p.Intent, + Targeted = (int)p.Targeted, + DependsOn = p.DependsOn?.ToList() ?? new List(), + InstallationState = (int)p.InstallationState, + DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen, + ProgressPercent = p.ProgressPercent, + BytesDownloaded = p.BytesDownloaded, + BytesTotal = p.BytesTotal + }).ToList(), + FilePositions = new Dictionary() + }; + + // Store file positions by filename only (log folder is known) + foreach (var kvp in _positionTracker.GetAllPositions()) + { + var fileName = Path.GetFileName(kvp.Key); + state.FilePositions[fileName] = new FilePositionData + { + Position = kvp.Value.Position, + LastKnownSize = kvp.Value.LastKnownSize }; - - // Store file positions by filename only (log folder is known) - foreach (var kvp in _positionTracker.GetAllPositions()) - { - var fileName = Path.GetFileName(kvp.Key); - state.FilePositions[fileName] = new FilePositionData - { - Position = kvp.Value.Position, - LastKnownSize = kvp.Value.LastKnownSize - }; - } - - // Write atomically: write to temp file then rename - Directory.CreateDirectory(_stateDirectory); - var tempPath = _stateFilePath + ".tmp"; - File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented)); - File.Copy(tempPath, _stateFilePath, overwrite: true); - try { File.Delete(tempPath); } catch { } - } - catch (Exception ex) - { - _logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}"); } + + _statePersistence.Save(state); } /// @@ -437,20 +369,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking /// public void DeleteState() { - if (_stateFilePath == null) return; - - try - { - if (File.Exists(_stateFilePath)) - { - File.Delete(_stateFilePath); - _logger.Info("ImeLogTracker: persisted state deleted"); - } - } - catch (Exception ex) - { - _logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}"); - } + _statePersistence?.Delete(); } #endregion @@ -629,11 +548,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // 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); + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message); } else if (!string.IsNullOrEmpty(id)) { - UpdateStateWithCallback(id, AppInstallationState.Error); + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: pattern.PatternId, errorDetail: message); } break; @@ -787,7 +706,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking } else if (string.Equals(to, "Error", StringComparison.OrdinalIgnoreCase)) { - UpdateStateWithCallback(id, AppInstallationState.Error); + UpdateStateWithCallback(id, AppInstallationState.Error, errorPatternId: "IME-ESP-TRACK-STATUS", errorDetail: $"ESP track status changed from {from} to {to}"); } } @@ -826,11 +745,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking _packageStates.SetCurrent(newId); } - private void UpdateStateWithCallback(string id, AppInstallationState newState, int? progressPercent = null) + 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); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeTrackerStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeTrackerStatePersistence.cs new file mode 100644 index 00000000..37f496ac --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/ImeTrackerStatePersistence.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AutopilotMonitor.Agent.Core.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// Handles persistence of ImeLogTracker state to disk (JSON serialization). + /// Extracted from ImeLogTracker to separate I/O concerns from log parsing. + /// + public class ImeTrackerStatePersistence + { + private readonly string _stateDirectory; + private readonly string _stateFilePath; + private readonly AgentLogger _logger; + + public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger) + { + _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); + _stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json"); + _logger = logger; + } + + public string StateFilePath => _stateFilePath; + + /// + /// Loads persisted state from disk. Returns null if no state file exists or on error. + /// + public ImeTrackerStateData Load() + { + if (!File.Exists(_stateFilePath)) + { + _logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)"); + return null; + } + + try + { + var json = File.ReadAllText(_stateFilePath); + var state = JsonConvert.DeserializeObject(json); + if (state == null) + { + _logger.Warning("ImeLogTracker: persisted state file was empty or invalid"); + return null; + } + return state; + } + catch (Exception ex) + { + _logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}"); + return null; + } + } + + /// + /// Persists tracker state to disk as JSON (atomic write via temp file). + /// + public void Save(ImeTrackerStateData state) + { + try + { + Directory.CreateDirectory(_stateDirectory); + var tempPath = _stateFilePath + ".tmp"; + File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented)); + File.Copy(tempPath, _stateFilePath, overwrite: true); + try { File.Delete(tempPath); } catch { } + } + catch (Exception ex) + { + _logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}"); + } + } + + /// + /// Deletes persisted state file. + /// + public void Delete() + { + try + { + if (File.Exists(_stateFilePath)) + { + File.Delete(_stateFilePath); + _logger.Info("ImeLogTracker: persisted state deleted"); + } + } + catch (Exception ex) + { + _logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}"); + } + } + } + + // ---- DTOs for state serialization ---- + + public class ImeTrackerStateData + { + public int CurrentPhaseOrder { get; set; } + public string LastEspPhaseDetected { get; set; } + public bool AllAppsCompletedFired { get; set; } + public bool LogPhaseIsCurrentPhase { get; set; } + public List SeenAppIds { get; set; } + public List IgnoreList { get; set; } + public string CurrentPackageId { get; set; } + public List Packages { get; set; } + public Dictionary FilePositions { get; set; } + } + + public class PackageStateData + { + public string Id { get; set; } + public int ListPos { get; set; } + public string Name { get; set; } + public int RunAs { get; set; } + public int Intent { get; set; } + public int Targeted { get; set; } + public List DependsOn { get; set; } + public int InstallationState { get; set; } + public bool DownloadingOrInstallingSeen { get; set; } + public int? ProgressPercent { get; set; } + public long BytesDownloaded { get; set; } + public long BytesTotal { get; set; } + } + + public class FilePositionData + { + public long Position { get; set; } + public long LastKnownSize { get; set; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs index f175a61d..495d5ba3 100644 --- a/src/Agent/AutopilotMonitor.Agent/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent/Program.cs @@ -7,6 +7,7 @@ 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 @@ -37,7 +38,7 @@ namespace AutopilotMonitor.Agent static void RunInstallMode(string[] args) { - var logDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"); + var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); var logger = new AgentLogger(logDir, enableDebug: true); var consoleMode = args.Contains("--console") || Environment.UserInteractive; @@ -49,7 +50,7 @@ namespace AutopilotMonitor.Agent var sourceExePath = Assembly.GetExecutingAssembly().Location; var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty; - var targetAgentDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Agent"); + var targetAgentDir = Environment.ExpandEnvironmentVariables(Path.Combine(Constants.AgentDataDirectory, "Agent")); var targetExePath = Path.Combine(targetAgentDir, "AutopilotMonitor.Agent.exe"); if (!string.Equals( @@ -65,7 +66,7 @@ namespace AutopilotMonitor.Agent logger.Info("Agent already running from target install directory; payload copy not required."); } - var taskName = "AutopilotMonitor-Agent"; + var taskName = Constants.ScheduledTaskName; var taskCommand = $"\"{targetExePath}\""; logger.Info($"Registering Scheduled Task '{taskName}' for executable: {targetExePath}"); @@ -129,7 +130,7 @@ namespace AutopilotMonitor.Agent // Try to log if possible, but this might fail if logger isn't initialized yet try { - var logDir = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"); + var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory); var logger = new AgentLogger(logDir, enableDebug: false); logger.Warning(message); } @@ -215,7 +216,7 @@ namespace AutopilotMonitor.Agent try { var crashPath = Path.Combine( - Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"), + Environment.ExpandEnvironmentVariables(Constants.LogDirectory), $"crash_{DateTime.UtcNow:yyyyMMdd_HHmmss}.log"); Directory.CreateDirectory(Path.GetDirectoryName(crashPath)); File.WriteAllText(crashPath, $"[{DateTime.UtcNow:u}] FATAL: {ex}"); @@ -268,8 +269,8 @@ namespace AutopilotMonitor.Agent TenantId = deviceConfig.TenantId, SpoolDirectory = Environment.ExpandEnvironmentVariables($@"%ProgramData%\AutopilotMonitor\MassRollout\{deviceConfig.DeviceName}\Spool"), LogDirectory = Environment.ExpandEnvironmentVariables($@"%ProgramData%\AutopilotMonitor\MassRollout\{deviceConfig.DeviceName}\Logs"), - UploadIntervalSeconds = 30, - MaxBatchSize = 100, + UploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds, + MaxBatchSize = Constants.MaxBatchSize, EnableDebugLogging = true, EnableSimulator = true, SimulateFailure = deviceConfig.SimulateFailure, @@ -417,8 +418,8 @@ namespace AutopilotMonitor.Agent } // Defaults - string apiBaseUrl = "https://autopilotmonitor-func.azurewebsites.net"; - int uploadIntervalSeconds = 30; + string apiBaseUrl = Constants.ApiBaseUrl; + int uploadIntervalSeconds = Constants.DefaultUploadIntervalSeconds; bool cleanupOnExit = false; bool selfDestructOnComplete = false; bool rebootOnCompleteConfig = false; @@ -450,7 +451,7 @@ namespace AutopilotMonitor.Agent tenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359"; // fallback for dev/testing } - var dataDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor"); + var dataDirectory = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); var sessionPersist = new SessionPersistence(dataDirectory); if (newSession) @@ -463,10 +464,10 @@ namespace AutopilotMonitor.Agent ApiBaseUrl = apiBaseUrl, SessionId = sessionId, TenantId = tenantId, - SpoolDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Spool"), - LogDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"), + SpoolDirectory = Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), + LogDirectory = Environment.ExpandEnvironmentVariables(Constants.LogDirectory), UploadIntervalSeconds = uploadIntervalSeconds, - MaxBatchSize = 100, + MaxBatchSize = Constants.MaxBatchSize, EnableDebugLogging = true, EnableSimulator = enableSimulator, SimulateFailure = simulateFailure, @@ -505,13 +506,13 @@ namespace AutopilotMonitor.Agent private static void EnsureAgentDirectories(AgentLogger logger) { - var basePath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor"); + var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory); var paths = new[] { basePath, Path.Combine(basePath, "Agent"), - Path.Combine(basePath, "Spool"), - Path.Combine(basePath, "Logs") + Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory), + Environment.ExpandEnvironmentVariables(Constants.LogDirectory) }; foreach (var path in paths) @@ -626,7 +627,7 @@ namespace AutopilotMonitor.Agent /// static bool CheckEnrollmentCompleteMarker(AgentConfiguration config, AgentLogger logger, bool consoleMode) { - var stateDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\State"); + var stateDirectory = Environment.ExpandEnvironmentVariables(Constants.StateDirectory); var markerPath = Path.Combine(stateDirectory, "enrollment-complete.marker"); if (!File.Exists(markerPath)) diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 3c032078..f64f5d8d 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -18 +27 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs index bb66bdad..b7375b53 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/AuthFunction.cs @@ -100,6 +100,17 @@ public class AuthFunction return suspendedResponse; } + // Auto-re-enable: if Disabled was true but DisabledUntil has expired, persist the re-enable + if (tenantConfig.Disabled && !tenantConfig.IsCurrentlyDisabled()) + { + _logger.LogInformation($"Tenant {tenantId} auto-re-enabled: DisabledUntil ({tenantConfig.DisabledUntil:o}) has expired"); + tenantConfig.Disabled = false; + tenantConfig.DisabledReason = null; + tenantConfig.DisabledUntil = null; + tenantConfig.UpdatedBy = "System (auto-re-enable)"; + await _tenantConfigService.SaveConfigurationAsync(tenantConfig); + } + // Check if user is galactic admin var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(upn); @@ -120,7 +131,8 @@ public class AuthFunction } // Record user login activity for metrics tracking - _ = _storageService.RecordUserLoginAsync(tenantId, upn, displayName, objectId); + _ = _storageService.RecordUserLoginAsync(tenantId, upn, displayName, objectId) + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); var userInfo = new { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 1e3b10af..8b68e26d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -44,45 +44,15 @@ namespace AutopilotMonitor.Functions.Functions { try { - // Parse request - support both NDJSON+gzip (new) and JSON (legacy) + // Parse NDJSON+gzip request IngestEventsRequest? request = null; - // Check Content-Encoding header for gzip - var contentEncoding = req.Headers.Contains("Content-Encoding") - ? req.Headers.GetValues("Content-Encoding").FirstOrDefault() + // Extract tenantId from header to get config (for payload size limit) + var tenantIdHeader = req.Headers.Contains("X-Tenant-Id") + ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() : null; - var contentType = req.Headers.Contains("Content-Type") - ? req.Headers.GetValues("Content-Type").FirstOrDefault() - : "application/json"; - - if (contentEncoding == "gzip" && contentType?.Contains("application/x-ndjson") == true) - { - // New format: NDJSON + gzip - _logger.LogDebug("Parsing gzip-compressed NDJSON request"); - - // Extract tenantId from header to get config (for payload size limit) - var tenantIdHeader = req.Headers.Contains("X-Tenant-Id") - ? req.Headers.GetValues("X-Tenant-Id").FirstOrDefault() - : null; - - request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader); - } - else - { - // Legacy format: standard JSON (backwards compatibility) - _logger.LogDebug("Parsing legacy JSON request"); - if (req.Headers.TryGetValues("Content-Length", out var clValues) - && long.TryParse(clValues.FirstOrDefault(), out var contentLength) - && contentLength > 1_048_576) // 1 MB limit - { - return await CreateErrorResponse(req, HttpStatusCode.BadRequest, "Request body too large"); - } - var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); - request = JsonConvert.DeserializeObject(requestBody); - if (request?.Events != null) - foreach (var evt in request.Events) NormalizeEventData(evt); - } + request = await ParseNdjsonGzipRequest(req.Body, tenantIdHeader); if (request?.Events == null || request.Events.Count == 0) { @@ -241,11 +211,14 @@ namespace AutopilotMonitor.Functions.Functions } // Increment platform stats (fire-and-forget, non-blocking) - _ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount); + _ = _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); + _ = _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"); + _ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments") + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); // Retrieve updated session data to include in SignalR messages var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs index 469bbee3..9fc96cdb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs @@ -86,7 +86,8 @@ namespace AutopilotMonitor.Functions.Functions } // Increment platform stats (fire-and-forget, non-blocking) - _ = _storageService.IncrementPlatformStatAsync("TotalEnrollments"); + _ = _storageService.IncrementPlatformStatAsync("TotalEnrollments") + .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); // Retrieve the stored session to include full data in SignalR message var session = await _storageService.GetSessionAsync(registration.TenantId, registration.SessionId); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index 0618d299..5c5e0639 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -6,6 +6,7 @@ using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using Azure; using Azure.Data.Tables; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -19,14 +20,15 @@ namespace AutopilotMonitor.Functions.Services private readonly TableClient _adminTableClient; private readonly TableClient _tenantConfigTableClient; private readonly ILogger _logger; + private readonly IMemoryCache _cache; - // In-memory cache for admin configuration (to avoid table lookups on every request) - private AdminConfiguration? _cachedConfig; - private DateTime? _cacheTime; + private const string CacheKey = "admin-config"; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); - public AdminConfigurationService(IConfiguration configuration, ILogger logger) + public AdminConfigurationService(IConfiguration configuration, ILogger logger, IMemoryCache cache) { _logger = logger; + _cache = cache; var connectionString = configuration["AzureTableStorageConnectionString"]; var serviceClient = new TableServiceClient(connectionString); @@ -40,13 +42,9 @@ namespace AutopilotMonitor.Functions.Services /// public async Task GetConfigurationAsync() { - // Check cache first (5-minute TTL) - if (_cachedConfig != null && _cacheTime.HasValue) + if (_cache.TryGetValue(CacheKey, out AdminConfiguration? cachedConfig) && cachedConfig != null) { - if (DateTime.UtcNow.Subtract(_cacheTime.Value).TotalMinutes < 5) - { - return _cachedConfig; - } + return cachedConfig; } try @@ -55,9 +53,7 @@ namespace AutopilotMonitor.Functions.Services var entity = await _adminTableClient.GetEntityAsync("GlobalConfig", "config"); var config = ConvertFromTableEntity(entity.Value); - // Update cache - _cachedConfig = config; - _cacheTime = DateTime.UtcNow; + _cache.Set(CacheKey, config, CacheDuration); return config; } @@ -73,9 +69,7 @@ namespace AutopilotMonitor.Functions.Services var entity = ConvertToTableEntity(defaultConfig); await _adminTableClient.UpsertEntityAsync(entity); - // Update cache - _cachedConfig = defaultConfig; - _cacheTime = DateTime.UtcNow; + _cache.Set(CacheKey, defaultConfig, CacheDuration); _logger.LogInformation("Default admin configuration created and saved"); } @@ -112,8 +106,7 @@ namespace AutopilotMonitor.Functions.Services await _adminTableClient.UpsertEntityAsync(entity); // Invalidate cache - _cachedConfig = null; - _cacheTime = null; + _cache.Remove(CacheKey); _logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}"); @@ -178,8 +171,7 @@ namespace AutopilotMonitor.Functions.Services /// public void InvalidateCache() { - _cachedConfig = null; - _cacheTime = null; + _cache.Remove(CacheKey); } private TableEntity ConvertToTableEntity(AdminConfiguration config) @@ -188,8 +180,7 @@ namespace AutopilotMonitor.Functions.Services { { "LastUpdated", config.LastUpdated }, { "UpdatedBy", config.UpdatedBy }, - { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute }, - { "CustomSettings", config.CustomSettings } + { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute } }; return entity; @@ -203,8 +194,7 @@ namespace AutopilotMonitor.Functions.Services RowKey = entity.RowKey, LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", - GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100, - CustomSettings = entity.GetString("CustomSettings") + GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100 }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs b/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs index 53bd1bfd..9dea6871 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs @@ -20,6 +20,7 @@ namespace AutopilotMonitor.Functions.Services CreateAppDownloadTimeoutRule(), CreateAppSlowInstallRule(), CreateAppRebootLoopRule(), + CreateAppTrackingSummaryErrorsRule(), // ===== ESP RULES ===== CreateEspBlockingAppTimeoutRule(), @@ -29,22 +30,28 @@ namespace AutopilotMonitor.Functions.Services CreateDeviceTpmNotReadyRule(), CreateDeviceBitlockerEscrowRule(), CreateDeviceSecureBootRule(), - - // ===== DEVICE RULES (continued) ===== CreateDeviceAadJoinNotDetectedRule(), - // ===== APP RULES (continued) ===== - CreateAppTrackingSummaryErrorsRule(), - // ===== CORRELATION RULES (cross-event analysis) ===== CreateCorrelationNetworkCausedInstallFailureRule(), CreateCorrelationDiskSpaceCausedInstallFailureRule(), CreateCorrelationProxyCausedDownloadFailureRule(), + + // ===== NEW RULES ===== + CreateEnrollmentTimeoutRule(), + CreateEnrollmentFailedRule(), + CreateWindowsHelloTimeoutRule(), + CreateAppDownloadStallRule(), + CreateHighMemoryDuringInstallRule(), }; } // ===== APPS ===== + /// + /// Fixed: Now checks errorPatternId and errorDetail fields in app_install_failed events + /// instead of relying on non-existent error_detected events. + /// private static AnalyzeRule CreateAppDetectionScriptFailureRule() => new AnalyzeRule { RuleId = "ANALYZE-APP-001", @@ -55,7 +62,11 @@ namespace AutopilotMonitor.Functions.Services BaseConfidence = 60, Conditions = new List { - new RuleCondition { Signal = "detection_failure", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = "detection|not detected|script.*fail|detection.*error", Required = true } + new RuleCondition { Signal = "detection_failure", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = @"detection|not detected|script.*fail|detection.*error|DetectionState|NotDetected", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "error_detail_match", Condition = "exists", Weight = 20 } }, Explanation = "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation.", Remediation = new List @@ -65,6 +76,10 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "apps", "detection", "win32" } }; + /// + /// Fixed: Now checks errorDetail field (which contains the IME log line) for MSI error codes + /// instead of the non-existent errorCode field. + /// private static AnalyzeRule CreateAppMsiErrorRule() => new AnalyzeRule { RuleId = "ANALYZE-APP-002", @@ -75,7 +90,7 @@ namespace AutopilotMonitor.Functions.Services BaseConfidence = 80, Conditions = new List { - new RuleCondition { Signal = "msi_error", Source = "event_type", EventType = "app_install_failed", DataField = "errorCode", Operator = "regex", Value = "^16[0-9]{2}$|^0x80070[0-9a-f]+$", Required = true } + new RuleCondition { Signal = "msi_error", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = @"16[0-9]{2}|0x80070[0-9a-fA-F]+|lpExitCode|unmapped.*exit", Required = true } }, Explanation = "An MSI installation failed with a specific error code. Common codes: 1603 (Fatal error), 1618 (Another install in progress), 1619 (Package could not be opened), 1625 (Installation prohibited by policy).", Remediation = new List @@ -85,6 +100,10 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "apps", "msi", "error-code" } }; + /// + /// Fixed: Now also checks errorDetail for dependency-related IME log messages. + /// The message field of app_install_failed events now contains the IME log line context. + /// private static AnalyzeRule CreateAppDependencyChainRule() => new AnalyzeRule { RuleId = "ANALYZE-APP-003", @@ -95,7 +114,7 @@ namespace AutopilotMonitor.Functions.Services BaseConfidence = 70, Conditions = new List { - new RuleCondition { Signal = "dependency_failure", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = "dependency|prerequisite|required app|depends on", Required = true } + new RuleCondition { Signal = "dependency_failure", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = @"dependency|prerequisite|required app|depends on|Dependency", Required = true } }, Explanation = "An app installation failed because one of its dependency apps failed to install. Fix the dependency first, then retry.", Remediation = new List @@ -105,43 +124,52 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "apps", "dependency" } }; + /// + /// Fixed: Now uses performance_snapshot events (which are actually emitted by the Agent) + /// instead of non-existent error_detected events. Detects low disk via performance data. + /// private static AnalyzeRule CreateAppDiskSpaceRule() => new AnalyzeRule { RuleId = "ANALYZE-APP-004", Title = "Insufficient Disk Space", - Description = "Detects when app installation fails due to insufficient disk space.", + Description = "Detects when disk space is critically low during enrollment, which can cause app installation failures.", Severity = "critical", Category = "apps", - BaseConfidence = 90, + BaseConfidence = 75, Conditions = new List { - new RuleCondition { Signal = "disk_space", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "disk space|storage.*full|not enough space|0x80070070", Required = true } + new RuleCondition { Signal = "low_disk", Source = "event_type", EventType = "performance_snapshot", DataField = "disk_free_gb", Operator = "lt", Value = "5", Required = true } }, - Explanation = "The device does not have enough disk space to install the required applications.", + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "app_failed", Condition = "exists", Weight = 15 }, + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 10 } + }, + Explanation = "Performance monitoring shows disk free space dropped below 5 GB during enrollment. This can cause app installation failures, especially for large Win32 apps.", Remediation = new List { - new RemediationStep { Title = "Free disk space", Steps = new List { "Check device disk size meets minimum requirements", "Clean up temporary files", "Consider using a larger disk image" } } + new RemediationStep { Title = "Free disk space", Steps = new List { "Check device disk size meets minimum requirements", "Clean up temporary files and IME cache (C:\\Windows\\IMECache)", "Consider using a larger disk image" } } }, Tags = new[] { "apps", "disk-space", "critical" } }; + /// + /// Fixed: Now checks errorDetail and errorPatternId for download-related IME error patterns + /// (IME-DO-TIMEOUT, IME-ERROR-DOWNLOAD, IME-ERROR-NO-CONTENT). + /// private static AnalyzeRule CreateAppDownloadTimeoutRule() => new AnalyzeRule { RuleId = "ANALYZE-APP-005", Title = "Content Download Timeout", - Description = "Detects when app content download times out or is very slow.", + Description = "Detects when app content download times out or fails.", Severity = "high", Category = "apps", - BaseConfidence = 60, + BaseConfidence = 70, Conditions = new List { - new RuleCondition { Signal = "download_timeout", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = "download.*timeout|content.*download.*fail|CDN.*error|0x80072ee7", Required = true } + new RuleCondition { Signal = "download_timeout", Source = "event_type", EventType = "app_install_failed", DataField = "errorPatternId", Operator = "regex", Value = @"IME-DO-TIMEOUT|IME-ERROR-DOWNLOAD|IME-ERROR-NO-CONTENT", Required = true } }, - ConfidenceFactors = new List - { - new ConfidenceFactor { Signal = "slow_download", Condition = "download_rate < 1000000", Weight = 20 } - }, - Explanation = "App content download timed out or failed. This is usually caused by network issues, proxy interference, or CDN problems.", + Explanation = "App content download timed out or failed. This is usually caused by network issues, proxy interference, or CDN problems. The IME log shows a Delivery Optimization timeout or download content error.", Remediation = new List { new RemediationStep { Title = "Improve download speed", Steps = new List { "Check network bandwidth", "Verify proxy doesn't block *.delivery.mp.microsoft.com", "Consider Delivery Optimization configuration" } } @@ -169,11 +197,39 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "apps", "reboot", "loop" } }; + private static AnalyzeRule CreateAppTrackingSummaryErrorsRule() => new AnalyzeRule + { + RuleId = "ANALYZE-APP-007", + Title = "Multiple App Installation Failures", + Description = "Detects when the app tracking summary reports multiple failed app installations.", + Severity = "high", + Category = "apps", + BaseConfidence = 75, + Conditions = new List + { + new RuleCondition { Signal = "summary_errors", Source = "event_type", EventType = "app_tracking_summary", DataField = "errorCount", Operator = "gte", Value = "2", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 15 } + }, + Explanation = "The app tracking summary reports 2 or more failed app installations during enrollment. This suggests a systemic issue rather than an isolated app failure.", + Remediation = new List + { + new RemediationStep { Title = "Review failed apps", Steps = new List { + "Check the individual app_install_failed events for specific error patterns", + "Look for a common pattern (all MSI apps, all large apps, all from a specific publisher)", + "Consider whether a shared prerequisite or dependency is failing" + }} + }, + Tags = new[] { "apps", "summary", "multiple-failures" } + }; + private static AnalyzeRule CreateAppSlowInstallRule() => new AnalyzeRule { RuleId = "ANALYZE-APP-008", Title = "Slow App Installation", - Description = "Detects app installations that take longer than 2 minutes (test threshold).", + Description = "Detects app installations that take longer than 2 minutes.", Severity = "warning", Category = "apps", BaseConfidence = 70, @@ -223,19 +279,27 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "esp", "blocking", "timeout" } }; + /// + /// Fixed: Now checks app_install_failed events for security/policy-related error patterns + /// instead of non-existent error_detected events. Also matches IME enforcement errors. + /// private static AnalyzeRule CreateEspSecurityPolicyFailureRule() => new AnalyzeRule { RuleId = "ANALYZE-ESP-002", Title = "Security Policy Application Failure", - Description = "Detects when a required security policy fails to apply during ESP.", + Description = "Detects when a required security policy fails to apply during ESP by looking for policy-related errors in app installation and IME log events.", Severity = "high", Category = "esp", - BaseConfidence = 70, + BaseConfidence = 65, Conditions = new List { - new RuleCondition { Signal = "policy_failure", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "policy.*fail|security.*policy|compliance.*fail|BitLocker.*fail", Required = true } + new RuleCondition { Signal = "policy_failure", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = @"policy.*fail|security.*policy|compliance.*fail|BitLocker.*fail|enforcementState.*Error|EnforcementState", Required = true } }, - Explanation = "A required security policy failed to apply during the ESP device setup phase. This blocks ESP completion.", + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "esp_stalled", Condition = "exists", Weight = 15 } + }, + Explanation = "An app installation failed with a message indicating a security or compliance policy issue. This may block ESP completion if the affected app is an ESP-blocking app.", Remediation = new List { new RemediationStep { Title = "Fix policy", Steps = new List { "Check which policy failed in the event details", "Verify device meets policy prerequisites (TPM, SecureBoot)", "Review policy targeting and assignments" } } @@ -245,17 +309,21 @@ namespace AutopilotMonitor.Functions.Services // ===== DEVICE ===== + /// + /// Fixed: Now checks app_install_failed events for TPM-related error messages + /// instead of non-existent error_detected events. + /// private static AnalyzeRule CreateDeviceTpmNotReadyRule() => new AnalyzeRule { RuleId = "ANALYZE-DEV-001", Title = "TPM Not Ready", - Description = "Detects when the TPM is not ready or not functioning properly.", + Description = "Detects TPM-related errors in enrollment events.", Severity = "critical", Category = "device", BaseConfidence = 80, Conditions = new List { - new RuleCondition { Signal = "tpm_error", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "TPM|Trusted Platform Module|0x80284001|0x80290300", Required = true } + new RuleCondition { Signal = "tpm_error", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = @"TPM|Trusted Platform Module|0x80284001|0x80290300|tpm.*not.*ready", Required = true } }, Explanation = "The TPM (Trusted Platform Module) is not ready or functioning. TPM is required for Autopilot, Windows Hello, and BitLocker.", Remediation = new List @@ -265,17 +333,21 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "device", "tpm", "critical" } }; + /// + /// Fixed: Now checks app_install_failed events for BitLocker-related error messages + /// instead of non-existent error_detected events. + /// private static AnalyzeRule CreateDeviceBitlockerEscrowRule() => new AnalyzeRule { RuleId = "ANALYZE-DEV-002", Title = "BitLocker Key Escrow Failure", - Description = "Detects when BitLocker recovery key fails to escrow to Azure AD.", + Description = "Detects BitLocker escrow-related errors in enrollment events.", Severity = "high", Category = "device", BaseConfidence = 70, Conditions = new List { - new RuleCondition { Signal = "bitlocker_escrow_failure", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "BitLocker.*escrow|recovery key.*backup|key.*protector.*fail", Required = true } + new RuleCondition { Signal = "bitlocker_escrow_failure", Source = "event_type", EventType = "app_install_failed", DataField = "message", Operator = "regex", Value = @"BitLocker.*escrow|recovery key.*backup|key.*protector.*fail|BitLocker|bitlocker", Required = true } }, Explanation = "BitLocker recovery key escrow to Azure AD failed. This may block ESP completion if 'Require BitLocker key escrow' is configured.", Remediation = new List @@ -305,9 +377,6 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "device", "secureboot" } }; - // ===== CORRELATION RULES ===== - // These combine signals from multiple event types for root-cause analysis - private static AnalyzeRule CreateDeviceAadJoinNotDetectedRule() => new AnalyzeRule { RuleId = "ANALYZE-DEV-004", @@ -332,34 +401,6 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "device", "aad", "join", "critical" } }; - private static AnalyzeRule CreateAppTrackingSummaryErrorsRule() => new AnalyzeRule - { - RuleId = "ANALYZE-APP-007", - Title = "Multiple App Installation Failures", - Description = "Detects when the app tracking summary reports multiple failed app installations.", - Severity = "high", - Category = "apps", - BaseConfidence = 75, - Conditions = new List - { - new RuleCondition { Signal = "summary_errors", Source = "event_type", EventType = "app_tracking_summary", DataField = "errorCount", Operator = "gte", Value = "2", Required = true } - }, - ConfidenceFactors = new List - { - new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 15 } - }, - Explanation = "The app tracking summary reports 2 or more failed app installations during enrollment. This suggests a systemic issue rather than an isolated app failure.", - Remediation = new List - { - new RemediationStep { Title = "Review failed apps", Steps = new List { - "Check the individual app_install_failed events for specific error codes", - "Look for a common pattern (all MSI apps, all large apps, all from a specific publisher)", - "Consider whether a shared prerequisite or dependency is failing" - }} - }, - Tags = new[] { "apps", "summary", "multiple-failures" } - }; - // ===== CORRELATION RULES ===== // These combine signals from multiple event types for root-cause analysis @@ -402,6 +443,10 @@ namespace AutopilotMonitor.Functions.Services Tags = new[] { "correlation", "apps", "network", "download", "root-cause" } }; + /// + /// Fixed: Second condition now uses app_install_failed instead of non-existent error_detected. + /// Correlates low disk space from performance_snapshot with app failures. + /// private static AnalyzeRule CreateCorrelationDiskSpaceCausedInstallFailureRule() => new AnalyzeRule { RuleId = "ANALYZE-CORR-004", @@ -414,15 +459,15 @@ namespace AutopilotMonitor.Functions.Services Conditions = new List { new RuleCondition { Signal = "low_disk", Source = "event_type", EventType = "performance_snapshot", DataField = "disk_free_gb", Operator = "lt", Value = "5", Required = true }, - new RuleCondition { Signal = "install_error", Source = "event_type", EventType = "error_detected", DataField = "message", Operator = "regex", Value = "disk space|storage|not enough space|0x80070070|0x80070008|ERROR_DISK_FULL", Required = true } + new RuleCondition { Signal = "app_failed", Source = "event_type", EventType = "app_install_failed", Operator = "exists", Value = "", Required = true } }, ConfidenceFactors = new List { - new ConfidenceFactor { Signal = "app_failed", Condition = "exists", Weight = 20 }, + new ConfidenceFactor { Signal = "multiple_failures", Condition = "count >= 2", Weight = 15 }, new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 10 } }, ConfidenceThreshold = 50, - Explanation = "**Root cause confirmed:** Performance monitoring shows disk free space dropped below 5 GB during enrollment, and error events contain disk-space-related error codes. The device ran out of storage during app installation.\n\nThis is common when deploying many large Win32 apps to devices with small drives (64 GB or 128 GB).", + Explanation = "**Root cause confirmed:** Performance monitoring shows disk free space dropped below 5 GB during enrollment, and apps failed to install. The device likely ran out of storage during app installation.\n\nThis is common when deploying many large Win32 apps to devices with small drives (64 GB or 128 GB).", Remediation = new List { new RemediationStep { Title = "Reduce disk consumption during enrollment", Steps = new List { @@ -477,5 +522,178 @@ namespace AutopilotMonitor.Functions.Services }, Tags = new[] { "correlation", "apps", "proxy", "network", "download", "root-cause" } }; + + // ===== NEW RULES ===== + + /// + /// Detects when the overall enrollment takes excessively long (> 60 minutes). + /// Uses the total time span between the first and last esp_phase_changed events. + /// + private static AnalyzeRule CreateEnrollmentTimeoutRule() => new AnalyzeRule + { + RuleId = "ANALYZE-ESP-003", + Title = "Enrollment Duration Exceeded 60 Minutes", + Description = "Detects when the total Autopilot enrollment takes longer than 60 minutes.", + Severity = "high", + Category = "esp", + BaseConfidence = 50, + Conditions = new List + { + new RuleCondition { Signal = "long_enrollment", Source = "phase_duration", EventType = "esp_phase_changed", DataField = "espPhase", Operator = "equals", Value = "DeviceSetup", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "long_esp", Condition = "phase_duration > 3600", Weight = 40 } + }, + ConfidenceThreshold = 70, + Explanation = "The Autopilot enrollment exceeded 60 minutes. This is unusually long and may indicate stuck apps, slow network, or policy conflicts. The default ESP timeout is 60 minutes, after which the enrollment may be marked as failed.", + Remediation = new List + { + new RemediationStep { Title = "Investigate long enrollment", Steps = new List { + "Check the app installation timeline - identify which app took the longest", + "Review download speeds in the performance data", + "Consider reducing the number of ESP-blocking apps", + "Check if the ESP timeout policy is configured (default: 60 min)" + }} + }, + Tags = new[] { "esp", "timeout", "duration" } + }; + + /// + /// Detects explicit enrollment failure (enrollment_failed event). + /// The Agent emits this when the enrollment fails for any reason. + /// + private static AnalyzeRule CreateEnrollmentFailedRule() => new AnalyzeRule + { + RuleId = "ANALYZE-ENRL-001", + Title = "Enrollment Failed", + Description = "Detects when the Autopilot enrollment has explicitly failed.", + Severity = "critical", + Category = "enrollment", + BaseConfidence = 95, + Conditions = new List + { + new RuleCondition { Signal = "enrollment_failed", Source = "event_type", EventType = "enrollment_failed", Operator = "exists", Value = "", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "app_failed", Condition = "exists", Weight = 5 } + }, + Explanation = "The Autopilot enrollment has explicitly failed. Check the enrollment_failed event details and other rule results for the root cause.", + Remediation = new List + { + new RemediationStep { Title = "Investigate enrollment failure", Steps = new List { + "Check the 'reason' field in the enrollment_failed event for specific failure details", + "Review other analyze rule results for correlated issues (disk space, network, app failures)", + "Check the device event timeline for the sequence of events leading to failure", + "Verify the Autopilot profile and ESP configuration in Intune" + }} + }, + Tags = new[] { "enrollment", "failed", "critical" } + }; + + /// + /// Detects when Windows Hello provisioning times out. + /// The HelloDetector emits hello_wait_timeout when Hello wizard doesn't start + /// within the configured timeout period after ESP exit. + /// + private static AnalyzeRule CreateWindowsHelloTimeoutRule() => new AnalyzeRule + { + RuleId = "ANALYZE-DEV-005", + Title = "Windows Hello Provisioning Timeout", + Description = "Detects when Windows Hello for Business provisioning did not start within the expected timeframe after ESP completion.", + Severity = "warning", + Category = "device", + BaseConfidence = 60, + Conditions = new List + { + new RuleCondition { Signal = "hello_timeout", Source = "event_type", EventType = "hello_wait_timeout", Operator = "exists", Value = "", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "hello_policy_detected", Condition = "exists", Weight = 30 } + }, + Explanation = "Windows Hello for Business provisioning did not start within the expected time after ESP exit. This may indicate that the Hello policy is not configured, the device doesn't meet prerequisites, or there's a connectivity issue with the Key Registration Service.", + Remediation = new List + { + new RemediationStep { Title = "Verify Windows Hello configuration", Steps = new List { + "Check if Windows Hello for Business is configured in the tenant", + "Verify the policy targets the device/user correctly", + "Ensure the device has a TPM 2.0 (required for WHfB)", + "Check connectivity to the Key Registration Service (enterpriseregistration.windows.net)" + }} + }, + Tags = new[] { "device", "hello", "timeout" } + }; + + /// + /// Detects when app downloads appear stalled based on download_progress events + /// with status "failed" and app installation failure patterns matching download errors. + /// + private static AnalyzeRule CreateAppDownloadStallRule() => new AnalyzeRule + { + RuleId = "ANALYZE-APP-009", + Title = "App Download Stalled", + Description = "Detects when app downloads appear to have stalled, based on failed download progress events.", + Severity = "warning", + Category = "apps", + Trigger = "correlation", + BaseConfidence = 55, + Conditions = new List + { + new RuleCondition { Signal = "download_failed", Source = "event_type", EventType = "download_progress", DataField = "status", Operator = "equals", Value = "failed", Required = true }, + new RuleCondition { Signal = "app_failed", Source = "event_type", EventType = "app_install_failed", Operator = "exists", Value = "", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "proxy_active", Condition = "exists", Weight = 15 }, + new ConfidenceFactor { Signal = "multiple_failures", Condition = "count >= 2", Weight = 15 } + }, + ConfidenceThreshold = 50, + Explanation = "Download progress events show a failed download, and at least one app installation also failed. This pattern suggests content delivery issues preventing apps from being downloaded successfully.", + Remediation = new List + { + new RemediationStep { Title = "Investigate download issues", Steps = new List { + "Check network connectivity and bandwidth during the enrollment", + "Verify DNS resolution for *.delivery.mp.microsoft.com", + "Check if a proxy or firewall is blocking content delivery", + "Review Delivery Optimization settings and peer availability" + }} + }, + Tags = new[] { "apps", "download", "stall", "correlation" } + }; + + /// + /// Detects high memory usage during enrollment which can cause app installation issues. + /// + private static AnalyzeRule CreateHighMemoryDuringInstallRule() => new AnalyzeRule + { + RuleId = "ANALYZE-DEV-006", + Title = "High Memory Usage During Enrollment", + Description = "Detects when memory usage exceeds 90% during enrollment, which can cause app installation failures or system instability.", + Severity = "warning", + Category = "device", + BaseConfidence = 55, + Conditions = new List + { + new RuleCondition { Signal = "high_memory", Source = "event_type", EventType = "performance_snapshot", DataField = "memory_used_percent", Operator = "gt", Value = "90", Required = true } + }, + ConfidenceFactors = new List + { + new ConfidenceFactor { Signal = "app_failed", Condition = "exists", Weight = 20 }, + new ConfidenceFactor { Signal = "enrollment_failed", Condition = "exists", Weight = 15 } + }, + Explanation = "Memory usage exceeded 90% during enrollment. High memory pressure can cause app installation failures, system slowdowns, and ESP timeouts.", + Remediation = new List + { + new RemediationStep { Title = "Reduce memory pressure", Steps = new List { + "Review which apps are being installed concurrently during ESP", + "Consider staggering resource-heavy installations", + "Check minimum RAM requirements for the device (8 GB recommended)", + "Investigate if background services are consuming excessive memory" + }} + }, + Tags = new[] { "device", "memory", "performance" } + }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs index d5bf4d98..5db1462b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs @@ -1131,7 +1131,6 @@ namespace AutopilotMonitor.Functions.Services ["Author"] = rule.Author ?? "Autopilot Monitor", ["Enabled"] = rule.Enabled, ["IsBuiltIn"] = rule.IsBuiltIn, - ["IsCommunity"] = rule.IsCommunity, ["CollectorType"] = rule.CollectorType ?? string.Empty, ["Target"] = rule.Target ?? string.Empty, ["ParametersJson"] = JsonConvert.SerializeObject(rule.Parameters ?? new Dictionary()), @@ -1215,7 +1214,6 @@ namespace AutopilotMonitor.Functions.Services Author = entity.GetString("Author") ?? "Autopilot Monitor", Enabled = entity.GetBoolean("Enabled") ?? true, IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, - IsCommunity = entity.GetBoolean("IsCommunity") ?? false, CollectorType = entity.GetString("CollectorType") ?? string.Empty, Target = entity.GetString("Target") ?? string.Empty, Parameters = DeserializeJson>(entity.GetString("ParametersJson")), @@ -1253,7 +1251,6 @@ namespace AutopilotMonitor.Functions.Services ["Author"] = rule.Author ?? "Autopilot Monitor", ["Enabled"] = rule.Enabled, ["IsBuiltIn"] = rule.IsBuiltIn, - ["IsCommunity"] = rule.IsCommunity, ["ConditionsJson"] = JsonConvert.SerializeObject(rule.Conditions ?? new List()), ["BaseConfidence"] = rule.BaseConfidence, ["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List()), @@ -1336,7 +1333,6 @@ namespace AutopilotMonitor.Functions.Services Author = entity.GetString("Author") ?? "Autopilot Monitor", Enabled = entity.GetBoolean("Enabled") ?? true, IsBuiltIn = entity.GetBoolean("IsBuiltIn") ?? false, - IsCommunity = entity.GetBoolean("IsCommunity") ?? false, Conditions = DeserializeJson>(entity.GetString("ConditionsJson")), BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50, ConfidenceFactors = DeserializeJson>(entity.GetString("ConfidenceFactorsJson")), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index baad4d47..61fb967a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -6,6 +6,7 @@ using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using Azure; using Azure.Data.Tables; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -19,15 +20,14 @@ namespace AutopilotMonitor.Functions.Services { private readonly TableClient _tableClient; private readonly ILogger _logger; + private readonly IMemoryCache _cache; - // In-memory cache for configuration (to avoid table lookups on every request) - private readonly System.Collections.Concurrent.ConcurrentDictionary _configCache; - private const int MaxCacheSize = 1000; + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); - public TenantConfigurationService(IConfiguration configuration, ILogger logger) + public TenantConfigurationService(IConfiguration configuration, ILogger logger, IMemoryCache cache) { _logger = logger; - _configCache = new System.Collections.Concurrent.ConcurrentDictionary(); + _cache = cache; var connectionString = configuration["AzureTableStorageConnectionString"]; var serviceClient = new TableServiceClient(connectionString); @@ -46,13 +46,11 @@ namespace AutopilotMonitor.Functions.Services return TenantConfiguration.CreateDefault("unknown"); } - // Check cache first (5-minute TTL) - if (_configCache.TryGetValue(tenantId, out var cached)) + var cacheKey = $"tenant-config:{tenantId}"; + + if (_cache.TryGetValue(cacheKey, out TenantConfiguration? cachedConfig) && cachedConfig != null) { - if (DateTime.UtcNow.Subtract(cached.CachedAt).TotalMinutes < 5) - { - return cached.Configuration; - } + return cachedConfig; } try @@ -61,13 +59,7 @@ namespace AutopilotMonitor.Functions.Services var entity = await _tableClient.GetEntityAsync(tenantId, "config"); var config = ConvertFromTableEntity(entity.Value); - // Update cache - _configCache[tenantId] = new CachedConfig - { - Configuration = config, - CachedAt = DateTime.UtcNow - }; - EvictIfOverLimit(); + _cache.Set(cacheKey, config, CacheDuration); return config; } @@ -83,13 +75,7 @@ namespace AutopilotMonitor.Functions.Services var entity = ConvertToTableEntity(defaultConfig); await _tableClient.UpsertEntityAsync(entity); - // Update cache - _configCache[tenantId] = new CachedConfig - { - Configuration = defaultConfig, - CachedAt = DateTime.UtcNow - }; - EvictIfOverLimit(); + _cache.Set(cacheKey, defaultConfig, CacheDuration); _logger.LogInformation($"Default configuration created and saved for tenant {tenantId}"); } @@ -126,7 +112,7 @@ namespace AutopilotMonitor.Functions.Services await _tableClient.UpsertEntityAsync(entity); // Invalidate cache - _configCache.TryRemove(config.TenantId, out _); + _cache.Remove($"tenant-config:{config.TenantId}"); _logger.LogInformation($"Configuration saved for tenant {config.TenantId} by {config.UpdatedBy}"); } @@ -142,7 +128,7 @@ namespace AutopilotMonitor.Functions.Services /// public void InvalidateCache(string tenantId) { - _configCache.TryRemove(tenantId, out _); + _cache.Remove($"tenant-config:{tenantId}"); } /// @@ -188,7 +174,6 @@ namespace AutopilotMonitor.Functions.Services { "DataRetentionDays", config.DataRetentionDays }, { "SessionTimeoutHours", config.SessionTimeoutHours }, { "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB }, - { "CustomSettings", config.CustomSettings }, { "EnablePerformanceCollector", config.EnablePerformanceCollector }, { "PerformanceCollectorIntervalSeconds", config.PerformanceCollectorIntervalSeconds } }; @@ -216,37 +201,9 @@ namespace AutopilotMonitor.Functions.Services DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5, - CustomSettings = entity.GetString("CustomSettings"), EnablePerformanceCollector = entity.GetBoolean("EnablePerformanceCollector") ?? false, PerformanceCollectorIntervalSeconds = entity.GetInt32("PerformanceCollectorIntervalSeconds") ?? 30 }; } - - private void EvictIfOverLimit() - { - if (_configCache.Count <= MaxCacheSize) - return; - - // Evict oldest entries (by CachedAt) to bring cache back to 75% capacity - var targetSize = (int)(MaxCacheSize * 0.75); - var entriesToRemove = _configCache - .OrderBy(kvp => kvp.Value.CachedAt) - .Take(_configCache.Count - targetSize) - .Select(kvp => kvp.Key) - .ToList(); - - foreach (var key in entriesToRemove) - { - _configCache.TryRemove(key, out _); - } - - _logger.LogInformation("Cache eviction: removed {Count} entries, cache size now {Size}", entriesToRemove.Count, _configCache.Count); - } - - private class CachedConfig - { - public TenantConfiguration Configuration { get; set; } = null!; - public DateTime CachedAt { get; set; } - } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 21ea915d..74533cee 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -5,15 +5,14 @@ namespace AutopilotMonitor.Shared /// public static class Constants { - /// - /// Agent version - /// - public const string AgentVersion = "1.0.0"; + // ----------------------------------------------------------------------- + // Agent runtime defaults + // ----------------------------------------------------------------------- /// - /// Registry path for agent configuration + /// Base data directory for all agent files (spool, logs, state) /// - public const string RegistryPath = @"SOFTWARE\AutopilotMonitor"; + public const string AgentDataDirectory = @"%ProgramData%\AutopilotMonitor"; /// /// Local spool directory for offline queueing @@ -26,106 +25,126 @@ namespace AutopilotMonitor.Shared public const string LogDirectory = @"%ProgramData%\AutopilotMonitor\Logs"; /// - /// Maximum batch size for events + /// Agent state directory (enrollment complete marker, IME tracker state, etc.) + /// + public const string StateDirectory = @"%ProgramData%\AutopilotMonitor\State"; + + /// + /// Scheduled Task name used to run the agent as SYSTEM + /// + public const string ScheduledTaskName = "AutopilotMonitor-Agent"; + + /// + /// Default backend API base URL (overridable via AUTOPILOT_MONITOR_API env var or --api-url CLI arg) + /// + public const string ApiBaseUrl = "https://autopilotmonitor-api.azurewebsites.net"; + + // ----------------------------------------------------------------------- + // Upload / batching defaults + // ----------------------------------------------------------------------- + + /// + /// Maximum number of events per upload batch /// public const int MaxBatchSize = 100; /// - /// Maximum batch age in seconds + /// Default upload interval in seconds (fallback timer; normal path uses FileSystemWatcher) /// - public const int MaxBatchAgeSeconds = 30; + public const int DefaultUploadIntervalSeconds = 30; + + // ----------------------------------------------------------------------- + // API endpoint paths (relative to ApiBaseUrl) + // ----------------------------------------------------------------------- /// - /// Maximum retry attempts for failed uploads - /// - public const int MaxRetryAttempts = 5; - - /// - /// API endpoint paths + /// API endpoint paths used by the agent /// public static class ApiEndpoints { public const string RegisterSession = "/api/sessions/register"; - public const string IngestEvents = "/api/events/ingest"; - public const string UploadBundle = "/api/bundles/upload"; - public const string GetAgentConfig = "/api/agent/config"; - public const string GatherRules = "/api/gather-rules"; - public const string AnalyzeRules = "/api/analyze-rules"; + public const string IngestEvents = "/api/events/ingest"; + public const string UploadBundle = "/api/bundles/upload"; + public const string GetAgentConfig = "/api/agent/config"; + public const string GatherRules = "/api/gather-rules"; + public const string AnalyzeRules = "/api/analyze-rules"; } + // ----------------------------------------------------------------------- + // Event types emitted by the agent + // ----------------------------------------------------------------------- + /// - /// Event types + /// Event type identifiers for EnrollmentEvent.EventType /// public static class EventTypes { - public const string PhaseTransition = "phase_transition"; - public const string AppInstallStart = "app_install_started"; - public const string AppInstallComplete = "app_install_completed"; - public const string AppInstallFailed = "app_install_failed"; - public const string AppDownloadStarted = "app_download_started"; - public const string AppInstallSkipped = "app_install_skipped"; - public const string NetworkStateChange = "network_state_change"; - public const string ErrorDetected = "error_detected"; + public const string PhaseTransition = "phase_transition"; + public const string AppInstallStart = "app_install_started"; + public const string AppInstallComplete = "app_install_completed"; + public const string AppInstallFailed = "app_install_failed"; + public const string AppDownloadStarted = "app_download_started"; + public const string AppInstallSkipped = "app_install_skipped"; + public const string NetworkStateChange = "network_state_change"; + public const string ErrorDetected = "error_detected"; public const string PerformanceSnapshot = "performance_snapshot"; - public const string LogEntry = "log_entry"; - public const string EspStateChange = "esp_state_change"; - public const string DownloadProgress = "download_progress"; - public const string CertValidation = "cert_validation"; - public const string EspUiState = "esp_ui_state"; - public const string GatherResult = "gather_result"; + public const string LogEntry = "log_entry"; + public const string EspStateChange = "esp_state_change"; + public const string DownloadProgress = "download_progress"; + public const string CertValidation = "cert_validation"; + public const string EspUiState = "esp_ui_state"; + public const string GatherResult = "gather_result"; } + // ----------------------------------------------------------------------- + // Event sources + // ----------------------------------------------------------------------- + /// - /// Event sources + /// Event source identifiers for EnrollmentEvent.Source /// public static class EventSources { - public const string Agent = "Agent"; - public const string IME = "IME"; - public const string EventLog = "EventLog"; + public const string Agent = "Agent"; + public const string IME = "IME"; public const string Registry = "Registry"; - public const string WMI = "WMI"; - public const string Network = "Network"; + public const string WMI = "WMI"; + public const string Network = "Network"; } - /// - /// Log file paths to monitor - /// - public static class LogPaths - { - public const string IMELog = @"C:\Windows\CCM\Logs\Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider\Admin.evtx"; - public const string ModernDeployment = @"C:\Windows\Logs\Autopilot\ModernDeployment-Diagnostics-Provider.log"; - } + // ----------------------------------------------------------------------- + // Azure Table Storage table names + // All table names are defined here centrally and initialized at application startup + // ----------------------------------------------------------------------- /// /// Azure Table Storage table names - /// All table names are defined here centrally and initialized at application startup /// public static class TableNames { // Core data tables - public const string Sessions = "Sessions"; - public const string Events = "Events"; - public const string AuditLogs = "AuditLogs"; - public const string UsageMetrics = "UsageMetrics"; - public const string UserActivity = "UserActivity"; + public const string Sessions = "Sessions"; + public const string Events = "Events"; + public const string AuditLogs = "AuditLogs"; + public const string UsageMetrics = "UsageMetrics"; + public const string UserActivity = "UserActivity"; // Rules engine tables - public const string RuleResults = "RuleResults"; - public const string GatherRules = "GatherRules"; - public const string AnalyzeRules = "AnalyzeRules"; + public const string RuleResults = "RuleResults"; + public const string GatherRules = "GatherRules"; + public const string AnalyzeRules = "AnalyzeRules"; // App metrics tables public const string AppInstallSummaries = "AppInstallSummaries"; - public const string PlatformStats = "PlatformStats"; + public const string PlatformStats = "PlatformStats"; // Configuration tables public const string TenantConfiguration = "TenantConfiguration"; - public const string AdminConfiguration = "AdminConfiguration"; + public const string AdminConfiguration = "AdminConfiguration"; // Admin tables public const string GalacticAdmins = "GalacticAdmins"; - public const string TenantAdmins = "TenantAdmins"; + public const string TenantAdmins = "TenantAdmins"; /// /// Returns all table names for initialization @@ -148,15 +167,5 @@ namespace AutopilotMonitor.Shared TenantAdmins }; } - - /// - /// Azure Blob Storage container names - /// - public static class ContainerNames - { - public const string Bundles = "bundles"; - public const string Screenshots = "screenshots"; - public const string Logs = "logs"; - } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs index ed5f7020..3dc18fd2 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AdminConfiguration.cs @@ -39,13 +39,6 @@ namespace AutopilotMonitor.Shared.Models /// public int GlobalRateLimitRequestsPerMinute { get; set; } = 100; - // ===== FUTURE SETTINGS (Extensible) ===== - - /// - /// Custom settings as JSON (for future extensibility) - /// - public string CustomSettings { get; set; } - /// /// Creates default configuration /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs index 22f21248..a2de6fcd 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AnalyzeRule.cs @@ -54,11 +54,6 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsBuiltIn { get; set; } = true; - /// - /// Whether this is a community-contributed rule (future use) - /// - public bool IsCommunity { get; set; } = false; - /// /// Rule trigger type: "single" (matches individual events) or "correlation" (combines multiple event types) /// Both types run at the same time during analysis - this field is organizational/descriptive diff --git a/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs b/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs index ab4a27ac..05fb3e03 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/EnrollmentEvent.cs @@ -80,6 +80,7 @@ namespace AutopilotMonitor.Shared.Models EnrollmentPhase.AppsDevice => "Apps (Device)", EnrollmentPhase.AccountSetup => "Account Setup", EnrollmentPhase.AppsUser => "Apps (User)", + EnrollmentPhase.FinalizingSetup => "Finalizing Setup", EnrollmentPhase.Complete => "Complete", EnrollmentPhase.Failed => "Failed", _ => "Unknown" diff --git a/src/Shared/AutopilotMonitor.Shared/Models/GatherRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/GatherRule.cs index a218f6a3..2549a903 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/GatherRule.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/GatherRule.cs @@ -51,11 +51,6 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsBuiltIn { get; set; } = true; - /// - /// Whether this is a community-contributed rule (future use) - /// - public bool IsCommunity { get; set; } = false; - // ===== WHAT TO COLLECT ===== /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs index d958366f..09df2e82 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -139,13 +139,6 @@ namespace AutopilotMonitor.Shared.Models public int PerformanceCollectorIntervalSeconds { get; set; } = 60; - // ===== FUTURE SETTINGS (Extensible) ===== - - /// - /// Custom settings as JSON (for future extensibility) - /// - public string CustomSettings { get; set; } - // ===== HELPER METHODS ===== ///