Summary
ml_wg_mgr.c installs a peer's advertised endpoint (endpoints[0] from the MapResponse) as the WireGuard endpoint at peer-add time, before any path validation. wireguardif's output path treats any non-zero endpoint as a working direct path. For a peer behind carrier-grade NAT (e.g. a laptop on a phone hotspot) the advertised endpoint is unreachable — no hole was ever punched — so the tunnel goes one-way: inbound arrives via DERP, but every outbound data packet is sent direct-UDP into a black hole.
Observed symptom: tailscale status shows the node active, the peer's pings/TCP SYNs reach the node (via DERP), but all replies are lost. Ping times out, TCP to the node's service never connects.
Environment
- ESP32-S3, ESP-IDF v5.3, microlink @ 216da33 (code unchanged on current
main)
- Peer: Windows PC with official Tailscale client on a cellular hotspot (symmetric CGNAT)
Root cause
components/microlink/src/ml_wg_mgr.c ~line 500: at peer-add, if endpoint_count > 0 the advertised endpoints[0] is copied into wg_peer.endpoint_ip/endport_port. has_direct_path is still false — no direct DISCO pong ever arrived — yet data TX uses the endpoint. Packet trace from the device: inbound data frames arrive via_derp=1, outbound data goes direct-UDP to the advertised CGNAT address, DERP TX carries zero data (hdr=04) frames.
Fix
Tailscale's own semantics: data flows via DERP until a direct path is validated by a DISCO pong. Fix is to leave the WG endpoint blank at peer-add; the only endpoint installs are the already-correctly-gated ones (process_disco_pong on a direct pong, and the handshake path gated on best_ip). Same-LAN peers still upgrade to direct within seconds because the direct pong validates immediately. ~14-line change; PR follows.
Verification
On hardware: before the fix, a CGNAT peer could never reach a TCP service on the node (ping timeout, connect fail) despite the node showing as online. After the fix: ping replies over DERP, TCP connects, a full print job relayed end-to-end over the DERP path. Same-LAN direct path unaffected (validated same day).
Summary
ml_wg_mgr.cinstalls a peer's advertised endpoint (endpoints[0]from the MapResponse) as the WireGuard endpoint at peer-add time, before any path validation.wireguardif's output path treats any non-zero endpoint as a working direct path. For a peer behind carrier-grade NAT (e.g. a laptop on a phone hotspot) the advertised endpoint is unreachable — no hole was ever punched — so the tunnel goes one-way: inbound arrives via DERP, but every outbound data packet is sent direct-UDP into a black hole.Observed symptom:
tailscale statusshows the node active, the peer's pings/TCP SYNs reach the node (via DERP), but all replies are lost. Ping times out, TCP to the node's service never connects.Environment
main)Root cause
components/microlink/src/ml_wg_mgr.c~line 500: at peer-add, ifendpoint_count > 0the advertisedendpoints[0]is copied intowg_peer.endpoint_ip/endport_port.has_direct_pathis still false — no direct DISCO pong ever arrived — yet data TX uses the endpoint. Packet trace from the device: inbound data frames arrivevia_derp=1, outbound data goes direct-UDP to the advertised CGNAT address, DERP TX carries zero data (hdr=04) frames.Fix
Tailscale's own semantics: data flows via DERP until a direct path is validated by a DISCO pong. Fix is to leave the WG endpoint blank at peer-add; the only endpoint installs are the already-correctly-gated ones (
process_disco_pongon a direct pong, and the handshake path gated onbest_ip). Same-LAN peers still upgrade to direct within seconds because the direct pong validates immediately. ~14-line change; PR follows.Verification
On hardware: before the fix, a CGNAT peer could never reach a TCP service on the node (ping timeout, connect fail) despite the node showing as online. After the fix: ping replies over DERP, TCP connects, a full print job relayed end-to-end over the DERP path. Same-LAN direct path unaffected (validated same day).