diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/NetworkChangeDetector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/NetworkChangeDetector.cs new file mode 100644 index 00000000..4585b575 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/NetworkChangeDetector.cs @@ -0,0 +1,594 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Detects network changes (SSID switches, wired↔wireless, IP changes) during provisioning + /// and runs MDM endpoint connectivity checks after each change. + /// Always-on collector — fires network_state_change and network_connectivity_check events. + /// + public class NetworkChangeDetector : IDisposable + { + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly AgentLogger _logger; + private readonly string _apiBaseUrl; + + private NetworkStateSnapshot _lastKnownState; + private Timer _debounceTimer; + private HttpClient _connectivityCheckClient; + private readonly object _stateLock = new object(); + private bool _disposed; + + private const int DebounceDelayMs = 3000; + private const int ConnectivityCheckDelayMs = 5000; + private const int ConnectivityCheckTimeoutMs = 8000; + private const int WifiCollectTimeoutMs = 3000; + private const string Source = "NetworkChangeDetector"; + + /// + /// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed. + /// + private static readonly (string Name, string Url)[] MdmEndpoints = + { + ("Entra ID", "https://login.microsoftonline.com"), + ("Device Registration", "https://enterpriseregistration.windows.net"), + ("Intune Portal", "https://portal.manage.microsoft.com"), + ("Microsoft Graph", "https://graph.microsoft.com"), + }; + + public NetworkChangeDetector( + string sessionId, + string tenantId, + Action onEventCollected, + AgentLogger logger, + string apiBaseUrl = null) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _apiBaseUrl = apiBaseUrl; + } + + public void Start() + { + _logger.Info("NetworkChangeDetector: starting (monitoring network changes)"); + + _connectivityCheckClient = new HttpClient + { + Timeout = TimeSpan.FromMilliseconds(ConnectivityCheckTimeoutMs) + }; + + // Capture initial network state as baseline + _lastKnownState = CaptureCurrentState(); + _logger.Info($"NetworkChangeDetector: initial state — {FormatSnapshotSummary(_lastKnownState)}"); + + NetworkChange.NetworkAddressChanged += OnNetworkAddressChanged; + } + + public void Stop() + { + NetworkChange.NetworkAddressChanged -= OnNetworkAddressChanged; + + lock (_stateLock) + { + _debounceTimer?.Dispose(); + _debounceTimer = null; + } + + _connectivityCheckClient?.Dispose(); + _connectivityCheckClient = null; + + _logger.Info("NetworkChangeDetector: stopped"); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop(); + } + + // ------------------------------------------------------------------- + // Event handling & debounce + // ------------------------------------------------------------------- + + private void OnNetworkAddressChanged(object sender, EventArgs e) + { + if (_disposed) return; + + lock (_stateLock) + { + // Reset debounce timer — collapses rapid OS events into one logical change + _debounceTimer?.Dispose(); + _debounceTimer = new Timer( + OnDebounceElapsed, + null, + DebounceDelayMs, + Timeout.Infinite); + } + } + + private void OnDebounceElapsed(object state) + { + if (_disposed) return; + + Task.Run(() => + { + try + { + ProcessNetworkChange(); + } + catch (Exception ex) + { + _logger.Warning($"NetworkChangeDetector: error processing network change: {ex.Message}"); + } + }); + } + + // ------------------------------------------------------------------- + // Core logic + // ------------------------------------------------------------------- + + private void ProcessNetworkChange() + { + if (_disposed) return; + + var newState = CaptureCurrentState(); + + NetworkStateSnapshot previousState; + lock (_stateLock) + { + previousState = _lastKnownState; + _lastKnownState = newState; + } + + // Suppress events when network hasn't actually changed (DHCP renewals, OS chatter) + if (previousState != null && previousState.IsSameNetwork(newState)) + { + _logger.Debug("NetworkChangeDetector: network address changed but same logical network — suppressing"); + return; + } + + _logger.Info($"NetworkChangeDetector: network change detected — {FormatSnapshotSummary(previousState)} → {FormatSnapshotSummary(newState)}"); + + // Emit network_state_change event + var changeType = ClassifyChange(previousState, newState); + var message = BuildChangeMessage(previousState, newState); + + var data = new Dictionary + { + // Before state + { "before_connectionType", previousState?.ConnectionType ?? "None" }, + { "before_adapterName", previousState?.AdapterName ?? "None" }, + { "before_ipAddress", previousState?.IpAddress ?? "None" }, + { "before_gateway", previousState?.Gateway ?? "None" }, + { "before_wifiSsid", previousState?.WifiSsid ?? "n/a" }, + { "before_linkSpeedMbps", previousState?.LinkSpeedMbps ?? 0 }, + + // After state + { "after_connectionType", newState.ConnectionType ?? "None" }, + { "after_adapterName", newState.AdapterName ?? "None" }, + { "after_ipAddress", newState.IpAddress ?? "None" }, + { "after_gateway", newState.Gateway ?? "None" }, + { "after_wifiSsid", newState.WifiSsid ?? "n/a" }, + { "after_linkSpeedMbps", newState.LinkSpeedMbps }, + + // Change classification + { "changeType", changeType }, + { "hadNetwork", previousState?.HasNetwork ?? false }, + { "hasNetwork", newState.HasNetwork } + }; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.NetworkStateChange, + Severity = EventSeverity.Warning, + Source = Source, + Phase = EnrollmentPhase.Unknown, + Message = message, + Data = data + }); + + // Wait for new connection to stabilize, then check MDM endpoint connectivity + try + { + Task.Delay(ConnectivityCheckDelayMs).Wait(); + if (!_disposed) + { + RunConnectivityChecksAsync().Wait(); + } + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: connectivity check failed: {ex.Message}"); + } + } + + private async Task RunConnectivityChecksAsync() + { + if (_disposed || _connectivityCheckClient == null) return; + + // Build endpoint list: static MDM endpoints + dynamic backend URL + var endpoints = new List<(string Name, string Url)>(MdmEndpoints); + if (!string.IsNullOrEmpty(_apiBaseUrl)) + { + endpoints.Add(("Autopilot Monitor API", _apiBaseUrl)); + } + + // Run all checks in parallel + var tasks = endpoints.Select(ep => CheckEndpointAsync(ep.Name, ep.Url)).ToArray(); + + Dictionary[] results; + try + { + results = await Task.WhenAll(tasks); + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: connectivity check tasks failed: {ex.Message}"); + return; + } + + if (_disposed) return; + + var reachableCount = results.Count(r => r.ContainsKey("reachable") && (bool)r["reachable"]); + var totalCount = results.Length; + var allReachable = reachableCount == totalCount; + + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.NetworkConnectivityCheck, + Severity = allReachable ? EventSeverity.Info : EventSeverity.Warning, + Source = Source, + Phase = EnrollmentPhase.Unknown, + Message = $"Connectivity check after network change: {reachableCount}/{totalCount} endpoints reachable", + Data = new Dictionary + { + { "reachableCount", reachableCount }, + { "totalCount", totalCount }, + { "allReachable", allReachable }, + { "checkDelayMs", ConnectivityCheckDelayMs }, + { "timeoutMs", ConnectivityCheckTimeoutMs }, + { "results", results.ToList() } + } + }); + + if (allReachable) + _logger.Info($"NetworkChangeDetector: connectivity check passed ({reachableCount}/{totalCount} reachable)"); + else + _logger.Warning($"NetworkChangeDetector: connectivity check partial — {reachableCount}/{totalCount} endpoints reachable"); + } + + private async Task> CheckEndpointAsync(string name, string url) + { + var sw = Stopwatch.StartNew(); + try + { + // Use GET — any HTTP response (even 401/403) means the network path works + var response = await _connectivityCheckClient.GetAsync(url); + sw.Stop(); + return new Dictionary + { + { "endpoint", name }, + { "url", url }, + { "reachable", true }, + { "httpStatus", (int)response.StatusCode }, + { "latencyMs", sw.ElapsedMilliseconds }, + { "error", (object)null } + }; + } + catch (Exception ex) + { + sw.Stop(); + // Unwrap AggregateException from async + var innerEx = ex is AggregateException agg ? agg.InnerException ?? ex : ex; + return new Dictionary + { + { "endpoint", name }, + { "url", url }, + { "reachable", false }, + { "httpStatus", (object)null }, + { "latencyMs", sw.ElapsedMilliseconds }, + { "error", $"{innerEx.GetType().Name}: {innerEx.Message}" } + }; + } + } + + // ------------------------------------------------------------------- + // State capture + // ------------------------------------------------------------------- + + private NetworkStateSnapshot CaptureCurrentState() + { + try + { + var activeNic = FindActiveNetworkInterface(); + if (activeNic == null) + { + return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow }; + } + + var isWifi = activeNic.NetworkInterfaceType == NetworkInterfaceType.Wireless80211; + var ipProps = activeNic.GetIPProperties(); + + // First IPv4 unicast address + string ipAddress = null; + foreach (var addr in ipProps.UnicastAddresses) + { + if (addr.Address.AddressFamily == AddressFamily.InterNetwork) + { + ipAddress = addr.Address.ToString(); + break; + } + } + + // First non-0.0.0.0 gateway + string gateway = null; + foreach (var gw in ipProps.GatewayAddresses) + { + if (gw.Address.ToString() != "0.0.0.0") + { + gateway = gw.Address.ToString(); + break; + } + } + + var snapshot = new NetworkStateSnapshot + { + HasNetwork = true, + AdapterName = activeNic.Name, + AdapterDescription = activeNic.Description, + InterfaceType = activeNic.NetworkInterfaceType.ToString(), + ConnectionType = isWifi ? "WiFi" : "Ethernet", + MacAddress = activeNic.GetPhysicalAddress().ToString(), + InterfaceId = activeNic.Id, + LinkSpeedMbps = activeNic.Speed / 1_000_000, + IpAddress = ipAddress, + Gateway = gateway, + CapturedAt = DateTime.UtcNow + }; + + // Collect WiFi SSID synchronously (needed for before/after comparison) + if (isWifi) + { + var wifiInfo = CollectWiFiInfo(); + snapshot.WifiSsid = wifiInfo.ssid; + snapshot.WifiSignalPercent = wifiInfo.signal; + snapshot.WifiRadioType = wifiInfo.radioType; + } + + return snapshot; + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: state capture failed: {ex.Message}"); + return new NetworkStateSnapshot { HasNetwork = false, CapturedAt = DateTime.UtcNow }; + } + } + + /// + /// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces. + /// Synchronous with timeout — needed before emitting the event. + /// + private (string ssid, int? signal, string radioType) CollectWiFiInfo() + { + string ssid = null; + int? signal = null; + string radioType = null; + + try + { + var psi = new ProcessStartInfo + { + FileName = "netsh.exe", + Arguments = "wlan show interfaces", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + string output; + using (var process = Process.Start(psi)) + { + output = process.StandardOutput.ReadToEnd(); + if (!process.WaitForExit(WifiCollectTimeoutMs)) + { + try { process.Kill(); } catch { } + _logger.Debug("NetworkChangeDetector: netsh timed out"); + return (null, null, null); + } + } + + if (string.IsNullOrEmpty(output)) + return (null, null, null); + + foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = line.Trim(); + var colonIndex = trimmed.IndexOf(':'); + if (colonIndex < 0) continue; + + var key = trimmed.Substring(0, colonIndex).Trim(); + var value = trimmed.Substring(colonIndex + 1).Trim(); + + if (key.Equals("SSID", StringComparison.OrdinalIgnoreCase) && + !key.Equals("BSSID", StringComparison.OrdinalIgnoreCase)) + { + ssid = value; + } + else if (key.Equals("Signal", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value.TrimEnd('%'), out var sig)) + signal = sig; + } + else if (key.Equals("Radio type", StringComparison.OrdinalIgnoreCase)) + { + radioType = value; + } + } + } + catch (Exception ex) + { + _logger.Debug($"NetworkChangeDetector: WiFi info collection failed: {ex.Message}"); + } + + return (ssid, signal, radioType); + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + /// + /// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway. + /// + private static NetworkInterface FindActiveNetworkInterface() + { + try + { + var interfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var nic in interfaces) + { + if (nic.OperationalStatus != OperationalStatus.Up) + continue; + if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback || + nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) + continue; + + var ipProps = nic.GetIPProperties(); + foreach (var gw in ipProps.GatewayAddresses) + { + if (gw.Address.ToString() != "0.0.0.0") + return nic; + } + } + } + catch + { + // Caller handles null + } + return null; + } + + private static string BuildChangeMessage(NetworkStateSnapshot before, NetworkStateSnapshot after) + { + var beforeDesc = FormatSnapshotBrief(before); + var afterDesc = FormatSnapshotBrief(after); + + if (before == null || !before.HasNetwork) + { + if (after.HasNetwork) + return $"Network connected: {afterDesc}"; + return "Network state changed (no connectivity)"; + } + + if (!after.HasNetwork) + return $"Network lost (was {beforeDesc})"; + + return $"Network changed: {beforeDesc} \u2192 {afterDesc}"; + } + + private static string FormatSnapshotBrief(NetworkStateSnapshot snapshot) + { + if (snapshot == null || !snapshot.HasNetwork) + return "None"; + + if (snapshot.ConnectionType == "WiFi" && !string.IsNullOrEmpty(snapshot.WifiSsid)) + return $"WiFi '{snapshot.WifiSsid}'"; + + return $"{snapshot.ConnectionType} ({snapshot.IpAddress ?? "no IP"})"; + } + + private static string FormatSnapshotSummary(NetworkStateSnapshot snapshot) + { + if (snapshot == null || !snapshot.HasNetwork) + return "no network"; + + var summary = $"{snapshot.ConnectionType}"; + if (!string.IsNullOrEmpty(snapshot.WifiSsid)) + summary += $" SSID='{snapshot.WifiSsid}'"; + if (!string.IsNullOrEmpty(snapshot.IpAddress)) + summary += $" IP={snapshot.IpAddress}"; + return summary; + } + + private static string ClassifyChange(NetworkStateSnapshot before, NetworkStateSnapshot after) + { + if (before == null || !before.HasNetwork) + return after.HasNetwork ? "network_restored" : "no_change"; + + if (!after.HasNetwork) + return "network_lost"; + + // Both have network — classify the change + if (before.ConnectionType != after.ConnectionType) + return "type_change"; + + if (before.ConnectionType == "WiFi" && after.ConnectionType == "WiFi" && + before.WifiSsid != after.WifiSsid) + return "ssid_change"; + + if (before.InterfaceId != after.InterfaceId) + return "adapter_change"; + + return "ip_change"; + } + + // ------------------------------------------------------------------- + // Inner type: network state snapshot + // ------------------------------------------------------------------- + + private class NetworkStateSnapshot + { + public bool HasNetwork { get; set; } + public string AdapterName { get; set; } + public string AdapterDescription { get; set; } + public string InterfaceType { get; set; } + public string ConnectionType { get; set; } + public string MacAddress { get; set; } + public string InterfaceId { get; set; } + public long LinkSpeedMbps { get; set; } + public string IpAddress { get; set; } + public string Gateway { get; set; } + public string WifiSsid { get; set; } + public int? WifiSignalPercent { get; set; } + public string WifiRadioType { get; set; } + public DateTime CapturedAt { get; set; } + + /// + /// Returns true if both snapshots represent the same logical network connection. + /// Suppresses events for DHCP renewals and other OS chatter. + /// + public bool IsSameNetwork(NetworkStateSnapshot other) + { + if (other == null) return false; + if (HasNetwork != other.HasNetwork) return false; + if (!HasNetwork && !other.HasNetwork) return true; + + // Same adapter + same IP + same SSID = same logical network + return InterfaceId == other.InterfaceId && + IpAddress == other.IpAddress && + WifiSsid == other.WifiSsid; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs index c3dc0369..abf5e213 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.CollectorLifecycle.cs @@ -114,6 +114,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _desktopArrivalDetector.Start(); _logger.Info("DesktopArrivalDetector started — monitoring for real user desktop"); + // Network change detector — monitors for NIC/SSID/IP changes during provisioning + _networkChangeDetector = new NetworkChangeDetector( + _configuration.SessionId, + _configuration.TenantId, + EmitEvent, + _logger, + _configuration.ApiBaseUrl + ); + _networkChangeDetector.Start(); + _logger.Info("NetworkChangeDetector started — monitoring for network changes"); + // Agent max lifetime safety net — prevents zombie agents var maxLifetimeMinutes = collectors?.AgentMaxLifetimeMinutes ?? _configuration.AgentMaxLifetimeMinutes; if (maxLifetimeMinutes > 0) @@ -154,6 +165,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _desktopArrivalDetector = null; } + _networkChangeDetector?.Dispose(); + _networkChangeDetector = null; + _performanceCollector?.Stop(); _performanceCollector?.Dispose(); _performanceCollector = null; diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs index 6311cd28..b958d246 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.EventUpload.cs @@ -726,6 +726,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _performanceCollector?.Dispose(); _agentSelfMetricsCollector?.Dispose(); _enrollmentTracker?.Dispose(); + _networkChangeDetector?.Dispose(); _gatherRuleExecutor?.Dispose(); _remoteConfigService?.Dispose(); _completionEvent.Dispose(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index 0317b950..64da7675 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -68,6 +68,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Desktop arrival detection (no-ESP scenarios) private DesktopArrivalDetector _desktopArrivalDetector; + // Network change detection (always on) + private NetworkChangeDetector _networkChangeDetector; + // Agent max lifetime safety net private Timer _maxLifetimeTimer; private readonly DateTime _agentStartTimeUtc = DateTime.UtcNow; diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 4619a8a0..534b992c 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -451 +453 diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 776d6016..b3c57087 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -134,6 +134,7 @@ namespace AutopilotMonitor.Shared public const string AppDownloadStarted = "app_download_started"; public const string AppInstallSkipped = "app_install_skipped"; public const string NetworkStateChange = "network_state_change"; + public const string NetworkConnectivityCheck = "network_connectivity_check"; public const string ErrorDetected = "error_detected"; public const string PerformanceSnapshot = "performance_snapshot"; public const string LogEntry = "log_entry";