Skip to content

Stand up 802.1X in monitor mode

  • “Which devices on the fabric could NOT pass 802.1X — before we ever turn enforcement on?”
  • “Show me every authentication attempt, on which switch port, with the decision we would have made.”
  • “I want a paper trail of network access, revisable as beliefs change, without risking a single patient-care port.”

l2trace already knows every legitimate MAC and the port/VLAN it belongs on, bitemporally. The RADIUS server turns that knowledge into an access-control decision: a switch relays each device’s authentication to l2trace, l2trace decides MAC Authentication Bypass (MAB) against what it has actually observed, and records the attempt in the bitemporal auth_event log. In monitor mode (the default) the reply is always Accept with no VLAN, so the network is left exactly as it is and the value is the recorded discrepancy flag: an unknown MAC, or a device on a VLAN its port doesn’t allow.

This is designed for a network where cutting the wrong port is a real harm. Three things enforce that:

  • Monitor mode is the default and makes zero changes. Every reply is Access-Accept with no VLAN attribute; the switch runs authentication open so the port forwards regardless. l2trace only observes and records.
  • Enforcement takes three independent switches to arm. Real Accept/Reject and dynamic-VLAN assignment happen only when RADIUS_MONITOR_MODE=false and RADIUS_ENFORCEMENT_PERMITTED=true and that one switch’s device_collector.extras.radius_enforce=true. A stray flag on a production row, by itself, can never start cutting ports — and every enforce-flagged switch is named loudly in the logs on each refresh.
  • Enforcement fails open. Even when armed, it rejects only a genuinely-unknown MAC; a topology gap (a port l2trace hasn’t mapped) or a decision-time DB error results in Accept, never a denial.

Prove enforcement on lab switches before arming a single production port.

On each switch, run 802.1X/MAB with authentication open so the port never blocks, point RADIUS at l2trace, and enable Change-of-Authorization so l2trace can push a re-auth later. Representative Cisco IOS-XE (exact syntax varies by train):

aaa new-model
radius server L2TRACE
address ipv4 <l2trace-ip> auth-port 1812 acct-port 1813
key <shared-secret>
aaa group server radius L2TRACE
server name L2TRACE
aaa authentication dot1x default group L2TRACE
aaa authorization network default group L2TRACE
dot1x system-auth-control
! CoA (l2trace pushes re-auth / VLAN change here)
aaa server radius dynamic-author
client <l2trace-ip> server-key <shared-secret>
interface GigabitEthernet1/0/5
authentication open ! monitor: the port NEVER blocks
authentication port-control auto
mab ! MAC Authentication Bypass
dot1x pae authenticator

authentication open is the load-bearing line: it makes the switch honor the auth exchange for logging while leaving the port up no matter what l2trace replies. Keep it on every port until enforcement is validated.

Modern IOS-XE sends a RADIUS Message-Authenticator by default (the BlastRADIUS mitigation); l2trace requires it. If you run older gear that can’t, see the security note below.

2. Register each switch as a RADIUS client

Section titled “2. Register each switch as a RADIUS client”

l2trace answers only switches it knows, resolved by source IP. Add a device_collector row with source=radius for each switch, carrying that switch’s shared secret as a secrets URI (never a literal in the DB — see secrets management):

{
"source": "radius",
"device_id": <the switch's device id>,
"enabled": true,
"auth": { "radius_secret": "env://L2TRACE_SW5_RADIUS_SECRET" },
"extras": { "radius_enforce": false }
}

The secret is resolved from the server’s environment at packet time and re-read on each refresh, so rotations land without a restart. A switch with no radius_secret falls back to RADIUS_SHARED_SECRET.

The RADIUS server runs as its own service so switch authentication is independent of the reconciler. Settings (all default-safe):

Env varDefaultMeaning
RADIUS_ENABLEDfalseTurn the server on
RADIUS_MONITOR_MODEtrueMaster safety: never change the network
RADIUS_REQUIRE_MESSAGE_AUTHENTICATORtrueDrop packets lacking a valid Message-Authenticator
RADIUS_ENFORCEMENT_PERMITTEDfalseGlobal arm for enforcement (second gate)
RADIUS_AUTH_PORT / RADIUS_COA_PORT1812 / 3799UDP ports

Single host: add the docker-compose.radius.yml overlay (publishes UDP 1812/1813/3799) or co-run it in reconcile with RADIUS_ENABLED=true. Swarm: it’s a standalone radius service (host-mode UDP, pinned to a node so switches have a stable target). Run it directly with:

Terminal window
l2trace radius

You don’t need a real switch to prove the pipeline. The bundled mock switch (a software NAS) sends a signed Access-Request and listens for CoA, exactly like a real one. Point it at the running server:

Terminal window
# Send a MAB Access-Request for a MAC and print the decision.
l2trace radius-mock-switch --server <l2trace-ip> --secret <shared-secret> \
--mac 00:11:22:33:44:55 --no-listen
# 00:11:22:33:44:55: ACCEPT

The mock’s source IP must be a registered source=radius switch. A known MAC comes back ACCEPT with no VLAN (monitor mode); the attempt lands in the auth_event log with its discrepancy verdict.

To watch the Change-of-Authorization path, keep the mock listening and push a CoA to it from another terminal:

Terminal window
# Terminal 1: authenticate, then listen for CoA on udp/3799.
l2trace radius-mock-switch --server <l2trace-ip> --secret <s> --mac 00:11:22:33:44:55
# Terminal 2: send a re-auth onto VLAN 20 (what Phase 4's approval loop will do).
l2trace radius-coa --host <mock-switch-ip> --secret <s> \
--mac 00:11:22:33:44:55 --vlan 20
# CoA <- coa mac=00-11-22-33-44-55 vlan=20 (acked=True)

Every attempt is in the Auth view of the web UI, and at the API:

Terminal window
curl -s "https://l2trace.example.net/api/auth-events" | jq '.[]
| {mac, switch, port, result, monitor_mode, discrepancy, reason}'

The signal in monitor mode is discrepancy: true: an unknown MAC, or a device observed on a VLAN its NAS port doesn’t allow. These are your before-you-enforce worklist — the devices that would be rejected or moved once enforcement is armed, surfaced with zero risk. Because the log is bitemporal, “what did we believe about this device’s access last Tuesday?” stays answerable.

RADIUS runs over UDP and an Access-Request Authenticator is a nonce, not keyed by the secret — so a datagram spoofing a registered switch’s source IP would otherwise be trusted. l2trace requires the RFC 2869 Message-Authenticator (HMAC keyed by the shared secret) on every request and drops anything lacking or failing it before any decision, log write, or port lookup. Leave RADIUS_REQUIRE_MESSAGE_AUTHENTICATOR on. Only relax it for legacy switches that genuinely can’t send one, and know that doing so leaves that intake source-IP-spoofable.

Beyond MAB, l2trace speaks PEAPv0 / EAP-MSCHAPv2 (username + password inside a TLS tunnel) for lab validation. Generate a CA + server cert and point the server at them:

Terminal window
uv run python scripts/gen_lab_ca.py # writes lab-certs/ (gitignored)
# then set RADIUS_TLS_CERT / RADIUS_TLS_KEY to lab-certs/server.{crt,key}
l2trace radius-mock-switch --server <l2trace-ip> --secret <s> \
--peap --username labuser --password labpass --ca lab-certs/ca.crt
# labuser: ACCEPT (eap=success)

What the RADIUS server does and doesn’t do

Section titled “What the RADIUS server does and doesn’t do”
  • Does: MAB decisioning, the bitemporal auth log, discrepancy flagging, dynamic-VLAN + CoA primitives, monitor mode, triple-gated enforcement, PEAP-MSCHAPv2 (validated against real wpa_supplicant on a lab switch), EAP-TLS with client certificates (see Authenticate with client certificates (EAP-TLS)), the provisioning / quarantine approval workflow (an unknown device lands on a provisioning VLAN, an operator approves it, and a CoA re-auth moves it onto its real VLAN), and MPPE session-key derivation so a wireless AP can complete WPA-Enterprise (see Stand up wireless 802.1X).
  • Doesn’t (yet): RADIUS accounting. That is later work.