Description
QUIC hole punching fails against a spec-compliant peer because nim dials the direct connection from both DCUtR roles. For QUIC that means both peers emit a client Initial, and neither acts as the server, so the handshake collides.
The DCUtR spec makes QUIC hole punching asymmetric. Only the Sync receiver (A) dials. The Sync sender (B) must not dial. It sends random-byte UDP packets and becomes the QUIC server. nim treats every hole-punchable address the same and dials from both roles, which is right for TCP simultaneous open but wrong for QUIC.
B is the DCUtR initiator in client.nim. It sends Sync, waits half the RTT, then dials every dialable address, QUIC included:
|
await stream.send(MsgType.Sync, @[]) |
|
debug "Dcutr initiator has sent a Sync message." |
|
await sleepAsync(halfRtt) |
|
|
|
if peerDialableAddrs.len > self.maxDialableAddrs: |
|
peerDialableAddrs = peerDialableAddrs[0 ..< self.maxDialableAddrs] |
|
debug "Dcutr initiator starting direct dial attempts", |
|
peerDialableAddrs, connectTimeout = self.connectTimeout |
|
let dialFuts = peerDialableAddrs.mapIt( |
|
switch.connect( |
|
stream.peerId, |
|
@[it], |
|
forceDial = true, |
|
reuseConnection = false, |
|
dir = Direction.In, |
|
) |
|
) |
Note B already labels itself the server here: it dials with dir = In and registers an expected incoming connection. For TCP that relabeling is harmless. For QUIC the client and server roles are real on the wire, so B emitting a client Initial defeats the very role it just claimed.
A is the responder in server.nim. It dials on receiving Sync, which is the correct role for both TCP and QUIC:
|
let syncMsg = DcutrMsg.decode(await stream.readLp(1024)).valueOr: |
|
raise newException(DcutrError, error) |
|
debug "Dcutr receiver has received a Sync message.", syncMsg |
|
|
|
if peerDialableAddrs.len > maxDialableAddrs: |
|
peerDialableAddrs = peerDialableAddrs[0 ..< maxDialableAddrs] |
|
debug "Dcutr receiver starting direct dial attempts", |
|
peerDialableAddrs, connectTimeout |
|
let dialFuts = peerDialableAddrs.mapIt( |
|
switch.connect( |
|
stream.peerId, |
|
@[it], |
|
forceDial = true, |
|
reuseConnection = false, |
|
dir = Direction.Out, |
|
) |
|
) |
Both paths take addresses from getHolePunchableAddrs, which returns TCP and QUIC_V1 addresses mixed together with no transport or role distinction:
|
proc getHolePunchableAddrs*( |
|
addrs: seq[MultiAddress] |
|
): seq[MultiAddress] {.raises: [LPError].} = |
|
var res = newSeq[MultiAddress]() |
|
for a in addrs: |
|
# This is necessary to also accept addrs like /ip4/198.51.100/tcp/1234/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N |
|
if [TCP, mapAnd(TCP_DNS, P2PPattern), mapAnd(TCP_IP, P2PPattern)].anyIt(it.match(a)): |
|
res.add(a[0 .. 1].tryGet()) |
|
elif [QUIC_V1, mapAnd(QUIC_V1_DNS, P2PPattern), mapAnd(QUIC_V1_IP, P2PPattern)].anyIt( |
|
it.match(a) |
|
): |
|
res.add(a[0 .. 2].tryGet()) |
|
return res |
This is not a NAT-mapping reuse problem. The QUIC transport already reuses the listener endpoint for dialing, so a punched connection leaves from the listener UDP port:
|
proc dialEndpointFor( |
|
self: QuicTransport, address: TransportAddress |
|
): QuicEndpoint {.raises: [TLSCertificateError, QuicError, TransportOsError].} = |
|
let listenerEndpoint = self.listenerEndpointFor(address) |
|
if listenerEndpoint.isSome(): |
|
return listenerEndpoint.get() |
|
|
|
self.dialOnlyEndpointFor(address.family) |
Spec Recommendation
DCUtR Simultaneous Connect, step 5:
For a QUIC address:
- Upon receiving the
Sync, A immediately dials the address to B.
- Upon expiry of the timer,
B starts to send UDP packets filled with random
bytes to A's address. Packets should be sent repeatedly in random intervals
between 10 and 200 ms.
- This will result in a QUIC connection where
A is the client and B is the
server.
A receives Sync and maps to server.nim. B sends Sync and maps to client.nim. For a QUIC address, B diverges from the spec: it dials instead of sending random-byte UDP packets.
Reproduction
unified-testing hole-punch suite, rust-v0.56 x nim-v2.2 (quic-v1), rust relay, linux router - libp2p/unified-testing#78
- rust dials the direct connection as
role_override: Dialer, port_use: Reuse. It is spec A.
- nim is the listener, so it initiates DCUtR and is spec
B. It dials too.
- rust closes the direct connection:
Connection closed with <nim peerid>: Some(IO(Custom { kind: Other, error: Error(Right(Decode(Io(Custom { kind: Other, error: Failed })))) })).
TCP hole punching passes in both directions. QUIC over the relay works. Only the direct hole-punched QUIC connection fails.
Description
QUIC hole punching fails against a spec-compliant peer because nim dials the direct connection from both DCUtR roles. For QUIC that means both peers emit a client Initial, and neither acts as the server, so the handshake collides.
The DCUtR spec makes QUIC hole punching asymmetric. Only the
Syncreceiver (A) dials. TheSyncsender (B) must not dial. It sends random-byte UDP packets and becomes the QUIC server. nim treats every hole-punchable address the same and dials from both roles, which is right for TCP simultaneous open but wrong for QUIC.Bis the DCUtR initiator inclient.nim. It sendsSync, waits half the RTT, then dials every dialable address, QUIC included:nim-libp2p/libp2p/protocols/connectivity/dcutr/client.nim
Lines 69 to 85 in c69bec5
Note
Balready labels itself the server here: it dials withdir = Inand registers an expected incoming connection. For TCP that relabeling is harmless. For QUIC the client and server roles are real on the wire, soBemitting a client Initial defeats the very role it just claimed.Ais the responder inserver.nim. It dials on receivingSync, which is the correct role for both TCP and QUIC:nim-libp2p/libp2p/protocols/connectivity/dcutr/server.nim
Lines 81 to 97 in c69bec5
Both paths take addresses from
getHolePunchableAddrs, which returns TCP and QUIC_V1 addresses mixed together with no transport or role distinction:nim-libp2p/libp2p/protocols/connectivity/dcutr/core.nim
Lines 80 to 92 in c69bec5
This is not a NAT-mapping reuse problem. The QUIC transport already reuses the listener endpoint for dialing, so a punched connection leaves from the listener UDP port:
nim-libp2p/libp2p/transports/quictransport.nim
Lines 565 to 572 in c69bec5
Spec Recommendation
DCUtR Simultaneous Connect, step 5:
AreceivesSyncand maps toserver.nim.BsendsSyncand maps toclient.nim. For a QUIC address,Bdiverges from the spec: it dials instead of sending random-byte UDP packets.Reproduction
unified-testing hole-punch suite,
rust-v0.56 x nim-v2.2 (quic-v1), rust relay, linux router - libp2p/unified-testing#78role_override: Dialer, port_use: Reuse. It is specA.B. It dials too.Connection closed with <nim peerid>: Some(IO(Custom { kind: Other, error: Error(Right(Decode(Io(Custom { kind: Other, error: Failed })))) })).TCP hole punching passes in both directions. QUIC over the relay works. Only the direct hole-punched QUIC connection fails.