added Network Change Detector

This commit is contained in:
Oliver Kieselbach
2026-03-17 15:54:48 +01:00
parent ec08d258c9
commit bd47760060
6 changed files with 614 additions and 1 deletions
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class NetworkChangeDetector : IDisposable
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _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";
/// <summary>
/// Well-known MDM/Entra endpoints that must be reachable for enrollment to succeed.
/// </summary>
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<EnrollmentEvent> 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<string, object>
{
// 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<string, object>[] 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<string, object>
{
{ "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<Dictionary<string, object>> 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<string, object>
{
{ "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<string, object>
{
{ "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 };
}
}
/// <summary>
/// Collects WiFi SSID, signal, and radio type via netsh wlan show interfaces.
/// Synchronous with timeout — needed before emitting the event.
/// </summary>
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
// -------------------------------------------------------------------
/// <summary>
/// Finds the active network interface: Up, not Loopback/Tunnel, has a non-0.0.0.0 gateway.
/// </summary>
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; }
/// <summary>
/// Returns true if both snapshots represent the same logical network connection.
/// Suppresses events for DHCP renewals and other OS chatter.
/// </summary>
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;
}
}
}
}
@@ -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;
@@ -726,6 +726,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_performanceCollector?.Dispose();
_agentSelfMetricsCollector?.Dispose();
_enrollmentTracker?.Dispose();
_networkChangeDetector?.Dispose();
_gatherRuleExecutor?.Dispose();
_remoteConfigService?.Dispose();
_completionEvent.Dispose();
@@ -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;
@@ -1 +1 @@
451
453
@@ -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";