Skip to content

Stand up TACACS+ device administration

  • “Who logged into that switch last night, and what did they type?”
  • “Give the network team a shell on the fabric from their AD credentials, and take it away when someone leaves — in one place.”
  • “Show me every configure-level command run this week, as of the moment it ran.”

TACACS+ is the other half of AAA from 802.1X: RADIUS decides whether a device joins the network; TACACS+ decides whether an engineer may log into a switch and run a command. l2trace runs both.

Step 1 — the local break-glass account (not optional)

Section titled “Step 1 — the local break-glass account (not optional)”

Before pointing any switch at l2trace, on each device:

username breakglass privilege 15 secret <a-real-password>
aaa authentication login default group tacacs+ local
aaa authorization exec default group tacacs+ local

Then log in with breakglass and confirm it works. A NAS only falls through to the local account when the TACACS+ server is unreachable or returns ERROR — which, by the doctrine, is what it does on any fault. The local account is your way back in. Test it now, while nothing depends on it.

Step 2 — an identity source and a service account

Section titled “Step 2 — an identity source and a service account”

l2trace authenticates engineers by binding to Active Directory as the user (so it needs LDAPS), and reads their group membership with a service account (authorization is a separate TACACS+ exchange that carries no password, so it cannot bind as the user to read their own groups). In .env:

Terminal window
TACACS_ENABLED=true
TACACS_SHARED_SECRET=env://TACACS_SECRET # per-device secrets are better; see Step 3
# Authenticate the engineer (LDAPS is required — a simple bind sends the real password):
LDAP_URI=ldaps://dc.hospital.example
LDAP_USER_TEMPLATE={username}@hospital.example
# Read their groups (least privilege: read memberOf on user objects, nothing else):
LDAP_BIND_DN=CN=svc-tacacs,OU=Service,DC=hospital,DC=example
LDAP_BIND_PASSWORD=env://TACACS_SVC_PW # a secrets URI, so it stays out of the DB
LDAP_SEARCH_BASE=DC=hospital,DC=example

l2trace refuses to start without an identity source, on purpose: a login server that can authenticate nobody is worse than one that is plainly absent, because absence lets every NAS fall through to local.

A switch is a device with an enabled device_collector row naming source tacacs — the same registration path you already use for SNMP. Its shared secret is a secrets URI, so it never lives in the database:

Terminal window
docker compose exec reconciler l2trace device register \
--hostname sw-lab-1 --mgmt-ip 192.0.2.10
docker compose exec reconciler l2trace device add-collector \
--hostname sw-lab-1 --source tacacs \
--auth tacacs_secret=env://SW_LAB_1_SECRET \
--extra tacacs_dialect=ios # ios | nxos | arista | generic

The tacacs_dialect matters because it decides how a privilege is phrased on the wire: IOS understands priv-lvl, while NX-OS and EOS want shell:roles. Send the wrong one as a mandatory argument and the switch fails the authorization and denies the engineer, so l2trace only ever sends a role to a dialect known to understand it. Unknown or unset → generic, which sends only priv-lvl (every Cisco-family device understands it).

Step 4 — deploy (host 49 → container 4949)

Section titled “Step 4 — deploy (host 49 → container 4949)”

TACACS+‘s well-known port is 49, which is privileged, and the container runs as a non-root user. So it listens high and the compose overlay publishes host 49 to it — Docker does the privileged bind:

Terminal window
docker compose -f docker-compose.yml -f docker-compose.tacacs.yml up -d tacacs
docker compose logs -f tacacs

Point a lab switch at it (tacacs server ... address ipv4 <l2trace-host> / key), and log in with an AD credential. It works because monitor mode changes nothing — see the next step.

Not shown but worth bringing up a lab switch on first: l2trace tacacs-lab is a standalone, database-free runner that proves the wire protocol against a real Catalyst before you wire in AD and Postgres.

Step 5 — map AD groups to privileges and commands

Section titled “Step 5 — map AD groups to privileges and commands”

Authorization is driven by AD group, first-match wins, from .env. A shell profile sets the privilege level; a command set filters what may be run:

Terminal window
# priv-lvl / roles per group (first match wins — put admins first):
TACACS_GROUP_PROFILES='[
{"group":"NetAdmins","priv_lvl":15,"roles":["network-admin"]},
{"group":"NetOps","priv_lvl":7,"idletime":10}
]'
# ordered permit/deny command rules per group (default-deny once a set applies):
TACACS_GROUP_COMMAND_SETS='[
{"group":"NetAdmins","default":"permit"},
{"group":"NetOps","default":"deny",
"rules":[{"permit":"show .*"},{"deny":"configure .*"}]}
]'

Both share one directory lookup per login. Command patterns are full-match (show permits only show; show .* permits every show command), so a permit conf cannot quietly authorize configure terminal.

Those patterns are matched with RE2 (google-re2), not Python’s re, on purpose: RE2 matches in guaranteed linear time, so a sloppy operator pattern can never catastrophically backtrack (a ReDoS) and stall the authorization path for the whole fabric. The trade-off is that RE2 rejects backreferences and lookaround — a pattern that uses them fails to compile, and a rule l2trace can’t compile is skipped and logged as a non-match rather than silently matching. A single bad rule can therefore never, by itself, permit or deny every command; the set’s later rules and its default decide.

Step 6 — watch, in monitor mode, before enforcing

Section titled “Step 6 — watch, in monitor mode, before enforcing”

Everything above runs with TACACS_MONITOR_MODE=true (the default). Logins are answered truthfully — an AD password works or it does not — but authorization changes nothing: the server computes the profile and the command verdict, logs what it would apply, and returns a no-op. Every login and every command lands in the audit trail. Open the Commands view in the web UI to see per-command accounting, newest first, under the DeLorean as-of control — “what commands had run by time T” is exactly what an incident review asks.

Run like this for as long as it takes to trust the would-denies in the log.

Enforcement is triple-gated, so turning it on takes three deliberate acts:

Terminal window
TACACS_MONITOR_MODE=false # global: stop flattening decisions to no-ops
TACACS_ENFORCEMENT_PERMITTED=true # global arm
# and per lab device:
l2trace device add-collector --hostname sw-lab-1 --source tacacs --extra tacacs_enforce=true

A per-device tacacs_enforce flag can never enforce by itself; all three must agree. Prove the full path on the operator’s spare lab switches — real login, correct privilege, a denied command, and a recovered login after killing the server (it should fall through to breakglass) — and only then consider a production port.