Skip to content

dcutr: quic hole punching dials from both peers #2806

Description

@rlve

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Fields

No fields configured for Bug.

Projects

Status
done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions