NSClient++ Release Notes
This release is dominated by a long-overdue cleanup of the Windows check_service / check_process paths, a
substantial overhaul of how thresholds (warn= / crit= / ok=) are evaluated against summary variables such as
count, a brand-new * IcingaClient* module for submitting passive results to Icinga 2's REST API, an HTTP stack
refactor, and finally support for Windows CA-store.
A large number of long-standing GitHub issues are closed by this release.
TL;DR
- New module: IcingaClient (Icinga 2 REST API passive submission).
- Windows ROOT store auto-export — service exports the system trust store to ${ca-path} at every boot, so HTTPS-bound
checks (check_http, Elastic, Op5, Icinga, Graphite, NRDP, Syslog) "just work" without manually staging a CA bundle. check_serviceis finally correct on busy machines, on mixed start-type services, and whenperf-syntax=noneis
used.check_processnow sees protected / cross-user processes when running asNETWORK SERVICE, and the realtime path is
finally case-insensitive like Windows itself.- The filter engine now evaluates
warn=/crit=after iteration, so summary variables such ascountare stable,
and mixed expressions are evaluated correctly even when no rows match. - HTTP client/server code: separate request/response types, chunked transfer decoding, normalised headers.
See Breaking changes at the bottom for behavioural changes you may need to react to in existing configurations.
New features
IcingaClient — Icinga 2 REST API submission
A new client module that submits passive check results directly to an Icinga 2 master/satellite via the
/v1/actions/process-check-result REST endpoint, as an alternative to NSCA or NRDP.
Real-world example — submit a check from a scheduled task / NSCP console:
[/modules]
IcingaClient = enabled
[/settings/IcingaClient/targets/default]
address = https://icinga2.example.com:5665
username = nscp
password = secret
hostname = ${hostname}# submit a passive result
nscp client --module IcingaClient \
--command submit_icinga \
--address https://icinga2.example.com:5665 \
--username nscp --password secret \
--command heartbeat \
--result 0 \
--message "Hello from NSClient++" \
--ensure-objectsNative support for Windows CA-store
On startup NSClient++ will export the machine's ROOT certificate store as a single PEM bundle, so any check that does
TLS (check_http, IcingaClient, NSCA over TLS, ...) can automatically validate certificates.
Real-world example — verify an internal HTTPS endpoint signed by your enterprise CA that is already trusted by Windows:
heck_http url=https://www.ibm.com
OK: https://www.ibm.com -> 303 ok (0B in 33ms)
check_http url=https://self-signed.badssl.com/
CRITICAL: https://self-signed.badssl.com/ -> 0 error: Failed to connect to self-signed.badssl.com:443: certificate verify failed (SSL routines) (0B in 0ms)check_service fixes
"Failed to enumerate service: 6f7" on busy hosts
Enumerating service might fail on server with many services.
The enumeration is now properly looped until the SCM signals end-of-data.
perf-syntax=none actually suppresses perfdata
check_service was emitting a stream of empty perfdata aliases (''=4;0;1 ''=4;0;1 ...) even when the user set
perf-syntax=none, making the output unusable over size-limited transports such as NRPE.
Real-world example — quietly checking 200 services over NRPE:
# before: blew past the 1 KB / 4 KB NRPE response limit
# after: no perfdata at all, message stays small
check_service "filter=start_type='auto'" "warn=state!='running'" "perf-syntax=none"no more TODO leaking into ${desc}
When using service=<name> instead of a filter=, the display name was constructed with the literal string "TODO"
and overwritten later. In some instances this was read before being populated causing TODO to end up in check results.
Real-world example:
# before
$ check_service service=Spooler "syntax-detail=${name}: ${desc}"
OK: Spooler: TODO
# after
OK: Spooler: Print Spoolerdelayed only reported for SERVICE_AUTO_START
QueryServiceConfig2(SERVICE_CONFIG_DELAYED_AUTO_START_INFO) only returns a meaningful value for auto-start services.
The old code checked the delayed flag before the start type, so manual / boot / system / disabled services could
randomly show up as delayed / delayed-trigger.
Real-world example:
# A service configured "Manual" used to render as start_type=delayed.
# Now:
$ check_service service=MyManualSvc "syntax-detail=${start_type}"
OK: manualcheck_process fixes
see protected / cross-user processes as NETWORK SERVICE
When NSClient++ runs under a non-administrative account it cannot OpenProcess(PROCESS_QUERY_INFORMATION) on critical
processes (csrss.exe, smss.exe, services.exe, winlogon.exe, ...) or on processes owned by other users — they
were silently dropped from the enumeration, causing false CRITICAL: <name>=stopped.
A third fallback using PROCESS_QUERY_LIMITED_INFORMATION + QueryFullProcessImageName is now attempted. The process
is visible by name and PID; detailed metrics (handle counts, VM, command line, modules) remain unavailable for those
processes because they require broader rights.
Real-world example — service is installed to run as NT AUTHORITY\NetworkService:
# before: CRITICAL: winlogon.exe=stopped
# after: OK: winlogon.exe=started
check_process "process=winlogon.exe" "crit=state!='started'"case-insensitive process= in realtime
Processnames were not caompared case insensetive so process=notepad.exe failed to match a process whose on-disk image
name was NOTEPAD.EXE.
Real-world example:
[/settings/system/windows/real-time/checks/notepad]
alias = notepad
filter = process = 'notepad.exe'
crit = count > 0This now fires regardless of how Windows happens to capitalise the image name.
Filter engine — stable summary thresholds
These changes touch the shared filter / threshold engine and therefore affect every modular check (check_files,
check_service, check_process, check_eventlog, ...).
stable count / total / *_count in warn= / crit=
warn= / crit= were evaluated during iteration. Summary variables such as count therefore exposed their running
value instead of the final post-iteration value, so a mixed expression like
crit = state = 'hung' OR count < 5
mis-fired on the very first row (count == 1 < 5) regardless of how many rows ultimately matched.
Per-row evaluation is now deferred: matched rows are recorded during iteration, and the warn/crit/ok engines run in
match_post() once the summary state is final. Realtime checks now also call match_post() so the deferred verdict is
materialised before the realtime helper inspects the return code.
Real-world impact:
# "alert if any process is hung, OR if fewer than 5 are alive"
check_process "filter=name='myworker.exe'" "crit=state='hung' OR count<5"
# Pre-fix: always CRITICAL on the first row.
# Post-fix: CRITICAL only when the final count of matching rows is < 5
# (or any matched row is hung).mixed warn= / crit= evaluated when no rows match
If a filter excluded every row, no per-row evaluation happened and the post-row pass only re-evaluated expressions whose
AST did not require an object. Pure-summary expressions like crit=count=0 worked, but mixed expressions like
crit = state = 'stopped' OR count = 0
were skipped entirely — leaving the check OK in the empty case.
A force-evaluation path is added: when no rows matched, object-bound variables resolve to their default (false) and
summary variables resolve to their final values, so the example above evaluates to (false OR true) = true and
correctly returns CRITICAL.
Real-world example:
# "CRITICAL if MyService is stopped, or if it doesn't exist at all"
check_service "filter=name='MyService'" "crit=state='stopped' OR count=0"
# Pre-fix: OK when MyService is missing.
# Post-fix: CRITICAL when MyService is missing.Quieter, more predictable expression evaluation
- Operators audited so
is_unsurepropagates consistently; invalid-type comparisons resolve tounsure-falseinstead
of erroring. - String variables on no-object cases now return an empty string with
is_unsure=trueand produce a warning in the
log instead of an error per row — log volume on complex queries drops dramatically. - Removed the misleading "most likely mutating" warnings.
- Summary variables return
sure-intduring deferred evaluation so they don't get demoted to "unsure" by the new code
path. - Substantial new test coverage for these paths.
HTTP refactor
- HTTP request and response are now distinct types instead of one shared bag.
- Chunked transfer-encoding is decoded properly (Icinga 2 responses use it).
- Header storage is normalised — case-insensitive lookup, no more duplicate-header surprises.
Real-world impact: check_http against servers using Transfer-Encoding: chunked (most modern reverse proxies, Icinga
2, Kubernetes ingress, ...) now returns the full body instead of a truncated/garbled one. The IcingaClient module relies
on this.
plugin_manager response formatting
Performance data is now appended to the response message only when it exists, so checks with no perfdata no longer end
with a stray |. The CLI parser also gained tighter option handling and clearer logging.
Build / quality
a7194df5,f7614b58,82d8e7a6: new GitHub Actions workflow that builds with-fsanitize=address,undefinedand
runs the test suite — sanitizers are now opt-in via the CMake config.12beda0c: documentation cleanup, link fixes; passive-monitoring scenario doc renamed to
passive-monitoring-nsca.md.
Breaking changes
Read this section if you have existing configurations or scripts on
top of NSClient++ — several long-standing-but-incorrect behaviours
have been corrected, which by definition is observable.
1. delayed is no longer reported for non-auto services
If you have any filter / threshold that matched start_type = 'delayed' on services that were actually configured as
Manual, Boot, System or Disabled, that match is gone — the field will now correctly report the real start type.
Impact example:
# Was: spuriously matched manual services that the SCM happened to
# flag as "delayed".
# Now: matches only true auto-start-with-delayed-start services.
filter = start_type = 'delayed'If you actually wanted to alert on "any non-running service that isn't disabled", you should now write:
filter = start_type IN ('auto','delayed','boot','system') AND state != 'running'2. warn= / crit= no longer fire mid-iteration on running counts
If a check incidentally relied on a mixed expression firing on the first matching row (e.g. crit=count<5 mixed with a
per-row term), the verdict will now be computed against the final counts. This is the documented and intuitive
behaviour, but configurations that were "tuned" against the buggy early-fire will produce different results.
Impact example:
crit = state = 'hung' OR count < 5
# Old: CRITICAL on the very first row (count == 1).
# New: CRITICAL only if any row is 'hung' OR final count < 5.
3. Mixed warn= / crit= now evaluate when no rows match
Mixed expressions used to be silently skipped on empty result sets, returning OK. They are now evaluated with
object-bound variables defaulting to false and summary variables at their final values.
Impact example:
crit = state = 'stopped' OR count = 0
# Old: OK when nothing matched.
# New: CRITICAL when nothing matched (because count = 0 is true).
If your old config was implicitly treating "empty" as "OK", you may want to add count > 0 AND ... guards, or move the
empty-case logic into a dedicated check.
4. Realtime check_process is now case-insensitive
The realtime path matched process= case-sensitively; the active path was already case-insensitive. They are now
consistent.
Impact: a realtime rule that intentionally matched only the exact casing (e.g. process='Notepad.exe' to ignore
notepad.exe) will now match both. This was almost certainly a bug in the original config.
5. ${desc} no longer returns the literal string TODO
If any monitoring backend was matching on the string TODO in the description field of check_service results to
detect "this is the NSClient++ default", that will stop working. Use the real display name instead.
6. perf-syntax=none now actually suppresses perfdata in check_service
Previously, perf-syntax=none was silently ignored and a stream of empty-aliased perfdata entries was produced. Any
monitoring backend that consumed those empty entries (highly unlikely, but possible) will see them disappear when the
user requests none. Match the documented semantics, shared with filter / ok / warn / crit.
7. HTTP request/response API changed (C++ consumers / module authors)
Internal C++ types http::request / http::response are now distinct types, headers are stored case-insensitively, and
chunked decoding happens transparently. Out-of-tree modules that linked against the old shared "request/response bag"
type will not compile against this release without a small adjustment — typically:
// before
http::packet pkt = client.send(...);
auto body = pkt.body;
// after
http::response resp = client.send(http::request{...});
auto body = resp.body(); // chunked decoding already applied8. Documentation reorganisation
Several old documentation pages have been merged or converted with the new scenarios so some old links might now be
broken.
Full Changelog: 0.12.0...0.12.1