Mark an unknown switch
When you need this
Section titled “When you need this”- “A vendor-managed switch sits mid-path and we have no SNMP or gNMI credentials for it — every trace through that segment dead-ends at the last device we own.”
- “There’s an unmanaged switch in a remote closet. We can’t collect from it, but both neighbors see its chassis in LLDP.”
- “We just acquired a company. Their access layer isn’t onboarded yet, but it’s already carrying traffic we need to trace across.”
In every case the box is real and forwarding frames — it just doesn’t emit telemetry l2trace can ingest. Without help, the traceroute CTE walks up to the last instrumented hop, finds no CAM on the far side, and stops. A virtual-switch placeholder turns that silent truncation into an explicit “passed through an opaque switch” hop. This is Lowekamp 2001 §6.1’s “virtual switches” idea: model the gear you know exists even when you can’t observe it directly.
Prerequisites
Section titled “Prerequisites”- The unknown device’s chassis ID (a MAC). You get this for free from
the LLDP of its neighbors — it’s the
remote_chassis_idon theadjacencyrows pointing at the opaque box. If the neighbors’ LLDP collection is working, the chassis ID is already in youradjacencytable even though the device itself isn’t. - At least one instrumented neighbor whose
adjacencyrows reference that chassis. Without a neighbor-side LLDP row, there’s nothing for the placeholder to attach to and the passthrough logic has no edge to walk. - A working
session_scope()— i.e. the usualDATABASE_URLyour other tooling uses.
There’s no CLI for this yet
Section titled “There’s no CLI for this yet”Be aware up front: marking an unknown switch is a programmatic helper,
not a l2trace ... subcommand. The entry point is
register_unknown_device in db/devices.py, callable from a Python
one-liner, a reconciler hook, or your own onboarding script. A dedicated
CLI verb may land later; today you drive it through the async session.
Register the placeholder
Section titled “Register the placeholder”The whole operation is one call:
import asynciofrom l2trace.db.session import session_scopefrom l2trace.db.devices import register_unknown_device
async def main() -> None: async with session_scope() as session: dev_id = await register_unknown_device( session, chassis_id="02:00:00:de:ad:01", # from the neighbors' LLDP # hostname=None → auto-generates "unknown-020000dead01" ) print(f"placeholder device id = {dev_id}")
asyncio.run(main())Run it as a throwaway one-liner against the running stack:
docker compose run --rm reconciler python -c 'import asynciofrom l2trace.db.session import session_scopefrom l2trace.db.devices import register_unknown_device
async def main(): async with session_scope() as s: print(await register_unknown_device(s, chassis_id="02:00:00:de:ad:01"))
asyncio.run(main())'Two things worth knowing about the call:
- It’s idempotent on
chassis_id. Call it twice with the same chassis and you get the same device id back — no duplicate placeholder rows. That makes it safe to wire into a reconciler that keeps re-encountering the same unresolved chassis on every poll. - The hostname is optional. Omit it and you get
unknown-<hex>(the colons stripped from the chassis), e.g.unknown-020000dead01, so the placeholders stand out in TUI and CLI device listings. Pass ahostname=if you’d rather give it a human label likeacme-closet-3-unmanaged.
Under the hood this inserts a device row with visibility = 'unknown'
and no ports, no CAM, no STP — exactly the fields a real collector would
populate and which this box never will. The neighbors’ existing LLDP
adjacency rows already point remote_device_id at it (or get backfilled
to once the chassis matches), which is what stitches it into the graph.
See the data model for where visibility lives
on device, and how virtual switches work
for the design reasoning.
What changes in a traceroute
Section titled “What changes in a traceroute”Once the placeholder exists, a trace that would have dead-ended at the last real device now appends a synthetic hop. There are two outcomes, and which one you get depends on what’s observable on the far side of the unknown box.
Passthrough to the destination (F5 v2). If the unknown device has
exactly one other neighbor (a single back-reference from a different real
device), and that exit device’s CAM has dst_mac on an access port,
the trace walks straight through. You get the last real ingress hop, then a
synthetic visibility = "unknown" hop for the opaque switch, then the exit
device as a final real hop landing on the destination’s access port.
Termination is reached, and TraceResult.notes carries a line like:
passed through unknown switch unknown-020000dead01 (chassis-only resolution; visibility incomplete)That note is the honesty knob. The trace reached its destination, but it
crossed a box you can’t see inside — operators reading termination=reached
shouldn’t assume the whole path was directly observed.
Stop at the placeholder (F5 v1 fallback). If the exit is ambiguous
(the unknown box has two or more other neighbors, so picking one would
invent a path) or there’s no evidence the destination is directly
attached on the far side (the exit device has no CAM for dst_mac, or has
it on a trunk rather than an access port), l2trace refuses to guess. The
synthetic unknown hop becomes the last hop and termination is
reached_via_unknown. No passthrough note is emitted, because no
passthrough happened — the trace honestly says “I got as far as this opaque
switch and can’t responsibly continue.”
The synthetic hop always carries a sentinel egress: out_port_id = 0 and
out_port_name = "?", because there are no port rows for gear we can’t
poll. Don’t treat that as a real port reference.
Reading the result
Section titled “Reading the result”import asynciofrom datetime import UTC, datetimefrom l2trace.db.session import session_scopefrom l2trace.db.queries import traceroute
async def main() -> None: async with session_scope() as session: res = await traceroute( session, "aa:bb:cc:00:00:01", # src "aa:bb:cc:00:00:02", # dst 10, # vlan as_of=datetime.now(UTC), ) print("termination:", res.termination) for h in res.hops: tag = " (UNKNOWN)" if h.visibility == "unknown" else "" print(f" step {h.step}: {h.device_hostname} → {h.out_port_name}{tag}") for n in res.notes: print("note:", n)
asyncio.run(main())A passthrough trace prints three hops with the middle one tagged
(UNKNOWN), termination: reached, and the passthrough note. A
fallback trace prints the unknown hop last with
termination: reached_via_unknown and no notes.
When the real switch gets onboarded
Section titled “When the real switch gets onboarded”The placeholder is keyed on chassis_id, and so is the real device once
you add credentials and start collecting from it. When the genuine
device row arrives with the same chassis, that’s the point to retire the
placeholder — otherwise you’ll have two rows for one box. Peer resolution
already matches neighbors to devices by chassis, so the topology stitching
carries over; see how peer resolution works
for how the chassis-keyed match behaves when a previously-unknown chassis
becomes a real, polled device.
See also
Section titled “See also”- How virtual switches work — the design
behind
visibility='unknown'and the synthetic-hop passthrough - How peer resolution works — how the chassis ID stitches the placeholder into the graph, and what happens when the real device shows up
- Data model reference —
device.visibility,adjacency, and the bitemporal columns the passthrough query reads - The source:
src/l2trace/db/devices.py::register_unknown_deviceandsrc/l2trace/db/queries.py::_maybe_append_unknown_hop