mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Bootstrap
- install only during enrollment Agent - added geolocation support - HelloDetector - Reboot on complete - WMI Model parsing fix ...and general small additions here and there
This commit is contained in:
@@ -38,7 +38,10 @@ param(
|
||||
[string]$ApiBaseUrl = "http://localhost:7071",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://yourcdn.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip"
|
||||
[string]$AgentDownloadUrl = "https://yourcdn.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxOsAgeHours = 5
|
||||
)
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
@@ -71,6 +74,49 @@ try {
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "API Base URL: $ApiBaseUrl"
|
||||
|
||||
# ── Pre-flight: Skip installation on already-enrolled devices ──
|
||||
|
||||
# Check 1: OS install date must be within threshold (fresh device)
|
||||
$osInstallDate = (Get-CimInstance Win32_OperatingSystem).InstallDate
|
||||
$osAge = (Get-Date) - $osInstallDate
|
||||
Write-Log "OS install date: $osInstallDate (age: $([math]::Round($osAge.TotalHours, 1))h)"
|
||||
|
||||
if ($osAge.TotalHours -gt $MaxOsAgeHours) {
|
||||
Write-Log "SKIP: OS was installed $([math]::Round($osAge.TotalHours, 1))h ago (threshold: ${MaxOsAgeHours}h). Device is not freshly provisioned."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check 2: MDM enrollment must not be completed yet
|
||||
$enrollmentPath = "HKLM:\SOFTWARE\Microsoft\Enrollments"
|
||||
$mdmEnrolled = $false
|
||||
|
||||
if (Test-Path $enrollmentPath) {
|
||||
$enrollmentEntries = Get-ChildItem -Path $enrollmentPath -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.PSChildName -match '^[0-9A-Fa-f\-]{36}$' }
|
||||
|
||||
foreach ($entry in $enrollmentEntries) {
|
||||
$providerID = (Get-ItemProperty -Path $entry.PSPath -Name "ProviderID" -ErrorAction SilentlyContinue).ProviderID
|
||||
if ($providerID) {
|
||||
$mdmEnrolled = $true
|
||||
Write-Log "Found MDM enrollment: ProviderID=$providerID (EnrollmentID=$($entry.PSChildName))"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($mdmEnrolled) {
|
||||
# Double-check: Is enrollment status tracking showing completed?
|
||||
$espPath = "HKLM:\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup"
|
||||
$hasCompleted = (Get-ItemProperty -Path $espPath -Name "HasProvisioningCompleted" -ErrorAction SilentlyContinue).HasProvisioningCompleted
|
||||
|
||||
if ($hasCompleted -eq 1) {
|
||||
Write-Log "SKIP: Autopilot enrollment already completed (HasProvisioningCompleted=1). Device is already enrolled."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed - device is freshly provisioned and enrollment in progress"
|
||||
|
||||
# Create agent configuration (JSON - NO REGISTRY!)
|
||||
Write-Log "Creating agent configuration..."
|
||||
$agentConfig = @{
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace AutopilotMonitor.Agent.Core.Configuration
|
||||
/// <summary>
|
||||
/// Whether to use client certificate authentication
|
||||
/// </summary>
|
||||
public bool UseClientCertAuth { get; set; } = false;
|
||||
public bool UseClientCertAuth { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Client certificate thumbprint (if using cert auth)
|
||||
@@ -87,6 +87,16 @@ namespace AutopilotMonitor.Agent.Core.Configuration
|
||||
/// </summary>
|
||||
public string ScheduledTaskName { get; set; } = "AutopilotMonitor-Agent";
|
||||
|
||||
/// <summary>
|
||||
/// Whether to reboot the device after enrollment completes
|
||||
/// </summary>
|
||||
public bool RebootOnComplete { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable geo-location detection (queries external IP services)
|
||||
/// </summary>
|
||||
public bool EnableGeoLocation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configuration
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.EventCollection
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects and tracks Windows Hello for Business provisioning during Autopilot enrollment.
|
||||
/// Monitors the "Microsoft-Windows-User Device Registration/Admin" event log channel
|
||||
/// and checks registry for WHfB policy configuration.
|
||||
///
|
||||
/// Key Event IDs:
|
||||
/// 358 - Prerequisites passed, provisioning will be launched
|
||||
/// 360 - Prerequisites failed, provisioning will NOT be launched
|
||||
/// 362 - Provisioning blocked
|
||||
/// 300 - NGC key registered successfully (Hello provisioned)
|
||||
/// 301 - NGC key registration failed
|
||||
/// </summary>
|
||||
public class HelloDetector : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _onEventCollected;
|
||||
private System.Diagnostics.Eventing.Reader.EventLogWatcher _watcher;
|
||||
|
||||
private const string EventLogChannel = "Microsoft-Windows-User Device Registration/Admin";
|
||||
|
||||
// WHfB policy registry paths
|
||||
private const string CspPolicyBasePath = @"SOFTWARE\Microsoft\Policies\PassportForWork";
|
||||
private const string GpoPolicyPath = @"SOFTWARE\Policies\Microsoft\PassportForWork";
|
||||
|
||||
// Tracked event IDs
|
||||
private const int EventId_NgcKeyRegistered = 300;
|
||||
private const int EventId_NgcKeyRegistrationFailed = 301;
|
||||
private const int EventId_ProvisioningWillLaunch = 358;
|
||||
private const int EventId_ProvisioningWillNotLaunch = 360;
|
||||
private const int EventId_ProvisioningBlocked = 362;
|
||||
private const int EventId_PinStatus = 376;
|
||||
|
||||
private static readonly HashSet<int> TrackedEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_NgcKeyRegistered,
|
||||
EventId_NgcKeyRegistrationFailed,
|
||||
EventId_ProvisioningWillLaunch,
|
||||
EventId_ProvisioningWillNotLaunch,
|
||||
EventId_ProvisioningBlocked,
|
||||
EventId_PinStatus
|
||||
};
|
||||
|
||||
public HelloDetector(string sessionId, string tenantId, Action<EnrollmentEvent> onEventCollected, AgentLogger logger)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_logger.Info("Starting Hello detector");
|
||||
|
||||
// Check if WHfB policy is configured
|
||||
CheckHelloPolicy();
|
||||
|
||||
// Subscribe to User Device Registration event log
|
||||
StartEventLogWatcher();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_logger.Info("Stopping Hello detector");
|
||||
|
||||
if (_watcher != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.EventRecordWritten -= OnEventRecordWritten;
|
||||
_watcher.Dispose();
|
||||
_watcher = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping Hello detector watcher", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckHelloPolicy()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (isEnabled, source) = DetectHelloPolicy();
|
||||
|
||||
if (isEnabled.HasValue)
|
||||
{
|
||||
var status = isEnabled.Value ? "enabled" : "disabled";
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = "hello_policy_detected",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "HelloDetector",
|
||||
Phase = EnrollmentPhase.EspDeviceSetup,
|
||||
Message = $"Windows Hello for Business policy detected: {status} (via {source})",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "helloEnabled", isEnabled.Value },
|
||||
{ "policySource", source }
|
||||
}
|
||||
});
|
||||
|
||||
_logger.Info($"WHfB policy: {status} (source: {source})");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("No WHfB policy found in registry - Hello may use tenant default");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Error checking WHfB policy: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private (bool? isEnabled, string source) DetectHelloPolicy()
|
||||
{
|
||||
// 1. Check CSP/Intune-delivered policy (per-tenant paths)
|
||||
try
|
||||
{
|
||||
using (var baseCspKey = Registry.LocalMachine.OpenSubKey(CspPolicyBasePath, false))
|
||||
{
|
||||
if (baseCspKey != null)
|
||||
{
|
||||
foreach (var tenantSubKey in baseCspKey.GetSubKeyNames())
|
||||
{
|
||||
using (var policiesKey = baseCspKey.OpenSubKey($@"{tenantSubKey}\Device\Policies", false))
|
||||
{
|
||||
if (policiesKey != null)
|
||||
{
|
||||
var value = policiesKey.GetValue("UsePassportForWork");
|
||||
if (value != null)
|
||||
{
|
||||
return (Convert.ToInt32(value) == 1, "CSP/Intune");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
// 2. Check GPO-delivered policy
|
||||
try
|
||||
{
|
||||
using (var gpoKey = Registry.LocalMachine.OpenSubKey(GpoPolicyPath, false))
|
||||
{
|
||||
if (gpoKey != null)
|
||||
{
|
||||
var value = gpoKey.GetValue("Enabled");
|
||||
if (value != null)
|
||||
{
|
||||
return (Convert.ToInt32(value) == 1, "GPO");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
private void StartEventLogWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Watch for specific WHfB-related event IDs
|
||||
var query = new EventLogQuery(
|
||||
EventLogChannel,
|
||||
PathType.LogName,
|
||||
"*[System[TimeCreated[timediff(@SystemTime) <= 1000]]]"
|
||||
);
|
||||
|
||||
_watcher = new System.Diagnostics.Eventing.Reader.EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
|
||||
_logger.Info($"Started watching: {EventLogChannel}");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"Event log not found: {EventLogChannel} (normal if not on a real device)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start Hello event log watcher", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var record = e.EventRecord;
|
||||
var eventId = record.Id;
|
||||
|
||||
if (!TrackedEventIds.Contains(eventId))
|
||||
return;
|
||||
|
||||
var description = record.FormatDescription() ?? $"Event ID {eventId}";
|
||||
|
||||
string eventType;
|
||||
EventSeverity severity;
|
||||
string message;
|
||||
|
||||
switch (eventId)
|
||||
{
|
||||
case EventId_ProvisioningWillLaunch: // 358
|
||||
eventType = "hello_provisioning_started";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business provisioning started - prerequisites passed";
|
||||
break;
|
||||
|
||||
case EventId_NgcKeyRegistered: // 300
|
||||
eventType = "hello_provisioning_completed";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello for Business provisioned successfully - NGC key registered";
|
||||
break;
|
||||
|
||||
case EventId_NgcKeyRegistrationFailed: // 301
|
||||
eventType = "hello_provisioning_failed";
|
||||
severity = EventSeverity.Error;
|
||||
message = "Windows Hello for Business provisioning failed - NGC key registration error";
|
||||
break;
|
||||
|
||||
case EventId_ProvisioningWillNotLaunch: // 360
|
||||
eventType = "hello_provisioning_skipped";
|
||||
severity = EventSeverity.Warning;
|
||||
message = "Windows Hello for Business provisioning skipped - prerequisites not met";
|
||||
break;
|
||||
|
||||
case EventId_ProvisioningBlocked: // 362
|
||||
eventType = "hello_provisioning_blocked";
|
||||
severity = EventSeverity.Warning;
|
||||
message = "Windows Hello for Business provisioning blocked";
|
||||
break;
|
||||
|
||||
case EventId_PinStatus: // 376
|
||||
eventType = "hello_pin_status";
|
||||
severity = EventSeverity.Info;
|
||||
message = "Windows Hello PIN status update";
|
||||
break;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
_onEventCollected(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = record.TimeCreated ?? DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Severity = severity,
|
||||
Source = "HelloDetector",
|
||||
Phase = EnrollmentPhase.EspDeviceSetup,
|
||||
Message = message,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", eventId },
|
||||
{ "providerName", record.ProviderName ?? "" },
|
||||
{ "description", description }
|
||||
}
|
||||
});
|
||||
|
||||
_logger.Info($"Hello event detected: {eventType} (EventID {eventId})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing Hello event record", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using AutopilotMonitor.Agent.Core.Api;
|
||||
using AutopilotMonitor.Agent.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.Core.EventCollection;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using AutopilotMonitor.Agent.Core.Network;
|
||||
using AutopilotMonitor.Agent.Core.Storage;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
@@ -31,6 +32,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
private EventLogWatcher _eventLogWatcher;
|
||||
private RegistryMonitor _registryMonitor;
|
||||
private PhaseDetector _phaseDetector;
|
||||
private HelloDetector _helloDetector;
|
||||
private AutopilotSimulator _simulator;
|
||||
|
||||
public MonitoringService(AgentConfiguration configuration, AgentLogger logger)
|
||||
@@ -96,12 +98,44 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
Message = "Autopilot Monitor Agent started"
|
||||
});
|
||||
|
||||
// Collect and emit device geo-location (if enabled)
|
||||
if (_configuration.EnableGeoLocation)
|
||||
{
|
||||
EmitGeoLocationEvent();
|
||||
}
|
||||
|
||||
// Start event collectors
|
||||
StartEventCollectors();
|
||||
|
||||
_logger.Info("Monitoring service started");
|
||||
}
|
||||
|
||||
private void EmitGeoLocationEvent()
|
||||
{
|
||||
try
|
||||
{
|
||||
var location = GeoLocationService.GetLocationAsync(_logger).Result;
|
||||
if (location != null)
|
||||
{
|
||||
EmitEvent(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
TenantId = _configuration.TenantId,
|
||||
EventType = "device_location",
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "Network",
|
||||
Phase = EnrollmentPhase.PreFlight,
|
||||
Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})",
|
||||
Data = location.ToDictionary()
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to collect geo-location: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts all event collection components
|
||||
/// </summary>
|
||||
@@ -138,6 +172,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
);
|
||||
_phaseDetector.Start();
|
||||
|
||||
// Start Hello detector (WHfB provisioning tracking)
|
||||
_helloDetector = new HelloDetector(
|
||||
_configuration.SessionId,
|
||||
_configuration.TenantId,
|
||||
EmitEvent,
|
||||
_logger
|
||||
);
|
||||
_helloDetector.Start();
|
||||
|
||||
// Start Autopilot Simulator if enabled
|
||||
if (_configuration.EnableSimulator)
|
||||
{
|
||||
@@ -238,6 +281,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
{
|
||||
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())
|
||||
@@ -312,6 +370,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
_phaseDetector?.Stop();
|
||||
_phaseDetector?.Dispose();
|
||||
|
||||
_helloDetector?.Stop();
|
||||
_helloDetector?.Dispose();
|
||||
|
||||
_simulator?.Stop();
|
||||
_simulator?.Dispose();
|
||||
|
||||
@@ -468,6 +529,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
{
|
||||
ExecuteCleanup();
|
||||
}
|
||||
else if (_configuration.RebootOnComplete)
|
||||
{
|
||||
_logger.Info("Reboot on complete enabled - initiating reboot");
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown.exe",
|
||||
Arguments = "/r /t 10 /c \"Autopilot enrollment completed - rebooting\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
|
||||
_logger.Info("Self-destruct sequence initiated. Agent will now exit.");
|
||||
|
||||
@@ -488,7 +561,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Executing FULL SELF-DESTRUCT (Scheduled Task + Files)");
|
||||
_logger.Info($"Executing FULL SELF-DESTRUCT (Scheduled Task + Files{(_configuration.RebootOnComplete ? " + Reboot" : "")})");
|
||||
|
||||
var currentProcessId = Process.GetCurrentProcess().Id;
|
||||
var agentBasePath = Path.GetFullPath(Path.Combine(
|
||||
@@ -522,6 +595,11 @@ try {{
|
||||
}}
|
||||
|
||||
Write-Host 'AutopilotMonitor Agent self-destruct complete'
|
||||
{(_configuration.RebootOnComplete ? @"
|
||||
# Reboot the device
|
||||
Write-Host 'Initiating reboot...'
|
||||
Restart-Computer -Force
|
||||
" : "")}
|
||||
";
|
||||
|
||||
// Write cleanup script to temp location (outside of agent folder)
|
||||
@@ -554,7 +632,7 @@ Write-Host 'AutopilotMonitor Agent self-destruct complete'
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("Executing CLEANUP (Files only, keeping Scheduled Task)");
|
||||
_logger.Info($"Executing CLEANUP (Files only, keeping Scheduled Task{(_configuration.RebootOnComplete ? " + Reboot" : "")})");
|
||||
|
||||
var currentProcessId = Process.GetCurrentProcess().Id;
|
||||
var agentBasePath = Path.GetFullPath(Path.Combine(
|
||||
@@ -578,6 +656,11 @@ try {{
|
||||
}}
|
||||
|
||||
Write-Host 'AutopilotMonitor Agent cleanup complete'
|
||||
{(_configuration.RebootOnComplete ? @"
|
||||
# Reboot the device
|
||||
Write-Host 'Initiating reboot...'
|
||||
Restart-Computer -Force
|
||||
" : "")}
|
||||
";
|
||||
|
||||
// Write cleanup script to temp location
|
||||
@@ -612,6 +695,7 @@ Write-Host 'AutopilotMonitor Agent cleanup complete'
|
||||
_eventLogWatcher?.Dispose();
|
||||
_registryMonitor?.Dispose();
|
||||
_phaseDetector?.Dispose();
|
||||
_helloDetector?.Dispose();
|
||||
_simulator?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.Core.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Network
|
||||
{
|
||||
public class GeoLocationResult
|
||||
{
|
||||
public string Country { get; set; }
|
||||
public string Region { get; set; }
|
||||
public string City { get; set; }
|
||||
public string Loc { get; set; }
|
||||
public string Timezone { get; set; }
|
||||
public string Source { get; set; }
|
||||
|
||||
public Dictionary<string, object> ToDictionary()
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
{ "country", Country },
|
||||
{ "region", Region },
|
||||
{ "city", City },
|
||||
{ "loc", Loc },
|
||||
{ "timezone", Timezone },
|
||||
{ "source", Source }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class GeoLocationService
|
||||
{
|
||||
private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public static async Task<GeoLocationResult> GetLocationAsync(AgentLogger logger)
|
||||
{
|
||||
// Try ipinfo.io first
|
||||
var result = await TryIpInfo(logger);
|
||||
if (result != null) return result;
|
||||
|
||||
// Fallback to ifconfig.co
|
||||
result = await TryIfConfigCo(logger);
|
||||
if (result != null) return result;
|
||||
|
||||
logger?.Warning("GeoLocation: All providers failed, skipping location event");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<GeoLocationResult> TryIpInfo(AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger?.Info("GeoLocation: Querying ipinfo.io...");
|
||||
|
||||
using (var client = new HttpClient { Timeout = RequestTimeout })
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
var response = await client.GetStringAsync("https://ipinfo.io/json");
|
||||
var json = JObject.Parse(response);
|
||||
|
||||
var result = new GeoLocationResult
|
||||
{
|
||||
Country = json.Value<string>("country"),
|
||||
Region = json.Value<string>("region"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = json.Value<string>("loc"),
|
||||
Timezone = json.Value<string>("timezone"),
|
||||
Source = "ipinfo"
|
||||
};
|
||||
|
||||
logger?.Info($"GeoLocation: ipinfo.io returned {result.City}, {result.Region}, {result.Country}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"GeoLocation: ipinfo.io failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<GeoLocationResult> TryIfConfigCo(AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger?.Info("GeoLocation: Falling back to ifconfig.co...");
|
||||
|
||||
using (var client = new HttpClient { Timeout = RequestTimeout })
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
var response = await client.GetStringAsync("https://ifconfig.co/json");
|
||||
var json = JObject.Parse(response);
|
||||
|
||||
var latitude = json.Value<string>("latitude") ?? "";
|
||||
var longitude = json.Value<string>("longitude") ?? "";
|
||||
var loc = !string.IsNullOrEmpty(latitude) && !string.IsNullOrEmpty(longitude)
|
||||
? $"{latitude},{longitude}"
|
||||
: "";
|
||||
|
||||
var result = new GeoLocationResult
|
||||
{
|
||||
Country = json.Value<string>("country_iso"),
|
||||
Region = json.Value<string>("region_name"),
|
||||
City = json.Value<string>("city"),
|
||||
Loc = loc,
|
||||
Timezone = json.Value<string>("time_zone"),
|
||||
Source = "ifconfig.co"
|
||||
};
|
||||
|
||||
logger?.Info($"GeoLocation: ifconfig.co returned {result.City}, {result.Region}, {result.Country}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Warning($"GeoLocation: ifconfig.co failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ namespace AutopilotMonitor.Agent
|
||||
|
||||
logger.Info("Agent starting in console mode");
|
||||
Console.WriteLine($"Session ID: {config.SessionId}");
|
||||
Console.WriteLine($"Tenant ID: {config.TenantId}");
|
||||
Console.WriteLine($"API URL: {config.ApiBaseUrl}");
|
||||
Console.WriteLine($"Log Directory: {logDir}");
|
||||
|
||||
@@ -48,6 +49,16 @@ namespace AutopilotMonitor.Agent
|
||||
Console.WriteLine($"Simulate Failure: {(config.SimulateFailure ? "YES" : "NO")}");
|
||||
}
|
||||
|
||||
if (config.RebootOnComplete)
|
||||
{
|
||||
Console.WriteLine($"Reboot on Complete: ENABLED");
|
||||
}
|
||||
|
||||
if (config.EnableGeoLocation)
|
||||
{
|
||||
Console.WriteLine($"GeoLocation Detection: ENABLED");
|
||||
}
|
||||
|
||||
if (config.UseClientCertAuth)
|
||||
{
|
||||
Console.WriteLine($"Client Certificate Auth: ENABLED");
|
||||
@@ -88,11 +99,14 @@ namespace AutopilotMonitor.Agent
|
||||
// Parse command-line arguments
|
||||
var enableSimulator = args?.Contains("--simulator") ?? false;
|
||||
var simulateFailure = args?.Contains("--simulate-failure") ?? false;
|
||||
var useAuth = args?.Contains("--use-auth") ?? false;
|
||||
var noAuth = args?.Contains("--no-auth") ?? false;
|
||||
var noCleanup = args?.Contains("--no-cleanup") ?? false;
|
||||
var rebootOnComplete = args?.Contains("--reboot-on-complete") ?? false;
|
||||
var enableGeoLocation = args?.Contains("--enable-geolocation") ?? false;
|
||||
|
||||
// Parse certificate thumbprint if provided
|
||||
string certThumbprint = null;
|
||||
string tenantIdOverride = null;
|
||||
if (args != null)
|
||||
{
|
||||
var thumbprintIndex = Array.IndexOf(args, "--cert-thumbprint");
|
||||
@@ -100,6 +114,12 @@ namespace AutopilotMonitor.Agent
|
||||
{
|
||||
certThumbprint = args[thumbprintIndex + 1];
|
||||
}
|
||||
|
||||
var tenantIdIndex = Array.IndexOf(args, "--tenant-id");
|
||||
if (tenantIdIndex >= 0 && tenantIdIndex + 1 < args.Length)
|
||||
{
|
||||
tenantIdOverride = args[tenantIdIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults (fallback values if no config file exists)
|
||||
@@ -107,6 +127,9 @@ namespace AutopilotMonitor.Agent
|
||||
int uploadIntervalSeconds = 30;
|
||||
bool cleanupOnExit = true;
|
||||
bool selfDestructOnComplete = true;
|
||||
bool rebootOnCompleteConfig = false;
|
||||
bool enableGeoLocationConfig = false;
|
||||
bool useClientCertAuthConfig = true;
|
||||
|
||||
// Try to load configuration from JSON file (written by PowerShell bootstrap script)
|
||||
var configFilePath = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Config\agent-config.json");
|
||||
@@ -131,6 +154,15 @@ namespace AutopilotMonitor.Agent
|
||||
if (configDict.ContainsKey("selfDestructOnComplete") && configDict["selfDestructOnComplete"] != null)
|
||||
selfDestructOnComplete = Convert.ToBoolean(configDict["selfDestructOnComplete"]);
|
||||
|
||||
if (configDict.ContainsKey("rebootOnComplete") && configDict["rebootOnComplete"] != null)
|
||||
rebootOnCompleteConfig = Convert.ToBoolean(configDict["rebootOnComplete"]);
|
||||
|
||||
if (configDict.ContainsKey("enableGeoLocation") && configDict["enableGeoLocation"] != null)
|
||||
enableGeoLocationConfig = Convert.ToBoolean(configDict["enableGeoLocation"]);
|
||||
|
||||
if (configDict.ContainsKey("useClientCertAuth") && configDict["useClientCertAuth"] != null)
|
||||
useClientCertAuthConfig = Convert.ToBoolean(configDict["useClientCertAuth"]);
|
||||
|
||||
Console.WriteLine($"Loaded configuration from {configFilePath}");
|
||||
}
|
||||
}
|
||||
@@ -161,7 +193,7 @@ namespace AutopilotMonitor.Agent
|
||||
ApiBaseUrl = apiBaseUrl,
|
||||
SessionId = Guid.NewGuid().ToString(),
|
||||
//TenantId = "deadbeef-dead-beef-dead-beefdeadbeef",
|
||||
TenantId = "b54dc1af-5320-4f60-b5d4-821e0cf2a359",
|
||||
TenantId = tenantIdOverride ?? "b54dc1af-5320-4f60-b5d4-821e0cf2a359",
|
||||
SpoolDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Spool"),
|
||||
LogDirectory = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor\Logs"),
|
||||
UploadIntervalSeconds = uploadIntervalSeconds,
|
||||
@@ -169,10 +201,12 @@ namespace AutopilotMonitor.Agent
|
||||
EnableDebugLogging = true,
|
||||
EnableSimulator = enableSimulator,
|
||||
SimulateFailure = simulateFailure,
|
||||
UseClientCertAuth = useAuth,
|
||||
UseClientCertAuth = !noAuth && useClientCertAuthConfig,
|
||||
ClientCertThumbprint = certThumbprint,
|
||||
CleanupOnExit = cleanupOnExit,
|
||||
SelfDestructOnComplete = selfDestructOnComplete
|
||||
SelfDestructOnComplete = selfDestructOnComplete,
|
||||
RebootOnComplete = rebootOnComplete || rebootOnCompleteConfig,
|
||||
EnableGeoLocation = enableGeoLocation || enableGeoLocationConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#3b82f6"/>
|
||||
<rect x="6" y="18" width="5" height="8" rx="1.5" fill="white"/>
|
||||
<rect x="13.5" y="12" width="5" height="14" rx="1.5" fill="white"/>
|
||||
<rect x="21" y="6" width="5" height="20" rx="1.5" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -33,6 +33,7 @@ interface Session {
|
||||
currentPhase: number;
|
||||
eventCount: number;
|
||||
durationSeconds: number;
|
||||
failureReason?: string;
|
||||
}
|
||||
|
||||
const phaseNames: Record<number, string> = {
|
||||
@@ -356,7 +357,7 @@ export default function SessionDetailPage() {
|
||||
<InfoItem label="Model" value={`${session.manufacturer} ${session.model}`} />
|
||||
<InfoItem label="Serial Number" value={session.serialNumber} />
|
||||
<InfoItem label="Session ID" value={session.sessionId} />
|
||||
<InfoItem label="Status" value={<StatusBadge status={session.status} />} />
|
||||
<InfoItem label="Status" value={<StatusBadge status={session.status} failureReason={session.failureReason} />} />
|
||||
<InfoItem label="Events" value={session.eventCount.toString()} />
|
||||
<InfoItem label="Duration" value={`${Math.round(session.durationSeconds / 60)} min`} />
|
||||
</div>
|
||||
@@ -640,7 +641,7 @@ function SeverityBadge({ severity }: { severity: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) {
|
||||
const statusConfig = {
|
||||
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
|
||||
Succeeded: { color: "bg-green-100 text-green-800", text: "Succeeded" },
|
||||
@@ -650,9 +651,19 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
|
||||
|
||||
const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out");
|
||||
|
||||
return (
|
||||
<span className={`px-2 inline-block text-xs leading-5 font-semibold rounded-full ${config.color}`}>
|
||||
<span
|
||||
className={`px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full ${config.color}`}
|
||||
title={failureReason || undefined}
|
||||
>
|
||||
{config.text}
|
||||
{isTimeout && (
|
||||
<span title={failureReason} className="inline-flex items-center">
|
||||
⏱️
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,19 +314,36 @@ export default function Navbar() {
|
||||
|
||||
{/* User Dropdown */}
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<p className="text-sm font-medium text-gray-900">{user?.displayName || 'User'}</p>
|
||||
<p className="text-sm text-gray-600 truncate">{user?.upn}</p>
|
||||
{user?.isGalacticAdmin && (
|
||||
<span className="inline-block mt-2 px-2 py-1 text-xs font-semibold text-purple-800 bg-purple-100 rounded-full">
|
||||
Galactic Admin
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-start space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-600 flex-shrink-0 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(() => {
|
||||
if (user?.displayName) {
|
||||
const names = user.displayName.split(' ');
|
||||
if (names.length >= 2) {
|
||||
return `${names[0].charAt(0)}${names[names.length - 1].charAt(0)}`.toUpperCase();
|
||||
}
|
||||
return user.displayName.charAt(0).toUpperCase();
|
||||
}
|
||||
return user?.upn?.charAt(0).toUpperCase() || 'U';
|
||||
})()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">{user?.displayName || 'User'}</p>
|
||||
<p className="text-sm text-gray-600 truncate">{user?.upn}</p>
|
||||
{user?.isGalacticAdmin && (
|
||||
<span className="inline-block mt-2 px-2 py-1 text-xs font-semibold text-purple-800 bg-purple-100 rounded-full">
|
||||
Galactic Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button onClick={() => { logout(); setShowUserMenu(false); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Sign out
|
||||
<button onClick={() => { logout(); setShowUserMenu(false); }} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user