Re-architecture, removal of dead code, security hardening, Rules re-work, same caching strategy everywhere used

This commit is contained in:
Oliver Kieselbach
2026-02-15 17:00:18 +01:00
parent 7a9272a5b0
commit 4802668e38
25 changed files with 1032 additions and 777 deletions
@@ -1,4 +1,5 @@
using System;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.Core.Configuration
{
@@ -85,7 +86,7 @@ namespace AutopilotMonitor.Agent.Core.Configuration
/// <summary>
/// Name of the Scheduled Task to remove during self-destruct
/// </summary>
public string ScheduledTaskName { get; set; } = "AutopilotMonitor-Agent";
public string ScheduledTaskName { get; set; } = Constants.ScheduledTaskName;
/// <summary>
/// Whether to reboot the device after enrollment completes
@@ -17,8 +17,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
/// <summary>
/// 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
///</summary>
/// </summary>
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))
@@ -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).
/// </summary>
public static class GatherRuleGuards
@@ -120,7 +122,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
// -----------------------------------------------------------------------
/// <summary>
/// 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).
/// </summary>
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] == '\\'));
}
/// <summary>
/// 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.
/// </summary>
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] == '\\'));
}
/// <summary>
/// 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).
/// </summary>
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])));
}
}
}
@@ -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
{
/// <summary>
/// Handles agent self-destruct and cleanup operations (PowerShell script generation and launch).
/// </summary>
public class CleanupService
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
public CleanupService(AgentConfiguration configuration, AgentLogger logger)
{
_configuration = configuration;
_logger = logger;
}
/// <summary>
/// Executes full self-destruct: removes scheduled task and deletes all files
/// </summary>
public 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);
}
}
/// <summary>
/// Executes cleanup only (files) without removing scheduled task
/// </summary>
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);
}
}
}
}
@@ -0,0 +1,86 @@
using System;
namespace AutopilotMonitor.Agent.Core.Monitoring.Core
{
/// <summary>
/// Provides device hardware and OS information via WMI and registry queries.
/// </summary>
public static class DeviceInfoProvider
{
public static string GetSerialNumber()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS"))
{
foreach (var obj in searcher.Get())
{
return obj["SerialNumber"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
public static string GetManufacturer()
{
try
{
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Manufacturer FROM Win32_ComputerSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Manufacturer"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
public static string GetModel()
{
try
{
var manufacturer = GetManufacturer();
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
{
// Lenovo stores the friendly model name (e.g. "ThinkPad X1 Carbon Gen 9")
// in Win32_ComputerSystemProduct.Version instead of Win32_ComputerSystem.Model
// which only contains the internal type number (e.g. "20Y3S0FP00")
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
{
foreach (var obj in searcher.Get())
{
return obj["Version"]?.ToString() ?? "Unknown";
}
}
}
using (var searcher = new System.Management.ManagementObjectSearcher("SELECT Model FROM Win32_ComputerSystem"))
{
foreach (var obj in searcher.Get())
{
return obj["Model"]?.ToString() ?? "Unknown";
}
}
}
catch { }
return "Unknown";
}
public static string GetOsEdition()
{
try
{
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"))
{
return key?.GetValue("EditionID")?.ToString() ?? "Unknown";
}
}
catch { }
return "Unknown";
}
}
}
@@ -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";
}
/// <summary>
/// Stops the monitoring service
/// </summary>
@@ -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
}
}
/// <summary>
/// Executes full self-destruct: removes scheduled task and deletes all files
/// </summary>
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);
}
}
/// <summary>
/// Executes cleanup only (files) without removing scheduled task
/// </summary>
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();
@@ -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<Win32AppState, AppInstallationState> Win32StateMap =
new Dictionary<Win32AppState, AppInstallationState>
@@ -120,7 +122,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
AppRunAs runAs, AppIntent intent, AppTargeted targeted,
HashSet<string> 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;
}
/// <summary>
/// Sets the error context for the app (called when an error pattern is matched).
/// </summary>
public void SetErrorContext(string patternId, string detail)
{
ErrorPatternId = patternId;
ErrorDetail = detail;
}
/// <summary>
/// Updates the installation state with transition protection.
/// Returns true if the state actually changed.
@@ -330,7 +344,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
/// </summary>
public Dictionary<string, object> ToEventData()
{
return new Dictionary<string, object>
var data = new Dictionary<string, object>
{
{ "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;
}
}
}
@@ -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()
});
}
@@ -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
/// <summary>
/// DTO for JSON serialization of tracker state.
/// </summary>
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<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
public string CurrentPackageId { get; set; }
public List<PackageStateData> Packages { get; set; }
public Dictionary<string, FilePositionData> FilePositions { get; set; }
}
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<string> DependsOn { get; set; }
public int InstallationState { get; set; }
public bool DownloadingOrInstallingSeen { get; set; }
public int? ProgressPercent { get; set; }
public long BytesDownloaded { get; set; }
public long BytesTotal { get; set; }
}
private class FilePositionData
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
}
/// <summary>
/// Loads persisted state from disk. Called on Start() to restore tracker state
/// after an agent restart, so parsing continues exactly where it left off.
/// </summary>
private void LoadState()
{
if (_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<ImeTrackerStateData>(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<string>(p.DependsOn) : new HashSet<string>(),
(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<string>(p.DependsOn) : new HashSet<string>(),
(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}");
}
/// <summary>
@@ -375,60 +319,48 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
/// </summary>
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<string>(),
InstallationState = (int)p.InstallationState,
DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
ProgressPercent = p.ProgressPercent,
BytesDownloaded = p.BytesDownloaded,
BytesTotal = p.BytesTotal
}).ToList(),
FilePositions = new Dictionary<string, FilePositionData>()
Id = p.Id,
ListPos = p.ListPos,
Name = p.Name,
RunAs = (int)p.RunAs,
Intent = (int)p.Intent,
Targeted = (int)p.Targeted,
DependsOn = p.DependsOn?.ToList() ?? new List<string>(),
InstallationState = (int)p.InstallationState,
DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
ProgressPercent = p.ProgressPercent,
BytesDownloaded = p.BytesDownloaded,
BytesTotal = p.BytesTotal
}).ToList(),
FilePositions = new Dictionary<string, FilePositionData>()
};
// Store file positions by filename only (log folder is known)
foreach (var kvp in _positionTracker.GetAllPositions())
{
var fileName = Path.GetFileName(kvp.Key);
state.FilePositions[fileName] = new FilePositionData
{
Position = kvp.Value.Position,
LastKnownSize = kvp.Value.LastKnownSize
};
// 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);
}
/// <summary>
@@ -437,20 +369,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
/// </summary>
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);
@@ -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
{
/// <summary>
/// Handles persistence of ImeLogTracker state to disk (JSON serialization).
/// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
/// </summary>
public class ImeTrackerStatePersistence
{
private readonly string _stateDirectory;
private readonly string _stateFilePath;
private readonly AgentLogger _logger;
public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger)
{
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
_stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json");
_logger = logger;
}
public string StateFilePath => _stateFilePath;
/// <summary>
/// Loads persisted state from disk. Returns null if no state file exists or on error.
/// </summary>
public ImeTrackerStateData Load()
{
if (!File.Exists(_stateFilePath))
{
_logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)");
return null;
}
try
{
var json = File.ReadAllText(_stateFilePath);
var state = JsonConvert.DeserializeObject<ImeTrackerStateData>(json);
if (state == null)
{
_logger.Warning("ImeLogTracker: persisted state file was empty or invalid");
return null;
}
return state;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}");
return null;
}
}
/// <summary>
/// Persists tracker state to disk as JSON (atomic write via temp file).
/// </summary>
public void Save(ImeTrackerStateData state)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var tempPath = _stateFilePath + ".tmp";
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
File.Copy(tempPath, _stateFilePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
_logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}");
}
}
/// <summary>
/// Deletes persisted state file.
/// </summary>
public void Delete()
{
try
{
if (File.Exists(_stateFilePath))
{
File.Delete(_stateFilePath);
_logger.Info("ImeLogTracker: persisted state deleted");
}
}
catch (Exception ex)
{
_logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}");
}
}
}
// ---- DTOs for state serialization ----
public class ImeTrackerStateData
{
public int CurrentPhaseOrder { get; set; }
public string LastEspPhaseDetected { get; set; }
public bool AllAppsCompletedFired { get; set; }
public bool LogPhaseIsCurrentPhase { get; set; }
public List<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
public string CurrentPackageId { get; set; }
public List<PackageStateData> Packages { get; set; }
public Dictionary<string, FilePositionData> FilePositions { get; set; }
}
public class PackageStateData
{
public string Id { get; set; }
public int ListPos { get; set; }
public string Name { get; set; }
public int RunAs { get; set; }
public int Intent { get; set; }
public int Targeted { get; set; }
public List<string> DependsOn { get; set; }
public int InstallationState { get; set; }
public bool DownloadingOrInstallingSeen { get; set; }
public int? ProgressPercent { get; set; }
public long BytesDownloaded { get; set; }
public long BytesTotal { get; set; }
}
public class FilePositionData
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
}
}
+18 -17
View File
@@ -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
/// </summary>
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))
@@ -1 +1 @@
18
27
@@ -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
{
@@ -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<IngestEventsRequest>(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);
@@ -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);
@@ -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<AdminConfigurationService> _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<AdminConfigurationService> logger)
public AdminConfigurationService(IConfiguration configuration, ILogger<AdminConfigurationService> logger, IMemoryCache cache)
{
_logger = logger;
_cache = cache;
var connectionString = configuration["AzureTableStorageConnectionString"];
var serviceClient = new TableServiceClient(connectionString);
@@ -40,13 +42,9 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public async Task<AdminConfiguration> 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<TableEntity>("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
/// </summary>
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
};
}
}
@@ -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 =====
/// <summary>
/// Fixed: Now checks errorPatternId and errorDetail fields in app_install_failed events
/// instead of relying on non-existent error_detected events.
/// </summary>
private static AnalyzeRule CreateAppDetectionScriptFailureRule() => new AnalyzeRule
{
RuleId = "ANALYZE-APP-001",
@@ -55,7 +62,11 @@ namespace AutopilotMonitor.Functions.Services
BaseConfidence = 60,
Conditions = new List<RuleCondition>
{
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<ConfidenceFactor>
{
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<RemediationStep>
@@ -65,6 +76,10 @@ namespace AutopilotMonitor.Functions.Services
Tags = new[] { "apps", "detection", "win32" }
};
/// <summary>
/// Fixed: Now checks errorDetail field (which contains the IME log line) for MSI error codes
/// instead of the non-existent errorCode field.
/// </summary>
private static AnalyzeRule CreateAppMsiErrorRule() => new AnalyzeRule
{
RuleId = "ANALYZE-APP-002",
@@ -75,7 +90,7 @@ namespace AutopilotMonitor.Functions.Services
BaseConfidence = 80,
Conditions = new List<RuleCondition>
{
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<RemediationStep>
@@ -85,6 +100,10 @@ namespace AutopilotMonitor.Functions.Services
Tags = new[] { "apps", "msi", "error-code" }
};
/// <summary>
/// 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.
/// </summary>
private static AnalyzeRule CreateAppDependencyChainRule() => new AnalyzeRule
{
RuleId = "ANALYZE-APP-003",
@@ -95,7 +114,7 @@ namespace AutopilotMonitor.Functions.Services
BaseConfidence = 70,
Conditions = new List<RuleCondition>
{
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<RemediationStep>
@@ -105,43 +124,52 @@ namespace AutopilotMonitor.Functions.Services
Tags = new[] { "apps", "dependency" }
};
/// <summary>
/// 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.
/// </summary>
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<RuleCondition>
{
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<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Free disk space", Steps = new List<string> { "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<string> { "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" }
};
/// <summary>
/// Fixed: Now checks errorDetail and errorPatternId for download-related IME error patterns
/// (IME-DO-TIMEOUT, IME-ERROR-DOWNLOAD, IME-ERROR-NO-CONTENT).
/// </summary>
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<RuleCondition>
{
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<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Improve download speed", Steps = new List<string> { "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<RuleCondition>
{
new RuleCondition { Signal = "summary_errors", Source = "event_type", EventType = "app_tracking_summary", DataField = "errorCount", Operator = "gte", Value = "2", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Review failed apps", Steps = new List<string> {
"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" }
};
/// <summary>
/// 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.
/// </summary>
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<RuleCondition>
{
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<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Fix policy", Steps = new List<string> { "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 =====
/// <summary>
/// Fixed: Now checks app_install_failed events for TPM-related error messages
/// instead of non-existent error_detected events.
/// </summary>
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<RuleCondition>
{
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<RemediationStep>
@@ -265,17 +333,21 @@ namespace AutopilotMonitor.Functions.Services
Tags = new[] { "device", "tpm", "critical" }
};
/// <summary>
/// Fixed: Now checks app_install_failed events for BitLocker-related error messages
/// instead of non-existent error_detected events.
/// </summary>
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<RuleCondition>
{
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<RemediationStep>
@@ -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<RuleCondition>
{
new RuleCondition { Signal = "summary_errors", Source = "event_type", EventType = "app_tracking_summary", DataField = "errorCount", Operator = "gte", Value = "2", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Review failed apps", Steps = new List<string> {
"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" }
};
/// <summary>
/// Fixed: Second condition now uses app_install_failed instead of non-existent error_detected.
/// Correlates low disk space from performance_snapshot with app failures.
/// </summary>
private static AnalyzeRule CreateCorrelationDiskSpaceCausedInstallFailureRule() => new AnalyzeRule
{
RuleId = "ANALYZE-CORR-004",
@@ -414,15 +459,15 @@ namespace AutopilotMonitor.Functions.Services
Conditions = new List<RuleCondition>
{
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<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Reduce disk consumption during enrollment", Steps = new List<string> {
@@ -477,5 +522,178 @@ namespace AutopilotMonitor.Functions.Services
},
Tags = new[] { "correlation", "apps", "proxy", "network", "download", "root-cause" }
};
// ===== NEW RULES =====
/// <summary>
/// Detects when the overall enrollment takes excessively long (> 60 minutes).
/// Uses the total time span between the first and last esp_phase_changed events.
/// </summary>
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<RuleCondition>
{
new RuleCondition { Signal = "long_enrollment", Source = "phase_duration", EventType = "esp_phase_changed", DataField = "espPhase", Operator = "equals", Value = "DeviceSetup", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Investigate long enrollment", Steps = new List<string> {
"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" }
};
/// <summary>
/// Detects explicit enrollment failure (enrollment_failed event).
/// The Agent emits this when the enrollment fails for any reason.
/// </summary>
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<RuleCondition>
{
new RuleCondition { Signal = "enrollment_failed", Source = "event_type", EventType = "enrollment_failed", Operator = "exists", Value = "", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Investigate enrollment failure", Steps = new List<string> {
"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" }
};
/// <summary>
/// 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.
/// </summary>
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<RuleCondition>
{
new RuleCondition { Signal = "hello_timeout", Source = "event_type", EventType = "hello_wait_timeout", Operator = "exists", Value = "", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Verify Windows Hello configuration", Steps = new List<string> {
"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" }
};
/// <summary>
/// Detects when app downloads appear stalled based on download_progress events
/// with status "failed" and app installation failure patterns matching download errors.
/// </summary>
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<RuleCondition>
{
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<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Investigate download issues", Steps = new List<string> {
"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" }
};
/// <summary>
/// Detects high memory usage during enrollment which can cause app installation issues.
/// </summary>
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<RuleCondition>
{
new RuleCondition { Signal = "high_memory", Source = "event_type", EventType = "performance_snapshot", DataField = "memory_used_percent", Operator = "gt", Value = "90", Required = true }
},
ConfidenceFactors = new List<ConfidenceFactor>
{
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<RemediationStep>
{
new RemediationStep { Title = "Reduce memory pressure", Steps = new List<string> {
"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" }
};
}
}
@@ -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<string, string>()),
@@ -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<Dictionary<string, string>>(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<RuleCondition>()),
["BaseConfidence"] = rule.BaseConfidence,
["ConfidenceFactorsJson"] = JsonConvert.SerializeObject(rule.ConfidenceFactors ?? new List<ConfidenceFactor>()),
@@ -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<List<RuleCondition>>(entity.GetString("ConditionsJson")),
BaseConfidence = entity.GetInt32("BaseConfidence") ?? 50,
ConfidenceFactors = DeserializeJson<List<ConfidenceFactor>>(entity.GetString("ConfidenceFactorsJson")),
@@ -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<TenantConfigurationService> _logger;
private readonly IMemoryCache _cache;
// In-memory cache for configuration (to avoid table lookups on every request)
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, CachedConfig> _configCache;
private const int MaxCacheSize = 1000;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public TenantConfigurationService(IConfiguration configuration, ILogger<TenantConfigurationService> logger)
public TenantConfigurationService(IConfiguration configuration, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
{
_logger = logger;
_configCache = new System.Collections.Concurrent.ConcurrentDictionary<string, CachedConfig>();
_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<TableEntity>(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
/// </summary>
public void InvalidateCache(string tenantId)
{
_configCache.TryRemove(tenantId, out _);
_cache.Remove($"tenant-config:{tenantId}");
}
/// <summary>
@@ -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; }
}
}
}
+80 -71
View File
@@ -5,15 +5,14 @@ namespace AutopilotMonitor.Shared
/// </summary>
public static class Constants
{
/// <summary>
/// Agent version
/// </summary>
public const string AgentVersion = "1.0.0";
// -----------------------------------------------------------------------
// Agent runtime defaults
// -----------------------------------------------------------------------
/// <summary>
/// Registry path for agent configuration
/// Base data directory for all agent files (spool, logs, state)
/// </summary>
public const string RegistryPath = @"SOFTWARE\AutopilotMonitor";
public const string AgentDataDirectory = @"%ProgramData%\AutopilotMonitor";
/// <summary>
/// Local spool directory for offline queueing
@@ -26,106 +25,126 @@ namespace AutopilotMonitor.Shared
public const string LogDirectory = @"%ProgramData%\AutopilotMonitor\Logs";
/// <summary>
/// Maximum batch size for events
/// Agent state directory (enrollment complete marker, IME tracker state, etc.)
/// </summary>
public const string StateDirectory = @"%ProgramData%\AutopilotMonitor\State";
/// <summary>
/// Scheduled Task name used to run the agent as SYSTEM
/// </summary>
public const string ScheduledTaskName = "AutopilotMonitor-Agent";
/// <summary>
/// Default backend API base URL (overridable via AUTOPILOT_MONITOR_API env var or --api-url CLI arg)
/// </summary>
public const string ApiBaseUrl = "https://autopilotmonitor-api.azurewebsites.net";
// -----------------------------------------------------------------------
// Upload / batching defaults
// -----------------------------------------------------------------------
/// <summary>
/// Maximum number of events per upload batch
/// </summary>
public const int MaxBatchSize = 100;
/// <summary>
/// Maximum batch age in seconds
/// Default upload interval in seconds (fallback timer; normal path uses FileSystemWatcher)
/// </summary>
public const int MaxBatchAgeSeconds = 30;
public const int DefaultUploadIntervalSeconds = 30;
// -----------------------------------------------------------------------
// API endpoint paths (relative to ApiBaseUrl)
// -----------------------------------------------------------------------
/// <summary>
/// Maximum retry attempts for failed uploads
/// </summary>
public const int MaxRetryAttempts = 5;
/// <summary>
/// API endpoint paths
/// API endpoint paths used by the agent
/// </summary>
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
// -----------------------------------------------------------------------
/// <summary>
/// Event types
/// Event type identifiers for EnrollmentEvent.EventType
/// </summary>
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
// -----------------------------------------------------------------------
/// <summary>
/// Event sources
/// Event source identifiers for EnrollmentEvent.Source
/// </summary>
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";
}
/// <summary>
/// Log file paths to monitor
/// </summary>
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
// -----------------------------------------------------------------------
/// <summary>
/// Azure Table Storage table names
/// All table names are defined here centrally and initialized at application startup
/// </summary>
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";
/// <summary>
/// Returns all table names for initialization
@@ -148,15 +167,5 @@ namespace AutopilotMonitor.Shared
TenantAdmins
};
}
/// <summary>
/// Azure Blob Storage container names
/// </summary>
public static class ContainerNames
{
public const string Bundles = "bundles";
public const string Screenshots = "screenshots";
public const string Logs = "logs";
}
}
}
@@ -39,13 +39,6 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public int GlobalRateLimitRequestsPerMinute { get; set; } = 100;
// ===== FUTURE SETTINGS (Extensible) =====
/// <summary>
/// Custom settings as JSON (for future extensibility)
/// </summary>
public string CustomSettings { get; set; }
/// <summary>
/// Creates default configuration
/// </summary>
@@ -54,11 +54,6 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool IsBuiltIn { get; set; } = true;
/// <summary>
/// Whether this is a community-contributed rule (future use)
/// </summary>
public bool IsCommunity { get; set; } = false;
/// <summary>
/// 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
@@ -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"
@@ -51,11 +51,6 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool IsBuiltIn { get; set; } = true;
/// <summary>
/// Whether this is a community-contributed rule (future use)
/// </summary>
public bool IsCommunity { get; set; } = false;
// ===== WHAT TO COLLECT =====
/// <summary>
@@ -139,13 +139,6 @@ namespace AutopilotMonitor.Shared.Models
public int PerformanceCollectorIntervalSeconds { get; set; } = 60;
// ===== FUTURE SETTINGS (Extensible) =====
/// <summary>
/// Custom settings as JSON (for future extensibility)
/// </summary>
public string CustomSettings { get; set; }
// ===== HELPER METHODS =====
/// <summary>