From bdbd9ed543e583e52beb2694787afad20c7dd40d Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 15 Jun 2026 20:14:35 +0400 Subject: [PATCH] Add not downloadable test --- tests/integration/nat/node-entrypoint.sh | 9 +- .../nat/not-downloadable/README.md | 42 +++++++ .../nat/not-downloadable/compose.yml | 112 ++++++++++++++++++ .../nat/not-downloadable/router-entrypoint.sh | 7 ++ .../not-downloadable/testnotdownloadable.nim | 96 +++++++++++++++ 5 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 tests/integration/nat/not-downloadable/README.md create mode 100644 tests/integration/nat/not-downloadable/compose.yml create mode 100755 tests/integration/nat/not-downloadable/router-entrypoint.sh create mode 100644 tests/integration/nat/not-downloadable/testnotdownloadable.nim diff --git a/tests/integration/nat/node-entrypoint.sh b/tests/integration/nat/node-entrypoint.sh index d8279976..118bca81 100644 --- a/tests/integration/nat/node-entrypoint.sh +++ b/tests/integration/nat/node-entrypoint.sh @@ -2,9 +2,12 @@ set -euo pipefail -# Redirect the traffic to our router instead -# of podman's own gateway to put B behind the NAT. -ip route replace default via "$ROUTER_LAN_IP" +# Redirect the traffic to our router instead of podman's own gateway to put the +# node behind the NAT. A node on the wan (reachable) leaves ROUTER_LAN_IP unset +# and keeps its default route. +if [[ -n "${ROUTER_LAN_IP:-}" ]]; then + ip route replace default via "$ROUTER_LAN_IP" +fi # Fetch the bootstrap SPR (retry: the bootstrap may still be starting). echo "fetching bootstrap SPR from $BOOTSTRAP_API ..." diff --git a/tests/integration/nat/not-downloadable/README.md b/tests/integration/nat/not-downloadable/README.md new file mode 100644 index 00000000..5581a52a --- /dev/null +++ b/tests/integration/nat/not-downloadable/README.md @@ -0,0 +1,42 @@ +# NAT not-downloadable scenario + +## Scenario + +A node behind a NAT with no relay is `NotReachable` and announces no dialable +address. A remote peer can never dial it, so a download from it fails. + +## Topology + +``` +node B ──── lan ──── router (NAT) ──── wan ──── bootstrap A + └────── node C (reachable) +``` + +- **bootstrap A** — public node on the wan, autonat server, started with + `--nat=extip`. Unlike not-reachable, it runs *without* `--relay-server`, so B + has no relay to fall back to. +- **router** — `lan -> wan` masquerade and *no* inbound forward, so B can dial + out but nothing can dial back in. +- **node B** — `nat=auto`, on the lan. It joins via A, AutoNAT finds it + unreachable, and with no relay it ends up announcing nothing dialable. +- **node C** — `nat=auto`, directly on the wan, so AutoNAT finds it + `Reachable`. It is the peer that tries (and fails) to download from B. + +## Run + +```bash +make testNatIntegration \ + STORAGE_INTEGRATION_TEST_INCLUDES=tests/integration/nat/not-downloadable/testnotdownloadable.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 is `NotReachable` and announces no address, while C is `Reachable`. B uploads +a file, then C tries to fetch its manifest over the network and fails. + +Per-run container logs (router, bootstrap, client, node) are written before teardown to +`tests/integration/logs/__NAT_not_downloadable//.log`. diff --git a/tests/integration/nat/not-downloadable/compose.yml b/tests/integration/nat/not-downloadable/compose.yml new file mode 100644 index 00000000..eb29d5f2 --- /dev/null +++ b/tests/integration/nat/not-downloadable/compose.yml @@ -0,0 +1,112 @@ +# A node behind a NAT with no relay can't be reached from outside, so it can't +# be downloaded from: it announces no dialable address, the reachable node C +# finds it as a provider but never dials it. Same real iptables NAT as +# not-reachable, but bootstrap A runs *without* the relay server. Run via +# testnotdownloadable.nim. +# +# node B ──── lan ──── router (NAT) ──── wan ──── bootstrap A +# └────── node C (reachable) +name: nat-not-downloadable + +# 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, autonat server (no relay) + bootstrap_ip: &bootstrap_ip 7.7.7.10 + # C: public node on the wan, reachable, the one that tries to download from B + client_ip: &client_ip 7.7.7.20 + # router's public face + router_wan_ip: &router_wan_ip 7.7.7.2 + # router's private face = B's gateway + 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 + LAN_SUBNET: *lan_subnet + # scripts mounted, 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 + - --autonat-server + - --no-bootstrap-node + - --data-dir=/data + - --log-level=DEBUG + + # C sits on the wan, directly reachable + client: + image: localhost/storage-nat + depends_on: [bootstrap] + networks: + wan: + ipv4_address: *client_ip + # C's API, published so the test can drive the download from C and poll it + ports: + - "127.0.0.1:18085:8080" + environment: + # C fetches A's SPR from this API at startup to join the network (bootstrap_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"] + + 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 upload to it and poll it + ports: + - "127.0.0.1:18084: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 + volumes: + - ../node-entrypoint.sh:/scripts/node-entrypoint.sh:ro,z + entrypoint: ["bash", "/scripts/node-entrypoint.sh"] diff --git a/tests/integration/nat/not-downloadable/router-entrypoint.sh b/tests/integration/nat/not-downloadable/router-entrypoint.sh new file mode 100755 index 00000000..21a8ef3b --- /dev/null +++ b/tests/integration/nat/not-downloadable/router-entrypoint.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +source "$(dirname "$0")/router-common.sh" + +echo "router ready (wan iface $wanif)" + +hold_until_stopped diff --git a/tests/integration/nat/not-downloadable/testnotdownloadable.nim b/tests/integration/nat/not-downloadable/testnotdownloadable.nim new file mode 100644 index 00000000..393ddabb --- /dev/null +++ b/tests/integration/nat/not-downloadable/testnotdownloadable.nim @@ -0,0 +1,96 @@ +## NAT not-downloadable scenario — a node behind a NAT with no relay cannot be +## downloaded from. +## +## Same shape as the not-reachable test: compose.yml brings up a real NAT +## topology, but bootstrap A runs without the relay server. B stays NotReachable +## and announces no dialable address, so a reachable peer C finds it as a +## provider but can never dial it — the manifest fetch fails. +## +## Requires podman-compose and the scenario image: +## podman build -t localhost/storage-nat \ +## -f tests/integration/nat/Dockerfile . + +import std/[json, os, 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 announcesNothing(info: JsonNode): bool = + ## An unreachable node with no relay has no dialable address to announce. + info{"announceAddresses"}.getElems.len == 0 + +asyncchecksuite "NAT not downloadable": + let + composeFile = currentSourcePath.parentDir / "compose.yml" + nodeApiUrl = "http://127.0.0.1:18084/api/storage/v1" + clientApiUrl = "http://127.0.0.1:18085/api/storage/v1" + suiteName = "NAT not downloadable" + testName = "a NAT'd node without relay cannot be downloaded from" + services = ["router", "bootstrap", "client", "node"] + startTime = now().format("yyyy-MM-dd'_'HH:mm:ss") + var + nodeClient: StorageClient + clientC: StorageClient + + setup: + compose(composeFile, "up -d") + nodeClient = StorageClient.new(nodeApiUrl) + clientC = StorageClient.new(clientApiUrl) + + teardown: + await nodeClient.close() + await clientC.close() + saveContainerLogs(composeFile, suiteName, testName, startTime, services) + compose(composeFile, "down -v") + + test testName: + # Make sure nodeClient is not reachable + check eventuallySafe( + block: + var settled = false + try: + let info = await nodeClient.info() + settled = + info.isOk and info.get{"nat"}{"reachability"}.getStr == "NotReachable" and + info.get.announcesNothing() + except HttpError: + discard + settled, + timeout = detectTimeout, + pollInterval = pollInterval, + ) + + let info = (await nodeClient.info()).get + # Double check to make sure nodeClient is not reachable and has + # nothing to announce + check info.announcesNothing() + + # C is reachable + check eventuallySafe( + block: + var reachable = false + try: + let cInfo = await clientC.info() + reachable = + cInfo.isOk and cInfo.get{"nat"}{"reachability"}.getStr == "Reachable" + except HttpError: + discard + reachable, + timeout = detectTimeout, + pollInterval = pollInterval, + ) + + # B uploads a file + let cid = (await nodeClient.upload("hello from behind the NAT")).get + + # C cannot download the manifest, as B is not reachable + let res = await clientC.downloadManifestOnly(cid) + check res.isErr