Skip to content

Collect from a wireless controller (WLC)

  • “A laptop shows up in switch CAM on the AP’s uplink trunk — but which radio, on which AP, is it actually associated to?”
  • “We have a mix of new Catalyst 9800s and older 5508s. I want wireless visibility from both without running two different tools.”
  • “Security wants every rogue AP the controller hears pinned to a switch port.”

A switch only ever sees an access point’s wired MAC on the uplink; it has no idea which client sits behind which radio, and a client roaming between APs is a sub-second L2 move a 5-minute polling fabric misses entirely. The controller is the only place that truth lives. l2trace’s wireless collector binds to one controller, registers every AP as a child device (by its wired ethernet MAC — the identity the upstream switch sees in CAM), and emits each client association on its AP’s radio port. The result flows into the exact same bitemporal CAM path as every other MAC observation.

Both controller families use the single wireless collector source — one device_collector row per controller. The wire protocol is chosen per device with extras.transport:

transportControllerProtocol
restconf (default)Catalyst 9800 (IOS-XE)RESTCONF / JSON over HTTPS
airespace-snmpAireOS (5508 / 8540 / 2504 / vWLC)AIRESPACE-WIRELESS-MIB over SNMPv2c

Aliases aireos-snmp, airespace, and snmp also select the SNMP path. Anything else (or nothing) is the RESTCONF path, so existing 9800 rows need no change.

Both transports emit the same three things, so the reconciler and every downstream view treat a wireless client identically regardless of controller:

  • One CAM_SNAPSHOT per AP — the AP’s currently-associated clients (MAC + VLAN) on its radio port. Snapshot semantics close a client that roamed away, so a roam from AP1 to AP2 shows as a close-here / learn-there across two AP devices.
  • One ARP_SNAPSHOT on the controller — every client’s IP↔MAC binding, the wifi analogue of a router ARP cache.
  • One ROGUE_AP_SNAPSHOT on the controller (opt-in, default on) — the rogues it heard over the air, each classified and flagged on-wire, for the find-a-rogue-ap correlation.

On IOS-XE 17.12 the RESTCONF path can’t read a wireless client’s VLAN from any oper leaf — it derives the VLAN from the client’s IP via a subnet→VLAN map in extras, and a client whose IP doesn’t match the map is dropped from CAM. In practice that map is rarely complete: on one production 9800 the RESTCONF path was placing only ~44% of associated clients, dropping the other ~56% for no resolvable VLAN.

AireOS reports the client VLAN directly (bsnMobileStationVlanId), with no map and no guessing — validated against a live controller at 415/415 clients placed. A Catalyst 9800 serves that same AIRESPACE VLAN column over SNMP for backward compat, so the RESTCONF collector can cross-fill it: set an SNMP community in auth and each poll does one cheap extra walk of just that column, supplying the VLAN for exactly the clients the subnet map misses. It’s fallback-only — a client the map already resolves keeps its mapped VLAN, so existing placements never churn — and best-effort, so a failed SNMP walk degrades to subnet-map-only rather than losing the poll. This keeps the 9800 on its richer RESTCONF rogue-oper while closing the client-VLAN gap.

Opt out with extras.vlan_from_snmp = false; with no community the cross-fill is simply inert (existing rows are unchanged).

from l2trace.collectors.base import CollectorConfig
from l2trace.events.schema import Source
cfg = CollectorConfig(
device_id=42,
hostname="wlc-9800-1",
mgmt_ip="10.0.0.9",
source=Source.WIRELESS,
auth={
"username": "l2trace-ro", "password": "...", # or {"token": "..."}
"community": "your-read-community", # enables the SNMP VLAN cross-fill
},
extras={
# transport defaults to "restconf" — no need to set it.
"subnet_vlan_map": [ # primary VLAN source (best-effort)
{"cidr": "10.20.0.0/16", "vlan": 20},
{"cidr": "10.30.0.0/16", "vlan": 30},
],
# "vlan_from_snmp": True is the default when a community is present;
# set False to disable the cross-fill and use the map alone.
},
)

With a community set, the map can even be left empty — the SNMP cross-fill supplies every client’s VLAN directly. Keep the map for controllers you can’t give SNMP access to.

cfg = CollectorConfig(
device_id=43,
hostname="wlc-5508-1",
mgmt_ip="10.0.0.8",
source=Source.WIRELESS,
auth={"community": "your-read-community"},
extras={
"transport": "airespace-snmp",
"snmp_port": 161,
"snmp_timeout_seconds": 5.0,
"poll_interval_seconds": 120, # SNMP walks a big controller heavily
"rogue_detection": True, # default; set false to skip the rogue walk
},
)

In production these come from a device_collector row (with auth values as env:// / file:// secret URIs), spawned by the orchestrator — you don’t construct the collector by hand. The SNMP path polls on a gentler default cadence (120s) than RESTCONF (60s) because a full AIRESPACE walk of a controller with hundreds of APs and thousands of clients is heavy.

The AireOS collector GETBULK-walks a targeted set of AIRESPACE-WIRELESS-MIB columns (Cisco PEN 1.3.6.1.4.1.14179), not whole tables:

TablePurpose
bsnAPTable (…2.2.1.1)Managed-AP inventory; row index is the AP’s wired ethernet MAC
bsnMobileStationTable (…2.1.4.1)Associated clients: AP MAC (the wired-MAC join key), VLAN, SSID, IP
bsnRogueAPTable (…2.1.7.1) + sub-tableDetected rogues, on-network flag, RSSI/channel

The client’s AP MAC is the same wired ethernet MAC that indexes bsnAPTable and appears in the switch CAM, so one identity threads the whole chain: client → AP-eth-MAC → registered AP device → wired switch port.

A Catalyst 9800 also exposes a backward-compat AIRESPACE tree over SNMP, but its rogue table reorders columns versus classic AireOS (RSSI/channel move into the main table, dates become strings). The SNMP rogue parser targets the classic AireOS layout, so keep 9800s on the restconf transport — its native rogue oper data is richer anyway. The client and AP tables are consistent across both.