Reading CPU Temps Without a BMC

Table of Contents

Preface

Servers with a BMC (iDRAC, iLO and friends) hand you CPU temperature over SNMP or Redfish. Mini PCs like the Minisforum MS-01s in my lab (Micro-S2D) don’t have one.

Without a BMC you read the CPU’s thermal sensors in-band, which needs a kernel-mode driver on the host. This post gets that reading into Zabbix with Memory Integrity (HVCI) left on, no extra service, no WMI provider and no listening port.

Disclaimer: this puts a third-party ring-0 driver on a hypervisor. Only do it on boxes you’re allowed to break.

What you need

  • PawnIO: a small, signed, sandboxed ring-0 driver. LibreHardwareMonitor (0.9.6), FanControl and OpenRGB use it instead of the old WinRing0, and it’s installed separately.
  • LibreHardwareMonitor (LHM), .NET Framework build: only its library DLL is used, not the app.
  • A short PowerShell script and a Zabbix agent UserParameter.

Step 1: Install PawnIO

Verify the signature, then install silently:

# grab it from github.com/namazso/PawnIO.Setup/releases/latest
(Get-AuthenticodeSignature C:\Temp\PawnIO_setup.exe).Status   # must be 'Valid'
Start-Process C:\Temp\PawnIO_setup.exe -ArgumentList '-install','-silent' -Wait -PassThru
# exit code 0 = installed, 3010 = installed (reboot pending)

Then check CodeIntegrity for a block:

Get-WinEvent -LogName 'Microsoft-Windows-CodeIntegrity/Operational' -MaxEvents 30 |
  Where-Object { $_.Id -in 3033,3077,3076 } | Format-List TimeCreated, Id, Message
  • 3033 / 3077 = blocked by an enforced policy.
  • 3076 = audit mode, “would have blocked”.

On Azure Local with HVCI and the vulnerable-driver blocklist enforced, the driver loaded (State = Running) with no 3033/3077. A 3076 for the installer exe itself is harmless. On Azure Local the code-integrity policy is layered (some policies audit, AS_Base_Policy and the driver blocklist enforce), so validate on one node before the rest.

Step 2: Install the LHM library and the script

Download LibreHardwareMonitor.zip from the LHM releases, the un-suffixed one: that’s the .NET Framework build, which loads in the built-in Windows PowerShell 5.1. The .NET-N builds need a runtime Windows Server doesn’t ship. Extract it to C:\Program Files\LibreHardwareMonitor.

Save this as C:\Program Files\Zabbix Agent 2\scripts\cpu_lhm.ps1. It loads the library, opens the CPU through PawnIO, prints the hottest package/core temperature (or the highest core clock) and exits, in about 260 ms:

param([ValidateSet("temp","clock")][string]$Metric = "temp")
$ErrorActionPreference = "Stop"
$dir = "C:\Program Files\LibreHardwareMonitor"
try {
  Get-ChildItem $dir -Filter *.dll | ForEach-Object {
    try { [void][Reflection.Assembly]::LoadFrom($_.FullName) } catch {}
  }
  $c = New-Object LibreHardwareMonitor.Hardware.Computer
  $c.IsCpuEnabled = $true
  $c.Open()
  foreach ($hw in $c.Hardware) { $hw.Update() }
  $s = foreach ($hw in $c.Hardware) { $hw.Sensors }
  switch ($Metric) {
    "temp"  { $sel = $s | ?{ $_.SensorType -eq [LibreHardwareMonitor.Hardware.SensorType]::Temperature -and $_.Name -match "CPU Package|Core \(Tctl|Tdie" } }
    "clock" { $sel = $s | ?{ $_.SensorType -eq [LibreHardwareMonitor.Hardware.SensorType]::Clock -and $_.Name -match "Core" } }
  }
  $v = @($sel | ?{ $null -ne $_.Value } | %{ [double]$_.Value })
  $c.Close()
  if ($v.Count) { [math]::Round(($v | Measure-Object -Max).Maximum) } else { "ZBX_NOTSUPPORTED"; exit 1 }
} catch { "ZBX_NOTSUPPORTED"; exit 1 }

Test it on the host: .\cpu_lhm.ps1 -Metric temp should print a whole number of °C. The library DLL is unsigned but still loads under the enforced Azure Local base policy; test it rather than assuming it’s blocked.

Step 3: Wire it into Zabbix

Add one UserParameter to the agent config and restart the agent:

1# zabbix_agent2.conf
2UserParameter=cpu.lhm[*],powershell -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Zabbix Agent 2\scripts\cpu_lhm.ps1" -Metric "$1"

Set the item timeout to about 10 s on the Zabbix items. PowerShell’s cold start can exceed the default, and the agent’s own Timeout= doesn’t override the item timeout in Zabbix 7.

Two items and a couple of triggers:

  • cpu.lhm[temp]: CPU package °C, warn > 90, high > 97 (TjMax on the i9-12900H is ~100 °C; set these from your CPU’s TjMax).
  • cpu.lhm[clock]: max core MHz.

Graph clock against temp to catch throttling

A CPU at 90 °C still boosting to 4.2 GHz is hot. A CPU at 90 °C that has dropped to base clock is throttling. You only know which by graphing temp and clock together.

That graph earned its keep: one MS-01 sat at ~90 °C under load while its identical twin ran ~78 °C doing the same work. A gap like that on identical hardware points at thermal paste, not airflow. After a re-paste it ran 78 °C and still boosted to 4.3 GHz.

On hosts with a BMC (temperature from the BMC, no LHM), you can get effective clock with no host changes from the agent’s built-in perf counters plus a calculated item:

  • \Processor Information(_total)\Processor Frequency (nominal)
  • \Processor Information(_total)\% Processor Performance (over 100 under boost)
  • calculated: effective = nominal × perf% / 100

Skip Win32_Processor.CurrentClockSpeed; it doesn’t track boost.

Gotchas

LHM shows no sensors, or WinRing0 won’t load

WinRing0 is blocked by HVCI and the Microsoft vulnerable-driver blocklist, for good reason: it’s been abused as an arbitrary ring-0 read primitive. Current LHM uses PawnIO instead, and PawnIO isn’t bundled. Fix: install PawnIO (step 1). Don’t turn off Memory Integrity.

LHM as a service: “not running”, “Paused”, or high privileged CPU

Don’t run the LHM app as a Windows service (NSSM or otherwise). It’s a WinForms GUI app; in Session 0 it tries to show a MessageBox at startup, can’t, and crashes. You’ll see .NET Runtime Event 1026 with System.InvalidOperationException … MessageBox.ShowCore, the service flapping in SCM Event 7036, NSSM showing Paused (restart backoff), and a ~200 MB crash dump written on every crash. With no backoff it can crash many times a minute and only show up as high privileged CPU time. Headless LHM never publishes its WMI namespace (root/LibreHardwareMonitor) either, and its web server is an unauthenticated listener. Fix: remove the service and use the on-demand script above.

MSAcpi_ThermalZoneTemperature returns a constant

It’s an ACPI thermal zone, not a CPU sensor. On the MS-01 it returned a static 3010 (27.85 °C) on every node. Don’t use it.

Items time out

See step 3: raise the item timeout, not the agent’s.

Takeaway

Monitor everything. Collect as much telemetry as you can before you have a problem, so when something starts to drift you can spot it and fix it before it becomes an outage.