Status: v0.2.1. The Manager surface — the
QSnmpSessionverbs,Value,OidandQSnmpError— is stable as of the first tagged release; while the project is pre-1.0, breaking changes to it bump the minor version. The Agent (v1/v2c/v3) works and is tested (unit + pysnmp interop) but its API may still change shape.
An ergonomic, RFC-compliant asynchronous SNMP library for C++23 and Qt 6,
providing both an SNMP Manager (v1, v2c, v3/USM) and an SNMP Agent
(v1, v2c, v3/USM). Header-only, built on asn1
(BER codec) and QtCoroutine
(C++23 coroutines for Qt), with QUdpSocket as the transport.
"RFC-compliant" is a checkable claim here, not a slogan:
docs/compliance.md tracks every normative
requirement row-by-row with test cross-references — including the
ratified interpretations and the two documented, deliberate deviations
(DEV-1, DEV-2). Conformance evidence includes byte-identical re-encoding
of every message shape by an independent stack (pysnmp/pyasn1) and a
live interop matrix against pysnmp in both directions.
#include <qsnmp/qsnmp.hpp>
QtCoroutine::QTask<void> printSysDescr(std::stop_token st) {
qsnmp::QSnmpSession session({
.target = {QHostAddress("192.0.2.1")}, // port defaults to 161
.version = qsnmp::Version::V2c,
.community = "public",
});
auto result = co_await session.get({*qsnmp::Oid::fromString("1.3.6.1.2.1.1.1.0")}, st);
if (!result) {
qWarning() << result.error().text(); // typed: Transport/Decode/AgentError/Mismatch
co_return;
}
for (const qsnmp::VarBind & vb : *result)
qInfo() << vb.oid.toString() << "=" << vb.value.toString();
}One session per target agent. Coroutine verbs, each returning
QTask<std::expected<QList<VarBind>, QSnmpError>>:
| Verb | Notes |
|---|---|
get(oids) |
RFC 3416 §4.2.1 |
getNext(oids) |
§4.2.2 |
getBulk(oids, nonRepeaters, maxRepetitions) |
§4.2.3; v2c only — rejected up front on v1 sessions, no silent downgrade |
set(varbinds) |
§4.2.5 |
getAll(oids, options) |
multi-OID get for lists of any size: splits into GETs of at most options.maxPerRequest varbinds, runs the chunks concurrently, merges results in request order; recovers from tooBig (and v3 over-msgMaxSize requests) by halving the offending chunk |
walk(root, options) |
subtree retrieval: getNext on v1, getBulk on v2c (options.useGetBulk = false to force getNext); terminates on endOfMibView / subtree exit; aborts looping agents (non-increasing OIDs) |
walkTable(entryOid, options) |
conceptual-table retrieval (RFC 2578 §7.7): groups the entry subtree's varbinds into rows — index suffix (pre-rendered dotted-decimal) → column arc → Value — in MIB index order; options.columns restricts to the declared column subtrees, walked concurrently |
sendInform(trapOid, varbinds) |
acknowledged notification (§4.2.6/7); sysUpTime.0 and snmpTrapOID.0 prepended automatically; the transport's retransmission machinery doubles as the delivery guarantee |
Every request gets per-attempt timeout × retransmission (default 1 s × 3, configurable per session), request-id correlation with any number of requests in flight, source-endpoint verification, and a community authenticity gate: a response whose community does not match is discarded (RFC 1157 §4.1 step 3) without consuming the pending request — unauthenticated noise cannot abort live requests.
Cancellation is std::stop_token end to end:
std::stop_source stop;
auto task = session.walk(*qsnmp::Oid::fromString("1.3.6.1.2.1.2"), {}, stop.get_token());
// ... stop.request_stop() from anywhere; the task settles promptly.Setting v3User switches the session to SNMPv3/USM — same verbs, same
walk, same informs. Engine discovery, timeliness synchronization, signing,
verification and (with QSNMP_WITH_PRIVACY) encryption are automatic, as
is the once-per-incident recovery for usmStatsNotInTimeWindows (resync)
and usmStatsUnknownEngineIDs (rediscovery); other USM Reports surface as
typed Kind::Security errors carrying the usm::UsmStat.
qsnmp::QSnmpSession session({
.target = {QHostAddress("192.0.2.1")},
.v3User = qsnmp::QSnmpV3User{
.userName = "operator",
.authProtocol = qsnmp::usm::AuthProtocol::Sha1,
.authPassword = "authpass123",
.privProtocol = qsnmp::usm::PrivProtocol::Aes128,
.privPassword = "privpass123",
.securityLevel = qsnmp::SecurityLevel::AuthPriv,
},
});
// co_await session.get(...) — discovery and key localization happen on
// first use; responses that fail MAC verification are discarded without
// consuming the request, like everything else unauthentic.qsnmp::QSnmpTrapListener traps;
traps.listener().setBufferLimit(256); // flood protection, drop-oldest
if (auto ok = traps.start(QHostAddress::AnyIPv4, 162); !ok)
qFatal("bind: %s", qPrintable(ok.error()));
for (;;) {
auto n = co_await traps.next(st);
if (!n) break; // cancelled
qInfo() << (n->isInform ? "INFORM" : "TRAP") << "from" << n->source.toString()
<< n->trapOid.toString() << n->varbinds.size() << "varbinds";
}Notifications are normalized across versions: v1 Trap-PDUs keep their
original fields in .v1 and get a trapOid synthesized per RFC 3584 §3.1;
v2c traps and informs carry theirs from snmpTrapOID.0. Informs are
acknowledged automatically (Response with the same request-id and
varbinds, RFC 3416 §4.2.7) before next() returns them — verified against
pysnmp's notification originator.
Failures are values, never exceptions. Every operation resolves to
std::expected<T, QSnmpError> with a typed kind():
- Transport — timeout (with attempt count), cancellation, socket errors
- Decode — the request would not encode, or the response is not valid BER
- AgentError —
error-status != noError, with the 1-basederrorIndex()and the failing OID resolved against the request - Mismatch — well-formed but not a valid response (wrong PDU type, version, request-id echo)
v2c per-varbind exceptions (noSuchObject, noSuchInstance,
endOfMibView) are not errors — they stay in the returned varbinds and
are queried via Value::isException(). The same std::expected discipline
runs through the codec (asn1::error with byte offsets) and the transport.
Non-coroutine code consumes tasks via .then() / .onError() /
.toFuture(), or blocks with QtCoroutine::waitFor() (CLI tools, tests).
Value::toJson() maps every ObjectSyntax alternative onto one documented
JSON shape, so exporters, dashboards and config-driven decoders agree on a
single table instead of each reinventing it: the integral types project as
numbers (TimeTicks stays raw centiseconds — presentation owns duration
formatting), Counter64 is qint64-exact with a decimal-string fallback above
2^63−1 (half-wrapped counters, a documented edge), OctetString becomes a
UTF-8 string when valid and colon-hex ("aa:bb:0c") otherwise, Opaque is
always colon-hex, and Oid / IpAddress render as dotted strings. Null and
the v2c exceptions return std::nullopt — a missing datum, not JSON
null.
For the SNMPv2-TC textual conventions that need real decoding, qsnmp::tc
(qsnmp/textualconventions.hpp) provides pure helpers over Value:
tc::truthValue (RFC 2579 INTEGER 1/2 → bool; anything else is a missing
datum, not false), tc::macAddress (6-octet OCTET STRING →
"aa:bb:cc:dd:ee:ff"), tc::dateAndTime (8/11-octet DateAndTime →
range-validated QDateTime, UTC offset preserved — render ISO 8601 with
toString(Qt::ISODate)), and tc::colonHex (force the hex rendering even
for valid UTF-8).
qsnmp follows QtCoroutine's threading contract: a session, transport,
listener — and every coroutine it returns — belongs to the thread that
created it, which must run an event loop. There is no internal locking;
run one session per thread (sessions are cheap — one UDP socket each).
Inputs from other threads are safe where documented: request_stop() on a
std::stop_source may be called from anywhere and the resume is marshalled
back to the owning thread. A session must outlive the tasks it returned;
to shut down, request stop, let the tasks settle, then destroy.
A command responder + notification originator serving SNMPv1, SNMPv2c and SNMPv3/USM with full RFC 3416 §4.2 / RFC 1157 §4.1 semantics, including the RFC 3584 coexistence rules (Counter64 invisibility to v1, exception and error-status translation). One agent per port; requests are served strictly in arrival order, with bursts absorbed by a bounded queue.
Configuring v3Users makes the agent the RFC 3414 AUTHORITATIVE engine:
engine-identity discovery, time synchronization, per-user MD5/SHA-1 auth and
AES/DES privacy (QSNMP_WITH_PRIVACY), usmStats Reports and the full §3.2
receive ladder — pysnmp-verified at every security level. Engine identity is
ephemeral by default; persist it (and the RFC 3414 §2.2 boots counter) with
the loadEngineState/saveEngineState callbacks or
QSnmpEngineIdentity::startWithFile. A configured user's security level
cannot be downgraded: requests below the level implied by its keys are
refused with usmStatsUnsupportedSecLevels (relax per user via
minimumSecurityLevel).
qsnmp::QSnmpAgent agent({
.port = 161, // 0 = ephemeral (tests)
.readCommunities = {"public"},
.writeCommunities = {"private"}, // write implies read
});
agent.enableSystemGroup({.descr = "my device", .name = "device-7"});
agent.mib().insert(*qsnmp::Oid::fromString("1.3.6.1.4.1.4242.1.1.0"),
qsnmp::Value::octetString("hello"));
if (auto started = agent.start(); !started)
qFatal("%s", qPrintable(started.error()));agent.mib() maps instance OIDs (e.g. sysDescr.0) to value sources;
the ordered map IS the MIB order, so exact GET and GETNEXT/GETBULK
traversal need no extra wiring. Conceptual tables are registered one
instance per cell.
| source | registration | read cost |
|---|---|---|
static Value |
insert(oid, value) |
copy |
| writable static | insertWritable(oid, initial) |
copy; SET replaces (type-checked) |
| callback | insert(oid, SyncReader) |
one call per request |
| coroutine | insert(oid, AsyncReader) |
awaited; the serve loop suspends, the thread never blocks |
Writes follow the RFC 3416 §4.2.5 two-phase contract. A WriteHandler
carries the optional pieces: expectedType (pre-checked → wrongType),
test() returning the precise verdict (wrongValue, wrongLength,
inconsistentValue, ...), commit(), and undo() for rollback when a
later binding's commit fails — the engine guarantees all-or-nothing Sets
with correct commitFailed/undoFailed reporting.
The registry may be mutated at any time on the agent's thread — even from a suspended coroutine reader mid-walk; traversal re-seeks by OID cursor and never holds iterators across a suspension.
qsnmp::QSnmpTrapTarget nms{{QHostAddress("192.0.2.9"), 162}, "public", qsnmp::Version::V2c};
agent.sendTrap(nms, *qsnmp::Oid::fromString("1.3.6.1.6.3.1.1.5.4")); // linkUp
auto acked = co_await agent.sendInform(nms, trapOid, objects); // confirmedOne call serves both versions: v2c targets get an SNMPv2-Trap (sysUpTime.0
and snmpTrapOID.0 prepended), v1 targets get the RFC 3584 §3.2-translated
Trap-PDU (standard-trap mapping, enterprise derivation, agent-addr) —
sendTrapV1() exists for callers who think in RFC 1157 terms. Informs are
retransmitted until acknowledged, with transport vs remote-error vs
malformed-ack outcomes distinguished in the result. Notifications leave
from a second, ephemeral socket, so inform acks never mix with requests.
- Unknown communities are dropped silently;
setAuthenticationFailureHandleris the hook for emitting authenticationFailure traps (suppressible, per RFC 1157 §4.1.6.5). - tooBig responses carry empty bindings in both versions; GetBulk responses
are instead trimmed to
maxMessageSize— never tooBig (RFC 3416 §4.2.3). - Ill-formed datagrams (bad BER, GetBulk-in-v1, Counter64 values in v1,
SMI-violating OIDs) are dropped without response and counted in
agent.stats().silentDrops. - The ruled interpretations and documented deviations live in
docs/compliance.md(RAT-1..4; DEV-1 v1-tooBig empty bindings, DEV-2 liberal BER receive policy; plus the codec decode/validate policy in §0c); design rationale indocs/design-agent.md.
Configure with -DQSNMP_BUILD_EXAMPLES=ON:
| Program | Shows |
|---|---|
qsnmpget |
session config, get, typed-error exit codes, waitFor bridge, v3 credentials (-u/--auth/-A/--priv/-X) |
qsnmpwalk |
walk with version-adaptive iteration, --no-bulk, result caps |
qsnmptrapd |
trap/inform receive loop, normalized notifications, automatic inform acks |
qsnmpagentd |
agent daemon: system group, every MIB value source, two-phase writes, startup trap, stats |
./examples/qsnmpget -c public 192.0.2.1 1.3.6.1.2.1.1.1.0
./examples/qsnmpget -v 3 -u operator --auth sha -A authpass123 --priv aes -X privpass123 \
192.0.2.1 1.3.6.1.2.1.1.1.0
./examples/qsnmpwalk -v 1 192.0.2.1 1.3.6.1.2.1.1
./examples/qsnmptrapd -p 10162 # unprivileged port for experiments
./examples/qsnmpagentd -p 10161 -w private --trap-target 127.0.0.1:10162| Version | Status |
|---|---|
| SNMPv1 (RFC 1157) | supported (Manager + Agent + traps) |
| SNMPv2c (RFC 1901/3416) | supported (Manager + Agent + traps/informs) |
| SNMPv3/USM (RFC 3411–3414, 3826, 7860) | supported (Manager + Agent: discovery, time sync, MD5/SHA-1/SHA-2 auth, AES/DES privacy behind QSNMP_WITH_PRIVACY; pysnmp-verified at all three security levels in both directions) |
docs/design-transport.md— datagram pump, request correlation, retry/cancellationdocs/design-session.md— Manager session, error mapping, trap listenerdocs/design-codec.md— value types and wire format on top of asn1docs/design-agent.md— agent enginedocs/design-v3-usm.md— SNMPv3/USMdocs/compliance.md— RFC requirement matrix with test cross-references
Header-only (the asn1 and
QtCoroutine dependencies are too);
every integration style reduces to getting the three include/ directories
onto the path and linking Qt.
git submodule add https://github.com/Goeries/qsnmp.git external/qsnmp
git -C external/qsnmp checkout v0.2.1 # pin a release
git -C external/qsnmp submodule update --init # pulls asn1 + qtcoroutineadd_subdirectory(external/qsnmp)
target_link_libraries(myapp PRIVATE qsnmp::qsnmp)include(FetchContent)
FetchContent_Declare(qsnmp
GIT_REPOSITORY https://github.com/Goeries/qsnmp.git
GIT_TAG v0.2.1
GIT_SUBMODULES_RECURSE ON
)
FetchContent_MakeAvailable(qsnmp)
target_link_libraries(myapp PRIVATE qsnmp::qsnmp)Both set up include paths, C++23 and the Qt6 Core/Network dependencies
transitively. SNMPv3 privacy (AES/DES) additionally needs OpenSSL —
controlled by QSNMP_WITH_PRIVACY (default: auto-detect; without it the
library still builds and serves noAuthNoPriv/authNoPriv).
If your project already vendors asn1 or
QtCoroutine, make their targets
available before add_subdirectory(external/qsnmp): qsnmp only adds its
own copies when asn1::asn1 / qtcoroutine::qtcoroutine do not exist yet,
so the parent project's pins win and nothing is built twice.
- C++23 — GCC >= 14.2 (Clang 18 with libstdc++ lacks
std::expected) - Qt 6.4+ (Core, Network; Test for the test suite)
- CMake 3.22+
- OpenSSL 3.x (optional, for SNMPv3 privacy)
git clone --recurse-submodules <this-repo>
cmake -S . -B build -G Ninja -DCMAKE_CXX_COMPILER=g++-14
cmake --build build
ctest --test-dir buildThe pysnmp interop suite skips (visibly) unless a python with pysnmp/pyasn1 is available; to run it locally:
python3 -m venv tests/interop/.venv
tests/interop/.venv/bin/pip install -r tests/interop/requirements.txt
ctest --test-dir build -L interop