Skip to content

Repository files navigation

qsnmp — asynchronous SNMP for Qt 6

CI License: MIT C++23 Qt6

Status: v0.2.1. The Manager surface — the QSnmpSession verbs, Value, Oid and QSnmpError — 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();
}

The Manager: QSnmpSession

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.

SNMPv3

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.

Receiving traps and informs: QSnmpTrapListener

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.

Error philosophy: std::expected end to end

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
  • AgentErrorerror-status != noError, with the 1-based errorIndex() 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).

Canonical JSON projection

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).

Thread affinity

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.

The Agent: QSnmpAgent

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()));

The MIB registry

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.

Notifications

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);          // confirmed

One 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.

Conformance corners worth knowing

  • Unknown communities are dropped silently; setAuthenticationFailureHandler is 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 in docs/design-agent.md.

Examples

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 support

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)

Design documents

  • docs/design-transport.md — datagram pump, request correlation, retry/cancellation
  • docs/design-session.md — Manager session, error mapping, trap listener
  • docs/design-codec.md — value types and wire format on top of asn1
  • docs/design-agent.md — agent engine
  • docs/design-v3-usm.md — SNMPv3/USM
  • docs/compliance.md — RFC requirement matrix with test cross-references

Using the library

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 (recommended)

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 + qtcoroutine
add_subdirectory(external/qsnmp)
target_link_libraries(myapp PRIVATE qsnmp::qsnmp)

FetchContent

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.

Requirements

  • 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)

Building

git clone --recurse-submodules <this-repo>
cmake -S . -B build -G Ninja -DCMAKE_CXX_COMPILER=g++-14
cmake --build build
ctest --test-dir build

The 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

License

MIT

About

Ergonomic, RFC-compliant header-only asynchronous SNMP library for C++23 and Qt 6, providing both an SNMP Manager (v1, v2c, v3/USM) and an SNMP Agent (v1, v2c).

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages