Per-vendor SNMP plugin dispatch
Switches don’t speak one SNMP dialect. Cisco’s IOS-XR ships a subtly broken Q-BRIDGE-MIB; Arista repurposes part of BRIDGE-MIB’s chassis encoding; per-VLAN spanning tree lives in vendor-private subtrees that look nothing alike across the field. The base walk — Q-BRIDGE-MIB FDB, BRIDGE-MIB STP, LLDP-MIB — works on every vendor we’ve tested, but the interesting extras are vendor-specific. F1 is the dispatch layer that lets the collector grow vendor-aware behavior without forking a collector per vendor.
The trick is that every SNMP agent already tells you what it is. The
scalar sysObjectID at 1.3.6.1.2.1.1.2.0 is an OID of the form
1.3.6.1.4.1.<PEN>.<vendor-private-suffix>, where <PEN> is the
Private Enterprise Number IANA assigned to the manufacturer. Read
that one OID and you know who you’re talking to before you parse a
single FDB row. This is the mechanism Breitbart 2004 §VI.B (“Device
Vendor Plug-in”) describes for exactly this purpose — vendor-aware
collection keyed on the agent’s self-reported identity.
The seven PENs we recognize
Section titled “The seven PENs we recognize”The vendor map is the dominant L2-switch manufacturers, drawn from the IANA PEN registry. Anything else falls through to a generic plugin.
| Plugin | PEN | Enterprise prefix |
|---|---|---|
cisco | 9 | 1.3.6.1.4.1.9 |
hp | 11 | 1.3.6.1.4.1.11 |
extreme | 1916 | 1.3.6.1.4.1.1916 |
juniper | 2636 | 1.3.6.1.4.1.2636 |
nokia | 6527 | 1.3.6.1.4.1.6527 |
mikrotik | 14988 | 1.3.6.1.4.1.14988 |
arista | 30065 | 1.3.6.1.4.1.30065 |
generic | — | (catch-all) |
The invariant that makes this safe
Section titled “The invariant that makes this safe”Here’s the design decision that lets the whole feature ship without a
per-vendor correctness audit: plugins are purely additive in v1. A
plugin may extend the base MIB walk with vendor-specific OIDs through
extra_walk_oids(), but it can never remove, replace, or filter the
base walk. The FDB, STP, and LLDP subtrees that drive every downstream
consumer are walked identically regardless of which plugin matched.
The consequence is that a misclassification cannot degrade correctness. Suppose an unusual agent reports a sysObjectID that accidentally matches the wrong plugin. The worst case is that the collector walks a handful of extra OIDs the agent doesn’t actually expose — and those simply return nothing. No base data is lost, no path query changes its answer. A missing or wrong plugin is a no-op, not a regression.
That’s why the plugin starts as GENERIC and only upgrades:
self._plugin: SnmpPlugin = GENERIC # base walk only, until we learn the vendorThe very first poll runs the base walk (sysObjectID is at the front of the walk OID list precisely so selection can happen on poll one), learns the vendor, and subsequent polls pick up any extras. There is never a window where vendor uncertainty costs you data.
Segment-aware prefix matching
Section titled “Segment-aware prefix matching”The subtle part of prefix matching is that OIDs are dotted-decimal
segment hierarchies, not strings — and a naive startswith betrays
that. Arista’s PEN is 30065. Some other vendor’s PEN might be 3, or 30,
or 300. A bare string match of "1.3.6.1.4.1.3" against
"1.3.6.1.4.1.30065.1.1" would return true (30065 starts with 3)
and silently hand an Arista box to the wrong plugin.
The fix is to require that the prefix end on a segment boundary — match the prefix exactly, or match it followed by a dot:
def matches(self, sys_object_id: str) -> bool: if not sys_object_id: return False normalized = sys_object_id.lstrip(".") return normalized == self.enterprise_prefix or normalized.startswith( self.enterprise_prefix + "." )So 1.3.6.1.4.1.9 matches 1.3.6.1.4.1.9 (the bare PEN) and
1.3.6.1.4.1.9.1.516 (a Catalyst’s full sysObjectID), but PEN 3 never
matches PEN 30065, because 1.3.6.1.4.1.30065 neither equals
1.3.6.1.4.1.3 nor starts with 1.3.6.1.4.1.3.. The leading-dot strip
handles agents that return the canonical OID with a leading .; both
forms normalize to the same thing.
Registry order is most-specific-first with generic last, and
select_plugin walks it skipping generic, returning the first real
match and falling through to generic on no match, None, or empty
input:
def select_plugin(sys_object_id: str | None) -> SnmpPlugin: if not sys_object_id: return GENERIC for plugin in _REGISTRY: if plugin is GENERIC: continue if plugin.matches(sys_object_id): return plugin return GENERICBecause each vendor here has exactly one PEN, the ordering is really
just “real plugins before the fallback” — there’s no
longest-prefix-wins subtlety to get wrong. If a future vendor needed
multiple disjoint PENs, you’d give it a bespoke matches() rather than
relying on registry position.
Where the vendor flows
Section titled “Where the vendor flows”Identification doesn’t stop at OID selection — it becomes a recorded fact about the device. Each poll, the collector re-derives sysObjectID and, when it differs from the cached value, re-evaluates the plugin:
sys_oid = parse_sys_object_id(oid_values)if sys_oid is not None and sys_oid != self._sys_object_id: self._sys_object_id = sys_oid new_plugin = select_plugin(sys_oid) if new_plugin is not self._plugin: self._plugin = new_pluginThe cache matters because sysObjectID is effectively immutable for the
life of a chassis — it only changes on a hardware swap or a firmware OID
renumber. Re-running select_plugin on every poll would be cheap, but
gating on change keeps the logs quiet (one “plugin: vendor=cisco” line
when it first locks on, not one per poll) and makes a genuine vendor
change visible as a single transition.
The matched plugin’s name, plus the raw sysObjectID, ride out on the
DeviceIdentified event the collector already emits for chassis-ID
learning:
vendor = self._plugin.name if self._sys_object_id is not None else Nonereturn build_device_identified_envelope( ..., vendor=vendor, sys_object_id=self._sys_object_id,)vendor is None only when sysObjectID was absent from the walk
entirely — in that case the reconciler leaves device.vendor untouched
rather than overwriting a known value with a guess. When it is present,
the reconciler’s device-identified handler UPDATEs device.vendor
(and reports vendor_updated / vendor_changed in its result so a
swap shows up in the logs). That’s how a column in the topology tables
comes to reflect a fact the agent volunteered about itself, with full
provenance back to the OID that produced it.
extra_walk_oids() is scaffolding, on purpose
Section titled “extra_walk_oids() is scaffolding, on purpose”extra_walk_oids() returns () for every vendor in v1. That’s not an
oversight — it’s the honest state of the feature. The dispatch
machinery, the segment-aware matching, the provenance plumbing, and the
additive-only invariant are all real and tested. What’s deliberately
deferred is the content: the actual vendor-specific OIDs that enrich
the walk. Each of those needs lab access to a real device of that
vendor to validate the OID exists, returns what we think it returns,
and parses correctly across firmware revisions.
Shipping the empty scaffold first is the right order precisely because of the additive invariant. The framework is provably safe to merge before any vendor extras exist (every plugin is currently a no-op over the generic behavior), so the per-vendor lab validation can land incrementally, one vendor at a time, without ever risking the base collection path that everything else depends on.
See also
Section titled “See also”- Data model — the
device.vendorcolumn the reconciler updates from the matched plugin name - Event envelope — the
DeviceIdentifiedpayload that carriesvendorandsys_object_idprovenance - Breitbart et al. 2004, §VI.B “Device Vendor Plug-in” — the sysObjectID-keyed vendor-dispatch pattern this implements
- The dispatch core:
select_pluginand_PrefixMatchPlugin.matchesincollectors/snmp_plugins.py - The wiring: plugin re-selection and envelope enrichment in
collectors/snmp.py(_poll_once,_build_device_identified_envelope)