Files
nucleic/scripts/nat-sim/run.sh
T

149 lines
6.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# NAT-traversal simulation for Covalence direct (SYNC_PROTOCOL §3.3). Stands up two "home
# networks" behind NATs, joined by a middle "internet" namespace running a STUN server, and
# drives the REAL punch core (`nucleic-punch-harness`, which is `NucleicProtocol/Sync/Direct`)
# across them — so a green run is evidence about the shipping STUN/punch logic, not a mock.
#
# Topology (all Linux network namespaces + veth pairs):
#
# peerA ── natA ──┐ ┌── natB ── peerB
# 10.0.1.2 │ inet ns │ 10.0.2.2
# 198.51.100.x (STUN) 203.0.113.x
#
# Each NAT namespace masquerades its home subnet toward `inet`. The masquerade *mode* is the
# whole experiment:
# • port-preserving (default nftables masquerade) → endpoint-independent → CONE (punchable)
# • `masquerade random` (per-flow random source port) → endpoint-dependent → SYMMETRIC
#
# Usage: sudo scripts/nat-sim/run.sh <cone|symmetric> [harness_path]
# cone both NATs port-preserving → punch must SUCCEED
# symmetric both NATs random-port → classification must detect symmetric, punch gives up
#
# Requires: root (netns/nft), iproute2, nftables, and a built harness (defaults to the debug
# build; pass an explicit path for a musl/release binary). Exercised inside a privileged
# container: `docker run --privileged … scripts/nat-sim/run.sh cone`.
set -euo pipefail
MODE="${1:-cone}"
HARNESS="${2:-.build/debug/nucleic-punch-harness}"
if [[ $EUID -ne 0 ]]; then echo "error: must run as root (netns + nftables)" >&2; exit 1; fi
if [[ ! -x "$HARNESS" ]]; then echo "error: harness not found at $HARNESS" >&2; exit 1; fi
case "$MODE" in cone|symmetric) ;; *) echo "error: mode must be cone|symmetric" >&2; exit 1;; esac
WORK="$(mktemp -d)"
cleanup() {
for ns in peerA peerB natA natB inet; do ip netns del "$ns" 2>/dev/null || true; done
rm -rf "$WORK"
}
trap cleanup EXIT
echo "==> building topology (mode: $MODE)"
for ns in peerA peerB natA natB inet; do ip netns add "$ns"; done
# veth: peerA<->natA, natA<->inet, inet<->natB, natB<->peerB
link() { # ns1 if1 addr1 ns2 if2 addr2
ip link add "$2" netns "$1" type veth peer name "$5" netns "$4"
ip -n "$1" addr add "$3" dev "$2"; ip -n "$1" link set "$2" up
ip -n "$4" addr add "$6" dev "$5"; ip -n "$4" link set "$5" up
}
link peerA a-nat 10.0.1.2/24 natA nat-a 10.0.1.1/24
link natA a-inet 198.51.100.2/24 inet inet-a 198.51.100.1/24
link inet inet-b 203.0.113.1/24 natB nat-b 203.0.113.2/24
link natB b-peer 10.0.2.1/24 peerB b-nat 10.0.2.2/24
for ns in peerA peerB natA natB inet; do ip -n "$ns" link set lo up; done
# Routing: peers default via their NAT; NATs reach the far *public* subnet via inet. The
# private home subnets (10.0.x) are deliberately NOT cross-routed — so the only path between
# the peers is through their public (reflexive) addresses, i.e. a genuine hole punch. (Cross-
# routing the private subnets would let the punch cheat via a directly-reachable private
# address, which would also make the symmetric test falsely "succeed".)
ip -n peerA route add default via 10.0.1.1
ip -n peerB route add default via 10.0.2.1
ip -n natA route add 203.0.113.0/24 via 198.51.100.1
ip -n natB route add 198.51.100.0/24 via 203.0.113.1
# Enable forwarding in the router namespaces. Prefer sysctl; fall back to the procfs knob
# directly (minimal container images often ship no `sysctl` binary).
enable_forwarding() {
if ip netns exec "$1" sh -c 'command -v sysctl >/dev/null'; then
ip netns exec "$1" sysctl -qw net.ipv4.ip_forward=1
else
ip netns exec "$1" sh -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
fi
}
for ns in natA natB inet; do enable_forwarding "$ns"; done
# NAT masquerade. `random` makes the source-port mapping endpoint-dependent = symmetric.
RANDFLAG=""; [[ "$MODE" == "symmetric" ]] && RANDFLAG="random"
nat_rules() { # ns wan-if
ip netns exec "$1" nft -f - <<EOF
table ip nat {
chain post {
type nat hook postrouting priority 100;
oifname "$2" masquerade $RANDFLAG
}
}
EOF
}
# Masquerade on each NAT's *WAN* interface (toward inet), not its LAN side.
nat_rules natA a-inet
nat_rules natB nat-b
STUN_PORT=3478
echo "==> starting two STUN servers in inet ns (198.51.100.1, 203.0.113.1)"
# Two servers, each bound to a *specific* inet interface, so every reply leaves from the exact
# IP the client queried (a wildcard-bound server would reply from the kernel-default source,
# which the peer's NAT conntrack then drops). Two distinct STUN endpoints is what lets
# `StunClient.discover` tell cone (same mapped port both servers) from symmetric (differing).
ip netns exec inet "$HARNESS" stun-server --port "$STUN_PORT" --bind 198.51.100.1 &
STUN_PID1=$!
ip netns exec inet "$HARNESS" stun-server --port "$STUN_PORT" --bind 203.0.113.1 &
STUN_PID2=$!
trap 'kill $STUN_PID1 $STUN_PID2 2>/dev/null || true; cleanup' EXIT
sleep 1
export_stun() { echo "NUCLEIC_STUN_SERVERS=198.51.100.1:$STUN_PORT,203.0.113.1:$STUN_PORT"; }
echo "==> classifying NAT from peerA"
set +e
ip netns exec peerA env "$(export_stun)" "$HARNESS" classify
CLASSIFY_A=$?
set -e
echo "==> punching peerA <-> peerB"
TOKEN_A="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
TOKEN_B="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
set +e
ip netns exec peerA env "$(export_stun)" "$HARNESS" punch \
--role a --out "$WORK/a.cand" --peer-file "$WORK/b.cand" \
--token "$TOKEN_A" --peer-token "$TOKEN_B" --budget 12 &
PA=$!
ip netns exec peerB env "$(export_stun)" "$HARNESS" punch \
--role b --out "$WORK/b.cand" --peer-file "$WORK/a.cand" \
--token "$TOKEN_B" --peer-token "$TOKEN_A" --budget 12 &
PB=$!
wait $PA; RA=$?
wait $PB; RB=$?
set -e
echo "==> results: classifyA=$CLASSIFY_A punchA=$RA punchB=$RB"
if [[ "$MODE" == "cone" ]]; then
# Cone: classification is cone (exit 0) and both sides validate a round trip (exit 0).
if [[ $RA -eq 0 && $RB -eq 0 ]]; then
echo "PASS: cone NATs punched through"
exit 0
fi
echo "FAIL: cone NATs should punch (punchA=$RA punchB=$RB)"; exit 1
else
# Symmetric: classification must NOT be cone, and the punch must fail or give up
# (non-zero) — validating that the policy doesn't waste effort where it can't win.
if [[ $CLASSIFY_A -ne 0 && $RA -ne 0 && $RB -ne 0 ]]; then
echo "PASS: symmetric NATs detected and punch abandoned"
exit 0
fi
echo "FAIL: symmetric NATs should be detected + punch should give up (classifyA=$CLASSIFY_A punchA=$RA punchB=$RB)"; exit 1
fi