Skip to the content.

← All documentation · English | 繁體中文

jt-snmpd compared with the Windows built-in SNMP Service

Why not rebuild Net-SNMP for Windows instead? That option was evaluated. The conclusion and the reasoning, including Net-SNMP’s own published failure history, are in ADR-0001.

Measurement Value
Date 2026-08-24
Poller LibreNMS 26.8.1, with no LibreNMS-side modifications
Control Windows 10 22H2, Windows built-in SNMP Service
Subject Windows 10 22H2, jt-snmpd

Addresses use the RFC 5737 documentation range (192.0.2.0/24). Only the addresses and serial numbers were changed; the counts are as measured.


What LibreNMS shows

This is the difference an operator actually sees.

LibreNMS table Built-in SNMP jt-snmpd Notes
entPhysical (Inventory) 0 5 The built-in service has no inventory at all. We parse chassis, mainboard, CPU, DIMMs and disks out of SMBIOS
ucd_diskio (Disk I/O) 0 2 No disk I/O in the built-in service. We serve it from IOCTL_DISK_PERFORMANCE
sensors (Temperature) 0 2 No sensors at all in the built-in service. We provide disk temperature and ACPI thermal zones (33 °C / 25 °C measured on real hardware)
applications (SMART) 0 1 Not available in the built-in service. We serve LibreNMS’s smart application through NET-SNMP-EXTEND-MIB (requires discovery_modules.applications)
mempools (Memory) 2 4 The built-in service has physical and virtual only. We add cached and swap
System graphs 3 8 The built-in service gives Processes, Users and Uptime only, because those three come from HOST-RESOURCES. The other five come from UCD-SNMP-MIB on Linux, and the built-in Windows service does not implement that MIB
storage (Disk Usage) 2 2 Same row count, different descriptions: we read the real volume label and serial number, including non-ASCII labels
processors 8 6 Each machine’s actual core count; no difference in behaviour
ipv4_addresses 2 2 The same
ports 9 1 Deliberately different, see below
hrDevice 68 9 Deliberately different, see below

System graphs: Windows only ever had three

LibreNMS’s System graph group shows eight graphs for a Linux device and three for Windows. That is not weak Windows support in LibreNMS: the other five are drawn from UCD-SNMP-MIB’s systemStats, a net-snmp enterprise MIB that the built-in Windows SNMP Service does not implement.

Graph Source Built-in SNMP jt-snmpd
Processes HOST-RESOURCES hrSystemProcesses
Users HOST-RESOURCES hrSystemNumUsers
Uptime sysUpTime
Detailed Processor Usage UCD ssCpuRawUser/Nice/System/Idle
Context Switches UCD ssRawContexts
Interrupts UCD ssRawInterrupts
I/O UCD ssIORawSent / ssIORawReceived
Swap I/O UCD ssRawSwapIn / ssRawSwapOut

The data comes from NtQuerySystemInformation (SystemPerformanceInformation plus per-CPU times); no WMI and no subprocess.

Fields that cannot be measured on Windows (ssCpuRawWait, ssCpuRawSteal, ssCpuRawSoftIRQ, ssCpuRawGuest) are not emitted rather than filled with zero. A zero would make LibreNMS create the graph and draw a flat line at zero, which reads as “measured, and it was zero” when the truth is “not measurable at all”.

ssCpuRawNice is the exception: Windows has no nice, but zero is emitted, because “there is never any nice time on Windows” is a true statement, and LibreNMS’s ucd-mib poller requires user, nice, system and idle to be all four present before it creates the Detailed Processor Usage graph. Omit one and the whole graph never appears.

Volume label encoding

hrStorageDescr carries the volume label, and in the field those labels are frequently non-ASCII. This is a place that genuinely breaks: pysnmp’s rfc1902.OctetString(str) raises PyAsn1UnicodeEncodeError on non-ASCII input, the snapshot fails to build, and the agent presents as “started, but no data”.

The fix is that every OCTET STRING is encoded to UTF-8 bytes before it reaches pysnmp, rather than letting pysnmp guess an encoding. The rule covers volume labels, interface descriptions, sysContact and sysLocation, and the strings parsed out of SMBIOS.

Measured result: a volume labelled 乙太網路 renders correctly on the LibreNMS Disk Usage page, with no mojibake and no question marks. This is verified end to end rather than only in an encoding unit test.

Why ports and hrDevice are so much smaller

The built-in service publishes every NDIS filter driver as a separate interface. The measured ports list on the control machine:

ethernet_32777    ethernet_32770    ppp_32768 (down)
ethernet_8   ethernet_9   ethernet_11   ethernet_12   ethernet_13   ethernet_15

Every one is auto-named and says nothing about what it is, and ppp_32768 is a WAN Miniport that is down. Of the 68 hrDevice rows, 51 are hrDeviceNetwork — again WFP filter drivers, the QoS scheduler and tunnel interfaces.

On a Hyper-V host that number grows to somewhere between 40 and 80 interfaces. Each one becomes a port and a set of RRDs in LibreNMS, and virtual interfaces come and go — when one goes, its RRDs are left with nothing pointing at them.

jt-snmpd publishes only interfaces where HardwareInterface = TRUE and FilterInterface is not set, and excludes loopback and NIC team members. On the same machine it picked exactly one physical adapter out of eleven interfaces, correctly excluding three WFP filter drivers, two VPN virtual adapters (PANGP and F5), Kernel Debug, Loopback, and Teredo / IP-HTTPS / 6to4.

This is a design decision, not an omission. Set interface_filter.mode to all when the complete list is what you want.

Total OIDs: 7,582 against 767

The gap is concentrated in a handful of tables. Each is accounted for below.

Deliberately withheld (information disclosure)

Subtree Built-in jt-snmpd Why it is withheld
hrSWInstalled 660 0 Exact version of every package = a ready-made CVE list
hrSWRun 1,449 0 Which EDR is running and where = tailored evasion
hrSWRunPerf 414 0 As above
tcpConnTable 1,230 0 The full connection list
udpTable 50 0 The service list
ipNetToMedia (ARP) 196 0 The internal ARP table = a target list for lateral movement

That is 3,999 OIDs, well over half of the difference. These counts were taken on the test-bed machine and move with it: how much software is installed, how many processes are running and how many connections are open all feed straight into the built-in service’s total.

All of these are implemented or implementable; they are off by default. The threat model treats the primary adversary as someone already inside the network: a single unauthenticated read-only walk would otherwise yield a complete vulnerability assessment and an internal network map, from a process running as LocalSystem.

What the withheld OIDs would actually buy you

“Deliberately withheld” carries an implication that publishing them would be useful. Checked against the LibreNMS 26.8.1 source, three of the four categories have no consumer in LibreNMS at all: publishing them produces no page, no graph and no table row.

Subtree OIDs Consumer in LibreNMS What publishing it gets you
hrSWInstalled 407 Only LibreNMS/OS/Junos.php, which reads two specific instances to parse a JUNOS version string No software inventory page exists. On Windows, nothing
hrSWRun / hrSWRunPerf 1,792 Only LibreNMS/OS/Edgeos.php and Edgeosolt.php LibreNMS has no processes module. Nothing
tcpConnTable / udpTable 528 None. Zero references in the entire source tree Nothing
ipNetToMedia / ipNetToPhysical 448 Yes: LibreNMS/Modules/ArpTable.php walks both and stores them in ipv4_mac ARP search, FDB search, and per-port neighbour data

Put plainly: 2,727 of those OIDs would disclose a vulnerability list and a connection state table in exchange for no LibreNMS functionality whatsoever. That is not a security-versus-features trade-off; there is simply no reason to publish them.

ARP is the one that genuinely is useful, and it is already implemented and off by default. To turn it on, edit C:\ProgramData\jt-snmpd\config.json:

{
  "enable_arp_table": true
}

Save it, restart the service (Restart-Service jt-snmpd), and it appears on the next discovery.

Weigh it before enabling: the ARP table is a list of this host’s neighbours, and to an attacker already inside the network that is a target list for lateral movement. On a Windows endpoint it is usually a handful of same-subnet entries, and its value to LibreNMS is mostly MAC-to-IP resolution, which pays off far better on routers and switches.

Since filled in (genuinely missing at first)

The first comparison found three tables we really did not have. They were added in 0.1.2:

Subtree Built-in jt-snmpd 0.1.2 Source
hrFSTable 27 18 GetVolumeInformationW, mount points C: and D:, type NTFS
hrPartitionTable 20 10 As above
ipRouteTable 130 42 GetIpForwardTable2, including the default gateway and directly connected networks

The counts are lower than the built-in service because it lists routes for every virtual interface, while we list only routes that map to a physical interface.

Only in jt-snmpd

Subtree Built-in jt-snmpd Contents
entPhysicalTable 0 80 Parsed from SMBIOS: chassis, mainboard, CPU, DIMMs (with part numbers and speeds), disks
entPhySensorTable 0 24 Disk temperature, ACPI thermal zones, CPU frequency
diskIOEntry (UCD) 0 20 Bytes and operations read and written, including the 64-bit forms
JT self-health OIDs 0 65 Version, RSS, snapshot age, per-collector health table
ipAddressTable (IPv6) 0 24 The built-in service has IPv4’s ipAddrTable only

Why SMART is not in entPhySensorTable

The first version published NVMe endurance and available spare as entPhySensorType = other(1), and none of it appeared in LibreNMS. The lookup table in includes/discovery/sensors/entity-sensor.inc.php recognises nine types:

voltsDC voltsAC amperes watts hertz percentRH rpm celsius dBm

other is not among them, so the whole row is discarded without a message. The agent was working, snmpwalk returned the values, and LibreNMS simply did not take them — the kind of gap where neither side is wrong and nothing reports an error is the hardest to find.

Counter-style SMART metrics therefore go through NET-SNMP-EXTEND-MIB, which is the route LibreNMS actually reads SMART from, and it is entirely over SNMP. On other platforms that route is fed by a helper script on the host that shells out to smartmontools; here the agent reads the attributes itself through IOCTL_STORAGE_QUERY_PROPERTY, so jt-snmpd is the only thing installed.

One upstream LibreNMS defect is worth recording while we are here: entity-sensor.inc.php:47 maps hertz to the class freq, but the valid class defined in LibreNMS/Enum/Sensor.php:24 is frequency. Every sensor reported as hertz is therefore discarded. The same pattern appears in cisco-entity-sensor.inc.php:56 and openbsd.inc.php:28, so the impact reaches well beyond this project.

Inventory as measured on real hardware

The Inventory page for a Dell Latitude E5270 running Windows 10 22H2, entirely from SMBIOS parsing — the built-in service provides none of it:

Latitude E5270 (DESKTOP-9PNNQ34)        Serial ****
└── 0DV5YH (Mainboard)                  Dell Inc.
    ├── Intel(R) Core(TM) i5-6300U @ 2.40GHz (U3E1)   2 cores, 2400 MHz
    └── HMA82GS6AFR8N-UH (DIMM A)        16384 MB 2133 MT/s   Serial ****
└── SAMSUNG SSD PM871b M.2 2280 256GB    238 GB (RAID)   Serial ****
    └── PhysicalDrive0 Temp              34 °C

Serial numbers are replaced with **** in this document. The agent itself does report the real ones — when someone has to decide which disk or which memory module to replace in the field, the serial is what makes it findable, and that data stays inside the customer’s own monitoring system. It is masked here only because this document is public.

Behavioural differences

Aspect Built-in SNMP jt-snmpd
Development status Deprecated by Microsoft, no longer in active development Maintained
SNMP version v1 / v2c / v3 v2c (v3 in development)
Writes SET supported Read-only
Traps Supported Not supported (out of scope for v1.0)
Source access control PermittedManagers, applied after parsing Pre-parse gate, ahead of the BER decoder
Rate limiting None Per-source token bucket
Response size control None Capped at 1400 bytes, never fragmented
Interface filtering None; everything is published Physical adapters only
ifIndex stability Windows’ native index, not guaranteed stable across reboots Kept against the NET_LUID
sysUpTime Time since the SNMP service started Time since the machine booted
Restarting the agent Reads to LibreNMS as a reboot (see below) No effect on reported uptime
Self-health monitoring None A private OID subtree
Deployment A Windows capability (DISM / Add-WindowsCapability) MSI (GPO / Intune / SCCM)

Deprecated is not the same as unsupported. By Microsoft’s own definition, deprecation means a feature is no longer in active development and might be removed in a future release; a deprecated component still ships, is supported for production deployments, and continues to receive security and quality updates for the product lifecycle. Replacing it is therefore planning, not an emergency. The reason to replace it is how little it reports, which is the rest of this document, not a support cliff.

The ifIndex row deserves particular attention. Replacing a driver, removing and reinserting an adapter, or rebuilding a vSwitch can all make Windows renumber, and LibreNMS matches ports by ifIndex. When the number changes, the old port is marked deleted and a new one is created, and the historical RRDs are left with nothing pointing at them. jt-snmpd keys the assignment on the NET_LUID and keeps it: an interface gets an ifIndex the first time it is seen and never changes afterwards.

Restarting the built-in service reads as a reboot

Measured on one machine, minutes apart, under each agent in turn:

OID Built-in SNMP Service jt-snmpd
sysUpTime.0 19 seconds 179 days
hrSystemUptime.0 179 days 179 days
snmpEngineTime.0 not served 179 days

The built-in service follows RFC 3418 literally: sysUpTime counts from the last re-initialisation of the network management portion, which is the SNMP service itself. jt-snmpd reports the host’s uptime, from GetTickCount64.

LibreNMS takes the largest of sysUpTime, snmpEngineTime and hrSystemUptime, but windows.yaml sets bad_hrSystemUptime: true and the built-in service serves no snmpEngineTime. So for the built-in service that maximum is 19 seconds, it is lower than the uptime recorded at the previous poll, and LibreNMS raises Device rebooted for a machine that has been up for half a year. Any restart of the SNMP service does it — a Windows Update, a service recovery, an uninstall.

jt-snmpd avoids this twice over: its sysUpTime does not restart when the service does, and snmpEngineTime gives LibreNMS a second stable source. That matters beyond false alerts, because sysUpTime is a TimeTicks counter and wraps at 497 days by definition of the type; snmpEngineTime is in seconds and does not.

Migration behaviour

At install time these are carried over from the built-in service’s registry:

Source Target Handling
ValidCommunities right 4 (read-only) community Imported as-is
ValidCommunities rights 8 / 16 (writable) community Downgraded to read-only with a warning
ValidCommunities rights 1 / 2 Not imported (meaningless for a read-only agent)
PermittedManagers Source ACL and firewall scope Host names are resolved to addresses; failures are listed as warnings
PermittedManagers empty Installation stops. Never migrated as Any/Any
RFC1156Agent\sysContact sysContact Carried over as-is
RFC1156Agent\sysLocation sysLocation Carried over as-is
RFC1156Agent\sysServices Not imported (fixed at 76); a differing value is noted in the report
TrapConfiguration Not imported. Listed in full, with a warning that traps will stop
ExtensionAgents Not imported. Names are listed, with a warning that their OIDs will no longer be available

When the built-in service is disabled, its original start type and state are recorded and restored on uninstall.

Reproducing this comparison

# On the LibreNMS host
for oid in 1.3.6.1.2.1.25.4 1.3.6.1.2.1.25.6 1.3.6.1.2.1.47.1.1.1.1 \
           1.3.6.1.2.1.4.21 1.3.6.1.2.1.25.3.8; do
  echo -n "$oid  "
  echo -n "built-in=$(snmpbulkwalk -v2c -c COMMUNITY -On -Cr20 <builtin-host> $oid 2>/dev/null | wc -l)  "
  echo    "jt-snmpd=$(snmpbulkwalk -v2c -c COMMUNITY -On -Cr20 <jt-host> $oid 2>/dev/null | wc -l)"
done

Row counts on the LibreNMS side:

SELECT 'ports', COUNT(*) FROM ports WHERE device_id=? AND deleted=0
UNION ALL SELECT 'entPhysical', COUNT(*) FROM entPhysical WHERE device_id=?
UNION ALL SELECT 'sensors', COUNT(*) FROM sensors WHERE device_id=?
UNION ALL SELECT 'mempools', COUNT(*) FROM mempools WHERE device_id=?
UNION ALL SELECT 'ucd_diskio', COUNT(*) FROM ucd_diskio WHERE device_id=?;