Skip to content

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 vendor map is the dominant L2-switch manufacturers, drawn from the IANA PEN registry. Anything else falls through to a generic plugin.

PluginPENEnterprise prefix
cisco91.3.6.1.4.1.9
hp111.3.6.1.4.1.11
extreme19161.3.6.1.4.1.1916
juniper26361.3.6.1.4.1.2636
nokia65271.3.6.1.4.1.6527
mikrotik149881.3.6.1.4.1.14988
arista300651.3.6.1.4.1.30065
generic(catch-all)

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 vendor

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

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 GENERIC

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

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_plugin

The 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 None
return 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.

  • Data model — the device.vendor column the reconciler updates from the matched plugin name
  • Event envelope — the DeviceIdentified payload that carries vendor and sys_object_id provenance
  • Breitbart et al. 2004, §VI.B “Device Vendor Plug-in” — the sysObjectID-keyed vendor-dispatch pattern this implements
  • The dispatch core: select_plugin and _PrefixMatchPlugin.matches in collectors/snmp_plugins.py
  • The wiring: plugin re-selection and envelope enrichment in collectors/snmp.py (_poll_once, _build_device_identified_envelope)