From f1e8fe6b4ae632653a0e624e0fa771f4aab82a93 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Sat, 5 Sep 2026 06:41:13 -0400 Subject: [PATCH] Collect NIC link state, boot type and Defender health Link speed and duplex are paired onto the existing interface entries by MAC from MSFT_NetAdapter, which is a second query because the IPEnabled config class carries no link state. Both fields stay null when unreadable: a disconnected adapter returns empty values on the live box, so defaulting would report every unplugged NIC as half-duplex. Boot info keeps two facts separate rather than collapsing them. The registry says whether fast startup is configured; Kernel-Boot event 27 says what the last boot actually did. The registry cannot answer the second and the event can age out of the log, so neither substitutes for the other. This exists because uptime lies under fast startup. Defender is collected and persisted but not surfaced, as specified: known false positives here would train operators to ignore the card. Ages are in days and a real zero means updated today, so the collector sends null when Defender did not report. --- agent/ShopDbEndpointAgent.Core/Contracts.cs | 13 ++ .../EndpointCollector.cs | 204 ++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index ec06728f..171a63de 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -50,6 +50,19 @@ public sealed class EndpointReport // reports, and on a shop floor PC nobody reboots voluntarily. [JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; } + // Which signal(s) drove pendingreboot, so the server can tell a box staged + // mid-servicing (pendingfilerename) from one with a stale servicing key. + // Empty array = checked, nothing pending. Omitted = the check failed. + [JsonPropertyName("rebootreasons")] public string[]? RebootReasons { get; set; } + + // hiberbootenabled (fast startup configured) and lastboottype (what the + // last boot actually did). Uptime is not trustworthy when these disagree. + [JsonPropertyName("bootinfo")] public object? BootInfo { get; set; } + + // Defender health. Persisted but deliberately not surfaced: known false + // positives in this environment. + [JsonPropertyName("defender")] public object? Defender { get; set; } + // Physical drive health from two sources: MSFT_PhysicalDisk (the only one // that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback). // Each entry carries a precomputed predictfailure so an alert is a boolean diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index 6d428db3..8efd363e 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -74,6 +74,9 @@ public sealed class EndpointCollector : ICollector Domain = GetDomain(), Applications = GetApplications(), PendingReboot = GetPendingReboot(), + RebootReasons = GetRebootReasons(), + BootInfo = GetBootInfo(), + Defender = GetDefender(), UserContext = GetUserContext(), }; @@ -506,6 +509,7 @@ public sealed class EndpointCollector : ICollector private object[] GetInterfaces() { var result = new List(); + var linkstate = GetLinkState(); try { using var searcher = new ManagementObjectSearcher( @@ -531,6 +535,10 @@ public sealed class EndpointCollector : ICollector var gateway = gateways is null || gateways.Length == 0 ? null : gateways[0]; + // Paired by MAC from MSFT_NetAdapter. Absent when that class is + // unavailable, which must leave the rest of the entry intact. + linkstate.TryGetValue(NormalizeMac(mac) ?? "", out var link); + result.Add(new { alias = mo["Description"]?.ToString()?.Trim() ?? "", @@ -542,6 +550,10 @@ public sealed class EndpointCollector : ICollector gateway = gateway ?? "", subnet = subnet ?? "", dhcp = mo["DHCPEnabled"] as bool? ?? false, + // Null rather than 0/false when link state was not + // readable: an unknown duplex must not read as half. + linkspeedbps = link.Speed, + fullduplex = link.FullDuplex, }); } } @@ -939,6 +951,198 @@ public sealed class EndpointCollector : ICollector // and a queued file-rename operation. Any one of them is sufficient. Checked // by key EXISTENCE rather than value, which is how these flags work - the // key is created when a reboot is needed and deleted once it happens. + // Link speed and duplex, keyed by MAC so the caller can pair them onto the + // matching GetInterfaces() entry. Deliberately a separate query: the + // IPEnabled config class above does not carry link state, and a NIC that + // negotiated 100Mb half-duplex on a gigabit switch port is a cable/port + // fault that otherwise presents as "the machine is slow sometimes". + private Dictionary GetLinkState() + { + var byMac = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + // MSFT_NetAdapter carries duplex; Win32_NetworkAdapter does not. It + // lives in the same Storage-style optional namespace family, so a + // failure here must not take the interface list with it. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\StandardCimv2", + "SELECT PermanentAddress, Speed, FullDuplex, MediaConnectState " + + "FROM MSFT_NetAdapter"); + foreach (var mo in searcher.Get()) + { + var mac = NormalizeMac(mo["PermanentAddress"]?.ToString()); + if (mac is null) + continue; + byMac[mac] = (ToLong(mo["Speed"]), mo["FullDuplex"] as bool?); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Net adapter link state query failed"); + } + + return byMac; + } + + // WMI reports MACs in several shapes (00:11:22, 001122AABBCC). Strip to hex + // so the two sources can be matched at all. + private static string? NormalizeMac(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return null; + var hex = new string(Array.FindAll(raw.ToCharArray(), Uri.IsHexDigit)); + return hex.Length == 12 ? hex : null; + } + + // Whether the last start was a true cold boot or a fast-startup resume. + // + // This matters because uptime LIES under fast startup: the box reports days + // of uptime after what the operator experienced as a shutdown, and a + // "reboot" that was really a hybrid resume does not clear the driver or + // kernel state that a support call is usually trying to clear. + // + // Two distinct facts, deliberately not collapsed: + // hiberbootenabled - fast startup is CONFIGURED (registry, always readable) + // lastboottype - what the last boot ACTUALLY did (Kernel-Boot event 27) + // The registry alone cannot tell you what the last boot did, and the event + // may have aged out of the log, so neither substitutes for the other. + private object? GetBootInfo() + { + bool? hiberboot = null; + try + { + using var power = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager\Power"); + if (power?.GetValue("HiberbootEnabled") is int raw) + hiberboot = raw != 0; + } + catch (Exception error) + { + logger.LogDebug(error, "Hiberboot registry read failed"); + } + + string? lastboottype = null; + try + { + // Kernel-Boot 27 BootType: 0 cold, 1 hybrid (fast startup), + // 2 resume from hibernate. + var query = new EventLogQuery( + "System", PathType.LogName, + "*[System[Provider[@Name='Microsoft-Windows-Kernel-Boot'] " + + "and (EventID=27)]]") + { ReverseDirection = true }; + using var reader = new EventLogReader(query); + using var record = reader.ReadEvent(); + if (record?.Properties.Count > 0) + { + lastboottype = ToInt(record.Properties[0].Value) switch + { + 0 => "Cold", + 1 => "Fast startup", + 2 => "Resume", + _ => null, + }; + } + } + catch (Exception error) + { + logger.LogDebug(error, "Boot type event read failed"); + } + + if (hiberboot is null && lastboottype is null) + return null; + + return new + { + hiberbootenabled = hiberboot, + lastboottype = lastboottype ?? "Unknown", + }; + } + + // Defender status. Collected and persisted but NOT surfaced: this + // environment is known to produce false positives, so wiring it to a card + // or an alert now would train operators to ignore the card. Stored so the + // signal is already there when someone is ready to interpret it. + private object? GetDefender() + { + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Defender", + "SELECT AMServiceEnabled, RealTimeProtectionEnabled, " + + "AntivirusSignatureAge, AntispywareSignatureAge, " + + "QuickScanAge FROM MSFT_MpComputerStatus"); + foreach (var mo in searcher.Get()) + { + return new + { + serviceenabled = mo["AMServiceEnabled"] as bool?, + realtimeenabled = mo["RealTimeProtectionEnabled"] as bool?, + // Ages in days. Null when Defender did not report rather + // than 0, which would read as "updated today". + signatureagedays = ToLong(mo["AntivirusSignatureAge"]), + antispywareagedays = ToLong(mo["AntispywareSignatureAge"]), + quickscanagedays = ToLong(mo["QuickScanAge"]), + }; + } + } + catch (Exception error) + { + // A third-party AV box or a stripped image has no Defender WMI + // provider at all. That is normal here, not a fault. + logger.LogDebug(error, "Defender status query failed"); + } + return null; + } + + // Which signal(s) drove pendingreboot=true, in report order. + // + // pendingreboot is a single bool OR-ed from three sources, so it cannot say + // WHY. That distinction is the point: PendingFileRenameOperations is the + // stronger signal - files are staged to be replaced on next boot, so the + // box is mid-servicing - whereas a lone CBS or Windows Update key can sit + // set for weeks on a machine nobody has rebooted. Same boolean, very + // different urgency. + // + // Empty array = checked, nothing pending. Null = the check failed, matching + // pendingreboot's own null-is-unknown rule. + private string[]? GetRebootReasons() + { + try + { + var reasons = new List(); + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending")) + { + reasons.Add("componentbasedservicing"); + } + + if (LocalMachineKeyExists( + @"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired")) + { + reasons.Add("windowsupdate"); + } + + using var sessionManager = Registry.LocalMachine.OpenSubKey( + @"SYSTEM\CurrentControlSet\Control\Session Manager"); + if (sessionManager?.GetValue("PendingFileRenameOperations") is string[] pending + && pending.Length > 0) + { + reasons.Add("pendingfilerename"); + } + + return reasons.ToArray(); + } + catch (Exception error) + { + logger.LogDebug(error, "Reboot reason check failed"); + return null; + } + } + private bool? GetPendingReboot() { try