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