Add not downloadable test

This commit is contained in:
Arnaud 2026-06-15 20:14:35 +04:00
parent 43db403b84
commit bdbd9ed543
No known key found for this signature in database
GPG Key ID: A6C7C781817146FA
5 changed files with 263 additions and 3 deletions

View File

@ -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 ..."

View File

@ -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/<timestamp>__NAT_not_downloadable/<test>/<service>.log`.

View File

@ -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"]

View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "$0")/router-common.sh"
echo "router ready (wan iface $wanif)"
hold_until_stopped

View File

@ -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