nexaroa 0.0.111__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- neuroshard/__init__.py +93 -0
- neuroshard/__main__.py +4 -0
- neuroshard/cli.py +466 -0
- neuroshard/core/__init__.py +92 -0
- neuroshard/core/consensus/verifier.py +252 -0
- neuroshard/core/crypto/__init__.py +20 -0
- neuroshard/core/crypto/ecdsa.py +392 -0
- neuroshard/core/economics/__init__.py +52 -0
- neuroshard/core/economics/constants.py +387 -0
- neuroshard/core/economics/ledger.py +2111 -0
- neuroshard/core/economics/market.py +975 -0
- neuroshard/core/economics/wallet.py +168 -0
- neuroshard/core/governance/__init__.py +74 -0
- neuroshard/core/governance/proposal.py +561 -0
- neuroshard/core/governance/registry.py +545 -0
- neuroshard/core/governance/versioning.py +332 -0
- neuroshard/core/governance/voting.py +453 -0
- neuroshard/core/model/__init__.py +30 -0
- neuroshard/core/model/dynamic.py +4186 -0
- neuroshard/core/model/llm.py +905 -0
- neuroshard/core/model/registry.py +164 -0
- neuroshard/core/model/scaler.py +387 -0
- neuroshard/core/model/tokenizer.py +568 -0
- neuroshard/core/network/__init__.py +56 -0
- neuroshard/core/network/connection_pool.py +72 -0
- neuroshard/core/network/dht.py +130 -0
- neuroshard/core/network/dht_plan.py +55 -0
- neuroshard/core/network/dht_proof_store.py +516 -0
- neuroshard/core/network/dht_protocol.py +261 -0
- neuroshard/core/network/dht_service.py +506 -0
- neuroshard/core/network/encrypted_channel.py +141 -0
- neuroshard/core/network/nat.py +201 -0
- neuroshard/core/network/nat_traversal.py +695 -0
- neuroshard/core/network/p2p.py +929 -0
- neuroshard/core/network/p2p_data.py +150 -0
- neuroshard/core/swarm/__init__.py +106 -0
- neuroshard/core/swarm/aggregation.py +729 -0
- neuroshard/core/swarm/buffers.py +643 -0
- neuroshard/core/swarm/checkpoint.py +709 -0
- neuroshard/core/swarm/compute.py +624 -0
- neuroshard/core/swarm/diloco.py +844 -0
- neuroshard/core/swarm/factory.py +1288 -0
- neuroshard/core/swarm/heartbeat.py +669 -0
- neuroshard/core/swarm/logger.py +487 -0
- neuroshard/core/swarm/router.py +658 -0
- neuroshard/core/swarm/service.py +640 -0
- neuroshard/core/training/__init__.py +29 -0
- neuroshard/core/training/checkpoint.py +600 -0
- neuroshard/core/training/distributed.py +1602 -0
- neuroshard/core/training/global_tracker.py +617 -0
- neuroshard/core/training/production.py +276 -0
- neuroshard/governance_cli.py +729 -0
- neuroshard/grpc_server.py +895 -0
- neuroshard/runner.py +3223 -0
- neuroshard/sdk/__init__.py +92 -0
- neuroshard/sdk/client.py +990 -0
- neuroshard/sdk/errors.py +101 -0
- neuroshard/sdk/types.py +282 -0
- neuroshard/tracker/__init__.py +0 -0
- neuroshard/tracker/server.py +864 -0
- neuroshard/ui/__init__.py +0 -0
- neuroshard/ui/app.py +102 -0
- neuroshard/ui/templates/index.html +1052 -0
- neuroshard/utils/__init__.py +0 -0
- neuroshard/utils/autostart.py +81 -0
- neuroshard/utils/hardware.py +121 -0
- neuroshard/utils/serialization.py +90 -0
- neuroshard/version.py +1 -0
- nexaroa-0.0.111.dist-info/METADATA +283 -0
- nexaroa-0.0.111.dist-info/RECORD +78 -0
- nexaroa-0.0.111.dist-info/WHEEL +5 -0
- nexaroa-0.0.111.dist-info/entry_points.txt +4 -0
- nexaroa-0.0.111.dist-info/licenses/LICENSE +190 -0
- nexaroa-0.0.111.dist-info/top_level.txt +2 -0
- protos/__init__.py +0 -0
- protos/neuroshard.proto +651 -0
- protos/neuroshard_pb2.py +160 -0
- protos/neuroshard_pb2_grpc.py +1298 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NAT Traversal & Connectivity - Residential Network Support
|
|
3
|
+
|
|
4
|
+
Implements NAT traversal for nodes behind residential NATs:
|
|
5
|
+
- STUN client for public IP/port discovery
|
|
6
|
+
- Hole punching coordination
|
|
7
|
+
- TURN relay fallback
|
|
8
|
+
- Connection success rate tracking
|
|
9
|
+
|
|
10
|
+
Target Metric: Connection success rate > 90% across different ISPs
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
- Option A: libp2p integration (recommended for production)
|
|
14
|
+
- Option B: STUN/TURN servers (simpler, more centralized)
|
|
15
|
+
|
|
16
|
+
This module implements Option B with hooks for Option A.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import logging
|
|
21
|
+
import random
|
|
22
|
+
import socket
|
|
23
|
+
import struct
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from enum import Enum
|
|
27
|
+
from typing import Dict, List, Optional, Tuple, Callable, Any
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NATType(Enum):
|
|
33
|
+
"""
|
|
34
|
+
Detected NAT type.
|
|
35
|
+
|
|
36
|
+
Different NAT types have different hole-punching success rates:
|
|
37
|
+
- OPEN: No NAT, direct connection works
|
|
38
|
+
- FULL_CONE: Easy hole punch (any external host can connect once mapped)
|
|
39
|
+
- RESTRICTED_CONE: Medium (only replied-to hosts can connect)
|
|
40
|
+
- PORT_RESTRICTED: Harder (specific port must match)
|
|
41
|
+
- SYMMETRIC: Hardest (different mapping per destination, requires relay)
|
|
42
|
+
"""
|
|
43
|
+
UNKNOWN = "unknown"
|
|
44
|
+
OPEN = "open" # Direct connection works
|
|
45
|
+
FULL_CONE = "full_cone" # Easy hole punch
|
|
46
|
+
RESTRICTED_CONE = "restricted" # Medium difficulty
|
|
47
|
+
PORT_RESTRICTED = "port_restricted" # Harder
|
|
48
|
+
SYMMETRIC = "symmetric" # Requires relay
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PeerConnectivity:
|
|
53
|
+
"""Peer connectivity information."""
|
|
54
|
+
peer_id: str
|
|
55
|
+
public_addr: Optional[Tuple[str, int]] = None
|
|
56
|
+
private_addrs: List[Tuple[str, int]] = field(default_factory=list)
|
|
57
|
+
nat_type: NATType = NATType.UNKNOWN
|
|
58
|
+
relay_addrs: List[str] = field(default_factory=list)
|
|
59
|
+
last_seen: float = field(default_factory=time.time)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def best_addr(self) -> Optional[Tuple[str, int]]:
|
|
63
|
+
"""Get best address to try connecting to."""
|
|
64
|
+
if self.public_addr:
|
|
65
|
+
return self.public_addr
|
|
66
|
+
if self.private_addrs:
|
|
67
|
+
return self.private_addrs[0]
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class STUNClient:
|
|
72
|
+
"""
|
|
73
|
+
STUN client for public IP/port discovery.
|
|
74
|
+
|
|
75
|
+
STUN (Session Traversal Utilities for NAT) allows discovering
|
|
76
|
+
the public IP and port assigned by the NAT.
|
|
77
|
+
|
|
78
|
+
Default STUN servers:
|
|
79
|
+
- stun.l.google.com:19302
|
|
80
|
+
- stun.cloudflare.com:3478
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Public STUN servers (free tier)
|
|
84
|
+
DEFAULT_STUN_SERVERS = [
|
|
85
|
+
("stun.l.google.com", 19302),
|
|
86
|
+
("stun.cloudflare.com", 3478),
|
|
87
|
+
("stun.stunprotocol.org", 3478),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
# STUN message types
|
|
91
|
+
BINDING_REQUEST = 0x0001
|
|
92
|
+
BINDING_RESPONSE = 0x0101
|
|
93
|
+
|
|
94
|
+
# STUN attribute types
|
|
95
|
+
MAPPED_ADDRESS = 0x0001
|
|
96
|
+
XOR_MAPPED_ADDRESS = 0x0020
|
|
97
|
+
|
|
98
|
+
# Magic cookie (RFC 5389)
|
|
99
|
+
MAGIC_COOKIE = 0x2112A442
|
|
100
|
+
|
|
101
|
+
def __init__(self, stun_servers: Optional[List[Tuple[str, int]]] = None):
|
|
102
|
+
"""
|
|
103
|
+
Initialize STUN client.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
stun_servers: List of (host, port) tuples for STUN servers
|
|
107
|
+
"""
|
|
108
|
+
self.stun_servers = stun_servers or self.DEFAULT_STUN_SERVERS
|
|
109
|
+
self.public_ip: Optional[str] = None
|
|
110
|
+
self.public_port: Optional[int] = None
|
|
111
|
+
self.nat_type: NATType = NATType.UNKNOWN
|
|
112
|
+
|
|
113
|
+
# Local socket (reused for hole punching)
|
|
114
|
+
self._socket: Optional[socket.socket] = None
|
|
115
|
+
self._local_port: int = 0
|
|
116
|
+
|
|
117
|
+
def _create_binding_request(self) -> Tuple[bytes, bytes]:
|
|
118
|
+
"""
|
|
119
|
+
Create STUN Binding Request message.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
(message_bytes, transaction_id)
|
|
123
|
+
"""
|
|
124
|
+
# Transaction ID (96 bits / 12 bytes)
|
|
125
|
+
transaction_id = random.randbytes(12)
|
|
126
|
+
|
|
127
|
+
# Message header (20 bytes):
|
|
128
|
+
# - Type: 2 bytes (0x0001 = Binding Request)
|
|
129
|
+
# - Length: 2 bytes (0 for empty body)
|
|
130
|
+
# - Magic Cookie: 4 bytes (0x2112A442)
|
|
131
|
+
# - Transaction ID: 12 bytes
|
|
132
|
+
header = struct.pack(
|
|
133
|
+
'>HHI12s',
|
|
134
|
+
self.BINDING_REQUEST,
|
|
135
|
+
0, # No attributes, length = 0
|
|
136
|
+
self.MAGIC_COOKIE,
|
|
137
|
+
transaction_id
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return header, transaction_id
|
|
141
|
+
|
|
142
|
+
def _parse_binding_response(
|
|
143
|
+
self,
|
|
144
|
+
data: bytes,
|
|
145
|
+
expected_tid: bytes
|
|
146
|
+
) -> Optional[Tuple[str, int]]:
|
|
147
|
+
"""
|
|
148
|
+
Parse STUN Binding Response.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
(public_ip, public_port) or None if invalid
|
|
152
|
+
"""
|
|
153
|
+
if len(data) < 20:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
# Parse header
|
|
157
|
+
msg_type, msg_len, cookie, tid = struct.unpack('>HHI12s', data[:20])
|
|
158
|
+
|
|
159
|
+
# Validate
|
|
160
|
+
if msg_type != self.BINDING_RESPONSE:
|
|
161
|
+
logger.debug(f"Unexpected message type: {msg_type:#x}")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if cookie != self.MAGIC_COOKIE:
|
|
165
|
+
logger.debug(f"Invalid magic cookie: {cookie:#x}")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
if tid != expected_tid:
|
|
169
|
+
logger.debug("Transaction ID mismatch")
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Parse attributes
|
|
173
|
+
pos = 20
|
|
174
|
+
while pos < len(data):
|
|
175
|
+
if pos + 4 > len(data):
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
attr_type, attr_len = struct.unpack('>HH', data[pos:pos+4])
|
|
179
|
+
pos += 4
|
|
180
|
+
|
|
181
|
+
if pos + attr_len > len(data):
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
attr_data = data[pos:pos+attr_len]
|
|
185
|
+
|
|
186
|
+
# Handle XOR-MAPPED-ADDRESS (preferred)
|
|
187
|
+
if attr_type == self.XOR_MAPPED_ADDRESS:
|
|
188
|
+
return self._parse_xor_mapped_address(attr_data)
|
|
189
|
+
|
|
190
|
+
# Handle MAPPED-ADDRESS (fallback)
|
|
191
|
+
elif attr_type == self.MAPPED_ADDRESS:
|
|
192
|
+
return self._parse_mapped_address(attr_data)
|
|
193
|
+
|
|
194
|
+
# Pad to 4-byte boundary
|
|
195
|
+
pos += attr_len
|
|
196
|
+
pos += (4 - (attr_len % 4)) % 4
|
|
197
|
+
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
def _parse_xor_mapped_address(self, data: bytes) -> Optional[Tuple[str, int]]:
|
|
201
|
+
"""Parse XOR-MAPPED-ADDRESS attribute."""
|
|
202
|
+
if len(data) < 8:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# Format: 1 byte reserved, 1 byte family, 2 bytes port, 4 bytes IP
|
|
206
|
+
_, family, xport = struct.unpack('>BBH', data[:4])
|
|
207
|
+
|
|
208
|
+
# XOR port with magic cookie (high 16 bits)
|
|
209
|
+
port = xport ^ (self.MAGIC_COOKIE >> 16)
|
|
210
|
+
|
|
211
|
+
if family == 0x01: # IPv4
|
|
212
|
+
xaddr = struct.unpack('>I', data[4:8])[0]
|
|
213
|
+
addr = xaddr ^ self.MAGIC_COOKIE
|
|
214
|
+
ip = socket.inet_ntoa(struct.pack('>I', addr))
|
|
215
|
+
return (ip, port)
|
|
216
|
+
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def _parse_mapped_address(self, data: bytes) -> Optional[Tuple[str, int]]:
|
|
220
|
+
"""Parse MAPPED-ADDRESS attribute (not XORed)."""
|
|
221
|
+
if len(data) < 8:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
_, family, port = struct.unpack('>BBH', data[:4])
|
|
225
|
+
|
|
226
|
+
if family == 0x01: # IPv4
|
|
227
|
+
ip = socket.inet_ntoa(data[4:8])
|
|
228
|
+
return (ip, port)
|
|
229
|
+
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
async def discover_public_address(
|
|
233
|
+
self,
|
|
234
|
+
timeout: float = 5.0
|
|
235
|
+
) -> Optional[Tuple[str, int]]:
|
|
236
|
+
"""
|
|
237
|
+
Query STUN servers to discover public IP and port.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
timeout: Total timeout for discovery
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
(public_ip, public_port) or None if failed
|
|
244
|
+
"""
|
|
245
|
+
# Create UDP socket if needed
|
|
246
|
+
if self._socket is None:
|
|
247
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
248
|
+
self._socket.setblocking(False)
|
|
249
|
+
self._socket.bind(('0.0.0.0', 0))
|
|
250
|
+
self._local_port = self._socket.getsockname()[1]
|
|
251
|
+
|
|
252
|
+
loop = asyncio.get_event_loop()
|
|
253
|
+
|
|
254
|
+
for server_host, server_port in self.stun_servers:
|
|
255
|
+
try:
|
|
256
|
+
# Resolve server address
|
|
257
|
+
try:
|
|
258
|
+
server_ip = socket.gethostbyname(server_host)
|
|
259
|
+
except socket.gaierror:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
# Create request
|
|
263
|
+
request, tid = self._create_binding_request()
|
|
264
|
+
|
|
265
|
+
# Send request
|
|
266
|
+
await loop.sock_sendto(self._socket, request, (server_ip, server_port))
|
|
267
|
+
|
|
268
|
+
# Wait for response
|
|
269
|
+
try:
|
|
270
|
+
data = await asyncio.wait_for(
|
|
271
|
+
loop.sock_recv(self._socket, 1024),
|
|
272
|
+
timeout=timeout / len(self.stun_servers)
|
|
273
|
+
)
|
|
274
|
+
except asyncio.TimeoutError:
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
# Parse response
|
|
278
|
+
result = self._parse_binding_response(data, tid)
|
|
279
|
+
|
|
280
|
+
if result:
|
|
281
|
+
self.public_ip, self.public_port = result
|
|
282
|
+
logger.info(f"Discovered public address: {self.public_ip}:{self.public_port}")
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
except Exception as e:
|
|
286
|
+
logger.debug(f"STUN query to {server_host} failed: {e}")
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
logger.warning("Could not discover public address via STUN")
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
async def detect_nat_type(self) -> NATType:
|
|
293
|
+
"""
|
|
294
|
+
Detect NAT type using multiple STUN queries.
|
|
295
|
+
|
|
296
|
+
Algorithm:
|
|
297
|
+
1. Query STUN server A from port P
|
|
298
|
+
2. Query STUN server B from same port P
|
|
299
|
+
3. Compare mapped addresses:
|
|
300
|
+
- Same = Full Cone / Restricted
|
|
301
|
+
- Different = Symmetric
|
|
302
|
+
4. Try to receive from different port to distinguish Cone types
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Detected NATType
|
|
306
|
+
"""
|
|
307
|
+
if len(self.stun_servers) < 2:
|
|
308
|
+
return NATType.UNKNOWN
|
|
309
|
+
|
|
310
|
+
# First query
|
|
311
|
+
result1 = await self.discover_public_address()
|
|
312
|
+
if not result1:
|
|
313
|
+
return NATType.UNKNOWN
|
|
314
|
+
|
|
315
|
+
# Save first result
|
|
316
|
+
first_ip, first_port = result1
|
|
317
|
+
|
|
318
|
+
# Query second server from same local port
|
|
319
|
+
if self._socket:
|
|
320
|
+
loop = asyncio.get_event_loop()
|
|
321
|
+
|
|
322
|
+
server2 = self.stun_servers[1]
|
|
323
|
+
try:
|
|
324
|
+
server2_ip = socket.gethostbyname(server2[0])
|
|
325
|
+
except:
|
|
326
|
+
return NATType.UNKNOWN
|
|
327
|
+
|
|
328
|
+
request, tid = self._create_binding_request()
|
|
329
|
+
await loop.sock_sendto(self._socket, request, (server2_ip, server2[1]))
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
data = await asyncio.wait_for(
|
|
333
|
+
loop.sock_recv(self._socket, 1024),
|
|
334
|
+
timeout=3.0
|
|
335
|
+
)
|
|
336
|
+
result2 = self._parse_binding_response(data, tid)
|
|
337
|
+
|
|
338
|
+
if result2:
|
|
339
|
+
second_ip, second_port = result2
|
|
340
|
+
|
|
341
|
+
if first_ip != second_ip or first_port != second_port:
|
|
342
|
+
# Different mapping = Symmetric NAT
|
|
343
|
+
self.nat_type = NATType.SYMMETRIC
|
|
344
|
+
logger.info("Detected NAT type: SYMMETRIC")
|
|
345
|
+
return NATType.SYMMETRIC
|
|
346
|
+
else:
|
|
347
|
+
# Same mapping = some form of Cone NAT
|
|
348
|
+
# For now, assume Restricted Cone (conservative)
|
|
349
|
+
self.nat_type = NATType.RESTRICTED_CONE
|
|
350
|
+
logger.info("Detected NAT type: RESTRICTED_CONE")
|
|
351
|
+
return NATType.RESTRICTED_CONE
|
|
352
|
+
|
|
353
|
+
except asyncio.TimeoutError:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
# If we got here with a public address, at least it's not Symmetric
|
|
357
|
+
self.nat_type = NATType.RESTRICTED_CONE
|
|
358
|
+
return self.nat_type
|
|
359
|
+
|
|
360
|
+
def get_local_socket(self) -> Optional[socket.socket]:
|
|
361
|
+
"""Get the local socket (for hole punching)."""
|
|
362
|
+
return self._socket
|
|
363
|
+
|
|
364
|
+
def close(self):
|
|
365
|
+
"""Close the STUN client socket."""
|
|
366
|
+
if self._socket:
|
|
367
|
+
self._socket.close()
|
|
368
|
+
self._socket = None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class HolePuncher:
|
|
372
|
+
"""
|
|
373
|
+
Coordinates UDP hole punching between two NATed peers.
|
|
374
|
+
|
|
375
|
+
Protocol:
|
|
376
|
+
1. Both peers register their public address with coordinator
|
|
377
|
+
2. When A wants to connect to B:
|
|
378
|
+
a. A requests B's public address from coordinator
|
|
379
|
+
b. Coordinator tells B that A wants to connect
|
|
380
|
+
c. Both start sending UDP packets to each other's public addr
|
|
381
|
+
d. NAT creates mapping, packets cross, connection established
|
|
382
|
+
|
|
383
|
+
Works best with Full Cone / Restricted Cone NATs.
|
|
384
|
+
Symmetric NAT requires relay fallback.
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
PUNCH_ATTEMPTS = 10
|
|
388
|
+
PUNCH_INTERVAL = 0.1 # 100ms between attempts
|
|
389
|
+
PUNCH_TIMEOUT = 5.0 # Total timeout
|
|
390
|
+
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
stun_client: STUNClient,
|
|
394
|
+
coordinator_callback: Optional[Callable] = None,
|
|
395
|
+
):
|
|
396
|
+
"""
|
|
397
|
+
Initialize hole puncher.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
stun_client: STUN client for address discovery
|
|
401
|
+
coordinator_callback: Function to coordinate with peer
|
|
402
|
+
"""
|
|
403
|
+
self.stun = stun_client
|
|
404
|
+
self.coordinator = coordinator_callback
|
|
405
|
+
|
|
406
|
+
# Punch statistics
|
|
407
|
+
self.attempts = 0
|
|
408
|
+
self.successes = 0
|
|
409
|
+
|
|
410
|
+
async def punch_hole(
|
|
411
|
+
self,
|
|
412
|
+
peer_addr: Tuple[str, int],
|
|
413
|
+
timeout: float = PUNCH_TIMEOUT,
|
|
414
|
+
) -> bool:
|
|
415
|
+
"""
|
|
416
|
+
Attempt to punch hole to peer.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
peer_addr: (ip, port) of peer's public address
|
|
420
|
+
timeout: Total timeout for hole punching
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
True if hole punched successfully
|
|
424
|
+
"""
|
|
425
|
+
self.attempts += 1
|
|
426
|
+
|
|
427
|
+
sock = self.stun.get_local_socket()
|
|
428
|
+
if not sock:
|
|
429
|
+
logger.error("No local socket available for hole punching")
|
|
430
|
+
return False
|
|
431
|
+
|
|
432
|
+
loop = asyncio.get_event_loop()
|
|
433
|
+
|
|
434
|
+
# Punch packet - simple magic bytes
|
|
435
|
+
punch_data = b'NEUROSHARD_PUNCH\x00'
|
|
436
|
+
|
|
437
|
+
start_time = time.time()
|
|
438
|
+
attempts = 0
|
|
439
|
+
|
|
440
|
+
while (time.time() - start_time) < timeout:
|
|
441
|
+
attempts += 1
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
# Send punch packet
|
|
445
|
+
await loop.sock_sendto(sock, punch_data, peer_addr)
|
|
446
|
+
|
|
447
|
+
# Check for incoming punch from peer
|
|
448
|
+
sock.setblocking(False)
|
|
449
|
+
try:
|
|
450
|
+
data, addr = await asyncio.wait_for(
|
|
451
|
+
loop.sock_recvfrom(sock, 64),
|
|
452
|
+
timeout=self.PUNCH_INTERVAL
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if data.startswith(b'NEUROSHARD_PUNCH'):
|
|
456
|
+
# Success!
|
|
457
|
+
self.successes += 1
|
|
458
|
+
logger.info(
|
|
459
|
+
f"Hole punch successful to {peer_addr} "
|
|
460
|
+
f"after {attempts} attempts"
|
|
461
|
+
)
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
except asyncio.TimeoutError:
|
|
465
|
+
pass
|
|
466
|
+
|
|
467
|
+
except Exception as e:
|
|
468
|
+
logger.debug(f"Punch attempt {attempts} failed: {e}")
|
|
469
|
+
|
|
470
|
+
await asyncio.sleep(self.PUNCH_INTERVAL)
|
|
471
|
+
|
|
472
|
+
logger.warning(f"Hole punch failed to {peer_addr} after {attempts} attempts")
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def success_rate(self) -> float:
|
|
477
|
+
"""Calculate hole punch success rate."""
|
|
478
|
+
if self.attempts == 0:
|
|
479
|
+
return 0.0
|
|
480
|
+
return self.successes / self.attempts
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class NATTraversalManager:
|
|
484
|
+
"""
|
|
485
|
+
Unified NAT traversal manager.
|
|
486
|
+
|
|
487
|
+
Combines STUN discovery, hole punching, and relay fallback
|
|
488
|
+
into a single interface for connection establishment.
|
|
489
|
+
|
|
490
|
+
Connection Priority:
|
|
491
|
+
1. Direct (if peer has public IP)
|
|
492
|
+
2. Hole punch (if NAT types are compatible)
|
|
493
|
+
3. Relay (fallback, higher latency)
|
|
494
|
+
|
|
495
|
+
Target Metric: Connection success rate > 90%
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
def __init__(
|
|
499
|
+
self,
|
|
500
|
+
node_id: str,
|
|
501
|
+
stun_servers: Optional[List[str]] = None,
|
|
502
|
+
relay_servers: Optional[List[str]] = None,
|
|
503
|
+
):
|
|
504
|
+
"""
|
|
505
|
+
Initialize NAT traversal manager.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
node_id: This node's identifier
|
|
509
|
+
stun_servers: STUN server addresses ("host:port")
|
|
510
|
+
relay_servers: TURN relay server addresses (future)
|
|
511
|
+
"""
|
|
512
|
+
self.node_id = node_id
|
|
513
|
+
|
|
514
|
+
# Parse STUN servers
|
|
515
|
+
stun_list = []
|
|
516
|
+
if stun_servers:
|
|
517
|
+
for s in stun_servers:
|
|
518
|
+
parts = s.split(':')
|
|
519
|
+
host = parts[0]
|
|
520
|
+
port = int(parts[1]) if len(parts) > 1 else 3478
|
|
521
|
+
stun_list.append((host, port))
|
|
522
|
+
|
|
523
|
+
self.stun = STUNClient(stun_list if stun_list else None)
|
|
524
|
+
self.hole_puncher = HolePuncher(self.stun)
|
|
525
|
+
|
|
526
|
+
self.relay_servers = relay_servers or []
|
|
527
|
+
|
|
528
|
+
# Peer connectivity cache
|
|
529
|
+
self.peer_connectivity: Dict[str, PeerConnectivity] = {}
|
|
530
|
+
|
|
531
|
+
# Connection statistics
|
|
532
|
+
self.direct_success = 0
|
|
533
|
+
self.holepunch_success = 0
|
|
534
|
+
self.relay_success = 0
|
|
535
|
+
self.total_attempts = 0
|
|
536
|
+
self.total_failures = 0
|
|
537
|
+
|
|
538
|
+
# Our public address
|
|
539
|
+
self.public_addr: Optional[Tuple[str, int]] = None
|
|
540
|
+
self.nat_type: NATType = NATType.UNKNOWN
|
|
541
|
+
|
|
542
|
+
async def initialize(self):
|
|
543
|
+
"""
|
|
544
|
+
Initialize NAT traversal.
|
|
545
|
+
|
|
546
|
+
Discovers public address and NAT type.
|
|
547
|
+
"""
|
|
548
|
+
logger.info("Initializing NAT traversal...")
|
|
549
|
+
|
|
550
|
+
# Discover public address
|
|
551
|
+
self.public_addr = await self.stun.discover_public_address()
|
|
552
|
+
|
|
553
|
+
if self.public_addr:
|
|
554
|
+
# Detect NAT type
|
|
555
|
+
self.nat_type = await self.stun.detect_nat_type()
|
|
556
|
+
logger.info(
|
|
557
|
+
f"NAT traversal initialized: "
|
|
558
|
+
f"public={self.public_addr}, type={self.nat_type.value}"
|
|
559
|
+
)
|
|
560
|
+
else:
|
|
561
|
+
logger.warning("Could not discover public address")
|
|
562
|
+
|
|
563
|
+
def register_peer(self, peer: PeerConnectivity):
|
|
564
|
+
"""Register peer connectivity info."""
|
|
565
|
+
self.peer_connectivity[peer.peer_id] = peer
|
|
566
|
+
|
|
567
|
+
async def connect(
|
|
568
|
+
self,
|
|
569
|
+
peer_id: str,
|
|
570
|
+
peer_addrs: Optional[List[Tuple[str, int]]] = None,
|
|
571
|
+
) -> Optional[Tuple[str, int]]:
|
|
572
|
+
"""
|
|
573
|
+
Connect to peer using best available method.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
peer_id: Peer identifier
|
|
577
|
+
peer_addrs: Optional list of peer addresses to try
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
(ip, port) for established connection, or None if failed
|
|
581
|
+
"""
|
|
582
|
+
self.total_attempts += 1
|
|
583
|
+
|
|
584
|
+
# Get peer info
|
|
585
|
+
peer = self.peer_connectivity.get(peer_id)
|
|
586
|
+
if peer is None and peer_addrs:
|
|
587
|
+
peer = PeerConnectivity(
|
|
588
|
+
peer_id=peer_id,
|
|
589
|
+
public_addr=peer_addrs[0] if peer_addrs else None,
|
|
590
|
+
private_addrs=peer_addrs[1:] if len(peer_addrs) > 1 else [],
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if peer is None:
|
|
594
|
+
logger.error(f"No connectivity info for peer {peer_id}")
|
|
595
|
+
self.total_failures += 1
|
|
596
|
+
return None
|
|
597
|
+
|
|
598
|
+
# Try connection methods in order
|
|
599
|
+
|
|
600
|
+
# 1. Try direct connection
|
|
601
|
+
if peer.public_addr:
|
|
602
|
+
if await self._try_direct(peer.public_addr):
|
|
603
|
+
self.direct_success += 1
|
|
604
|
+
return peer.public_addr
|
|
605
|
+
|
|
606
|
+
# 2. Try hole punching
|
|
607
|
+
if peer.public_addr and self._can_hole_punch(peer):
|
|
608
|
+
if await self.hole_puncher.punch_hole(peer.public_addr):
|
|
609
|
+
self.holepunch_success += 1
|
|
610
|
+
return peer.public_addr
|
|
611
|
+
|
|
612
|
+
# 3. Try relay (not implemented yet)
|
|
613
|
+
if self.relay_servers:
|
|
614
|
+
relay = await self._try_relay(peer_id)
|
|
615
|
+
if relay:
|
|
616
|
+
self.relay_success += 1
|
|
617
|
+
return relay
|
|
618
|
+
|
|
619
|
+
self.total_failures += 1
|
|
620
|
+
logger.warning(f"All connection methods failed for peer {peer_id}")
|
|
621
|
+
return None
|
|
622
|
+
|
|
623
|
+
def _can_hole_punch(self, peer: PeerConnectivity) -> bool:
|
|
624
|
+
"""Check if hole punching is likely to succeed."""
|
|
625
|
+
# Symmetric NAT on both sides won't work
|
|
626
|
+
if self.nat_type == NATType.SYMMETRIC and peer.nat_type == NATType.SYMMETRIC:
|
|
627
|
+
return False
|
|
628
|
+
return True
|
|
629
|
+
|
|
630
|
+
async def _try_direct(self, addr: Tuple[str, int], timeout: float = 2.0) -> bool:
|
|
631
|
+
"""Try direct UDP connectivity test."""
|
|
632
|
+
sock = self.stun.get_local_socket()
|
|
633
|
+
if not sock:
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
loop = asyncio.get_event_loop()
|
|
638
|
+
|
|
639
|
+
# Send test packet
|
|
640
|
+
test_data = b'NEUROSHARD_TEST\x00'
|
|
641
|
+
await loop.sock_sendto(sock, test_data, addr)
|
|
642
|
+
|
|
643
|
+
# Wait for response
|
|
644
|
+
sock.setblocking(False)
|
|
645
|
+
data, _ = await asyncio.wait_for(
|
|
646
|
+
loop.sock_recvfrom(sock, 64),
|
|
647
|
+
timeout=timeout
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
return data.startswith(b'NEUROSHARD_')
|
|
651
|
+
|
|
652
|
+
except:
|
|
653
|
+
return False
|
|
654
|
+
|
|
655
|
+
async def _try_relay(self, peer_id: str) -> Optional[Tuple[str, int]]:
|
|
656
|
+
"""Try connecting via relay server (placeholder for TURN)."""
|
|
657
|
+
# TODO: Implement TURN relay
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
@property
|
|
661
|
+
def connection_success_rate(self) -> float:
|
|
662
|
+
"""Calculate overall connection success rate."""
|
|
663
|
+
if self.total_attempts == 0:
|
|
664
|
+
return 0.0
|
|
665
|
+
total_success = self.direct_success + self.holepunch_success + self.relay_success
|
|
666
|
+
return total_success / self.total_attempts
|
|
667
|
+
|
|
668
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
669
|
+
"""Get NAT traversal statistics."""
|
|
670
|
+
return {
|
|
671
|
+
"public_addr": f"{self.public_addr[0]}:{self.public_addr[1]}" if self.public_addr else None,
|
|
672
|
+
"nat_type": self.nat_type.value,
|
|
673
|
+
"total_attempts": self.total_attempts,
|
|
674
|
+
"direct_success": self.direct_success,
|
|
675
|
+
"holepunch_success": self.holepunch_success,
|
|
676
|
+
"relay_success": self.relay_success,
|
|
677
|
+
"total_failures": self.total_failures,
|
|
678
|
+
"success_rate": self.connection_success_rate,
|
|
679
|
+
"holepunch_rate": self.hole_puncher.success_rate,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
def close(self):
|
|
683
|
+
"""Close all resources."""
|
|
684
|
+
self.stun.close()
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# Convenience function
|
|
688
|
+
async def discover_public_address() -> Optional[Tuple[str, int]]:
|
|
689
|
+
"""Quick helper to discover public address."""
|
|
690
|
+
client = STUNClient()
|
|
691
|
+
try:
|
|
692
|
+
return await client.discover_public_address()
|
|
693
|
+
finally:
|
|
694
|
+
client.close()
|
|
695
|
+
|