From 182cd8df259c56643755c45de6b3323a1751bcf6 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 17 Jun 2026 02:11:51 +0400 Subject: [PATCH] Add hole punch test --- tests/integration/nat/hole-punch/README.md | 43 ++++++ tests/integration/nat/hole-punch/compose.yml | 126 ++++++++++++++++++ .../nat/hole-punch/router-entrypoint.sh | 11 ++ .../nat/hole-punch/testholepunch.nim | 64 +++++++++ 4 files changed, 244 insertions(+) create mode 100644 tests/integration/nat/hole-punch/README.md create mode 100644 tests/integration/nat/hole-punch/compose.yml create mode 100644 tests/integration/nat/hole-punch/router-entrypoint.sh create mode 100644 tests/integration/nat/hole-punch/testholepunch.nim diff --git a/tests/integration/nat/hole-punch/README.md b/tests/integration/nat/hole-punch/README.md new file mode 100644 index 00000000..809b7aea --- /dev/null +++ b/tests/integration/nat/hole-punch/README.md @@ -0,0 +1,43 @@ +# NAT hole-punch scenario + +## Scenario + +Two nodes are each behind their **own** NAT, so neither can reach the other on a +shared lan. Both are `NotReachable` and take a relay reservation on A. When D +downloads from B through the relay, B drives a **coordinated** DCUtR +simultaneous-open and starts hole-punching. + +## Topology + +``` +node B ── lan1 ── router1 (NAT) ──┐ + ├── wan ── bootstrap A (relay + autonat) +node D ── lan2 ── router2 (NAT) ──┘ +``` + +- **bootstrap A** — public node on the wan, autonat + relay server. +- **router1 / router2** — `lan -> wan` masquerade, *no* inbound forward. +- **node B** — behind router1, NotReachable, uploaded to and downloaded from. + Holds the inbound relayed connection, so it is the DCUtR initiator. +- **node D** — behind router2, NotReachable, downloads from B through the relay. + +## Run + +```bash +make testNatIntegration \ + STORAGE_INTEGRATION_TEST_INCLUDES=tests/integration/nat/hole-punch/testholepunch.nim +``` + +Rootless, but needs the host netfilter modules — if a router fails on iptables: +`sudo modprobe iptable_nat nf_conntrack`. + +## Expected result + +Both nodes are `NotReachable`. D downloads from B through the relay, opening a +relayed connection; B then runs DCUtR and the connection is upgraded to a direct +one. The test asserts B's log line +`Dcutr initiator has directly connected to the remote peer.` — the line is +unique to the simultaneous-open path, so it cannot be produced by a reversal. + +Per-run container logs are written before teardown to +`tests/integration/logs/__NAT_hole_punching//.log`. diff --git a/tests/integration/nat/hole-punch/compose.yml b/tests/integration/nat/hole-punch/compose.yml new file mode 100644 index 00000000..9a3b641c --- /dev/null +++ b/tests/integration/nat/hole-punch/compose.yml @@ -0,0 +1,126 @@ +# NAT hole-punch scenario — see README.md. Run via testholepunch.nim. +name: nat-hole-punch + +# Two NATed nodes, one behind each router (separate lans), so neither can reach +# the other on a shared lan: the only direct path is a coordinated hole punch. +x-addresses: + wan_subnet: &wan_subnet 7.7.7.0/24 + lan1_subnet: &lan1_subnet 10.99.1.0/24 + lan2_subnet: &lan2_subnet 10.99.2.0/24 + # A: public bootstrap, autonat + relay server + bootstrap_ip: &bootstrap_ip 7.7.7.10 + # router1 (B's NAT) + router1_wan_ip: &router1_wan_ip 7.7.7.3 + router1_lan_ip: &router1_lan_ip 10.99.1.2 + # router2 (D's NAT) + router2_wan_ip: &router2_wan_ip 7.7.7.4 + router2_lan_ip: &router2_lan_ip 10.99.2.2 + # B behind router1, D behind router2 + node_ip: &node_ip 10.99.1.10 + peer_ip: &peer_ip 10.99.2.10 + +networks: + wan: + internal: true + ipam: + config: + - subnet: *wan_subnet + lan1: + ipam: + config: + - subnet: *lan1_subnet + lan2: + ipam: + config: + - subnet: *lan2_subnet + +services: + router1: + image: localhost/storage-nat + cap_add: [NET_ADMIN] + sysctls: + net.ipv4.ip_forward: 1 + networks: + wan: + ipv4_address: *router1_wan_ip + lan1: + ipv4_address: *router1_lan_ip + environment: + ROUTER_WAN_IP: *router1_wan_ip + LAN_SUBNET: *lan1_subnet + volumes: + - ../router-common.sh:/scripts/router-common.sh:ro,z + - ./router-entrypoint.sh:/scripts/router-entrypoint.sh:ro,z + entrypoint: ["bash", "/scripts/router-entrypoint.sh"] + + router2: + image: localhost/storage-nat + cap_add: [NET_ADMIN] + sysctls: + net.ipv4.ip_forward: 1 + networks: + wan: + ipv4_address: *router2_wan_ip + lan2: + ipv4_address: *router2_lan_ip + environment: + ROUTER_WAN_IP: *router2_wan_ip + LAN_SUBNET: *lan2_subnet + volumes: + - ../router-common.sh:/scripts/router-common.sh:ro,z + - ./router-entrypoint.sh:/scripts/router-entrypoint.sh:ro,z + entrypoint: ["bash", "/scripts/router-entrypoint.sh"] + + bootstrap: + image: localhost/storage-nat + networks: + wan: + ipv4_address: *bootstrap_ip + entrypoint: ["/app/build/storage"] + command: + - --listen-ip=0.0.0.0 + - --api-bindaddr=0.0.0.0 + - --listen-port=8070 + - --disc-port=8090 + - --api-port=8080 + # bootstrap_ip (anchors can't go inside a string) + - --nat=extip:7.7.7.10 + - --relay-server + - --autonat-server + - --no-bootstrap-node + - --data-dir=/data + - --log-level=DEBUG + + # B: behind router1, uploaded to and downloaded from + node: + image: localhost/storage-nat + cap_add: [NET_ADMIN] + depends_on: [router1, bootstrap] + networks: + lan1: + ipv4_address: *node_ip + ports: + - "127.0.0.1:18090:8080" + environment: + ROUTER_LAN_IP: *router1_lan_ip + BOOTSTRAP_API: http://7.7.7.10:8080 + volumes: + - ../node-entrypoint.sh:/scripts/node-entrypoint.sh:ro,z + entrypoint: ["bash", "/scripts/node-entrypoint.sh"] + + # D: behind router2, the one that downloads from B through the relay + peer: + image: localhost/storage-nat + cap_add: [NET_ADMIN] + depends_on: [router2, bootstrap] + networks: + lan2: + ipv4_address: *peer_ip + ports: + - "127.0.0.1:18091:8080" + environment: + ROUTER_LAN_IP: *router2_lan_ip + BOOTSTRAP_API: http://7.7.7.10:8080 + volumes: + - ../node-entrypoint.sh:/scripts/node-entrypoint.sh:ro,z + entrypoint: ["bash", "/scripts/node-entrypoint.sh"] diff --git a/tests/integration/nat/hole-punch/router-entrypoint.sh b/tests/integration/nat/hole-punch/router-entrypoint.sh new file mode 100644 index 00000000..11d6d17d --- /dev/null +++ b/tests/integration/nat/hole-punch/router-entrypoint.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +source "$(dirname "$0")/router-common.sh" + +# Drop early punch SYN so TCP retransmits until the +# pinhole is open and the SYN gets forwarded. +iptables -A INPUT -i "$wanif" -p tcp --dport 8070 -j DROP + +echo "router ready (wan iface $wanif)" + +hold_until_stopped diff --git a/tests/integration/nat/hole-punch/testholepunch.nim b/tests/integration/nat/hole-punch/testholepunch.nim new file mode 100644 index 00000000..e72464a2 --- /dev/null +++ b/tests/integration/nat/hole-punch/testholepunch.nim @@ -0,0 +1,64 @@ +## Coordinated DCUtR hole-punching scenario (both peers NATed). See README.md. + +import std/[json, os, sequtils, strutils, times] +import pkg/chronos +import pkg/questionable/results + +import ../../../asynctest +import ../../../checktest +import ../../storageclient +import ../composehelper + +const dcutrConnectedLog = "Dcutr initiator has directly connected to the remote peer." + +proc announcesCircuitAddr(info: JsonNode): bool = + info{"announceAddresses"}.getElems.anyIt("p2p-circuit" in it.getStr) + +asyncchecksuite "NAT hole punching": + let + composeFile = currentSourcePath.parentDir / "compose.yml" + nodeApiUrl = "http://127.0.0.1:18090/api/storage/v1" + peerApiUrl = "http://127.0.0.1:18091/api/storage/v1" + suiteName = "NAT hole punching" + testName = "two NATed nodes upgrade a relayed connection to a direct one" + services = ["router1", "router2", "bootstrap", "node", "peer"] + startTime = now().format("yyyy-MM-dd'_'HH:mm:ss") + var + nodeClient: StorageClient + peerClient: StorageClient + + setup: + compose(composeFile, "up -d") + nodeClient = StorageClient.new(nodeApiUrl) + peerClient = StorageClient.new(peerApiUrl) + + teardown: + await nodeClient.close() + await peerClient.close() + saveContainerLogs(composeFile, suiteName, testName, startTime, services) + compose(composeFile, "down -v") + + test testName: + # Both nodes are NotReachable behind their own NAT and take a relay reservation. + check eventuallyInfo( + nodeClient, + info{"nat"}{"reachability"}.getStr == "NotReachable" and + info.announcesCircuitAddr(), + ) + check eventuallyInfo( + peerClient, + info{"nat"}{"reachability"}.getStr == "NotReachable" and + info.announcesCircuitAddr(), + ) + + # D downloads from B through the relay; that opens the relayed connection. + let cid = (await nodeClient.upload("punch me for real")).get + check (await peerClient.download(cid)).isOk + + # B sees the relayed peer D join and, since D has no public address, drives + # the coordinated DCUtR simultaneous-open instead of a unilateral reversal. + check eventuallySafe( + dcutrConnectedLog in serviceLogs(composeFile, "node"), + timeout = 60_000, + pollInterval = 2_000, + )