BUG: One-way blackhole when peer is only reachable via relay (stale HostInfo.remote never cleared)
Summary
HostInfo.remote (the cached "best known direct underlay" for a peer) can become stale and pin a tunnel to a dead direct underlay address indefinitely. The tunnel is kept marked alive by inbound relay traffic, so the connection manager never tears it down or triggers a rehandshake. The result is a persistent one-way blackhole: the local node keeps sending to a dead direct address while the peer is reaching us fine via a relay.
Process restart (or any event that drops the HostInfo) recovers the tunnel because the new tunnel is established via relay only and HostInfo.remote stays unset, so sendNoMetrics falls into the relay branch.
Root cause (two interacting invariants)
sendNoMetricsininside.goprefers direct over relay wheneverhostinfo.remote.IsValid(). There is no fallback to relay if the direct path is dead:useRelay := !remote.IsValid() && !hostinfo.remote.IsValid() ... if remote.IsValid() { err = f.writers[q].WriteTo(out, remote) } else if hostinfo.remote.IsValid() { err = f.writers[q].WriteTo(out, hostinfo.remote) // pinned to dead underlay } else { // Try to send via a relay ... }handleHostRoaminginoutside.goonly updateshostinfo.remotefor non-relayed inbound packets:func (f *Interface) handleHostRoaming(hostinfo *HostInfo, via ViaSender) { if !via.IsRelayed && hostinfo.remote != via.UdpAddr { ... hostinfo.SetRemote(via.UdpAddr) } }Relayed packets still call
connectionManager.In(hostinfo), so the tunnel is considered alive purely from relay traffic.connectionManager.makeTrafficDecisiondoes not distinguish direct vs. relay traffic in its in/out tracking, soinTraffic == truefrom relay-only liveness is enough to keep the tunnel alive forever.tryRehandshakeonly fires for cert version drift, never for staleremote.
Two manifestations
Forward case: direct died, peer fell back to relay, we stayed pinned to direct
- Tunnel is up direct.
hostinfo.remote = X. - Underlay
Xbecomes unreachable from the peer (NAT timeout, cellular reattach, route flap). - Peer keeps the tunnel alive via a relay it has access to.
- Our node decrypts relayed packets →
In(hostinfo)→ tunnel is "alive".handleHostRoamingskips becausevia.IsRelayed == true, sohostinfo.remotestays atX. - Our outbound packets go direct to
Xand are blackholed. - No rehandshake fires (cert is fine), no teardown fires (relay traffic refreshes liveness).
- Tunnel is permanently one-way broken.
Reverse case: roamed to a bad direct address, then direct broke
- Tunnel was relay-only (or direct via
X).hostinfo.remoteeither invalid orX. - A single direct packet arrives from address
Y(transient NAT rebind, brief cell handoff, asymmetric path). handleHostRoamingaccepts and setshostinfo.remote = Y. There is no validation thatYis bidirectionally reachable.Yis dead for outbound (NAT entry already reaped, asymmetric path, etc). Peer keeps reaching us via relay.- Same stuck state as the forward case, but reached via a different history.
RoamingSuppressSeconds = 2 only suppresses roaming back to the previous remote. It does not prevent roaming to a bad address in the first place.
Minimum reproduction
Three nodes:
LH— lighthouse.R—relay.am_relay: true, advertises itself as a relay.AandB— clients withuse_relays: trueandrelay.relays: [R], both pointing atLH.
Forward case
Bring up all four nodes. From
ApingB's overlay address; tunnel establishes direct (each side learns the other's underlay from the lighthouse). ConfirmA → Bworks andA'shostSSH command showsRemote:populated withB's direct underlay address.Block the direct path between
AandBonly, leaving the relay path intact. For example on the host runningA:sudo iptables -I OUTPUT 1 -d <B-direct-underlay-ip> -j DROP sudo iptables -I INPUT 1 -s <B-direct-underlay-ip> -j DROPFrom
B, generate a small steady stream of overlay traffic towardA(e.g.ping <A-overlay>once a second). BecauseAis not reachable directly anymore, the packets travel viaR.Adecrypts them, marks the tunnel alive.Ping
A → Bover the overlay. Expected: packets fall back to the relay and reachB. Actual: all outbound packets fromAgo to the cached dead direct underlay; nothing reachesB.A's tunnel toBstays "alive" indefinitely.connectionManagernever decidesdeleteTunnel, no rehandshake fires.Restart
A(or otherwise drop theHostInfo) and the next handshake establishes via relay only; ping recovers.
Reverse case
Same topology. Establish the tunnel via relay only first (block direct between A and B before they handshake). Then:
- Briefly unblock the direct path long enough for one direct encrypted packet from
Bto reachA(a single test packet is enough). A'shandleHostRoamingsetshostinfo.remotetoB's direct underlay.- Re-block direct in both directions. Keep relay path up.
- From
A, pingB. Outbound goes to the just-pinned direct address and is blackholed. Tunnel stays alive on relay traffic. No recovery.
Why the existing safeguards don't catch this
connectionManager.makeTrafficDecisiononly checksinTrafficandoutTrafficaggregated across both paths.tryRehandshakeis gated on cert/version state, not onremotehealth.pendingDeletionrequiresinTraffic == false, which never happens while relay traffic flows.Punchypuncheshostinfo.remote(or all remotes), but that doesn't change which addresssendNoMetricsuses for data.lighthouse.remote_allow_listcan prevent acceptance of inbound roams, but it does not validate outbound deliverability of an already-acceptedremote.
Suggested fix
A few possible directions:
- Track direct vs. relay traffic separately on
HostInfo(e.g. aninDirect atomic.Boolset only in the non-relay decrypt path). InconnectionManager.makeTrafficDecision, if the host is the primary, has had outbound traffic, and has had no direct inbound forKticks, clearhostinfo.remote(forcing the relay branch insendNoMetrics) and trigger a rehandshake. - Validate new direct addresses before pinning. In
handleHostRoaming, instead of immediately callingSetRemote(via.UdpAddr), send a test packet to the candidate and only pin once a reply is observed from that exact address. This eliminates the reverse manifestation (roam-to-bad). - Add an outbound deliverability heartbeat. When
hostinfo.remote.IsValid()and we've sent N packets without any direct inbound reply, fall back to relay automatically until a direct path proves itself again.
(1) is the minimal change and addresses both manifestations. (2) is complementary and prevents the reverse case from ever entering the bad state.
Environment
- Reproduced on upstream nebula
02f78b099c726dc940fb65a9834158a16ec70562(master, late September 2025). The relevant code paths ininside.go,outside.go, andconnection_manager.goare unchanged on currentmain/release, so the issue should still apply. - Linux clients, IPv6 overlay, mixed v4/v6 underlays,
cipher: aes,use_relays: true,am_relay: falseon the affected clients.
Workarounds
- Restart the affected client to clear
HostInfo.remote. - A liveness probe (e.g. periodic overlay ping to a canary peer) that restarts the process on failure recovers from this without manual intervention.
- Enabling
stats: type: prometheusand alerting on per-peer outbound/inbound byte ratio surfaces stuck tunnels before they are reported by users.
Happy to follow up with logs or a failing e2e test if helpful.
Source: slackhq/nebula