"""TCP-to-Reticulum bridge for Radicle. Bridges radicle-node's TCP connections over Reticulum mesh network. Allows using Radicle normally while syncing over LoRa, packet radio, etc. Architecture: ┌─────────────┐ TCP ┌─────────────┐ Reticulum ┌─────────────┐ │ radicle-node│ ◄──────────► │ Bridge │ ◄───────────► │Remote Bridge│ └─────────────┘ localhost └─────────────┘ LoRa/mesh └─────────────┘ The bridge: 1. Listens on localhost TCP (default: 8776, Radicle's default port) 2. Accepts connections from local radicle-node 3. Tunnels traffic over Reticulum to remote bridges 4. Remote bridges forward to their local radicle-node """ import socket import select import struct import subprocess import threading import time from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional, Set, Tuple import RNS from radicle_reticulum.identity import RadicleIdentity # Default ports RADICLE_DEFAULT_PORT = 8776 BRIDGE_DEFAULT_PORT = 8777 # Local listen port for bridge # App name for RNS destinations APP_NAME = "radicle" ASPECT_BRIDGE = "bridge" # App data identifier for bridge announces (used for filtering) BRIDGE_APP_DATA_MAGIC = b"RADICLE_BRIDGE_V1" # Buffer sizes TCP_BUFFER_SIZE = 65536 RNS_BUFFER_SIZE = 32768 # Smaller for RNS to avoid fragmentation @dataclass class TunnelConnection: """Represents a tunneled TCP connection.""" tunnel_id: int tcp_socket: Optional[socket.socket] rns_link: Optional[RNS.Link] remote_destination: Optional[bytes] created_at: float = field(default_factory=time.time) bytes_sent: int = 0 bytes_received: int = 0 active: bool = True def close(self): """Close the tunnel.""" self.active = False if self.tcp_socket: try: self.tcp_socket.close() except: pass if self.rns_link: try: self.rns_link.teardown() except: pass class RadicleBridge: """Bridges Radicle TCP connections over Reticulum. Modes: - Server mode: Listens for TCP from local radicle-node, tunnels to remote bridges - Accepts incoming tunnels from remote bridges, forwards to local radicle-node """ def __init__( self, identity: Optional[RadicleIdentity] = None, listen_port: int = BRIDGE_DEFAULT_PORT, radicle_host: str = "127.0.0.1", radicle_port: int = RADICLE_DEFAULT_PORT, config_path: Optional[str] = None, auto_connect: bool = True, auto_seed: bool = True, announce_retry_delays: Tuple[int, ...] = (5, 15, 30), ): """Initialize the bridge. Args: identity: RNS identity for this bridge listen_port: TCP port to listen on for local radicle-node radicle_host: Host where radicle-node listens (for incoming tunnels) radicle_port: Port where radicle-node listens config_path: Path to Reticulum config auto_connect: Automatically connect to discovered bridges auto_seed: Automatically register discovered NIDs with radicle-node announce_retry_delays: Seconds between startup re-announces. Use longer intervals on LoRa to respect duty cycle limits, e.g. (60, 300, 900). """ self.listen_port = listen_port self.radicle_host = radicle_host self.radicle_port = radicle_port self.auto_connect = auto_connect self.auto_seed = auto_seed self.announce_retry_delays = announce_retry_delays # Initialize Reticulum self.reticulum = RNS.Reticulum(config_path) # Identity if identity is None: identity = RadicleIdentity.generate() self.identity = identity # Create RNS destination for incoming tunnels self.destination = RNS.Destination( self.identity.rns_identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, ASPECT_BRIDGE, ) self.destination.set_link_established_callback(self._on_incoming_link) # Known remote bridges: hash -> last_seen self._remote_bridges: Dict[bytes, float] = {} self._remote_bridges_lock = threading.Lock() # Active tunnels self._tunnels: Dict[int, TunnelConnection] = {} self._tunnel_counter = 0 self._tunnels_lock = threading.Lock() # TCP server self._tcp_server: Optional[socket.socket] = None self._running = False # Callbacks self._tunnel_opened_cb: Optional[Callable[[TunnelConnection], None]] = None self._tunnel_closed_cb: Optional[Callable[[TunnelConnection], None]] = None self._on_bridge_discovered: Optional[Callable[[bytes, Optional[str]], None]] = None # Local radicle node NID (for announcing to remote bridges) self._local_radicle_nid: Optional[str] = None # Remote bridge NIDs: bridge_hash -> radicle_nid self._bridge_nids: Dict[bytes, str] = {} def start(self): """Start the bridge.""" self._running = True # Register announce handler to discover other bridges RNS.Transport.register_announce_handler(self._handle_announce) RNS.log(f"Registered announce handler for bridge discovery", RNS.LOG_INFO) # Start TCP server self._tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._tcp_server.bind(("127.0.0.1", self.listen_port)) self._tcp_server.listen(5) self._tcp_server.setblocking(False) # Start accept thread self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) self._accept_thread.start() # Announce presence; repeat a few times so peers that come up shortly # after us don't miss it due to interface initialisation timing self.announce() threading.Thread(target=self._startup_announce_loop, daemon=True).start() RNS.log(f"Radicle bridge started", RNS.LOG_INFO) RNS.log(f" TCP listen: 127.0.0.1:{self.listen_port}", RNS.LOG_INFO) RNS.log(f" RNS hash: {self.destination.hexhash}", RNS.LOG_INFO) RNS.log(f" Radicle target: {self.radicle_host}:{self.radicle_port}", RNS.LOG_INFO) def _startup_announce_loop(self): """Re-announce after startup to catch peers that come up slightly later. Delays are configurable — use long values on LoRa to respect duty cycle. RNS itself also rate-limits announces per interface. """ for delay in self.announce_retry_delays: time.sleep(delay) if not self._running: return self.announce() def stop(self): """Stop the bridge.""" self._running = False # Close all tunnels with self._tunnels_lock: for tunnel in list(self._tunnels.values()): tunnel.close() self._tunnels.clear() # Close TCP server if self._tcp_server: self._tcp_server.close() RNS.log("Radicle bridge stopped", RNS.LOG_INFO) def set_local_radicle_nid(self, nid: str): """Set the local radicle node's NID for announcement. This allows remote bridges to know which radicle NID is reachable through this bridge. """ self._local_radicle_nid = nid RNS.log(f"Local radicle NID set: {nid[:32]}...", RNS.LOG_INFO) def get_remote_bridge_nid(self, bridge_hash: bytes) -> Optional[str]: """Get the radicle NID served by a remote bridge.""" return self._bridge_nids.get(bridge_hash) def announce(self): """Announce this bridge on the network.""" # Build app_data: magic + optional NID app_data = BRIDGE_APP_DATA_MAGIC if self._local_radicle_nid: nid_bytes = self._local_radicle_nid.encode("utf-8") app_data += struct.pack("!H", len(nid_bytes)) + nid_bytes self.destination.announce(app_data=app_data) RNS.log(f"Announced bridge: {self.destination.hexhash} (app_data={len(app_data)} bytes)", RNS.LOG_INFO) def connect_to_bridge(self, destination_hash: bytes, timeout: float = 30.0) -> bool: """Connect to a remote bridge. This establishes the RNS link. Actual tunneling happens when local radicle-node connects to our TCP port. """ # Check if we have a path if not RNS.Transport.has_path(destination_hash): RNS.Transport.request_path(destination_hash) deadline = time.time() + timeout while not RNS.Transport.has_path(destination_hash): if time.time() > deadline: RNS.log(f"Path timeout: {destination_hash.hex()}", RNS.LOG_WARNING) return False time.sleep(0.1) # Remember this bridge with self._remote_bridges_lock: self._remote_bridges[destination_hash] = time.time() RNS.log(f"Registered remote bridge: {destination_hash.hex()}", RNS.LOG_INFO) return True def get_remote_bridges(self) -> List[bytes]: """Get list of known remote bridge hashes.""" with self._remote_bridges_lock: return list(self._remote_bridges.keys()) def _accept_loop(self): """Accept incoming TCP connections from local radicle-node.""" while self._running: try: readable, _, _ = select.select([self._tcp_server], [], [], 1.0) if readable: client_socket, addr = self._tcp_server.accept() RNS.log(f"TCP connection from {addr}", RNS.LOG_DEBUG) # Handle in new thread thread = threading.Thread( target=self._handle_local_connection, args=(client_socket,), daemon=True, ) thread.start() except Exception as e: if self._running: RNS.log(f"Accept error: {e}", RNS.LOG_ERROR) def _handle_local_connection(self, tcp_socket: socket.socket): """Handle a connection from local radicle-node. Creates tunnel to remote bridge and forwards traffic. """ # Get a remote bridge to tunnel to remote_bridges = self.get_remote_bridges() if not remote_bridges: RNS.log("No remote bridges available", RNS.LOG_WARNING) tcp_socket.close() return # Use first available bridge (could add load balancing later) remote_hash = remote_bridges[0] # Create RNS link to remote bridge remote_identity = RNS.Identity.recall(remote_hash) if not remote_identity: RNS.log(f"Cannot recall identity for {remote_hash.hex()}", RNS.LOG_WARNING) tcp_socket.close() return remote_dest = RNS.Destination( remote_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAME, ASPECT_BRIDGE, ) rns_link = RNS.Link(remote_dest) # Wait for link establishment deadline = time.time() + 30.0 while rns_link.status != RNS.Link.ACTIVE: if rns_link.status == RNS.Link.CLOSED: RNS.log("Link closed before becoming active (remote refused or unreachable)", RNS.LOG_WARNING) tcp_socket.close() return if time.time() > deadline: RNS.log("Link establishment timed out after 30s", RNS.LOG_WARNING) tcp_socket.close() return time.sleep(0.1) # Create tunnel with self._tunnels_lock: self._tunnel_counter += 1 tunnel_id = self._tunnel_counter tunnel = TunnelConnection( tunnel_id=tunnel_id, tcp_socket=tcp_socket, rns_link=rns_link, remote_destination=remote_hash, ) with self._tunnels_lock: self._tunnels[tunnel_id] = tunnel RNS.log(f"Tunnel {tunnel_id} opened to {remote_hash.hex()[:16]}", RNS.LOG_INFO) if self._tunnel_opened_cb: self._tunnel_opened_cb(tunnel) # Set up bidirectional forwarding rns_link.set_packet_callback( lambda data, pkt: self._on_rns_data(tunnel_id, data) ) rns_link.set_link_closed_callback( lambda link: self._on_tunnel_closed(tunnel_id) ) # Forward TCP to RNS self._forward_tcp_to_rns(tunnel) def _forward_tcp_to_rns(self, tunnel: TunnelConnection): """Forward data from TCP socket to RNS link.""" tcp_socket = tunnel.tcp_socket rns_link = tunnel.rns_link tcp_socket.setblocking(False) while tunnel.active and self._running: try: readable, _, errored = select.select([tcp_socket], [], [tcp_socket], 1.0) if errored: break if readable: data = tcp_socket.recv(RNS_BUFFER_SIZE) if not data: break # Connection closed # Send over RNS if rns_link.status == RNS.Link.ACTIVE: packet = RNS.Packet(rns_link, data) packet.send() tunnel.bytes_sent += len(data) else: break except socket.error: break except Exception as e: RNS.log(f"Forward error: {e}", RNS.LOG_DEBUG) break self._on_tunnel_closed(tunnel.tunnel_id) def _on_rns_data(self, tunnel_id: int, data: bytes): """Handle data received from RNS link.""" with self._tunnels_lock: tunnel = self._tunnels.get(tunnel_id) if tunnel and tunnel.active and tunnel.tcp_socket: try: tunnel.tcp_socket.sendall(data) tunnel.bytes_received += len(data) except Exception as e: RNS.log(f"TCP send error: {e}", RNS.LOG_DEBUG) self._on_tunnel_closed(tunnel_id) def _on_tunnel_closed(self, tunnel_id: int): """Handle tunnel closure.""" with self._tunnels_lock: tunnel = self._tunnels.pop(tunnel_id, None) if tunnel: tunnel.close() RNS.log( f"Tunnel {tunnel_id} closed (sent: {tunnel.bytes_sent}, recv: {tunnel.bytes_received})", RNS.LOG_INFO ) if self._tunnel_closed_cb: self._tunnel_closed_cb(tunnel) def _on_incoming_link(self, link: RNS.Link): """Handle incoming RNS link from remote bridge.""" RNS.log(f"Incoming link from remote bridge", RNS.LOG_DEBUG) # Connect to local radicle-node try: tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) tcp_socket.connect((self.radicle_host, self.radicle_port)) except Exception as e: RNS.log(f"Cannot connect to local radicle-node: {e}", RNS.LOG_ERROR) link.teardown() return # Create tunnel with self._tunnels_lock: self._tunnel_counter += 1 tunnel_id = self._tunnel_counter tunnel = TunnelConnection( tunnel_id=tunnel_id, tcp_socket=tcp_socket, rns_link=link, remote_destination=link.destination.hash if link.destination else None, ) with self._tunnels_lock: self._tunnels[tunnel_id] = tunnel RNS.log(f"Incoming tunnel {tunnel_id} opened", RNS.LOG_INFO) # Set up bidirectional forwarding link.set_packet_callback( lambda data, pkt: self._on_rns_data(tunnel_id, data) ) link.set_link_closed_callback( lambda l: self._on_tunnel_closed(tunnel_id) ) # Forward TCP to RNS in background thread = threading.Thread( target=self._forward_tcp_to_rns, args=(tunnel,), daemon=True, ) thread.start() def set_on_bridge_discovered(self, callback: Optional[Callable[[bytes, Optional[str]], None]]): """Set callback for when a new bridge is discovered. Callback receives (destination_hash, radicle_nid) where radicle_nid may be None if the remote bridge hasn't set one. """ self._on_bridge_discovered = callback def _handle_announce( self, destination_hash: bytes, announced_identity: RNS.Identity, app_data: Optional[bytes], ): """Handle announce - only process if it's a bridge announce.""" RNS.log(f"Received announce: {destination_hash.hex()[:16]}... app_data={app_data[:20] if app_data else None}", RNS.LOG_VERBOSE) # Ignore our own announcements if destination_hash == self.destination.hash: RNS.log("Ignoring own announcement", RNS.LOG_VERBOSE) return # Filter: only accept announces with bridge magic in app_data if app_data is None or not app_data.startswith(BRIDGE_APP_DATA_MAGIC): # Not a bridge announce, ignore RNS.log(f"Ignoring non-bridge announce (no magic)", RNS.LOG_VERBOSE) return # Extract radicle NID if present radicle_nid = None if len(app_data) > len(BRIDGE_APP_DATA_MAGIC): try: offset = len(BRIDGE_APP_DATA_MAGIC) nid_len = struct.unpack("!H", app_data[offset:offset+2])[0] offset += 2 nid_str = app_data[offset:offset+nid_len].decode("utf-8") radicle_nid = nid_str if nid_str else None except Exception as e: RNS.log(f"Failed to parse bridge app_data: {e}", RNS.LOG_DEBUG) with self._remote_bridges_lock: is_new = destination_hash not in self._remote_bridges self._remote_bridges[destination_hash] = time.time() # Check and update NID mapping under the same lock to avoid double-registering nid_is_new = bool(radicle_nid and destination_hash not in self._bridge_nids) if radicle_nid: self._bridge_nids[destination_hash] = radicle_nid if is_new: nid_info = f" (NID: {radicle_nid[:32]}...)" if radicle_nid else "" RNS.log(f"Discovered bridge: {destination_hash.hex()}{nid_info}", RNS.LOG_INFO) # Notify callback if self._on_bridge_discovered: self._on_bridge_discovered(destination_hash, radicle_nid) # Auto-connect if enabled if self.auto_connect: threading.Thread( target=self._auto_connect_to_bridge, args=(destination_hash,), daemon=True, ).start() # Auto-register seed whenever we first learn the NID (covers --connect path # where the bridge is pre-registered before its announce arrives) if self.auto_seed and nid_is_new: threading.Thread( target=self._auto_register_seed, args=(radicle_nid,), daemon=True, ).start() def _auto_connect_to_bridge(self, destination_hash: bytes): """Auto-connect to a discovered bridge in background.""" RNS.log(f"Auto-connecting to bridge: {destination_hash.hex()}", RNS.LOG_INFO) if self.connect_to_bridge(destination_hash, timeout=30.0): RNS.log(f"Auto-connected to bridge: {destination_hash.hex()}", RNS.LOG_INFO) else: RNS.log(f"Auto-connect failed for bridge: {destination_hash.hex()}", RNS.LOG_WARNING) def register_seed(self, radicle_nid: str) -> bool: """Register a remote radicle NID as a seed through this bridge. Calls 'rad node connect @127.0.0.1:' to tell radicle-node that the given NID is reachable through our bridge. Returns True if successful. """ addr = f"{radicle_nid}@127.0.0.1:{self.listen_port}" RNS.log(f"Registering seed: {addr}", RNS.LOG_INFO) try: result = subprocess.run( ["rad", "node", "connect", addr], capture_output=True, text=True, timeout=30, ) if result.returncode == 0: RNS.log(f"Seed registered: {radicle_nid[:16]}...", RNS.LOG_INFO) return True else: RNS.log(f"Failed to register seed: {result.stderr}", RNS.LOG_WARNING) return False except FileNotFoundError: RNS.log("'rad' command not found - cannot auto-register seed", RNS.LOG_WARNING) return False except subprocess.TimeoutExpired: RNS.log("Timeout registering seed", RNS.LOG_WARNING) return False except Exception as e: RNS.log(f"Error registering seed: {e}", RNS.LOG_WARNING) return False def _auto_register_seed(self, radicle_nid: str): """Auto-register a seed in background after discovery.""" # Small delay to allow bridge connection to establish first time.sleep(2.0) self.register_seed(radicle_nid) def get_stats(self) -> dict: """Get bridge statistics.""" with self._tunnels_lock: active_tunnels = len(self._tunnels) total_sent = sum(t.bytes_sent for t in self._tunnels.values()) total_recv = sum(t.bytes_received for t in self._tunnels.values()) with self._remote_bridges_lock: known_bridges = len(self._remote_bridges) return { "active_tunnels": active_tunnels, "known_bridges": known_bridges, "bytes_sent": total_sent, "bytes_received": total_recv, "rns_hash": self.destination.hexhash, }