aboutsummaryrefslogtreecommitdiffstats
path: root/poc/spike4_nat.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 03:52:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 03:52:58 +0200
commit271adc8504aad32075d75d06fd42023877a649ec (patch)
treefe8697761d635a5cac7e0693f2e588a38a7968b9 /poc/spike4_nat.py
downloadmeshbay-271adc8504aad32075d75d06fd42023877a649ec.tar.gz
chore: initialize monorepo structure for MeshBay
3-package layout: meshbay-common (shared crypto/protocol), meshbay-hub (FastAPI server), meshbay-node (local daemon). Includes validated POC spikes 1-6 in poc/, architecture drafts v1/v2 in docs/, and CLAUDE.md project conventions. All cryptographic primitives extracted from POC into meshbay_common/crypto.py (GEK wrap/unwrap, chunk key derivation, keystore encryption, chunk signing). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'poc/spike4_nat.py')
-rw-r--r--poc/spike4_nat.py279
1 files changed, 279 insertions, 0 deletions
diff --git a/poc/spike4_nat.py b/poc/spike4_nat.py
new file mode 100644
index 0000000..44bcdca
--- /dev/null
+++ b/poc/spike4_nat.py
@@ -0,0 +1,279 @@
+"""
+MeshBay Node POC — Spike 4: NAT Traversal (v2)
+
+Tests:
+ A. STUN — external address discovery (pure UDP, no lib)
+ B. NAT type probe — does external port change per destination? (cone vs symmetric)
+ C. Bidirectional UDP hole punching:
+ node → meshbay.org:19002 (creates NAT entry for that dest)
+ meshbay.org receives, echoes to the source addr it saw
+ node receives echo → bidirectional UDP confirmed
+ D. UPnP — attempt port mapping on SFR box
+ E. Update hub endpoint_hint
+"""
+
+import asyncio, socket, struct, os, json, httpx, time, subprocess
+from pathlib import Path
+
+PASS = "✓"; FAIL = "✗"; SKIP = "–"
+
+LOCAL_PORT = 19000
+MESHBAY_IP = "164.132.246.44" # meshbay.org resolved
+MESHBAY_ECHO_PORT = 19002
+STATE_FILE = Path("node_state.json")
+HUB_URL = "http://meshbay.org"
+MESH_SERVER = "cbesson@meshbay.org"
+
+STUN_SERVERS = [
+ ("stun.cloudflare.com", 3478),
+ ("stun.l.google.com", 19302),
+]
+
+# ── STUN (pure UDP) ────────────────────────────────────────────────────────────
+
+def stun_query(local_port: int, stun_host: str, stun_port: int) -> tuple[str|None, int|None]:
+ """Single STUN query from local_port. Returns (ext_ip, ext_port) or (None, None)."""
+ MAGIC = 0x2112A442
+ try:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.settimeout(3)
+ sock.bind(('', local_port))
+ txn = os.urandom(12)
+ sock.sendto(struct.pack('>HHI12s', 0x0001, 0, MAGIC, txn), (stun_host, stun_port))
+ data, _ = sock.recvfrom(1024)
+ sock.close()
+ msg_type, msg_len, magic, _ = struct.unpack_from('>HHI12s', data)
+ if msg_type != 0x0101 or magic != MAGIC:
+ return None, None
+ offset = 20
+ while offset < 20 + msg_len:
+ atype, alen = struct.unpack_from('>HH', data, offset)
+ offset += 4
+ if atype == 0x0020:
+ family = struct.unpack_from('>xB', data, offset)[0]
+ if family == 0x01:
+ xport = struct.unpack_from('>H', data, offset + 2)[0]
+ xaddr = struct.unpack_from('>I', data, offset + 4)[0]
+ return (socket.inet_ntoa(struct.pack('>I', xaddr ^ MAGIC)),
+ xport ^ (MAGIC >> 16))
+ offset += alen + (4 - alen % 4) % 4
+ except Exception:
+ pass
+ return None, None
+
+
+# ── UPnP ──────────────────────────────────────────────────────────────────────
+
+def try_upnp(port: int) -> tuple[str|None, int|None]:
+ try:
+ import miniupnpc
+ u = miniupnpc.UPnP()
+ u.discoverdelay = 500
+ if u.discover() == 0:
+ return None, None
+ u.selectigd()
+ ext_ip = u.externalipaddress()
+ local_ip = u.lanaddr
+ if u.addportmapping(port, 'TCP', local_ip, port, 'MeshBay POC', ''):
+ return ext_ip, port
+ except Exception:
+ pass
+ return None, None
+
+
+# ── Bidirectional UDP hole punch test ─────────────────────────────────────────
+
+async def start_echo_server_on_meshbay() -> asyncio.subprocess.Process:
+ """SSH to meshbay.org and start a one-shot UDP echo server."""
+ echo_script = (
+ f"python3 -c \""
+ f"import socket;"
+ f"s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);"
+ f"s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);"
+ f"s.bind(('0.0.0.0',{MESHBAY_ECHO_PORT}));"
+ f"s.settimeout(15);"
+ f"print('ECHO_READY',flush=True);"
+ f"data,addr=s.recvfrom(256);"
+ f"print('RECEIVED from',addr,'data',data.decode(),flush=True);"
+ f"s.sendto(b'ECHO:'+data,addr);"
+ f"print('ECHOED to',addr,flush=True);"
+ f"s.close()\""
+ )
+ proc = await asyncio.create_subprocess_exec(
+ 'ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=5',
+ MESH_SERVER, echo_script,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ # Wait for ECHO_READY
+ line = await asyncio.wait_for(proc.stdout.readline(), timeout=8)
+ if b'ECHO_READY' not in line:
+ proc.terminate()
+ return None
+ return proc
+
+
+async def udp_hole_punch_test(local_port: int) -> tuple[bool, str|None, str|None]:
+ """
+ 1. Start UDP listener locally
+ 2. Send probe to meshbay.org echo server (creates NAT mapping)
+ 3. Echo server responds to actual source addr/port it received
+ 4. Check if echo arrives locally
+ Returns (success, seen_ext_addr, echo_content)
+ """
+ loop = asyncio.get_event_loop()
+ result = {'addr': None, 'data': None}
+ recv_event = asyncio.Event()
+
+ class RxProtocol(asyncio.DatagramProtocol):
+ def __init__(self, transport_holder):
+ self._transport_holder = transport_holder
+ def connection_made(self, transport):
+ self._transport_holder.append(transport)
+ def datagram_received(self, data, addr):
+ result['addr'] = addr
+ result['data'] = data
+ recv_event.set()
+ def error_received(self, exc):
+ pass
+
+ transport_holder = []
+ transport, _ = await loop.create_datagram_endpoint(
+ lambda: RxProtocol(transport_holder),
+ local_addr=('0.0.0.0', local_port)
+ )
+
+ # Send probe — this is the hole punch
+ transport.sendto(b'PING:MESHBAY:HOLEPUNCH', (MESHBAY_IP, MESHBAY_ECHO_PORT))
+
+ try:
+ await asyncio.wait_for(recv_event.wait(), timeout=8)
+ except asyncio.TimeoutError:
+ pass
+ finally:
+ transport.close()
+
+ if result['data']:
+ return True, str(result['addr']), result['data'].decode()
+ return False, None, None
+
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+async def main():
+ print("\n=== MeshBay Node POC — Spike 4: NAT Traversal ===\n")
+
+ state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else {}
+
+ endpoint_hint = None
+ stun_ip1 = stun_ip2 = None
+ stun_port1 = stun_port2 = None
+
+ # ── Phase A: STUN discovery ────────────────────────────────────────────────
+ print("[ A ] STUN external address discovery")
+ stun_ip1, stun_port1 = stun_query(LOCAL_PORT, *STUN_SERVERS[0])
+ if stun_ip1:
+ print(f" {PASS} via {STUN_SERVERS[0][0]}: {stun_ip1}:{stun_port1}")
+ else:
+ print(f" {FAIL} {STUN_SERVERS[0][0]} unreachable")
+
+ # ── Phase B: NAT type probe (cone vs symmetric) ────────────────────────────
+ print("\n[ B ] NAT type detection")
+ stun_ip2, stun_port2 = stun_query(LOCAL_PORT, *STUN_SERVERS[1])
+ if stun_ip1 and stun_ip2:
+ if stun_port1 == stun_port2:
+ nat_type = "Cone NAT (same external port for both STUN servers)"
+ print(f" {PASS} {nat_type}")
+ print(f" Cloudflare STUN : {stun_ip1}:{stun_port1}")
+ print(f" Google STUN : {stun_ip2}:{stun_port2}")
+ endpoint_hint = f"{stun_ip1}:{stun_port1}"
+ else:
+ nat_type = "Symmetric NAT (different port per destination)"
+ print(f" {FAIL} {nat_type}")
+ print(f" Cloudflare STUN : {stun_ip1}:{stun_port1}")
+ print(f" Google STUN : {stun_ip2}:{stun_port2}")
+ print(f" → Hole punching unreliable; TURN relay required")
+ elif stun_ip1:
+ nat_type = "Unknown (only one STUN server responded)"
+ print(f" {SKIP} {nat_type}")
+ endpoint_hint = f"{stun_ip1}:{stun_port1}"
+ else:
+ nat_type = "Unknown (STUN unavailable)"
+ print(f" {FAIL} {nat_type}")
+
+ # ── Phase C: Bidirectional UDP hole punching ───────────────────────────────
+ print(f"\n[ C ] Bidirectional UDP hole punch (node → meshbay.org → echo back)")
+ print(f" Starting UDP echo server on meshbay.org:{MESHBAY_ECHO_PORT} ...")
+ udp_ok = False
+ seen_ext_addr = None
+ try:
+ echo_proc = await asyncio.wait_for(
+ start_echo_server_on_meshbay(), timeout=10)
+ if echo_proc:
+ print(f" Echo server ready. Sending probe from local:{LOCAL_PORT} ...")
+ udp_ok, seen_ext_addr, echo_data = await udp_hole_punch_test(LOCAL_PORT)
+ stdout, _ = await asyncio.wait_for(echo_proc.communicate(), timeout=5)
+ server_log = stdout.decode().strip()
+
+ if udp_ok:
+ print(f" {PASS} Echo received: '{echo_data}'")
+ print(f" {PASS} meshbay.org saw us as: {seen_ext_addr}")
+ print(f" {PASS} Server log: {server_log}")
+ # seen_ext_addr is the actual external addr for meshbay.org dest
+ # May differ from STUN if symmetric NAT
+ actual_ext = seen_ext_addr.replace("('", "").replace("'", "").replace(", ", ":")
+ if endpoint_hint and actual_ext != endpoint_hint:
+ print(f" ⚠ STUN addr {endpoint_hint} ≠ actual {actual_ext} (symmetric NAT confirmed)")
+ endpoint_hint = actual_ext
+ else:
+ print(f" {FAIL} No echo received (timeout)")
+ print(f" Server log: {server_log}")
+ else:
+ print(f" {FAIL} Could not start echo server on meshbay.org")
+ except Exception as e:
+ print(f" {FAIL} Error: {e}")
+
+ # ── Phase D: UPnP ─────────────────────────────────────────────────────────
+ print(f"\n[ D ] UPnP port mapping")
+ upnp_ip, upnp_port = try_upnp(LOCAL_PORT)
+ if upnp_ip:
+ print(f" {PASS} Mapped {upnp_ip}:{upnp_port}")
+ endpoint_hint = f"{upnp_ip}:{upnp_port}"
+ else:
+ print(f" {FAIL} UPnP not available on this router")
+
+ # ── Phase E: Update hub ────────────────────────────────────────────────────
+ print(f"\n[ E ] Update endpoint_hint on hub")
+ if endpoint_hint and state.get("username"):
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.post(f"{HUB_URL}/v1/users/login", json={
+ "username": state["username"], "password": state["password"]})
+ access_token = r.json()["access_token"]
+ pk_b64 = state.get("sk_ed25519_b64", "")
+ r = await client.post(f"{HUB_URL}/v1/nodes/announce",
+ json={"pk_node": pk_b64, "endpoint_hint": endpoint_hint},
+ headers={"Authorization": f"Bearer {access_token}"})
+ if r.status_code == 201:
+ state["node_id"] = r.json()["node_id"]
+ state["endpoint_hint"] = endpoint_hint
+ print(f" {PASS} endpoint_hint={endpoint_hint} on hub")
+ else:
+ print(f" {SKIP} No endpoint to register")
+
+ STATE_FILE.write_text(json.dumps(state, indent=2))
+
+ print(f"\n{'='*55}")
+ print("Spike 4 — NAT Traversal Summary")
+ print(f" External IP (STUN) : {stun_ip1 or 'unknown'}")
+ print(f" NAT type : {nat_type if stun_ip1 else 'unknown'}")
+ print(f" UDP bidirectional : {PASS + ' works' if udp_ok else FAIL + ' blocked'}")
+ print(f" UPnP : {PASS + ' works' if upnp_ip else FAIL + ' disabled'}")
+ print(f" endpoint_hint : {endpoint_hint or 'none'}")
+ if udp_ok:
+ print(f"\n P2P UDP is functional. QUIC transport will work.")
+ print(f" Spike 4 COMPLETE.")
+ else:
+ print(f"\n P2P blocked — TURN relay needed for this configuration.")
+ print(f" Spike 4 COMPLETE (with finding: relay required).")
+
+asyncio.run(main())