diff --git a/scripts/relay-test-tcp-dport.py b/scripts/relay-test-tcp-dport.py new file mode 100755 index 00000000..fc7bc8c5 --- /dev/null +++ b/scripts/relay-test-tcp-dport.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""TCP dport 8001 round trip — connect to a Solana entrypoint (ip_echo path). + +The mangle rule matches -p tcp --dport 8001, so connecting TO port 8001 +on any host triggers SNAT to the relay IP. The entrypoint responds with +ip_echo (4 bytes: our IP in network order). +""" +import socket +import sys + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8001 +HOST = sys.argv[2] if len(sys.argv) > 2 else "34.83.231.102" # entrypoint.mainnet-beta.solana.com + +# Resolve hostname +try: + addr = socket.getaddrinfo(HOST, PORT, socket.AF_INET)[0][4][0] +except socket.gaierror: + addr = HOST + +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.settimeout(5) + +try: + s.connect((addr, PORT)) + print(f"OK TCP handshake to {addr}:{PORT}") + # ip_echo: peer sends our IP back as 4 bytes + s.settimeout(2) + try: + data = s.recv(64) + if len(data) >= 4: + ip = socket.inet_ntoa(data[:4]) + print(f"OK ip_echo says we are {ip}") + else: + print(f"OK got {len(data)} bytes: {data.hex()}") + except socket.timeout: + print("NOTE: no ip_echo response (handshake succeeded)") +except socket.timeout: + print("TIMEOUT") + sys.exit(1) +except ConnectionRefusedError: + print(f"OK connection refused by {addr}:{PORT} (host reachable)") +except Exception as e: + print(f"ERROR {e}") + sys.exit(1) +finally: + s.close() diff --git a/scripts/relay-test-tcp-sport.py b/scripts/relay-test-tcp-sport.py new file mode 100755 index 00000000..236ad305 --- /dev/null +++ b/scripts/relay-test-tcp-sport.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""TCP sport 8001 round trip via HTTP HEAD to 1.1.1.1.""" +import socket +import sys + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8001 + +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.settimeout(5) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("0.0.0.0", PORT)) + +try: + s.connect(("1.1.1.1", 80)) + s.sendall(b"HEAD / HTTP/1.0\r\nHost: 1.1.1.1\r\n\r\n") + resp = s.recv(256) + if b"HTTP" in resp: + print("OK HTTP response received") + else: + print(f"OK {len(resp)} bytes (non-HTTP)") +except socket.timeout: + print("TIMEOUT") + sys.exit(1) +except Exception as e: + print(f"ERROR {e}") + sys.exit(1) +finally: + s.close() diff --git a/scripts/relay-test-udp.py b/scripts/relay-test-udp.py new file mode 100755 index 00000000..61e8a6f6 --- /dev/null +++ b/scripts/relay-test-udp.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""UDP sport 8001 round trip via DNS query to 8.8.8.8.""" +import socket +import sys + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8001 + +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("0.0.0.0", PORT)) + +# DNS query: txn ID 0x1234, standard query for example.com A +query = ( + b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" + b"\x07example\x03com\x00\x00\x01\x00\x01" +) +s.sendto(query, ("8.8.8.8", 53)) +s.settimeout(5) + +try: + resp, addr = s.recvfrom(512) + print(f"OK {len(resp)} bytes from {addr[0]}:{addr[1]}") +except socket.timeout: + print("TIMEOUT") + sys.exit(1) +except Exception as e: + print(f"ERROR {e}") + sys.exit(1) +finally: + s.close() diff --git a/scripts/test-ashburn-relay.sh b/scripts/test-ashburn-relay.sh new file mode 100755 index 00000000..2968747f --- /dev/null +++ b/scripts/test-ashburn-relay.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# End-to-end test for Ashburn validator relay +# +# Sends real packets from the kind node through the full relay path +# and waits for responses. A response proves both directions work. +# +# Outbound: kind node (172.20.0.2:8001) → biscayne mangle (fwmark 0x64) +# → policy route table ashburn → gre-ashburn → mia-sw01 Tunnel100 +# (VRF relay) → egress-vrf default → backbone Et4/1 → was-sw01 Et1/1 +# → internet (src 137.239.194.65) +# +# Inbound: internet → was-sw01 Et1/1 (dst 137.239.194.65) → static route +# → backbone → mia-sw01 → egress-vrf relay → Tunnel100 → biscayne +# gre-ashburn → conntrack reverse-SNAT → kind node (172.20.0.2:8001) +# +# Runs from the ansible controller host. +# +# Usage: +# ./scripts/test-ashburn-relay.sh +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR/.." + +KIND_NODE=laconic-70ce4c4b47e23b85-control-plane +BISCAYNE_INV=inventory/biscayne.yml +GOSSIP_PORT=8001 + +PASS=0 +FAIL=0 + +pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } + +# Copy test scripts to biscayne (once) +setup() { + for f in "$SCRIPT_DIR"/relay-test-*.py; do + ansible biscayne -i "$BISCAYNE_INV" -m ansible.builtin.copy \ + -a "src=$f dest=/tmp/$(basename "$f") mode=0755" \ + --become >/dev/null 2>&1 + done + + # Get kind node PID for nsenter (run in its network namespace, + # use biscayne's python3 since the kind node only has perl) + KIND_PID=$(ansible biscayne -i "$BISCAYNE_INV" -m ansible.builtin.shell \ + -a "docker inspect --format '{{ '{{' }}.State.Pid{{ '}}' }}' $KIND_NODE" \ + --become 2>&1 | grep -oP '^\d+$' || true) + + if [[ -z "$KIND_PID" ]]; then + echo "FATAL: could not get kind node PID" + exit 1 + fi + echo "Kind node PID: $KIND_PID" +} + +# Run a test script in the kind node's network namespace +run_test() { + local name=$1 + shift + ansible biscayne -i "$BISCAYNE_INV" -m ansible.builtin.shell \ + -a "nsenter --net --target $KIND_PID python3 /tmp/$name $*" \ + --become 2>&1 | grep -E '^OK|^TIMEOUT|^ERROR|^REFUSED|^NOTE' || echo "NO OUTPUT" +} + +echo "=== Ashburn Relay End-to-End Test ===" +echo "" + +setup +echo "" + +# Test 1: UDP sport 8001 → DNS query to 8.8.8.8 +# Triggers: mangle -p udp --sport 8001 → mark → SNAT → tunnel +echo "--- Test 1: UDP sport $GOSSIP_PORT (DNS query) ---" +result=$(run_test relay-test-udp.py "$GOSSIP_PORT") +if echo "$result" | grep -q "^OK"; then + pass "UDP sport $GOSSIP_PORT: $result" +else + fail "UDP sport $GOSSIP_PORT: $result" +fi +echo "" + +# Test 2: TCP sport 8001 → HTTP HEAD to 1.1.1.1 +# Triggers: mangle -p tcp --sport 8001 → mark → SNAT → tunnel +echo "--- Test 2: TCP sport $GOSSIP_PORT (HTTP request) ---" +result=$(run_test relay-test-tcp-sport.py "$GOSSIP_PORT") +if echo "$result" | grep -q "^OK"; then + pass "TCP sport $GOSSIP_PORT: $result" +else + fail "TCP sport $GOSSIP_PORT: $result" +fi +echo "" + +# Test 3: TCP dport 8001 → connect to Solana entrypoint (ip_echo) +# Triggers: mangle -p tcp --dport 8001 → mark → SNAT → tunnel +# REFUSED counts as pass — proves the round trip completed. +echo "--- Test 3: TCP dport $GOSSIP_PORT (ip_echo path) ---" +result=$(run_test relay-test-tcp-dport.py "$GOSSIP_PORT") +if echo "$result" | grep -q "^OK"; then + pass "TCP dport $GOSSIP_PORT: $result" +else + fail "TCP dport $GOSSIP_PORT: $result" +fi +echo "" + +# Summary +echo "=== Results: $PASS passed, $FAIL failed ===" +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi