Skip to content

Mark an unknown switch

  • “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.

  • The unknown device’s chassis ID (a MAC). You get this for free from the LLDP of its neighbors — it’s the remote_chassis_id on the adjacency rows pointing at the opaque box. If the neighbors’ LLDP collection is working, the chassis ID is already in your adjacency table even though the device itself isn’t.
  • At least one instrumented neighbor whose adjacency rows 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 usual DATABASE_URL your other tooling uses.

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.

The whole operation is one call:

import asyncio
from l2trace.db.session import session_scope
from 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:

Terminal window
docker compose run --rm reconciler python -c '
import asyncio
from l2trace.db.session import session_scope
from 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 a hostname= if you’d rather give it a human label like acme-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.

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.

import asyncio
from datetime import UTC, datetime
from l2trace.db.session import session_scope
from 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.

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.

  • 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 referencedevice.visibility, adjacency, and the bitemporal columns the passthrough query reads
  • The source: src/l2trace/db/devices.py::register_unknown_device and src/l2trace/db/queries.py::_maybe_append_unknown_hop