Collect from an SNMP-only device
When you need this
Section titled “When you need this”- “Our gNMI rollout hasn’t reached the 1U closet switches yet — but they still need CAM visibility.”
- “This is a vendor-X switch that doesn’t ship gNMI at all. SNMPv2c is the only telemetry it supports.”
- “We want a backstop in case gNMI streaming goes silent.”
SNMP is the universal backbone. Every managed switch built in the last
two decades speaks it. l2trace’s SNMP collector polls the Q-BRIDGE-MIB
(VLAN-aware FDB) and IF-MIB (port names) on a configurable cadence,
joins the two tables, and emits the same MacLearned envelopes the
gNMI collector emits.
How the walk works
Section titled “How the walk works”The collector does three sequential GETBULK walks per poll cycle:
| OID | Purpose |
|---|---|
1.3.6.1.2.1.17.7.1.2.2.1.2 (dot1qTpFdbPort) | VLAN-indexed MAC table |
1.3.6.1.2.1.17.1.4.1.2 (dot1dBasePortIfIndex) | Bridge port → ifIndex map |
1.3.6.1.2.1.31.1.1.1.1 (ifName) | ifIndex → port name (with ifDescr fallback for older devices) |
Alongside the FDB, LLDP/CDP and STP walks, the poll also does two optional enrichment walks — optional meaning a subtree that errors or times out is skipped without failing the poll, so a device that doesn’t answer them still yields its MAC table:
| OID | Purpose |
|---|---|
vmVlan + vlanTrunkPortTable (CISCO-VLAN-MEMBERSHIP / CISCO-VTP-MIB) | Per-port switchport config: access/trunk role, native VLAN, allowed-VLAN list |
1.3.6.1.4.1.9.9.98.1.1.1.1.8 (pagpGroupIfIndex, CISCO-PAGP-MIB) | EtherChannel membership: which physical ports aggregate into which port-channel (covers LACP too) |
The switchport role is what lets a trace start (ingress is an access port); the
LAG membership is what lets a trace cross a port-channel uplink (CAM points at the
logical Po, adjacency is on the members). See
the traceroute algorithm for how both are used.
dot1qTpFdbPort’s OID index is the load-bearing tricky bit:
<vlan>.<mac-as-6-dotted-octets>. The varBind name
1.3.6.1.2.1.17.7.1.2.2.1.2.10.0.27.131.176.55.18 decodes to VLAN
10, MAC 00:1b:83:b0:37:12.
Sequential walks (not parallel) because most switches throttle parallel SNMP queries. Total walk volume on a 48-port access switch is ~5 KB per subtree; the round-trip cost is dominated by per-walk latency, not by data volume.
Configuration
Section titled “Configuration”The collector takes a CollectorConfig with these fields:
from l2trace.collectors.base import CollectorConfigfrom l2trace.collectors.snmp import SnmpCollectorfrom l2trace.events.schema import Source
cfg = CollectorConfig( device_id=42, hostname="sw-access-7", mgmt_ip="10.0.0.7", source=Source.SNMP, auth={"community": "your-read-community"}, extras={"snmp_port": 161, "snmp_timeout_seconds": 5.0},)
collector = SnmpCollector(cfg, emit=publish_to_nats, poll_interval_seconds=60.0)await collector.run() # long-running looppoll_interval_seconds defaults to 60s. Tune by device count + churn:
- High-churn access tier (lots of laptop comings/goings): 30s
- Stable data center fabric: 120s
- Bandwidth-constrained WAN: 300s
The compactor’s SNMP_POLL_INTERVAL_SECONDS=120 default means rows
stay “live” for at least 2× the poll interval, so a missed poll
doesn’t immediately age a MAC out.
Trying it against the mock agent
Section titled “Trying it against the mock agent”The test suite ships an in-process SNMPv2c mock agent
(tests/fixtures/mock_snmp_agent.py) — a real pysnmp CommandResponder
serving a static MIB tree. Useful for development without a real switch:
import asynciofrom tests.fixtures.mock_snmp_agent import MockSnmpAgent, build_canonical_walk
async def main(): walk = build_canonical_walk( fdb=[(10, "00:1a:a1:11:22:33", 1, 3)], # (vlan, mac, port, status) bridge_to_ifindex={1: 1001}, ifname={1001: "Gi0/1"}, ) async with MockSnmpAgent(oid_values=walk) as agent: # The agent is listening on (agent.host, agent.port). # Point the SnmpCollector at it. ...
asyncio.run(main())The mock validates the full wire round trip — pysnmp GETBULK over UDP → varBind decode → parser → envelope — without needing a real device or external simulator.
The Cisco per-VLAN FDB path
Section titled “The Cisco per-VLAN FDB path”Classic Cisco IOS / IOS-XE Catalyst gear leaves the standard Q-BRIDGE
dot1qTpFdbTable empty. Its forwarding table lives in the older
per-VLAN BRIDGE-MIB dot1dTpFdbTable, and the only way to read a given
VLAN’s slice of it is to re-query with the community string suffixed
@<vlan-id> — that suffix puts the agent into that VLAN’s bridge context.
The VLAN list itself comes from CISCO-VTP-MIB vtpVlanState. So on a
Catalyst, one “poll” is really one walk per active VLAN.
Which FDB path runs is the extras["snmp_fdb_mode"] knob:
snmp_fdb_mode | Behavior |
|---|---|
auto (default) | Try Q-BRIDGE first; fall back to the Cisco per-VLAN path only when Q-BRIDGE comes back empty and the device looks Cisco-enterprise (or the vendor is unknown). |
q-bridge | Force the standard path only. Never do the per-VLAN walk. |
cisco-vlan | Force the per-VLAN path on. |
auto is right for almost everyone; set cisco-vlan explicitly only if a
device needs the per-VLAN path but auto-detection isn’t catching it, and
q-bridge to hard-disable it.
Prove-then-trust: not dropping healthy VLANs
Section titled “Prove-then-trust: not dropping healthy VLANs”The per-VLAN walk has a hazard the standard path doesn’t. Probing a VLAN
the switch has no local bridge context for makes the agent fire an
authenticationFailure trap — do that for every VTP-advertised VLAN on
every poll and you’ve built an ~8,000-trap-an-hour storm. The obvious guard
(probe once with no retries, blacklist any VLAN whose walk errors) has a
nastier bug: a merely-dropped UDP packet raises the same error as a
genuine reject, so one lost packet would permanently drop a healthy
VLAN’s MACs for the rest of the process.
So the collector treats a VLAN’s first answer as proof:
- An unproven VLAN (never answered) is probed once with no retries and blacklisted the instant it’s silent — one trap on the first poll, then never again.
- A proven VLAN (answered at least once) is walked with retries, so a single dropped packet is retried instead of mistaken for a torn-down VLAN. Continued silence contributes no entries but is treated as transient, not as a reject.
- A proven VLAN that goes genuinely silent for three consecutive polls (VTP-pruned, last local port removed) is demoted back to the blacklist, so a real teardown doesn’t leak retries-worth of traps forever. A single miss-then-answer resets the counter and never demotes.
This is what makes “this VLAN’s walk succeeded” trustworthy — which matters most under snapshot mode below, where a wrongly-absent VLAN doesn’t just lose a poll, it retracts every MAC in that VLAN.
Cutting ingest volume: FDB snapshot mode
Section titled “Cutting ingest volume: FDB snapshot mode”A classic Catalyst with a busy access tier can hold several hundred MACs
per VLAN. In the default events mode the collector emits one
MAC_LEARNED envelope per FDB entry, so a single poll of a stacked
switch can publish many hundreds of messages. On a large fleet that
per-message volume is the main driver of reconciler ingest lag.
Set extras["snmp_fdb_emit"] = "snapshot" to instead emit one
CAM_SNAPSHOT per VLAN. Each snapshot carries that VLAN’s complete
forwarding table, and the reconciler retracts any MAC that has departed
since the last poll as part of the same message — so you get accurate
disappearance tracking and roughly a hundred-fold drop in message
count for a dense VLAN.
extras={"snmp_port": 161, "snmp_fdb_emit": "snapshot"}snmp_fdb_emit | Behavior |
|---|---|
events (default) | One MAC_LEARNED per entry. Additive — departed MACs age out via the compactor. |
snapshot | One CAM_SNAPSHOT per VLAN. Absence-of-a-MAC is a retraction, so a departed endpoint is closed immediately. |
Snapshot mode applies only to the Cisco per-VLAN BRIDGE-MIB path (the
community@<vlan> walk that classic Catalyst gear needs). Devices that
answer the standard Q-BRIDGE table stay on per-MAC events regardless of
this flag.
A snapshot retracts on absence, so it only ships when the collector can prove the VLAN’s walk was complete. A VLAN whose walk failed, timed out, or resolved only part of its bridge-port map is never turned into a retracting snapshot — it falls back to additive events for that poll, so a partial read can never mass-retract live MACs. See collection cadence for how snapshot diffs interact with the compactor’s liveness window.
What SNMP can’t do (vs gNMI)
Section titled “What SNMP can’t do (vs gNMI)”| Capability | gNMI | SNMP |
|---|---|---|
| Streaming updates | ✅ subscribed paths push instantly | ❌ poll-based, latency = poll interval |
| Per-event device timestamp | ✅ nanosecond timestamp per notification | ❌ snapshot only — collector wall-clock |
| Forwarding-state diffs | ✅ explicit add/delete | ❌ snapshot diff; loses transients |
| Vendor support | ⚠️ Cisco IOS-XR, Arista, Juniper, Nokia | ✅ Universal |
The reconciler’s source-priority order (gnmi > snmp > netconf > ssh)
means SNMP observations are kept but get downgraded when gNMI is also
running for the same MAC. The
disagreement view surfaces cases where
SNMP and gNMI disagree about a port — usually that means gNMI saw a
move SNMP hasn’t polled yet.
See also
Section titled “See also”- The collector source:
src/l2trace/collectors/snmp.py - The mock agent:
tests/fixtures/mock_snmp_agent.py - Event envelope reference — what the four timestamps mean when SNMP can only provide collector wall-clock
- Why bitemporal? — SNMP polls can arrive AFTER faster gNMI updates; the bitemporal model handles this natively as belief revision