Skip to content

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?”

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
unknown

d1 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.

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_termination
if 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.

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_id
FROM adjacency a
JOIN port p ON p.id = a.local_port_id
JOIN device d ON d.id = p.device_id
WHERE a.remote_device_id = :unknown_dev_id
AND a.local_port_id <> :entry_port_id
AND a.valid_during @> :as_of
LIMIT 2

LIMIT 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::text
FROM mac_observation cam
JOIN port p ON p.id = cam.port_id
WHERE cam.mac = :dst_mac
AND cam.device_id = :exit_device_id
AND cam.vlan = :vlan
AND cam.valid_during @> :as_of
ORDER BY CASE cam.source WHEN 'gnmi' THEN 1 WHEN 'snmp' THEN 2
WHEN 'netconf' THEN 3 WHEN 'ssh' THEN 4 END

If 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.

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.