From 12524c63e592680c96b97da2757f174077a35fce Mon Sep 17 00:00:00 2001 From: Tinnitus97 <85833526+Tinnitus97@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:19:22 +0100 Subject: [PATCH] Add LibreHardwareMonitor based OHM replacement Open Hardware Monitor was removed from the distribution with Werk #18537 because Windows Defender reports its WinRing0 driver as vulnerable (CVE-2020-14979). This adds a headless replacement for OpenHardwareMonitorCLI.exe based on LibreHardwareMonitor and the signed PawnIO driver. It is a drop-in replacement: same two files (OpenHardwareMonitorCLI.exe, OpenHardwareMonitorLib.dll), same WMI namespace and classes (root\OpenHardwareMonitor: Sensor, Hardware). The agent, the bakery plugin win_openhardwaremonitor and the check plugins need no changes. LibreHardwareMonitor data is adapted to what the check plugins expect: OHM compatible identifiers, Data in GB, "Remaining Life" for the SMART check, no constant limit values as temperatures, fans only after their first rotation, separators and NaN values sanitized. Note: to work with the unchanged agent, the CLI deliberately presents itself as Open Hardware Monitor ("Libre" becomes "Open"): file names, WMI namespace root\OpenHardwareMonitor and OHM identifiers. This is a temporary compatibility layer. For the official integration it has to be renamed to LibreHardwareMonitor in the agent (providers/ohm.h, section_header.h, bakery plugin) and in the CLI in one change. All places are listed in agents/wnx/ohm/README.md and marked with TODO(LHM rename) in the CLI source. The source is placed in agents/wnx/ohm, where the source of the original CLI used to be. build.cmd builds LibreHardwareMonitor from a pinned commit together with the CLI. Related: https://ideas.checkmk.com/suggestions/535894 Related: https://forum.checkmk.com/t/47175 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GitjeitbSTC17n3ESBjdQN --- agents/wnx/ohm/.gitignore | 5 + .../wnx/ohm/OpenHardwareMonitorCLI/App.config | 31 ++ .../OpenHardwareMonitorCLI/AssemblyInfo.cs | 21 ++ .../OpenHardwareMonitorCLI/DataProvider.cs | 139 +++++++ .../EmbeddedAssemblyResolver.cs | 146 ++++++++ .../IdentifierMapper.cs | 222 ++++++++++++ agents/wnx/ohm/OpenHardwareMonitorCLI/Log.cs | 83 +++++ .../OpenHardwareMonitorCLI.csproj | 109 ++++++ .../wnx/ohm/OpenHardwareMonitorCLI/Options.cs | 338 ++++++++++++++++++ .../wnx/ohm/OpenHardwareMonitorCLI/Program.cs | 264 ++++++++++++++ .../OpenHardwareMonitorCLI/UpdateVisitor.cs | 45 +++ .../OpenHardwareMonitorCLI/WMI/WmiObjects.cs | 92 +++++ .../OpenHardwareMonitorCLI/WMI/WmiProvider.cs | 259 ++++++++++++++ agents/wnx/ohm/README.md | 174 +++++++++ agents/wnx/ohm/build.cmd | 37 ++ agents/wnx/ohm/nuget.config | 8 + 16 files changed, 1973 insertions(+) create mode 100644 agents/wnx/ohm/.gitignore create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/App.config create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/AssemblyInfo.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/DataProvider.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/EmbeddedAssemblyResolver.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/IdentifierMapper.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/Log.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/OpenHardwareMonitorCLI.csproj create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/Options.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/Program.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/UpdateVisitor.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiObjects.cs create mode 100644 agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiProvider.cs create mode 100644 agents/wnx/ohm/README.md create mode 100644 agents/wnx/ohm/build.cmd create mode 100644 agents/wnx/ohm/nuget.config diff --git a/agents/wnx/ohm/.gitignore b/agents/wnx/ohm/.gitignore new file mode 100644 index 00000000000..1975cd4712d --- /dev/null +++ b/agents/wnx/ohm/.gitignore @@ -0,0 +1,5 @@ +# Build output of the LibreHardwareMonitor based OpenHardwareMonitorCLI +/external/ +/dist/ +/OpenHardwareMonitorCLI/bin/ +/OpenHardwareMonitorCLI/obj/ diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/App.config b/agents/wnx/ohm/OpenHardwareMonitorCLI/App.config new file mode 100644 index 00000000000..12954e1e6ca --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/App.config @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/AssemblyInfo.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/AssemblyInfo.cs new file mode 100644 index 00000000000..d1c521d8370 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/AssemblyInfo.cs @@ -0,0 +1,21 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System.ComponentModel; +using System.Management.Instrumentation; + +// Same WMI namespace as the original OpenHardwareMonitorCLI.exe. The Checkmk Windows agent queries +// "SELECT Index,Name,Parent,SensorType,Value FROM Sensor" in Root\OpenHardwareMonitor. +// TODO(LHM rename): change together with kWmiPathOhm (agents/wnx/include/wnx/section_header.h) +// and the namespace in kResetCommand (agents/wnx/include/providers/ohm.h). +[assembly: Instrumented("root/OpenHardwareMonitor")] + +/// +/// Allows the WMI schema to be registered up-front with InstallUtil.exe +/// (optional; the schema is also registered automatically on first start as administrator/SYSTEM). +/// +[RunInstaller(true)] +public class InstanceInstaller : DefaultManagementProjectInstaller +{ +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/DataProvider.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/DataProvider.cs new file mode 100644 index 00000000000..c18667f4c8d --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/DataProvider.cs @@ -0,0 +1,139 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System; +using System.Threading; +using LibreHardwareMonitor.Hardware; +using OpenHardwareMonitor.WMI; + +namespace OpenHardwareMonitor.CLI +{ + /// + /// Owns the LibreHardwareMonitor Computer object, the WMI provider and the update timer. + /// + internal sealed class DataProvider : IDisposable + { + private readonly Computer _computer; + private readonly UpdateVisitor _visitor = new UpdateVisitor(); + private readonly Options _options; + private WmiProvider _wmiProvider; + private Timer _timer; + private int _updating; + private bool _disposed; + + public DataProvider(Options options, bool publishToWmi) + { + _options = options; + + _computer = new Computer + { + IsCpuEnabled = options.IsEnabled("cpu"), + IsGpuEnabled = options.IsEnabled("gpu"), + IsMemoryEnabled = options.IsEnabled("memory"), + IsMotherboardEnabled = options.IsEnabled("motherboard"), + IsControllerEnabled = options.IsEnabled("controller"), + IsStorageEnabled = options.IsEnabled("storage"), + IsNetworkEnabled = options.IsEnabled("network"), + IsPsuEnabled = options.IsEnabled("psu"), + IsBatteryEnabled = options.IsEnabled("battery"), + IsPowerMonitorEnabled = options.IsEnabled("powermonitor") + }; + + Log.Debug("Opening LibreHardwareMonitor (groups: " + string.Join(",", options.Groups) + ")"); + _computer.Open(); + + UpdateNow(); + + _wmiProvider = new WmiProvider(_computer, new IdentifierMapper(options.CompatIdentifiers), publishToWmi); + _wmiProvider.Update(); + } + + public IComputer Computer + { + get { return _computer; } + } + + public WmiProvider Wmi + { + get { return _wmiProvider; } + } + + public void Start() + { + if (_timer == null) + _timer = new Timer(OnTick, null, _options.IntervalMs, _options.IntervalMs); + else + _timer.Change(_options.IntervalMs, _options.IntervalMs); + } + + public void Stop() + { + if (_timer != null) + _timer.Change(Timeout.Infinite, Timeout.Infinite); + } + + /// Runs one full update cycle synchronously. + public void UpdateNow() + { + _computer.Accept(_visitor); + if (_wmiProvider != null) + _wmiProvider.Update(); + } + + private void OnTick(object state) + { + // Never run two updates in parallel (slow SMART/GPU reads can exceed the interval). + if (Interlocked.CompareExchange(ref _updating, 1, 0) != 0) + return; + + try + { + if (!_disposed) + UpdateNow(); + } + catch (Exception e) + { + Log.Warn("Update cycle failed: " + e.Message); + } + finally + { + Interlocked.Exchange(ref _updating, 0); + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + if (_timer != null) + { + using (ManualResetEvent done = new ManualResetEvent(false)) + { + _timer.Dispose(done); + done.WaitOne(5000); + } + + _timer = null; + } + + if (_wmiProvider != null) + { + _wmiProvider.Dispose(); + _wmiProvider = null; + } + + try + { + _computer.Close(); + } + catch (Exception e) + { + Log.Debug("Computer.Close failed: " + e.Message); + } + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/EmbeddedAssemblyResolver.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/EmbeddedAssemblyResolver.cs new file mode 100644 index 00000000000..9d23570aee8 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/EmbeddedAssemblyResolver.cs @@ -0,0 +1,146 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace OpenHardwareMonitor.CLI +{ + /// + /// Resolves the dependencies of the CLI without additional files on disk: + /// + /// * LibreHardwareMonitorLib -> OpenHardwareMonitorLib.dll next to the EXE + /// (this is the file name the Checkmk agent bakery deploys) + /// * everything else (HidSharp, DiskInfoToolkit, RAMSPDToolkit, System.Memory, ...) + /// -> embedded resources "deps.<name>.dll" inside this EXE + /// + /// Regular .NET probing still wins: if a real LibreHardwareMonitorLib.dll / HidSharp.dll + /// lies next to the EXE, it is used and this resolver is never asked. + /// + internal static class EmbeddedAssemblyResolver + { + private const string LhmAssemblyName = "LibreHardwareMonitorLib"; + // TODO(LHM rename): LibreHardwareMonitorLib.dll is shipped as OpenHardwareMonitorLib.dll only because + // the bakery plugin (cmk/base/plugins/bakery/win_openhardwaremonitor.py) deploys that file name. + private const string LhmFileName = "OpenHardwareMonitorLib.dll"; + + private static readonly Dictionary Loaded = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static readonly object Sync = new object(); + private static bool _installed; + + public static void Install() + { + if (_installed) + return; + + _installed = true; + AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; + } + + private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args) + { + AssemblyName requested = new AssemblyName(args.Name); + string name = requested.Name; + + // Localized satellite assemblies are not shipped on purpose (stable, English sensor + // names on every host). Returning null makes ResourceManager use the neutral resources. + if (name.EndsWith(".resources", StringComparison.OrdinalIgnoreCase)) + return null; + + lock (Sync) + { + Assembly assembly; + if (Loaded.TryGetValue(name, out assembly)) + return assembly; + + // Already loaded under the same simple name (e.g. different version requested)? + foreach (Assembly a in AppDomain.CurrentDomain.GetAssemblies()) + { + if (string.Equals(a.GetName().Name, name, StringComparison.OrdinalIgnoreCase)) + { + Loaded[name] = a; + return a; + } + } + + assembly = LoadAssembly(name); + if (assembly != null) + Loaded[name] = assembly; + + return assembly; + } + } + + private static Assembly LoadAssembly(string name) + { + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + + if (string.Equals(name, LhmAssemblyName, StringComparison.OrdinalIgnoreCase)) + { + string path = Path.Combine(baseDir, LhmFileName); + if (File.Exists(path)) + return Assembly.LoadFrom(path); + } + + Assembly self = typeof(EmbeddedAssemblyResolver).Assembly; + string resourceName = "deps." + name + ".dll"; + + byte[] data = ReadResource(self, resourceName); + if (data != null) + return Assembly.Load(data); + + // Fallback: the embedded file name may differ from the assembly name. + // Load the remaining embedded assemblies and look for a matching identity. + foreach (string candidate in self.GetManifestResourceNames()) + { + if (!candidate.StartsWith("deps.", StringComparison.Ordinal)) + continue; + + byte[] bytes = ReadResource(self, candidate); + if (bytes == null) + continue; + + Assembly assembly = Assembly.Load(bytes); + string simpleName = assembly.GetName().Name; + if (!Loaded.ContainsKey(simpleName)) + Loaded[simpleName] = assembly; + + if (string.Equals(simpleName, name, StringComparison.OrdinalIgnoreCase)) + return assembly; + } + + return null; + } + + private static readonly HashSet LoadedResources = new HashSet(StringComparer.Ordinal); + + private static byte[] ReadResource(Assembly assembly, string resourceName) + { + if (LoadedResources.Contains(resourceName)) + return null; + + using (Stream stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + return null; + + LoadedResources.Add(resourceName); + + byte[] data = new byte[stream.Length]; + int offset = 0; + while (offset < data.Length) + { + int read = stream.Read(data, offset, data.Length - offset); + if (read <= 0) + break; + offset += read; + } + + return data; + } + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/IdentifierMapper.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/IdentifierMapper.cs new file mode 100644 index 00000000000..6123fd8e18b --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/IdentifierMapper.cs @@ -0,0 +1,222 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System; +using System.Collections.Generic; +using LibreHardwareMonitor.Hardware; + +namespace OpenHardwareMonitor.CLI +{ + /// + /// Translates LibreHardwareMonitor identifiers to the identifiers the original + /// OpenHardwareMonitor used, so Checkmk service names (items) and the default + /// temperature levels per device class ("cpu", "hdd", ...) stay the same. + /// + /// LibreHardwareMonitor OpenHardwareMonitor (compat mode) + /// /motherboard -> /mainboard + /// /lpc/nct6798d/0 -> /lpc/nct6798d + /// /gpu-nvidia/0 -> /nvidiagpu/0 + /// /gpu-amd/0 -> /atigpu/0 + /// /nvme/1, /ata/0, /scsi/2 -> /hdd/1, /hdd/0, /hdd/2 + /// /intelcpu/0, /amdcpu/0, /ram unchanged + /// + /// Identifiers OHM never had (e.g. /gpu-intel-integrated/..., /memory/dimm/0, /vram, + /// /nic/..., /battery/...) are passed through unchanged. + /// + /// TODO(LHM rename): the identifier mapping (MapIdentifier) exists only for the OHM based item names in + /// cmk/plugins/openhardwaremonitor (_create_openhardwaremonitor_full_name). It can be dropped once the + /// check plugins understand LibreHardwareMonitor identifiers. The other adaptations (GB conversion, + /// limit values, fans, NaN and separator sanitizing) stay useful. + /// + public sealed class IdentifierMapper + { + private static readonly HashSet StoragePrefixes = new HashSet(StringComparer.Ordinal) + { + "ata", "scsi", "nvme", "usb", "sd", "mmc", "hdd", "ssd", "storage" + }; + + private const float BytesPerGigabyte = 1024f * 1024f * 1024f; + + // LibreHardwareMonitor up to 0.9.6 reported SensorType.Data in GB (and had SmallData in MB), + // like OpenHardwareMonitor. Since commit 677a3a5 (2026-09-29) Data is reported in bytes and + // SmallData no longer exists. Checkmk multiplies Data values by 2^30, i.e. expects GB. + private static readonly bool DataIsInBytes = !Enum.IsDefined(typeof(SensorType), "SmallData"); + + private readonly bool _compat; + + public IdentifierMapper(bool compat) + { + _compat = compat; + } + + public static bool LibraryReportsDataInBytes + { + get { return DataIsInBytes; } + } + + /// + /// Factor applied to Value/Min/Max. In compat mode Data sensors are published in GB + /// (OpenHardwareMonitor semantics) regardless of the LibreHardwareMonitor version. + /// + public float GetValueScale(ISensor sensor) + { + if (_compat && DataIsInBytes && sensor.SensorType == SensorType.Data) + return 1f / BytesPerGigabyte; + + return 1f; + } + + public bool Compat + { + get { return _compat; } + } + + public string MapIdentifier(string identifier) + { + // Separators would break the agent's CSV output in every mode (see MapName). + identifier = StripSeparators(identifier, '_'); + + if (!_compat || string.IsNullOrEmpty(identifier) || identifier[0] != '/') + return identifier; + + string[] parts = identifier.Substring(1).Split('/'); + if (parts.Length == 0) + return identifier; + + switch (parts[0]) + { + case "motherboard": + parts[0] = "mainboard"; + break; + + case "gpu-nvidia": + parts[0] = "nvidiagpu"; + break; + + case "gpu-amd": + parts[0] = "atigpu"; + break; + + case "lpc": + // LHM: /lpc//[//] OHM: /lpc/[//] + // Only the first SuperIO chip (index 0) is collapsed, further chips keep their index + // so identifiers stay unique. + if (parts.Length >= 3 && parts[1] != "ec" && parts[2] == "0") + { + List list = new List(parts); + list.RemoveAt(2); + parts = list.ToArray(); + } + break; + + default: + if (StoragePrefixes.Contains(parts[0]) && parts.Length >= 2) + parts[0] = "hdd"; + break; + } + + return "/" + string.Join("/", parts); + } + + /// + /// Sensor name as published. In compat mode, storage sensors are renamed to the names + /// the Checkmk check plugins expect from OHM (e.g. "Life" -> "Remaining Life", used by + /// the openhardwaremonitor_smart check). + /// + public string MapSensorName(ISensor sensor) + { + string name = MapName(sensor.Name); + if (!_compat) + return name; + + if (IsStorage(sensor) && sensor.SensorType == SensorType.Level && name == "Life") + return "Remaining Life"; + + return name; + } + + /// + /// In compat mode, constant limit/configuration values that LibreHardwareMonitor reports as + /// temperature sensors are not published. Checkmk would discover them as temperature services, + /// and e.g. a DIMM "Thermal Sensor Critical High Limit" of 85 °C would be permanently CRIT. + /// NVMe: Warning Temperature, Critical Temperature, Available Spare Threshold + /// DIMM: Temperature Sensor Resolution, Thermal Sensor (Critical) Low/High Limit + /// + public bool IsExcluded(ISensor sensor) + { + if (!_compat) + return false; + + string name = sensor.Name ?? ""; + + if (sensor.SensorType == SensorType.Temperature) + { + if (name == "Warning Temperature" || name == "Critical Temperature") + return true; + + if (name.IndexOf("Limit", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Resolution", StringComparison.OrdinalIgnoreCase) >= 0 || + name.IndexOf("Threshold", StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + + if (sensor.SensorType == SensorType.Level && IsStorage(sensor) && name == "Available Spare Threshold") + return true; + + return false; + } + + /// + /// OpenHardwareMonitor only activated a fan sensor after it had reported a speed > 0, so + /// unconnected fan headers never appeared. In compat mode the same applies here. + /// + public bool PublishOnlyWhenActive(ISensor sensor) + { + return _compat && sensor.SensorType == SensorType.Fan; + } + + private bool IsStorage(ISensor sensor) + { + IHardware hardware = sensor.Hardware; + if (hardware == null) + return false; + + if (hardware.HardwareType == HardwareType.Storage) + return true; + + return MapIdentifier(hardware.Identifier.ToString()).StartsWith("/hdd/", StringComparison.Ordinal); + } + + /// + /// The Checkmk agent emits the WMI data as comma separated values (sep(44)), one sensor per + /// line, and its parser drops every line that does not have exactly 5 (+ WMIStatus) columns. + /// Commas and line breaks are therefore always replaced, in compat and native mode. + /// + public string MapName(string name) + { + return StripSeparators(name, ' ').Trim(); + } + + private static string StripSeparators(string value, char replacement) + { + if (value == null) + return ""; + + if (value.IndexOf(',') < 0 && value.IndexOf('\r') < 0 && value.IndexOf('\n') < 0) + return value; + + return value.Replace(',', replacement).Replace('\r', replacement).Replace('\n', replacement); + } + + /// + /// The agent formats floats with std::to_wstring, which turns NaN into "-nan(ind)". + /// Checkmk's parser cannot convert that and the whole openhardwaremonitor section fails + /// (all services UNKNOWN). Invalid values are therefore published as 0, like a missing value. + /// + public static float SanitizeValue(float value) + { + return float.IsNaN(value) || float.IsInfinity(value) ? 0f : value; + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/Log.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/Log.cs new file mode 100644 index 00000000000..7a238bae147 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/Log.cs @@ -0,0 +1,83 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System; +using System.Globalization; +using System.IO; + +namespace OpenHardwareMonitor.CLI +{ + /// Minimal logger: stderr and an optional log file (size-capped). + internal static class Log + { + private const long MaxLogSize = 5 * 1024 * 1024; + private static readonly object Sync = new object(); + + public static string FilePath { get; set; } + public static bool Verbose { get; set; } + public static bool Quiet { get; set; } + + public static void Info(string message) + { + Write("INFO ", message); + } + + public static void Warn(string message) + { + Write("WARN ", message); + } + + public static void Error(string message) + { + Write("ERROR", message); + } + + public static void Debug(string message) + { + if (Verbose) + Write("DEBUG", message); + } + + private static void Write(string level, string message) + { + string line = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) + " " + level + " " + message; + + lock (Sync) + { + if (!Quiet) + { + try + { + Console.Error.WriteLine(line); + } + catch (Exception) + { + // no console attached + } + } + + if (string.IsNullOrEmpty(FilePath)) + return; + + try + { + FileInfo info = new FileInfo(FilePath); + if (info.Exists && info.Length > MaxLogSize) + { + string old = FilePath + ".old"; + if (File.Exists(old)) + File.Delete(old); + File.Move(FilePath, old); + } + + File.AppendAllText(FilePath, line + Environment.NewLine); + } + catch (Exception) + { + // logging must never break the provider + } + } + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/OpenHardwareMonitorCLI.csproj b/agents/wnx/ohm/OpenHardwareMonitorCLI/OpenHardwareMonitorCLI.csproj new file mode 100644 index 00000000000..1111b12f67d --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/OpenHardwareMonitorCLI.csproj @@ -0,0 +1,109 @@ + + + + + + Exe + net472 + + OpenHardwareMonitorCLI + OpenHardwareMonitor.CLI + 7.3 + x64 + false + 1.1.0 + LibreHardwareMonitorCLI for Checkmk + Headless LibreHardwareMonitor WMI provider (root\OpenHardwareMonitor) for the Checkmk Windows agent + Checkmk GmbH (GPLv2); WMI classes based on Open Hardware Monitor (MPL-2.0) + true + true + disable + + + repo + $(MSBuildProjectDirectory)\..\external\LibreHardwareMonitor + 0.9.6 + + + + + + + + + + win-x64 + + + + + + + + + + + + + + + + + + + + + <_EmbedCandidate Include="@(ReferenceCopyLocalPaths)" + Condition="'%(ReferenceCopyLocalPaths.Extension)' == '.dll' And '%(ReferenceCopyLocalPaths.Filename)' != 'LibreHardwareMonitorLib' And '%(ReferenceCopyLocalPaths.DestinationSubDirectory)' == '' And !$([System.String]::Copy('%(ReferenceCopyLocalPaths.Filename)').EndsWith('.resources'))" /> + + deps.%(Filename)%(Extension) + false + Non-Resx + + + + + + + + + $(MSBuildProjectDirectory)\..\dist\ + + + + + + + + + + diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/Options.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/Options.cs new file mode 100644 index 00000000000..aee36ef0e69 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/Options.cs @@ -0,0 +1,338 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System; +using System.Collections.Generic; +using System.Configuration; +using System.Globalization; +using System.IO; + +namespace OpenHardwareMonitor.CLI +{ + internal enum RunMode + { + Provider, + Dump, + Report, + Help, + Version + } + + /// + /// Settings come from (in this order, later wins): + /// 1. built-in defaults (= the hardware groups the original OpenHardwareMonitorCLI enabled) + /// 2. OpenHardwareMonitorCLI.exe.config (appSettings, optional) + /// 3. command line + /// The Checkmk agent starts the EXE without arguments, so for agent deployments use the .config file. + /// + internal sealed class Options + { + public static readonly string[] AllGroups = + { + "cpu", "gpu", "memory", "motherboard", "controller", "storage", "network", "psu", "battery", "powermonitor" + }; + + // Same groups as the original OpenHardwareMonitorCLI: CPU, RAM, mainboard, fan controller, HDD. + private static readonly string[] DefaultGroups = { "cpu", "memory", "motherboard", "controller", "storage" }; + + public Options() + { + Groups = new HashSet(DefaultGroups, StringComparer.OrdinalIgnoreCase); + IntervalMs = 1000; + CompatIdentifiers = true; + Mode = RunMode.Provider; + DumpCycles = 2; + } + + public HashSet Groups { get; private set; } + public int IntervalMs { get; set; } + public bool CompatIdentifiers { get; set; } + public RunMode Mode { get; set; } + public string LogFile { get; set; } + public bool Verbose { get; set; } + public bool NoWmi { get; set; } + public int DumpCycles { get; set; } + public bool IncludeHeader { get; set; } + public int DurationSeconds { get; set; } + + public bool IsEnabled(string group) + { + return Groups.Contains(group); + } + + public static Options Load(string[] args) + { + Options options = new Options(); + options.ApplyAppSettings(); + options.ApplyArguments(args); + return options; + } + + private void ApplyAppSettings() + { + try + { + string value = ConfigurationManager.AppSettings["Enable"]; + if (!string.IsNullOrWhiteSpace(value)) + SetGroups(value, true); + + value = ConfigurationManager.AppSettings["Disable"]; + if (!string.IsNullOrWhiteSpace(value)) + SetGroups(value, false); + + value = ConfigurationManager.AppSettings["IntervalMs"]; + if (!string.IsNullOrWhiteSpace(value)) + IntervalMs = ParseInterval(value); + + value = ConfigurationManager.AppSettings["CompatIdentifiers"]; + if (!string.IsNullOrWhiteSpace(value)) + CompatIdentifiers = ParseBool(value); + + value = ConfigurationManager.AppSettings["LogFile"]; + if (!string.IsNullOrWhiteSpace(value)) + LogFile = ResolvePath(value); + + value = ConfigurationManager.AppSettings["Verbose"]; + if (!string.IsNullOrWhiteSpace(value)) + Verbose = ParseBool(value); + } + catch (Exception e) + { + throw new ArgumentException("Invalid OpenHardwareMonitorCLI.exe.config: " + e.Message, e); + } + } + + private void ApplyArguments(string[] args) + { + for (int i = 0; i < args.Length; i++) + { + string arg = args[i]; + string key = arg; + string value = null; + + int eq = arg.IndexOf('='); + if (eq > 0) + { + key = arg.Substring(0, eq); + value = arg.Substring(eq + 1); + } + + key = key.TrimStart('-', '/').ToLowerInvariant(); + + switch (key) + { + case "h": + case "?": + case "help": + Mode = RunMode.Help; + break; + + case "version": + Mode = RunMode.Version; + break; + + case "d": + case "dump": + Mode = RunMode.Dump; + break; + + case "header": + IncludeHeader = true; + break; + + case "cycles": + DumpCycles = Math.Max(1, int.Parse(RequireValue(args, ref i, key, value), CultureInfo.InvariantCulture)); + break; + + case "r": + case "report": + Mode = RunMode.Report; + break; + + case "enable": + SetGroups(RequireValue(args, ref i, key, value), true); + break; + + case "disable": + SetGroups(RequireValue(args, ref i, key, value), false); + break; + + case "all": + foreach (string g in AllGroups) + Groups.Add(g); + break; + + case "interval": + IntervalMs = ParseInterval(RequireValue(args, ref i, key, value)); + break; + + case "native-ids": + CompatIdentifiers = false; + break; + + case "compat-ids": + CompatIdentifiers = true; + break; + + case "log": + LogFile = ResolvePath(RequireValue(args, ref i, key, value)); + break; + + case "v": + case "verbose": + Verbose = true; + break; + + case "no-wmi": + NoWmi = true; + break; + + case "duration": + DurationSeconds = Math.Max(1, int.Parse(RequireValue(args, ref i, key, value), CultureInfo.InvariantCulture)); + break; + + default: + throw new ArgumentException("Unknown option: " + arg + " (use --help)"); + } + } + } + + private static string RequireValue(string[] args, ref int i, string key, string value) + { + if (value != null) + return value; + + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + return args[++i]; + + throw new ArgumentException("Option --" + key + " needs a value"); + } + + private void SetGroups(string list, bool enable) + { + foreach (string raw in list.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + string group = NormalizeGroup(raw.Trim()); + + if (group == "all") + { + foreach (string g in AllGroups) + { + if (enable) + Groups.Add(g); + else + Groups.Remove(g); + } + + continue; + } + + if (Array.IndexOf(AllGroups, group) < 0) + throw new ArgumentException("Unknown hardware group '" + raw + "'. Valid: " + string.Join(", ", AllGroups) + ", all"); + + if (enable) + Groups.Add(group); + else + Groups.Remove(group); + } + } + + private static string NormalizeGroup(string group) + { + switch (group.ToLowerInvariant()) + { + case "ram": + return "memory"; + case "mainboard": + case "mb": + return "motherboard"; + case "fancontroller": + case "fan": + return "controller"; + case "hdd": + case "disk": + case "disks": + return "storage"; + case "nic": + return "network"; + default: + return group.ToLowerInvariant(); + } + } + + private static int ParseInterval(string value) + { + int ms = int.Parse(value.Trim(), CultureInfo.InvariantCulture); + if (ms < 250) + ms = 250; + return ms; + } + + private static bool ParseBool(string value) + { + switch (value.Trim().ToLowerInvariant()) + { + case "1": + case "true": + case "yes": + case "on": + return true; + case "0": + case "false": + case "no": + case "off": + return false; + default: + throw new FormatException("'" + value + "' is not a boolean"); + } + } + + private static string ResolvePath(string path) + { + path = Environment.ExpandEnvironmentVariables(path.Trim()); + return Path.IsPathRooted(path) ? path : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, path); + } + + public const string HelpText = +@"OpenHardwareMonitorCLI (LibreHardwareMonitor edition) - headless sensor provider for Checkmk + +Without arguments the program runs like the original OpenHardwareMonitorCLI.exe: +it reads all sensors every second and publishes them via WMI in +root\OpenHardwareMonitor (classes Sensor and Hardware), where the Checkmk Windows +agent picks them up (section <<>>). Run as administrator/SYSTEM. + +Usage: + OpenHardwareMonitorCLI.exe [options] + +Modes: + (none) WMI provider, runs until terminated (used by the Checkmk agent) + --dump, -d Print sensors once in Checkmk format and exit (no WMI needed) + --header ... include the header line Index,Name,Parent,SensorType,Value + --cycles=N ... number of update cycles before printing (default 2) + --report, -r Print the full LibreHardwareMonitor diagnostic report and exit + --version Print versions and exit + +Options: + --enable=LIST Enable hardware groups (comma separated) + --disable=LIST Disable hardware groups + --all Enable all hardware groups + Groups: cpu, gpu, memory, motherboard, controller, storage, + network, psu, battery, powermonitor + Default: cpu, memory, motherboard, controller, storage + --interval=MS Update interval in milliseconds (default 1000, min 250) + --native-ids Publish LibreHardwareMonitor identifiers unchanged + (default: OHM compatible, e.g. /nvme/0 -> /hdd/0, /gpu-nvidia/0 -> /nvidiagpu/0) + --log=FILE Write log messages to FILE + --verbose, -v More log output + --no-wmi Provider mode without publishing to WMI (testing) + --duration=SEC Provider mode: exit after SEC seconds (testing) + +The same settings can be put into OpenHardwareMonitorCLI.exe.config (appSettings keys +Enable, Disable, IntervalMs, CompatIdentifiers, LogFile, Verbose). + +Low level sensors (CPU temperature, mainboard/SuperIO, RAM SPD) require the PawnIO +driver: https://pawnio.eu (winget install namazso.PawnIO) +"; + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/Program.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/Program.cs new file mode 100644 index 00000000000..1fa4dd76e16 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/Program.cs @@ -0,0 +1,264 @@ +// Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +// This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +// conditions defined in the file COPYING, which is part of this source code package. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Security.Principal; +using System.Text; +using System.Threading; +using Microsoft.Win32; +using OpenHardwareMonitor.WMI; + +// TODO(LHM rename): the namespaces OpenHardwareMonitor.CLI / OpenHardwareMonitor.WMI and the executable name +// are kept from Open Hardware Monitor for compatibility with the unchanged agent (see agents/wnx/ohm/README.md). +namespace OpenHardwareMonitor.CLI +{ + internal static class Program + { + private const string InstanceMutexName = @"Global\OpenHardwareMonitorCLI_LibreHardwareMonitor"; + + private static readonly ManualResetEvent ExitEvent = new ManualResetEvent(false); + + public static int Main(string[] args) + { + // Must happen before any LibreHardwareMonitor type is touched (JIT of Run()). + EmbeddedAssemblyResolver.Install(); + + try + { + return Run(args); + } + catch (Exception e) + { + Log.Error(e.ToString()); + return 1; + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int Run(string[] args) + { + Options options; + try + { + options = Options.Load(args); + } + catch (Exception e) + { + Console.Error.WriteLine(e.Message); + return 2; + } + + Log.FilePath = options.LogFile; + Log.Verbose = options.Verbose; + + switch (options.Mode) + { + case RunMode.Help: + Console.WriteLine(Options.HelpText); + return 0; + + case RunMode.Version: + PrintVersion(); + return 0; + + case RunMode.Dump: + return RunDump(options); + + case RunMode.Report: + return RunReport(options); + + default: + return RunProvider(options); + } + } + + private static int RunProvider(Options options) + { + bool createdNew; + using (Mutex mutex = new Mutex(true, InstanceMutexName, out createdNew)) + { + if (!createdNew) + { + Log.Warn("Another OpenHardwareMonitorCLI instance is already running - exiting."); + return 0; + } + + Log.Info("Starting " + VersionString() + ", interval " + options.IntervalMs + " ms, groups: " + + string.Join(",", options.Groups) + (options.CompatIdentifiers ? ", OHM compatible identifiers" : ", native identifiers")); + CheckEnvironment(); + + using (DataProvider provider = new DataProvider(options, !options.NoWmi)) + { + Log.Info("Publishing " + provider.Wmi.Snapshot().Count + " WMI objects in root\\OpenHardwareMonitor" + + (options.NoWmi ? " (disabled by --no-wmi)" : "")); + + Console.CancelKeyPress += delegate(object sender, ConsoleCancelEventArgs e) + { + e.Cancel = true; + ExitEvent.Set(); + }; + AppDomain.CurrentDomain.ProcessExit += delegate { ExitEvent.Set(); }; + + provider.Start(); + if (options.DurationSeconds > 0) + ExitEvent.WaitOne(TimeSpan.FromSeconds(options.DurationSeconds)); + else + ExitEvent.WaitOne(); + + Log.Info("Stopping"); + provider.Stop(); + } + + mutex.ReleaseMutex(); + } + + return 0; + } + + private static int RunDump(Options options) + { + CheckEnvironment(); + + using (DataProvider provider = new DataProvider(options, false)) + { + // Load values need two samples - do a few cycles before printing. + for (int i = 1; i < options.DumpCycles; i++) + { + Thread.Sleep(Math.Min(options.IntervalMs, 1000)); + provider.UpdateNow(); + } + + List sensors = new List(); + foreach (IWmiObject o in provider.Wmi.Snapshot()) + { + Sensor s = o as Sensor; + if (s != null) + sensors.Add(s); + } + + sensors.Sort(delegate(Sensor a, Sensor b) + { + int c = string.CompareOrdinal(a.Parent, b.Parent); + if (c == 0) + c = string.CompareOrdinal(a.SensorType, b.SensorType); + if (c == 0) + c = a.Index.CompareTo(b.Index); + return c; + }); + + StringBuilder sb = new StringBuilder(); + sb.AppendLine("<<>>"); + if (options.IncludeHeader) + sb.AppendLine("Index,Name,Parent,SensorType,Value"); + + foreach (Sensor s in sensors) + { + sb.Append(s.Index.ToString(CultureInfo.InvariantCulture)).Append(',') + .Append(s.Name).Append(',') + .Append(s.Parent).Append(',') + .Append(s.SensorType).Append(',') + .Append(s.Value.ToString("0.000000", CultureInfo.InvariantCulture)) + .AppendLine(); + } + + Console.Write(sb.ToString()); + } + + return 0; + } + + private static int RunReport(Options options) + { + CheckEnvironment(); + + using (DataProvider provider = new DataProvider(options, false)) + { + Thread.Sleep(1000); + provider.UpdateNow(); + Console.WriteLine(provider.Computer.GetReport()); + } + + return 0; + } + + private static void CheckEnvironment() + { + if (!IsElevated()) + Log.Warn("Not running as administrator - most sensors will be missing and WMI publishing will fail."); + + string pawnIo = GetPawnIoVersion(); + if (pawnIo == null) + Log.Warn("PawnIO driver is not installed - CPU temperatures, mainboard (SuperIO) and RAM SPD sensors are unavailable. " + + "Install it from https://pawnio.eu or: winget install namazso.PawnIO"); + else + Log.Debug("PawnIO driver version " + pawnIo); + } + + private static bool IsElevated() + { + try + { + using (WindowsIdentity identity = WindowsIdentity.GetCurrent()) + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator) || identity.IsSystem; + } + catch (Exception) + { + return false; + } + } + + private static string GetPawnIoVersion() + { + const string key = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\PawnIO"; + + foreach (RegistryView view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + try + { + using (RegistryKey baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (RegistryKey sub = baseKey.OpenSubKey(key)) + { + string version = sub == null ? null : sub.GetValue("DisplayVersion") as string; + if (!string.IsNullOrEmpty(version)) + return version; + } + } + catch (Exception) + { + // ignored + } + } + + return null; + } + + private static string VersionString() + { + return "OpenHardwareMonitorCLI " + typeof(Program).Assembly.GetName().Version + + " (LibreHardwareMonitorLib " + LhmVersion() + ")"; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static string LhmVersion() + { + Assembly lhm = typeof(LibreHardwareMonitor.Hardware.Computer).Assembly; + AssemblyInformationalVersionAttribute info = + (AssemblyInformationalVersionAttribute)Attribute.GetCustomAttribute(lhm, typeof(AssemblyInformationalVersionAttribute)); + return info != null ? info.InformationalVersion : lhm.GetName().Version.ToString(); + } + + private static void PrintVersion() + { + Console.WriteLine(VersionString()); + Console.WriteLine("Data unit: " + (IdentifierMapper.LibraryReportsDataInBytes ? "bytes (converted to GB in compat mode)" : "GB")); + Console.WriteLine("Runtime: .NET Framework " + Environment.Version + (Environment.Is64BitProcess ? " (64 bit)" : " (32 bit)")); + string pawnIo = GetPawnIoVersion(); + Console.WriteLine("PawnIO: " + (pawnIo ?? "not installed")); + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/UpdateVisitor.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/UpdateVisitor.cs new file mode 100644 index 00000000000..d82f65c43f8 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/UpdateVisitor.cs @@ -0,0 +1,45 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Based on the update visitor (GUI/UpdateVisitor.cs) of Open Hardware Monitor +// (https://github.com/openhardwaremonitor/openhardwaremonitor): +// Copyright (C) 2009-2010 Michael Möller +// Adapted for LibreHardwareMonitor and the Checkmk Windows agent, 2026. + +using System; +using LibreHardwareMonitor.Hardware; + +namespace OpenHardwareMonitor.CLI +{ + /// Visitor that updates every hardware node (same as in the original CLI). + internal sealed class UpdateVisitor : IVisitor + { + public void VisitComputer(IComputer computer) + { + computer.Traverse(this); + } + + public void VisitHardware(IHardware hardware) + { + try + { + hardware.Update(); + } + catch (Exception e) + { + Log.Debug("Update of " + hardware.Identifier + " failed: " + e.Message); + } + + foreach (IHardware subHardware in hardware.SubHardware) + subHardware.Accept(this); + } + + public void VisitSensor(ISensor sensor) + { + } + + public void VisitParameter(IParameter parameter) + { + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiObjects.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiObjects.cs new file mode 100644 index 00000000000..44f1bfe9e5a --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiObjects.cs @@ -0,0 +1,92 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Based on the WMI classes (WMI/Hardware.cs, WMI/Sensor.cs, WMI/IWmiObject.cs) of Open Hardware Monitor +// (https://github.com/openhardwaremonitor/openhardwaremonitor): +// Copyright (C) 2009-2010 Paul Werelds +// Adapted for LibreHardwareMonitor and the Checkmk Windows agent, 2026. +// +// WMI classes published in root\OpenHardwareMonitor. +// Class names, property names and property types are identical to the original +// OpenHardwareMonitorCLI.exe so the Checkmk agent and existing WMI consumers keep working. + +using System.Management.Instrumentation; +using LibreHardwareMonitor.Hardware; +using OpenHardwareMonitor.CLI; + +namespace OpenHardwareMonitor.WMI +{ + internal interface IWmiObject + { + string Name { get; } + string Identifier { get; } + void Update(); + } + + [InstrumentationClass(InstrumentationType.Instance)] + public class Hardware : IWmiObject + { + public string HardwareType { get; private set; } + public string Identifier { get; private set; } + public string Name { get; private set; } + public string Parent { get; private set; } + + internal Hardware(IHardware hardware, IdentifierMapper mapper) + { + Name = mapper.MapName(hardware.Name); + Identifier = mapper.MapIdentifier(hardware.Identifier.ToString()); + HardwareType = hardware.HardwareType.ToString(); + Parent = hardware.Parent == null ? "" : mapper.MapIdentifier(hardware.Parent.Identifier.ToString()); + } + + public void Update() + { + } + } + + [InstrumentationClass(InstrumentationType.Instance)] + public class Sensor : IWmiObject + { + private readonly ISensor _sensor; + private readonly float _scale; + + public string SensorType { get; private set; } + public string Identifier { get; private set; } + public string Parent { get; private set; } + public string Name { get; private set; } + public float Value { get; private set; } + public float Min { get; private set; } + public float Max { get; private set; } + public int Index { get; private set; } + + internal Sensor(ISensor sensor, IdentifierMapper mapper) + { + _sensor = sensor; + _scale = mapper.GetValueScale(sensor); + Name = mapper.MapSensorName(sensor); + Index = sensor.Index; + SensorType = sensor.SensorType.ToString(); + Identifier = mapper.MapIdentifier(sensor.Identifier.ToString()); + Parent = mapper.MapIdentifier(sensor.Hardware.Identifier.ToString()); + } + + internal string RawIdentifier + { + get { return _sensor.Identifier.ToString(); } + } + + public void Update() + { + float? value = _sensor.Value; + Value = value.HasValue ? IdentifierMapper.SanitizeValue(value.Value * _scale) : 0f; + + float? min = _sensor.Min; + if (min.HasValue) + Min = IdentifierMapper.SanitizeValue(min.Value * _scale); + + float? max = _sensor.Max; + if (max.HasValue) + Max = IdentifierMapper.SanitizeValue(max.Value * _scale); + } + } +} diff --git a/agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiProvider.cs b/agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiProvider.cs new file mode 100644 index 00000000000..2c8f3ddac50 --- /dev/null +++ b/agents/wnx/ohm/OpenHardwareMonitorCLI/WMI/WmiProvider.cs @@ -0,0 +1,259 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Based on the WMI provider (WMI/WmiProvider.cs) of Open Hardware Monitor +// (https://github.com/openhardwaremonitor/openhardwaremonitor): +// Copyright (C) 2009-2010 Paul Werelds +// Copyright (C) 2012 Michael Möller +// Adapted for LibreHardwareMonitor and the Checkmk Windows agent, 2026. + +using System; +using System.Collections.Generic; +using System.Management.Instrumentation; +using LibreHardwareMonitor.Hardware; +using OpenHardwareMonitor.CLI; + +namespace OpenHardwareMonitor.WMI +{ + /// + /// Publishes hardware and sensors as WMI instances (decoupled WMI.NET provider), + /// mirroring the behaviour of the original OpenHardwareMonitorCLI.exe. + /// + internal sealed class WmiProvider : IDisposable + { + private readonly object _lock = new object(); + private readonly IdentifierMapper _mapper; + private readonly bool _publish; + private List _activeInstances = new List(); + + // Sensors that are only published once they report a value > 0 (fans in compat mode, + // like OpenHardwareMonitor did: unconnected fan headers never show up). + private readonly List _pending = new List(); + private int _publishErrors; + + public WmiProvider(IComputer computer, IdentifierMapper mapper, bool publish) + { + _mapper = mapper; + _publish = publish; + + foreach (IHardware hardware in computer.Hardware) + ComputerHardwareAdded(hardware); + + computer.HardwareAdded += ComputerHardwareAdded; + computer.HardwareRemoved += ComputerHardwareRemoved; + } + + public int PublishErrors + { + get { return _publishErrors; } + } + + /// Snapshot of all currently known WMI objects. + public List Snapshot() + { + lock (_lock) + return _activeInstances == null ? new List() : new List(_activeInstances); + } + + public void Update() + { + lock (_lock) + { + if (_activeInstances == null) + return; + + foreach (IWmiObject instance in _activeInstances) + { + try + { + instance.Update(); + } + catch (Exception e) + { + Log.Debug("Update of " + instance.Identifier + " failed: " + e.Message); + } + } + + for (int i = _pending.Count - 1; i >= 0; i--) + { + Sensor sensor = _pending[i]; + try + { + sensor.Update(); + } + catch (Exception) + { + continue; + } + + if (sensor.Value > 0) + { + _pending.RemoveAt(i); + _activeInstances.Add(sensor); + Publish(sensor); + Log.Debug("Sensor became active: " + sensor.Identifier + " (" + sensor.Name + ")"); + } + } + } + } + + private void ComputerHardwareAdded(IHardware hardware) + { + lock (_lock) + { + if (_activeInstances == null) + return; + + if (!Exists(_mapper.MapIdentifier(hardware.Identifier.ToString()))) + { + foreach (ISensor sensor in hardware.Sensors) + HardwareSensorAdded(sensor); + + hardware.SensorAdded += HardwareSensorAdded; + hardware.SensorRemoved += HardwareSensorRemoved; + + Hardware hw = new Hardware(hardware, _mapper); + _activeInstances.Add(hw); + Publish(hw); + Log.Debug("Hardware added: " + hw.Identifier + " (" + hw.Name + ", " + hw.HardwareType + ")"); + } + + foreach (IHardware subHardware in hardware.SubHardware) + ComputerHardwareAdded(subHardware); + } + } + + private void HardwareSensorAdded(ISensor data) + { + lock (_lock) + { + if (_activeInstances == null) + return; + + if (_mapper.IsExcluded(data)) + { + Log.Debug("Skipping sensor " + data.Identifier + " (" + data.Name + ")"); + return; + } + + // LHM keeps a 24h value history per sensor by default (for the GUI plots). + // A headless provider never needs it - disable it to keep memory flat. + try + { + data.ValuesTimeWindow = TimeSpan.Zero; + } + catch (Exception) + { + // not supported by this sensor implementation + } + + Sensor sensor = new Sensor(data, _mapper); + if (Exists(sensor.Identifier)) + return; + + sensor.Update(); + + if (_mapper.PublishOnlyWhenActive(data) && !(sensor.Value > 0)) + { + _pending.Add(sensor); + Log.Debug("Sensor deferred until it reports a value: " + sensor.Identifier + " (" + sensor.Name + ")"); + return; + } + + _activeInstances.Add(sensor); + Publish(sensor); + } + } + + private void ComputerHardwareRemoved(IHardware hardware) + { + lock (_lock) + { + hardware.SensorAdded -= HardwareSensorAdded; + hardware.SensorRemoved -= HardwareSensorRemoved; + + foreach (ISensor sensor in hardware.Sensors) + HardwareSensorRemoved(sensor); + + foreach (IHardware subHardware in hardware.SubHardware) + ComputerHardwareRemoved(subHardware); + + RevokeInstance(_mapper.MapIdentifier(hardware.Identifier.ToString())); + } + } + + private void HardwareSensorRemoved(ISensor sensor) + { + lock (_lock) + RevokeInstance(_mapper.MapIdentifier(sensor.Identifier.ToString())); + } + + private bool Exists(string identifier) + { + return _activeInstances.Exists(h => h.Identifier == identifier) || + _pending.Exists(h => h.Identifier == identifier); + } + + private void RevokeInstance(string identifier) + { + if (_activeInstances == null) + return; + + _pending.RemoveAll(item => item.Identifier == identifier); + + int index = _activeInstances.FindIndex(item => item.Identifier == identifier); + if (index == -1) + return; + + Revoke(_activeInstances[index]); + _activeInstances.RemoveAt(index); + } + + private void Publish(object instance) + { + if (!_publish) + return; + + try + { + Instrumentation.Publish(instance); + } + catch (Exception e) + { + _publishErrors++; + if (_publishErrors <= 3) + Log.Warn("WMI publish failed (are we running as administrator/SYSTEM?): " + e.Message); + } + } + + private void Revoke(object instance) + { + if (!_publish) + return; + + try + { + Instrumentation.Revoke(instance); + } + catch (Exception) + { + // ignored, same as original implementation + } + } + + public void Dispose() + { + lock (_lock) + { + if (_activeInstances == null) + return; + + foreach (IWmiObject instance in _activeInstances) + Revoke(instance); + + _activeInstances = null; + _pending.Clear(); + } + } + } +} diff --git a/agents/wnx/ohm/README.md b/agents/wnx/ohm/README.md new file mode 100644 index 00000000000..050ac8f7fcc --- /dev/null +++ b/agents/wnx/ohm/README.md @@ -0,0 +1,174 @@ +# OpenHardwareMonitorCLI based on LibreHardwareMonitor + +Headless replacement for the `OpenHardwareMonitorCLI.exe` that provides the agent section +`<<>>`. Open Hardware Monitor was removed from the distribution with +[Werk #18537](https://checkmk.com/werk/18537) because Windows Defender reports its WinRing0 driver as +vulnerable (CVE-2020-14979). This implementation uses the actively developed +[LibreHardwareMonitor](https://github.com/LibreHardwareMonitor/LibreHardwareMonitor) library and the +signed [PawnIO](https://pawnio.eu) driver instead. + +It is a drop-in replacement: no changes are needed in the Windows agent, in the bakery plugin +`win_openhardwaremonitor` or in the check plugins `cmk/plugins/openhardwaremonitor`. + +## Important: Open Hardware Monitor naming is a temporary compatibility layer + +To work with the current, unchanged agent and bakery, this CLI deliberately presents itself as Open +Hardware Monitor, i.e. "Libre" becomes "Open": + +- the executable is named `OpenHardwareMonitorCLI.exe`, assembly and C# namespaces are + `OpenHardwareMonitor.*` +- `LibreHardwareMonitorLib.dll` is shipped renamed as `OpenHardwareMonitorLib.dll` +- the sensors are published in the WMI namespace `root\OpenHardwareMonitor` +- LibreHardwareMonitor identifiers are translated to Open Hardware Monitor identifiers (`/nvme/0` → + `/hdd/0`, `/motherboard` → `/mainboard`, ...) + +This is not meant to stay. For the official integration, the naming should be changed to +LibreHardwareMonitor **on both sides in one change**, otherwise agent and CLI no longer find each other. + +In Checkmk: + +- `agents/wnx/include/providers/ohm.h`: `kExeModule` / `kExeModuleWide` (`OpenHardwareMonitorCLI.exe`), + `kResetCommand` (deletes the WMI namespace `OpenHardwareMonitor`), `kDriverNameWide` + (`winring0_1_2_0`, obsolete with PawnIO) +- `agents/wnx/include/wnx/section_header.h`: `kWmiPathOhm` (`Root\OpenHardwareMonitor`) and the section + name `kOhm` (`openhardwaremonitor`) +- `agents/wnx/src/engine/providers/ohm.cpp` and `agents/wnx/src/engine/providers/wmi.cpp` (WMI object + `Sensor` and its columns) +- `cmk/base/plugins/bakery/win_openhardwaremonitor.py` (deployed file names) and + `cmk/plugins/collection/rulesets/win_openhardwaremonitor.py` (rule title and help text) +- `agents/wnx/Makefile` (`PACKAGE_OHM`, `OHM_VERSION`), `third_party/openhardwaremonitor`, + `doc/treasures/windows/ohm`, `agents/wnx/scripts/tools/openhardwaremonitor-clean.cmd`, + `doc/treasures/windows/openhardwaremonitor-clean.cmd` +- `cmk/plugins/openhardwaremonitor`: the section and plugin names and the item naming in + `_create_openhardwaremonitor_full_name`. Keeping them avoids vanished services; if the CLI publishes + native LibreHardwareMonitor identifiers, the item naming has to handle them (e.g. `nvme`, + `gpu-nvidia`, `memory/dimm`). + +In this CLI (all places are marked with `TODO(LHM rename)`): + +- `OpenHardwareMonitorCLI.csproj`: `AssemblyName` and the renaming of `LibreHardwareMonitorLib.dll` in + the target `CreateDist` +- `EmbeddedAssemblyResolver.cs`: `LhmFileName` +- `AssemblyInfo.cs`: `[assembly: Instrumented("root/OpenHardwareMonitor")]` +- `Program.cs` and the other files: namespaces `OpenHardwareMonitor.CLI` / `OpenHardwareMonitor.WMI`, + mutex name and help text +- `IdentifierMapper.cs`: the identifier mapping can be dropped once the check plugins understand + LibreHardwareMonitor identifiers. The other adaptations (GB conversion, limit values, fans, NaN and + separator sanitizing) stay useful. + +## How it works + +- Same behaviour as the original CLI: all sensors are read every second and published as WMI instances + in `root\OpenHardwareMonitor` (classes `Sensor` and `Hardware`, same properties and types). The agent + starts `bin\OpenHardwareMonitorCLI.exe` without arguments and queries + `SELECT Index,Name,Parent,SensorType,Value FROM Sensor`. +- Same two files as before, so the bakery can deploy them unchanged: + - `OpenHardwareMonitorCLI.exe` contains all dependencies (HidSharp, DiskInfoToolkit, RAMSPDToolkit, + System.Memory, ...) as embedded resources and loads them itself. + - `OpenHardwareMonitorLib.dll` is the unmodified `LibreHardwareMonitorLib.dll`, renamed. +- Default hardware groups are the ones the original CLI enabled: CPU, memory, mainboard, fan + controllers and storage. + +## Compatibility with the check plugins + +All adjustments are in [`OpenHardwareMonitorCLI/IdentifierMapper.cs`](OpenHardwareMonitorCLI/IdentifierMapper.cs): + +- LibreHardwareMonitor identifiers are mapped to the Open Hardware Monitor ones, so existing service + names stay the same: `/nvme/0`, `/ata/1`, ... → `/hdd/0`, `/hdd/1`; `/motherboard` → `/mainboard`; + `/lpc//0` → `/lpc/`; `/gpu-nvidia/0` → `/nvidiagpu/0`; `/gpu-amd/0` → `/atigpu/0`. +- `Data` sensors are published in GB. LibreHardwareMonitor reports bytes since commit + [677a3a5](https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/commit/677a3a5); the parser + multiplies `Data` by 2^30. +- The storage sensor `Life` is published as `Remaining Life`, which `openhardwaremonitor_smart` expects. +- Constant limit values that LibreHardwareMonitor reports as temperatures are not published (NVMe + `Warning/Critical Temperature`, DIMM `Thermal Sensor ... Limit`, `Temperature Sensor Resolution`). + They would be discovered as temperature services that are permanently CRIT. +- Fans are published after their first rotation, like in Open Hardware Monitor, so unconnected fan + headers do not show up as services with 0 RPM. +- Commas and line breaks in names and identifiers are replaced (the section uses `sep(44)`). +- NaN and infinite values are published as 0. The agent formats NaN with `std::to_wstring` as + `-nan(ind)`, and `float()` in the parser fails for the whole section. +- Localized satellite assemblies are not shipped, so sensor names do not depend on the Windows display + language. + +`--native-ids` (or `CompatIdentifiers=false`) publishes the LibreHardwareMonitor data unchanged. + +## Requirements on the monitored host + +- Windows x64, .NET Framework 4.7.2 or newer +- PawnIO driver for CPU, mainboard (SuperIO) and DIMM sensors, e.g. `winget install namazso.PawnIO`. + Without it, storage (SMART/NVMe), CPU load and memory usage still work. + +## Build + +Requirements: git and the .NET 10 SDK (`winget install Microsoft.DotNet.SDK.10`). + +```console +build.cmd :: LibreHardwareMonitor at the pinned, tested commit +build.cmd :: any other LibreHardwareMonitor revision +``` + +`build.cmd` fetches LibreHardwareMonitor into `external\LibreHardwareMonitor` and builds the library +from source together with the CLI. The result is written to `dist\`: +`OpenHardwareMonitorCLI.exe`, `OpenHardwareMonitorLib.dll` and an example configuration. + +Alternatively, the last stable NuGet release can be used: + +```console +dotnet build OpenHardwareMonitorCLI\OpenHardwareMonitorCLI.csproj -c Release -p:LhmSource=nuget +``` + +## Deployment + +- Bakery: copy both files to `local/share/check_mk/agents/windows/` and enable the rule + _OpenHardwareMonitor (Windows)_. +- Manually: copy both files to `C:\ProgramData\checkmk\agent\bin` and restart the agent service. + +## Testing + +```console +OpenHardwareMonitorCLI.exe --dump --header :: sensors in agent format, without WMI +OpenHardwareMonitorCLI.exe --version :: versions and PawnIO status +OpenHardwareMonitorCLI.exe --help :: all options +``` + +What has been verified: + +- The current agent code (`agents/wnx`: process start, WMI query, value formatting, status column), + the bakery plugin and the check plugins were reviewed against this implementation. +- Parser, discovery and all five check plugins (clock, temperature, fan, power, SMART) were executed with + real sensor data of a Ryzen system (NVMe and SATA SSDs, Nuvoton NCT6799D, DDR5); the existing unit + tests in `tests/unit/cmk/plugins/openhardwaremonitor` pass. +- On `windows-latest` (GitHub Actions), the CLI was built from the LibreHardwareMonitor source and the + WMI query of the agent returned the published sensors. + +The standalone project with CI and release builds is maintained at +. + +## Configuration (optional) + +Rename `OpenHardwareMonitorCLI.exe.config.example` to `OpenHardwareMonitorCLI.exe.config` next to the +executable. Keys: `Enable`/`Disable` (hardware groups `cpu, gpu, memory, motherboard, controller, +storage, network, psu, battery, powermonitor, all`), `IntervalMs`, `CompatIdentifiers`, `LogFile`, +`Verbose`. The same settings exist as command line options. + +## Licensing + +- New files are licensed under the GNU GPL v2 like the rest of the agent. +- `OpenHardwareMonitorCLI/WMI/*.cs` and `OpenHardwareMonitorCLI/UpdateVisitor.cs` are based on Open + Hardware Monitor and remain under the Mozilla Public License 2.0, which is compatible with the GPL + (MPL-2.0 section 3.3). +- LibreHardwareMonitor (MPL-2.0) and its dependencies are fetched at build time and keep their own + licenses. + +## Open points for the integration + +- Rename the Open Hardware Monitor compatibility layer to LibreHardwareMonitor on both sides, see + [above](#important-open-hardware-monitor-naming-is-a-temporary-compatibility-layer). +- Build pipeline: `agents/wnx/Makefile` still extracts `third_party/openhardwaremonitor`. The + LibreHardwareMonitor revision could be provided the same way (`third_party/librehardwaremonitor`) + instead of being fetched by `build.cmd`. +- Distribution of the PawnIO driver (prerequisite, optional agent dependency or documentation). +- `doc/treasures/windows/ohm` and the text of Werk #18537 still point to the old binaries. +- Independent of this CLI, the parser could skip lines with non-numeric values, and the default + temperature levels (`cpu` 60/70 °C, `hdd` 40/50 °C) are low for current hardware. diff --git a/agents/wnx/ohm/build.cmd b/agents/wnx/ohm/build.cmd new file mode 100644 index 00000000000..eb4e216abe3 --- /dev/null +++ b/agents/wnx/ohm/build.cmd @@ -0,0 +1,37 @@ +@echo off +rem Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2 +rem This file is part of Checkmk (https://checkmk.com). It is subject to the terms and +rem conditions defined in the file COPYING, which is part of this source code package. +rem +rem Builds the LibreHardwareMonitor based OpenHardwareMonitorCLI into .\dist: +rem OpenHardwareMonitorCLI.exe - the CLI, all dependencies except LHM embedded +rem OpenHardwareMonitorLib.dll - LibreHardwareMonitorLib.dll, renamed for the bakery +rem +rem Usage: build.cmd [LibreHardwareMonitor commit, tag or branch] +rem Requirements: git, .NET 10 SDK (winget install Microsoft.DotNet.SDK.10) + +setlocal +cd /d "%~dp0" + +rem Last LibreHardwareMonitor commit tested on real hardware (2026-10-28) +set "LHM_REF=%~1" +if "%LHM_REF%"=="" set "LHM_REF=71040ce2083ecde1f8471acd740a5f1d599744f7" +set "LHM_DIR=%~dp0external\LibreHardwareMonitor" + +where git >nul 2>nul || (echo git is required & exit /b 1) +where dotnet >nul 2>nul || (echo .NET 10 SDK is required: winget install Microsoft.DotNet.SDK.10 & exit /b 1) + +if not exist "%LHM_DIR%\.git" ( + git init -q "%LHM_DIR%" || exit /b 1 + git -C "%LHM_DIR%" remote add origin https://github.com/LibreHardwareMonitor/LibreHardwareMonitor.git || exit /b 1 +) +echo Fetching LibreHardwareMonitor %LHM_REF% ... +git -C "%LHM_DIR%" fetch -q --depth 1 origin %LHM_REF% || exit /b 1 +git -C "%LHM_DIR%" checkout -q --force FETCH_HEAD || exit /b 1 +git -C "%LHM_DIR%" clean -q -fdx || exit /b 1 + +dotnet build "%~dp0OpenHardwareMonitorCLI\OpenHardwareMonitorCLI.csproj" -c Release -p:LhmSource=repo "-p:LhmRepoDir=%LHM_DIR%" || exit /b 1 + +echo. +echo Done. Files for the agent are in %~dp0dist +endlocal diff --git a/agents/wnx/ohm/nuget.config b/agents/wnx/ohm/nuget.config new file mode 100644 index 00000000000..83a2aaaa904 --- /dev/null +++ b/agents/wnx/ohm/nuget.config @@ -0,0 +1,8 @@ + + + + + + + +