Virtual switches and the single-hop passthrough
Every real network has switches you can’t collect from. An old access switch with SNMP disabled. A vendor appliance with its own opaque forwarding plane. A device behind a credential you don’t have yet. You know it exists — its neighbors advertise its chassis ID over LLDP — but it contributes no CAM table, no STP state, no adjacency rows of its own. To the traceroute walker it’s a hole in the fabric.
Lowekamp et al. called these virtual switches in their 2001 layer-2
topology paper (§6.1): inferred nodes that exist in the topology graph
only because something else pointed at them. l2trace borrows the idea
directly. A device row with visibility = 'unknown' is a placeholder
for gear we can’t poll, created from the chassis ID that showed up in a
neighbor’s LLDP.
The interesting question isn’t “how do we store the placeholder” — it’s “what should a trace do when it hits one?”
The dead-end problem
Section titled “The dead-end problem”Here’s the failure mode without virtual switches. Take a three-switch chain where the middle switch is unpollable:
SRC ──[Eth0]── d1 ──[Eth1]══( d2 )══[?]── d3 ──[Eth1]── DST access trunk opaque trunk access unknownd1 and d3 are instrumented; d2 is opaque. d1’s CAM for DST
points at Eth1, its trunk toward d2. The recursive CTE walks
ingress on d1.Eth0, looks up DST, finds it on the trunk, follows
the adjacency to d2 — and stops. d2 has no CAM, so there’s no next
hop to compute. The walk terminates.
Without any special handling, that terminates as dead-end or floods
and the operator sees a trace that just stops at d1 with no
explanation. The frame clearly goes somewhere — d3 two hops away has
DST sitting on an access port — but the trace gives no hint that an
opaque switch is the reason it can’t say more. That’s the worst kind of
output: confidently incomplete, indistinguishable from a real
forwarding bug.
The fix is to make the boundary of observability visible in the
result. That’s what visibility = 'unknown' buys us, and it lands in
two stages.
v1 — detect and label
Section titled “v1 — detect and label”The first stage just makes the hole show up. When the CTE terminates
short, a post-processor checks the last hop’s outbound port: does its
LLDP adjacency point at a device whose visibility = 'unknown'? If so,
append a synthetic hop standing in for the opaque switch.
synthetic = TraceHop( step=last.step + 1, device_id=unknown_dev_id, device_hostname=unknown_hostname, in_port_id=in_port_id, # the port on the unknown side LLDP saw in_port_name=None, # no port rows exist for unknown gear out_port_id=0, # sentinel — we don't know the egress port out_port_name="?", via_source="passthrough", visibility="unknown",)The synthetic hop carries an out_port_id = 0 sentinel because there
are no port rows for gear we can’t poll — we know the frame entered
d2, but we have no telemetry about which port it leaves on. The
termination classifier sees a final hop with visibility = 'unknown'
and returns reached_via_unknown:
# in _classify_terminationif last.visibility == "unknown": return "reached_via_unknown"So instead of a silent stop at d1, the operator gets a two-hop trace:
d1 → (unknown d2), terminated reached_via_unknown. That reads as
“the path enters an opaque switch here and we can’t see past it” —
honest, and actionable. The operator now knows exactly which device
to go instrument.
v2 — single-hop passthrough
Section titled “v2 — single-hop passthrough”v1 stops at the wall. But in our chain there’s enough evidence to step
over d2 entirely: d2 has exactly one other neighbor (d3), and
d3 has DST on an access port. If the unknown switch has a single
unambiguous exit and the device on the far side has the destination
directly attached, we can extend the trace one more real hop and call
it reached.
Two conditions gate this, both strict on purpose:
1. Exactly one other exit. Find every LLDP adjacency pointing at the unknown device, excluding the port we entered from:
SELECT a.local_port_id, p.device_id, d.hostname, d.mlag_group_idFROM adjacency aJOIN port p ON p.id = a.local_port_idJOIN device d ON d.id = p.device_idWHERE a.remote_device_id = :unknown_dev_id AND a.local_port_id <> :entry_port_id AND a.valid_during @> :as_ofLIMIT 2LIMIT 2 is the whole trick. Zero rows means the unknown switch is a
stub — nowhere to continue. Two or more rows means it’s a transit
switch with multiple known neighbors, and we have no basis for
picking which one the frame actually egressed toward. Guessing would
invent a path that might be wrong, which is worse than admitting we
don’t know. Only the single-row case is unambiguous, and only that case
proceeds.
2. The exit device has the destination on an access port. Look up
dst_mac’s CAM entry on that one exit device:
SELECT cam.port_id, p.name, p.role, p.kind, cam.source::textFROM mac_observation camJOIN port p ON p.id = cam.port_idWHERE cam.mac = :dst_mac AND cam.device_id = :exit_device_id AND cam.vlan = :vlan AND cam.valid_during @> :as_ofORDER BY CASE cam.source WHEN 'gnmi' THEN 1 WHEN 'snmp' THEN 2 WHEN 'netconf' THEN 3 WHEN 'ssh' THEN 4 ENDIf the exit device knows DST and it’s on an access port, the
destination is directly attached there — the frame’s journey ends on
that switch, and we can append it as a real terminal hop. If the CAM
lookup comes up empty, or finds DST on a trunk (meaning we’d need
to keep walking past yet another switch), v2 declines and falls back to
v1. Same gnmi > snmp > netconf > ssh source priority the recursive
CTE uses, so the passthrough doesn’t suddenly trust a lower-confidence
source than the main walk would.
When both conditions hold, the three-hop trace lands:
d1 ──→ (unknown d2) ──→ d3 termination: reached real synthetic real notes: ["passed through unknown switch unknown-0200... (chassis-only resolution; visibility incomplete)"]The notes channel is doing important work here. The termination says
reached, which is true — we found the destination — but the path went
through a switch we couldn’t see into. Without the note, an operator
reading reached might assume the whole path was directly observed.
The note keeps the result honest: we got there, but one hop is
inferred, not measured.
Why a post-processor, not a second UNION arm
Section titled “Why a post-processor, not a second UNION arm”The natural instinct is to teach the recursive CTE itself about unknown
devices — add another UNION ALL arm that handles the
visibility=‘unknown’ case inline. We deliberately don’t.
The traceroute CTE’s correctness story is already dense. It tracks a
path array for loop detection, filters STP-blocked edges, collapses
MLAG peers so a vPC pair doesn’t look like a flapping move, and honors
the audit-time recorded_during guard so historical traces reflect
what the system believed at that time, not what it knows now. Every
one of those invariants has to hold on every arm of the recursion.
Threading “and sometimes the next device has no rows so synthesize a
fake one with a sentinel port” into that machinery means re-proving all
of those invariants against the new branch.
Keeping the passthrough as a post-processor over the CTE’s output sidesteps
that entirely. The CTE runs unchanged, produces a correct (if short)
hop list, and only then does _maybe_append_unknown_hop inspect the
final hop and decide whether to extend it. It also only runs when the
CTE terminated as something other than reached:
initial_term = _classify_termination(hops, max_hops)if initial_term != "reached": hops, notes = await _maybe_append_unknown_hop(...)A trace that reached its destination on its own never gains a synthetic hop — the post-processor can’t corrupt a correct result because it never touches one. The two concerns stay separated: the CTE owns forwarding correctness, the post-processor owns the boundary-of-observability behavior, and neither has to reason about the other’s invariants.
There’s one more guard worth calling out. The post-processor refuses to
chain off a hop that’s already synthetic (last.visibility == 'unknown'), because two adjacent unknown switches would otherwise let
it walk forever appending placeholders. A single passthrough is the
hard limit in v2.
What’s deferred
Section titled “What’s deferred”v3 — recursive multi-hop passthrough — is future work, tracked
separately. It would walk chains of unknown switches (opaque → opaque
→ real) and carry a confidence score for the multi-exit case rather
than refusing it outright. That needs a real model of “how sure are we
this is the exit,” which v2’s binary one-exit-or-bust rule sidesteps on
purpose. Until then, two unknowns back-to-back, or an unknown with
several plausible exits, terminates honestly at reached_via_unknown
and tells the operator where to point the next collector.
See also
Section titled “See also”- Mark an unknown switch — the operator workflow for registering a placeholder from a chassis ID
- The L2 traceroute algorithm — the recursive CTE this post-processor runs after
- Peer resolution — how chassis IDs in neighbor LLDP get matched to device rows in the first place