1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
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())
|