Skip to content

Collect from an SNMP-only device

  • “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.

The collector does three sequential GETBULK walks per poll cycle:

OIDPurpose
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:

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

The collector takes a CollectorConfig with these fields:

from l2trace.collectors.base import CollectorConfig
from l2trace.collectors.snmp import SnmpCollector
from 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 loop

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

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 asyncio
from 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.

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_modeBehavior
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-bridgeForce the standard path only. Never do the per-VLAN walk.
cisco-vlanForce 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.

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_emitBehavior
events (default)One MAC_LEARNED per entry. Additive — departed MACs age out via the compactor.
snapshotOne 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.

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

  • 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