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.
Files changed (78) hide show
  1. neuroshard/__init__.py +93 -0
  2. neuroshard/__main__.py +4 -0
  3. neuroshard/cli.py +466 -0
  4. neuroshard/core/__init__.py +92 -0
  5. neuroshard/core/consensus/verifier.py +252 -0
  6. neuroshard/core/crypto/__init__.py +20 -0
  7. neuroshard/core/crypto/ecdsa.py +392 -0
  8. neuroshard/core/economics/__init__.py +52 -0
  9. neuroshard/core/economics/constants.py +387 -0
  10. neuroshard/core/economics/ledger.py +2111 -0
  11. neuroshard/core/economics/market.py +975 -0
  12. neuroshard/core/economics/wallet.py +168 -0
  13. neuroshard/core/governance/__init__.py +74 -0
  14. neuroshard/core/governance/proposal.py +561 -0
  15. neuroshard/core/governance/registry.py +545 -0
  16. neuroshard/core/governance/versioning.py +332 -0
  17. neuroshard/core/governance/voting.py +453 -0
  18. neuroshard/core/model/__init__.py +30 -0
  19. neuroshard/core/model/dynamic.py +4186 -0
  20. neuroshard/core/model/llm.py +905 -0
  21. neuroshard/core/model/registry.py +164 -0
  22. neuroshard/core/model/scaler.py +387 -0
  23. neuroshard/core/model/tokenizer.py +568 -0
  24. neuroshard/core/network/__init__.py +56 -0
  25. neuroshard/core/network/connection_pool.py +72 -0
  26. neuroshard/core/network/dht.py +130 -0
  27. neuroshard/core/network/dht_plan.py +55 -0
  28. neuroshard/core/network/dht_proof_store.py +516 -0
  29. neuroshard/core/network/dht_protocol.py +261 -0
  30. neuroshard/core/network/dht_service.py +506 -0
  31. neuroshard/core/network/encrypted_channel.py +141 -0
  32. neuroshard/core/network/nat.py +201 -0
  33. neuroshard/core/network/nat_traversal.py +695 -0
  34. neuroshard/core/network/p2p.py +929 -0
  35. neuroshard/core/network/p2p_data.py +150 -0
  36. neuroshard/core/swarm/__init__.py +106 -0
  37. neuroshard/core/swarm/aggregation.py +729 -0
  38. neuroshard/core/swarm/buffers.py +643 -0
  39. neuroshard/core/swarm/checkpoint.py +709 -0
  40. neuroshard/core/swarm/compute.py +624 -0
  41. neuroshard/core/swarm/diloco.py +844 -0
  42. neuroshard/core/swarm/factory.py +1288 -0
  43. neuroshard/core/swarm/heartbeat.py +669 -0
  44. neuroshard/core/swarm/logger.py +487 -0
  45. neuroshard/core/swarm/router.py +658 -0
  46. neuroshard/core/swarm/service.py +640 -0
  47. neuroshard/core/training/__init__.py +29 -0
  48. neuroshard/core/training/checkpoint.py +600 -0
  49. neuroshard/core/training/distributed.py +1602 -0
  50. neuroshard/core/training/global_tracker.py +617 -0
  51. neuroshard/core/training/production.py +276 -0
  52. neuroshard/governance_cli.py +729 -0
  53. neuroshard/grpc_server.py +895 -0
  54. neuroshard/runner.py +3223 -0
  55. neuroshard/sdk/__init__.py +92 -0
  56. neuroshard/sdk/client.py +990 -0
  57. neuroshard/sdk/errors.py +101 -0
  58. neuroshard/sdk/types.py +282 -0
  59. neuroshard/tracker/__init__.py +0 -0
  60. neuroshard/tracker/server.py +864 -0
  61. neuroshard/ui/__init__.py +0 -0
  62. neuroshard/ui/app.py +102 -0
  63. neuroshard/ui/templates/index.html +1052 -0
  64. neuroshard/utils/__init__.py +0 -0
  65. neuroshard/utils/autostart.py +81 -0
  66. neuroshard/utils/hardware.py +121 -0
  67. neuroshard/utils/serialization.py +90 -0
  68. neuroshard/version.py +1 -0
  69. nexaroa-0.0.111.dist-info/METADATA +283 -0
  70. nexaroa-0.0.111.dist-info/RECORD +78 -0
  71. nexaroa-0.0.111.dist-info/WHEEL +5 -0
  72. nexaroa-0.0.111.dist-info/entry_points.txt +4 -0
  73. nexaroa-0.0.111.dist-info/licenses/LICENSE +190 -0
  74. nexaroa-0.0.111.dist-info/top_level.txt +2 -0
  75. protos/__init__.py +0 -0
  76. protos/neuroshard.proto +651 -0
  77. protos/neuroshard_pb2.py +160 -0
  78. 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
+