diff --git a/src/radicle_reticulum/bridge.py b/src/radicle_reticulum/bridge.py index 4cf5add..3a75279 100644 --- a/src/radicle_reticulum/bridge.py +++ b/src/radicle_reticulum/bridge.py @@ -9,9 +9,9 @@ Architecture: └─────────────┘ 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 +1. Allocates a dedicated TCP port per discovered remote bridge +2. Accepts connections from local radicle-node on those ports +3. Tunnels traffic over Reticulum to the correct remote bridge 4. Remote bridges forward to their local radicle-node """ @@ -32,7 +32,7 @@ from radicle_reticulum.identity import RadicleIdentity # Default ports RADICLE_DEFAULT_PORT = 8776 -BRIDGE_DEFAULT_PORT = 8777 # Local listen port for bridge +BRIDGE_DEFAULT_PORT = 8777 # Base listen port (first bridge gets this) # App name for RNS destinations APP_NAME = "radicle" @@ -64,21 +64,21 @@ class TunnelConnection: if self.tcp_socket: try: self.tcp_socket.close() - except: + except OSError: pass if self.rns_link: try: self.rns_link.teardown() - except: + except Exception: 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 + Each discovered remote bridge gets its own dedicated TCP listen port so + that radicle-node connections are always routed to the correct peer. + The first bridge gets listen_port; subsequent bridges get OS-assigned ports. """ def __init__( @@ -97,7 +97,8 @@ class RadicleBridge: Args: identity: RNS identity for this bridge - listen_port: TCP port to listen on for local radicle-node + listen_port: Base TCP port; first discovered bridge gets this port, + subsequent ones get OS-assigned ports. radicle_host: Host where radicle-node listens (for incoming tunnels) radicle_port: Port where radicle-node listens config_path: Path to Reticulum config @@ -138,13 +139,16 @@ class RadicleBridge: self._remote_bridges: Dict[bytes, float] = {} self._remote_bridges_lock = threading.Lock() + # Per-bridge dedicated TCP servers: bridge_hash -> server socket / port + self._bridge_servers: Dict[bytes, socket.socket] = {} + self._bridge_ports: Dict[bytes, int] = {} + self._servers_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 @@ -155,7 +159,7 @@ class RadicleBridge: # Local radicle node NID (for announcing to remote bridges) self._local_radicle_nid: Optional[str] = None - # Remote bridge NIDs: bridge_hash -> radicle_nid + # Remote bridge NIDs: bridge_hash -> radicle_nid (guarded by _remote_bridges_lock) self._bridge_nids: Dict[bytes, str] = {} def start(self): @@ -164,35 +168,23 @@ class RadicleBridge: # 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() + RNS.log("Registered announce handler for bridge discovery", RNS.LOG_INFO) # 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("Radicle bridge started", 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) + RNS.log( + f" TCP ports: allocated per remote bridge (base port {self.listen_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. - """ + """Re-announce after startup to catch peers that come up slightly later.""" for delay in self.announce_retry_delays: time.sleep(delay) if not self._running: @@ -209,43 +201,47 @@ class RadicleBridge: tunnel.close() self._tunnels.clear() - # Close TCP server - if self._tcp_server: - self._tcp_server.close() + # Close all per-bridge TCP servers + with self._servers_lock: + for srv in self._bridge_servers.values(): + try: + srv.close() + except OSError: + pass + self._bridge_servers.clear() + self._bridge_ports.clear() 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. - """ + """Set the local radicle node's NID for announcement.""" 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) + with self._remote_bridges_lock: + 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) + 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. + """Connect to a remote bridge (establish RNS path). - This establishes the RNS link. Actual tunneling happens when - local radicle-node connects to our TCP port. + Actual per-connection tunneling is set up lazily when radicle-node + connects to the allocated 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 @@ -255,7 +251,6 @@ class RadicleBridge: return False time.sleep(0.1) - # Remember this bridge with self._remote_bridges_lock: self._remote_bridges[destination_hash] = time.time() @@ -267,45 +262,78 @@ class RadicleBridge: with self._remote_bridges_lock: return list(self._remote_bridges.keys()) - def _accept_loop(self): - """Accept incoming TCP connections from local radicle-node.""" + # ── Per-bridge TCP server ──────────────────────────────────────────────── + + def _allocate_port_for_bridge(self, bridge_hash: bytes) -> int: + """Allocate a dedicated TCP listen port for a remote bridge. + + The first bridge claims listen_port if available; subsequent bridges + get OS-assigned ephemeral ports. Idempotent — returns the same port + on repeated calls for the same bridge_hash. + """ + with self._servers_lock: + if bridge_hash in self._bridge_ports: + return self._bridge_ports[bridge_hash] + + # Try the configured listen_port first (first bridge keeps the + # well-known port); fall back to OS-assigned if already taken. + preferred = self.listen_port if not self._bridge_ports else 0 + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + srv.bind(("127.0.0.1", preferred)) + except OSError: + srv.bind(("127.0.0.1", 0)) + srv.listen(5) + srv.setblocking(False) + port = srv.getsockname()[1] + + self._bridge_servers[bridge_hash] = srv + self._bridge_ports[bridge_hash] = port + + threading.Thread( + target=self._accept_loop_for_bridge, + args=(srv, bridge_hash), + daemon=True, + ).start() + + RNS.log( + f"Allocated TCP port {port} for bridge {bridge_hash.hex()[:16]}", + RNS.LOG_INFO, + ) + return port + + def _accept_loop_for_bridge(self, server: socket.socket, bridge_hash: bytes): + """Accept TCP connections destined for a specific remote bridge.""" while self._running: try: - readable, _, _ = select.select([self._tcp_server], [], [], 1.0) + readable, _, _ = select.select([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, + client_socket, addr = server.accept() + RNS.log( + f"TCP from {addr} → bridge {bridge_hash.hex()[:16]}", + RNS.LOG_DEBUG, ) - thread.start() + threading.Thread( + target=self._handle_local_connection, + args=(client_socket, bridge_hash), + daemon=True, + ).start() except Exception as e: if self._running: - RNS.log(f"Accept error: {e}", RNS.LOG_ERROR) + RNS.log( + f"Accept error for bridge {bridge_hash.hex()[:16]}: {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) + def _handle_local_connection(self, tcp_socket: socket.socket, bridge_hash: bytes): + """Handle a connection from local radicle-node, tunneled to bridge_hash.""" + remote_identity = RNS.Identity.recall(bridge_hash) if not remote_identity: - RNS.log(f"Cannot recall identity for {remote_hash.hex()}", RNS.LOG_WARNING) + RNS.log( + f"Cannot recall identity for {bridge_hash.hex()[:16]}", + RNS.LOG_WARNING, + ) tcp_socket.close() return @@ -319,11 +347,14 @@ class RadicleBridge: rns_link = RNS.Link(remote_dest) - # Wait for link establishment + # Wait for link establishment (Noise XK handshake) 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) + if rns_link.status in (RNS.Link.CLOSED, RNS.Link.FAILED): + RNS.log( + "Link closed/failed before becoming active", + RNS.LOG_WARNING, + ) tcp_socket.close() return if time.time() > deadline: @@ -332,7 +363,6 @@ class RadicleBridge: return time.sleep(0.1) - # Create tunnel with self._tunnels_lock: self._tunnel_counter += 1 tunnel_id = self._tunnel_counter @@ -341,18 +371,17 @@ class RadicleBridge: tunnel_id=tunnel_id, tcp_socket=tcp_socket, rns_link=rns_link, - remote_destination=remote_hash, + remote_destination=bridge_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) + RNS.log(f"Tunnel {tunnel_id} opened to {bridge_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) ) @@ -360,7 +389,6 @@ class RadicleBridge: 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): @@ -379,9 +407,8 @@ class RadicleBridge: if readable: data = tcp_socket.recv(RNS_BUFFER_SIZE) if not data: - break # Connection closed + break - # Send over RNS if rns_link.status == RNS.Link.ACTIVE: packet = RNS.Packet(rns_link, data) packet.send() @@ -418,17 +445,17 @@ class RadicleBridge: if tunnel: tunnel.close() RNS.log( - f"Tunnel {tunnel_id} closed (sent: {tunnel.bytes_sent}, recv: {tunnel.bytes_received})", - RNS.LOG_INFO + f"Tunnel {tunnel_id} closed " + f"(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) + RNS.log("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)) @@ -437,7 +464,6 @@ class RadicleBridge: link.teardown() return - # Create tunnel with self._tunnels_lock: self._tunnel_counter += 1 tunnel_id = self._tunnel_counter @@ -454,7 +480,6 @@ class RadicleBridge: 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) ) @@ -462,19 +487,20 @@ class RadicleBridge: lambda l: self._on_tunnel_closed(tunnel_id) ) - # Forward TCP to RNS in background - thread = threading.Thread( + threading.Thread( target=self._forward_tcp_to_rns, args=(tunnel,), daemon=True, - ) - thread.start() + ).start() - def set_on_bridge_discovered(self, callback: Optional[Callable[[bytes, Optional[str]], None]]): + # ── Bridge discovery ───────────────────────────────────────────────────── + + 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. + Callback: (destination_hash: bytes, radicle_nid: Optional[str]) """ self._on_bridge_discovered = callback @@ -484,28 +510,30 @@ class RadicleBridge: 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) + """Handle RNS announce — process only bridge announces.""" + RNS.log( + f"Received announce: {destination_hash.hex()[:16]}... " + f"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) + RNS.log("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] + nid_len = struct.unpack("!H", app_data[offset:offset + 2])[0] offset += 2 - nid_str = app_data[offset:offset+nid_len].decode("utf-8") + available = len(app_data) - offset + nid_len = min(nid_len, available) + 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) @@ -513,20 +541,22 @@ class RadicleBridge: 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) + 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) + 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, @@ -534,12 +564,10 @@ class RadicleBridge: 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,), + args=(radicle_nid, destination_hash), daemon=True, ).start() @@ -549,17 +577,23 @@ class RadicleBridge: 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) + 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. + def register_seed(self, radicle_nid: str, port: Optional[int] = None) -> bool: + """Register a remote radicle NID as reachable through this bridge. - Calls 'rad node connect @127.0.0.1:' to tell - radicle-node that the given NID is reachable through our bridge. + Calls 'rad node connect @127.0.0.1:' to tell radicle-node + that the given NID is reachable through our bridge TCP server. - Returns True if successful. + port: the bridge's per-NID listen port. Falls back to self.listen_port + if not specified (backward-compatible for single-bridge setups). """ - addr = f"{radicle_nid}@127.0.0.1:{self.listen_port}" + if port is None: + port = self.listen_port + addr = f"{radicle_nid}@127.0.0.1:{port}" RNS.log(f"Registering seed: {addr}", RNS.LOG_INFO) env = None @@ -582,7 +616,7 @@ class RadicleBridge: 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) + 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) @@ -591,11 +625,11 @@ class RadicleBridge: 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 + def _auto_register_seed(self, radicle_nid: str, bridge_hash: bytes): + """Allocate a dedicated TCP port for this bridge and register its NID.""" time.sleep(2.0) - self.register_seed(radicle_nid) + port = self._allocate_port_for_bridge(bridge_hash) + self.register_seed(radicle_nid, port) def get_stats(self) -> dict: """Get bridge statistics.""" diff --git a/src/radicle_reticulum/cli.py b/src/radicle_reticulum/cli.py index 03dfffd..3652ac1 100644 --- a/src/radicle_reticulum/cli.py +++ b/src/radicle_reticulum/cli.py @@ -299,15 +299,15 @@ def cmd_gossip(args): signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - last_stats = None + last_known_peers = -1 try: while running: stats = relay.get_stats() - if stats != last_stats: + if stats["known_peers"] != last_known_peers: + last_known_peers = stats["known_peers"] print(f"[Status] Peers: {stats['known_peers']}, " f"Repos: {stats['watched_repos']}, " f"Refs: {stats['refs_per_repo']}") - last_stats = dict(stats) time.sleep(5) finally: relay.stop() @@ -315,7 +315,7 @@ def cmd_gossip(args): def cmd_seed(args): - """Start a dedicated seed radicle-node and bridge it to the mesh.""" + """Start a dedicated seed radicle-node, bridge, and gossip relay.""" seed_home = Path(args.seed_home) seed = SeedNode(seed_home=seed_home, port=args.seed_port) @@ -376,6 +376,20 @@ def cmd_seed(args): bridge.set_on_bridge_discovered(on_peer_seed_discovered) + # Gossip relay: watches seed's storage, notifies remote seeds of ref changes. + # bridge_port=None: bridge's auto_seed already registered NIDs on correct ports. + gossip = GossipRelay( + identity=identity, + rids=[], + storage=seed_home / "storage", + radicle_nid=nid, + bridge_port=None, + poll_interval=args.poll_interval, + announce_retry_delays=announce_retry_delays, + auto_discover=True, + rad_home=str(seed_home), + ) + running = True def signal_handler(sig, frame): @@ -388,14 +402,16 @@ def cmd_seed(args): try: bridge.start() + gossip.start() print() print("Seed node running.") print(f" Seed NID: {nid}") print(f" Seed port: {args.seed_port}") print(f" Bridge hash: {bridge.destination.hexhash}") + print(f" Gossip hash: {gossip.destination.hexhash}") print() - print("Add this seed to your radicle node:") + print("Add this seed to your radicle node (one-time setup):") print(f" rad node connect {nid}@127.0.0.1:{args.seed_port}") print() print("Other machines running 'radicle-rns seed' will discover this") @@ -414,10 +430,12 @@ def cmd_seed(args): if stats["known_bridges"] != last_known_bridges: last_known_bridges = stats["known_bridges"] print(f"[Status] Remote seeds: {stats['known_bridges']}, " - f"Tunnels: {stats['active_tunnels']}") + f"Tunnels: {stats['active_tunnels']}, " + f"Gossip peers: {gossip.get_stats()['known_peers']}") time.sleep(5) finally: + gossip.stop() bridge.stop() seed.stop() print("Seed stopped.") @@ -675,6 +693,13 @@ def main(): metavar="PORT", help="TCP listen port for the seed bridge (default: 8778)", ) + seed_parser.add_argument( + "--poll-interval", + type=int, + default=30, + metavar="SECONDS", + help="Seconds between gossip ref polls (default: 30)", + ) seed_parser.add_argument( "--announce-retry-delays", default="5,15,30", diff --git a/src/radicle_reticulum/gossip.py b/src/radicle_reticulum/gossip.py index 75cec17..1e60364 100644 --- a/src/radicle_reticulum/gossip.py +++ b/src/radicle_reticulum/gossip.py @@ -15,6 +15,7 @@ Flow: """ import json +import os import struct import subprocess import threading @@ -87,10 +88,12 @@ class GossipRelay: rids: List[str], storage: Optional[Path] = None, radicle_nid: Optional[str] = None, - bridge_port: int = 8777, + bridge_port: Optional[int] = 8777, poll_interval: int = DEFAULT_POLL_INTERVAL, announce_retry_delays: Tuple[int, ...] = (5, 15, 30), config_path: Optional[str] = None, + auto_discover: bool = False, + rad_home: Optional[str] = None, ): """ Args: @@ -98,10 +101,15 @@ class GossipRelay: rids: List of Radicle repository IDs to watch (e.g. 'rad:z3abc...'). storage: Path to radicle storage dir. Auto-detected if None. radicle_nid: Local radicle NID to advertise to peers. - bridge_port: TCP port the bridge listens on (for rad node connect). + bridge_port: TCP port for 'rad node connect' in _trigger_sync. + Pass None to skip that step (seed mode: bridge's auto_seed + already registered NIDs on correct per-bridge ports). poll_interval: Seconds between ref polls. announce_retry_delays: Startup re-announce delays (seconds). config_path: Reticulum config path (None = default). + auto_discover: Scan storage each poll cycle and add new repo dirs + to rids automatically. Useful in seed mode. + rad_home: RAD_HOME override for rad CLI calls. None = system default. """ self.identity = identity self.rids = list(rids) @@ -110,6 +118,8 @@ class GossipRelay: self.bridge_port = bridge_port self.poll_interval = poll_interval self.announce_retry_delays = announce_retry_delays + self.auto_discover = auto_discover + self.rad_home = rad_home self.reticulum = RNS.Reticulum(config_path) @@ -196,8 +206,25 @@ class GossipRelay: return self.announce() + def _discover_rids(self): + """Scan storage for new repo dirs and add them to self.rids.""" + if not self.storage.exists(): + return + try: + for repo_dir in self.storage.iterdir(): + if repo_dir.is_dir(): + rid = f"rad:{repo_dir.name}" + if rid not in self.rids: + self.rids.append(rid) + RNS.log(f"Auto-discovered repo: {rid[:40]}", RNS.LOG_INFO) + except Exception as e: + RNS.log(f"Error scanning storage: {e}", RNS.LOG_DEBUG) + def _poll_loop(self): while self._running: + if self.auto_discover: + self._discover_rids() + for rid in self.rids: try: refs = _read_refs(self.storage, rid) @@ -304,15 +331,20 @@ class GossipRelay: def _trigger_sync(self, rid: str, nid: str): """Run rad node connect (if needed) then rad sync --fetch.""" - if nid: + env = None + if self.rad_home: + env = os.environ.copy() + env["RAD_HOME"] = self.rad_home + + if nid and self.bridge_port is not None: subprocess.run( ["rad", "node", "connect", f"{nid}@127.0.0.1:{self.bridge_port}"], - capture_output=True, timeout=15, + capture_output=True, timeout=15, env=env, ) result = subprocess.run( ["rad", "sync", "--fetch", "--rid", rid], - capture_output=True, text=True, timeout=120, + capture_output=True, text=True, timeout=120, env=env, ) if result.returncode == 0: