From cc6135b1a11016398be3debbce40f526828a80ac Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 15 Jun 2026 10:16:08 +0400 Subject: [PATCH] Add PCP tests --- tests/integration/nat/pcp/README.md | 64 +++++++++++++ tests/integration/nat/pcp/compose.yml | 96 +++++++++++++++++++ .../integration/nat/pcp/router-entrypoint.sh | 48 ++++++++++ tests/integration/nat/pcp/testpcp.nim | 70 ++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 tests/integration/nat/pcp/README.md create mode 100644 tests/integration/nat/pcp/compose.yml create mode 100644 tests/integration/nat/pcp/router-entrypoint.sh create mode 100644 tests/integration/nat/pcp/testpcp.nim diff --git a/tests/integration/nat/pcp/README.md b/tests/integration/nat/pcp/README.md new file mode 100644 index 00000000..848156e4 --- /dev/null +++ b/tests/integration/nat/pcp/README.md @@ -0,0 +1,64 @@ +# NAT pcp scenario + +## Scenario + +A node behind a NAT becomes `Reachable` by mapping its port over PCP — the router +forwards nothing on its own, the node asks for the mapping and no relay is needed. + +## Topology + +``` +node B ──── lan ──── router (NAT + miniupnpd/PCP) ──── wan ──── bootstrap A +``` + +- **bootstrap A** — public node on the wan, runs the relay + autonat server. +- **router** — `lan -> wan` masquerade and *no* static forward. It runs + `miniupnpd` (real nftables backend) with PCP/NAT-PMP enabled. libplum tries PCP + first, so the mapping request goes over PCP and installs a real DNAT into the + nft chains the entrypoint pre-creates. +- **node B** — `nat=auto`, on the lan. First detected `NotReachable`, it maps its + TCP listen (8070) and UDP disc (8090) ports over PCP; the resulting DNAT lets + A's dial-back reach it, so the next AutoNAT round flips it to `Reachable`. + +The wan public range and `internal` flag work as in +[not-reachable](../not-reachable/README.md); the public wan IP also keeps +miniupnpd from refusing PCP/NAT-PMP as double-NAT. + +## Run + +Every NAT scenario: + +```bash +make testNatIntegration +``` + +Just this one — same `STORAGE_INTEGRATION_TEST_INCLUDES` filter as testIntegration, +with the test file path: + +```bash +make testNatIntegration \ + STORAGE_INTEGRATION_TEST_INCLUDES=tests/integration/nat/pcp/testpcp.nim +``` + +Builds the shared image and brings the compose topology up and down. Rootless, but +needs the host netfilter modules — if the router fails on iptables: +`sudo modprobe iptable_nat nf_conntrack`. + +## Expected result + +B ends up `Reachable`, the relay not running, announcing its direct address with +an active PCP mapping. Its `debug/info`: + +```json +{ + "nat": { + "reachability": "Reachable", + "clientMode": false, + "relayRunning": false, + "portMapping": "pcp" + } +} +``` + +Per-run container logs (router, bootstrap, node) are written before teardown to +`tests/integration/logs/__NAT_pcp//.log`. diff --git a/tests/integration/nat/pcp/compose.yml b/tests/integration/nat/pcp/compose.yml new file mode 100644 index 00000000..1cf8d43e --- /dev/null +++ b/tests/integration/nat/pcp/compose.yml @@ -0,0 +1,96 @@ +# Same NAT topology as upnp, but miniupnpd has PCP enabled and the node maps its +# port over PCP (libplum's preferred protocol), which installs a real DNAT on the +# router, so AutoNAT's dial-back reaches it and it is detected Reachable — no +# relay. Run via testpcp.nim. +# +# node B ──── lan ──── router (NAT + miniupnpd/PCP) ──── wan ──── bootstrap A +name: nat-pcp + +# Topology addresses, named for their role (defined once, referenced below). +x-addresses: + # fake public internet; a routable range so B looks public to A + wan_subnet: &wan_subnet 7.7.7.0/24 + # private network behind the NAT + lan_subnet: &lan_subnet 10.99.0.0/24 + # A: public bootstrap, relay + autonat server + bootstrap_ip: &bootstrap_ip 7.7.7.10 + # router's public face + router_wan_ip: &router_wan_ip 7.7.7.2 + # router's private face = B's gateway, and the PCP/NAT-PMP control point + router_lan_ip: &router_lan_ip 10.99.0.2 + # B, behind the NAT + node_ip: &node_ip 10.99.0.10 + +networks: + wan: + # Keep the fake public range private, not exposed to the host + internal: true + ipam: + config: + - subnet: *wan_subnet + lan: + ipam: + config: + - subnet: *lan_subnet + +services: + router: + image: localhost/storage-nat + cap_add: [NET_ADMIN] + sysctls: + net.ipv4.ip_forward: 1 + networks: + wan: + ipv4_address: *router_wan_ip + lan: + ipv4_address: *router_lan_ip + environment: + ROUTER_WAN_IP: *router_wan_ip + ROUTER_LAN_IP: *router_lan_ip + LAN_SUBNET: *lan_subnet + # scripts mounted, not baked, so editing them needs no image rebuild + 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 + + node: + image: localhost/storage-nat + cap_add: [NET_ADMIN] + depends_on: [router, bootstrap] + networks: + lan: + ipv4_address: *node_ip + # B's API, published so the test can poll it + ports: + - "127.0.0.1:18083:8080" + environment: + ROUTER_LAN_IP: *router_lan_ip + # B fetches A's SPR from this API at startup to join the network (bootstrap_ip) + BOOTSTRAP_API: http://7.7.7.10:8080 + EXTRA_STORAGE_ARGS: >- + --nat-port-mapping-discover-timeout=5000 + --nat-port-mapping-timeout=5000 + volumes: + - ../node-entrypoint.sh:/scripts/node-entrypoint.sh:ro,z + entrypoint: ["bash", "/scripts/node-entrypoint.sh"] diff --git a/tests/integration/nat/pcp/router-entrypoint.sh b/tests/integration/nat/pcp/router-entrypoint.sh new file mode 100644 index 00000000..257ce6cb --- /dev/null +++ b/tests/integration/nat/pcp/router-entrypoint.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +source "$(dirname "$0")/router-common.sh" + +# miniupnpd serves PCP/NAT-PMP (UDP 5351) on the lan face, so find it by its IP +# like router-common.sh finds the wan one. +lanif=$(ip -o -4 addr show | awk -v ip="$ROUTER_LAN_IP" '$0 ~ ip {print $2; exit}') + +# Reuse miniupnpd's chains (as nft_init.sh sets them up) without its forward drop +# policy. +nft -f - <<'EOF' +table inet filter { + chain prerouting_miniupnpd {} + chain postrouting_miniupnpd {} + chain miniupnpd {} + chain prerouting { + type nat hook prerouting priority -100; policy accept; + jump prerouting_miniupnpd + } + chain postrouting { + type nat hook postrouting priority 100; policy accept; + jump postrouting_miniupnpd + } +} +EOF + +conf=/tmp/miniupnpd.conf +cat > "$conf" </dev/null \ + || { echo "ERROR: miniupnpd failed to start" >&2; exit 1; } + +echo "router ready (wan iface $wanif, miniupnpd on $lanif)" + +hold_until_stopped diff --git a/tests/integration/nat/pcp/testpcp.nim b/tests/integration/nat/pcp/testpcp.nim new file mode 100644 index 00000000..2785f6d6 --- /dev/null +++ b/tests/integration/nat/pcp/testpcp.nim @@ -0,0 +1,70 @@ +## NAT pcp scenario — node behind a real NAT becomes Reachable by mapping its +## port over PCP. +## +## Same shape as the upnp test, but miniupnpd has PCP enabled and the node maps +## its TCP/UDP ports via PCP (libplum's preferred protocol), which installs a real +## DNAT on the router. AutoNAT's dial-back then reaches the node, so it is +## detected Reachable with an active PCP mapping — no relay. +## +## Requires podman-compose and the scenario image: +## podman build -t localhost/storage-nat -f tests/integration/nat/Dockerfile . + +import std/[json, os, sequtils, strutils, times] +import pkg/chronos +import pkg/questionable/results + +import ../../../asynctest +import ../../../checktest +import ../../storageclient +import ../composehelper + +const + detectTimeout = 300_000 # ms + pollInterval = 5_000 # ms + +proc announcesDirectAddr(info: JsonNode): bool = + ## A reachable node announces at least one direct (non-circuit) address. + info{"announceAddresses"}.getElems.anyIt("p2p-circuit" notin it.getStr) + +asyncchecksuite "NAT pcp": + let + composeFile = currentSourcePath.parentDir / "compose.yml" + nodeApiUrl = "http://127.0.0.1:18083/api/storage/v1" + suiteName = "NAT pcp" + testName = "node behind NAT maps its port over PCP and is Reachable" + services = ["router", "bootstrap", "node"] + startTime = now().format("yyyy-MM-dd'_'HH:mm:ss") + var client: StorageClient + + setup: + compose(composeFile, "up -d") + client = StorageClient.new(nodeApiUrl) + + teardown: + await client.close() + saveContainerLogs(composeFile, suiteName, testName, startTime, services) + compose(composeFile, "down -v") + + test testName: + # Reachable is the settling signal: wait for it, then assert each expected + # property separately so a failure points at the exact condition. + check eventuallySafe( + block: + var reachable = false + try: + let info = await client.info() + reachable = + info.isOk and info.get{"nat"}{"reachability"}.getStr == "Reachable" + except HttpError: + discard # B's API is not up yet, keep polling + reachable, + timeout = detectTimeout, + pollInterval = pollInterval, + ) + + let info = (await client.info()).get + let nat = info{"nat"} + check nat{"reachability"}.getStr == "Reachable" + check nat{"relayRunning"}.getBool == false + check nat{"portMapping"}.getStr == "pcp" + check info.announcesDirectAddr()