fix: per-bridge TCP routing, gossip in seed mode, and thread-safety

Multi-bridge routing was broken: all incoming radicle-node connections
were routed to remote_bridges[0] regardless of which NID was being
reached. Fixed by allocating a dedicated OS-assigned TCP port per
discovered remote bridge; auto_seed registers each NID at its specific
port so radicle-node connections always reach the correct peer.

Gossip relay integrated into 'radicle-rns seed': watches the seed's
storage, auto-discovers repos as they are seeded, notifies remote gossip
peers of ref changes.  bridge_port=None skips the redundant rad-node-
connect step (bridge's auto_seed already registered NIDs correctly).

Other fixes:
- bridge.py: link FAILED state now breaks the wait loop immediately
  instead of waiting out the full 30 s timeout
- bridge.py: get_remote_bridge_nid now reads _bridge_nids under lock
- bridge.py: NID length bounds-checked before slice in _handle_announce
- gossip.py: add rad_home param so seed's rad calls use correct RAD_HOME
- gossip.py: add auto_discover flag to scan storage for new repos each cycle
- gossip.py: import os moved to top-level
- cli.py: gossip status line only prints when peer count changes (not
  every 5 s on any stats change)
- cli.py: add --poll-interval to seed command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciek "mab122" Bator 2026-04-22 19:33:19 +02:00
parent e051d82af1
commit 5fa6f890b4
3 changed files with 233 additions and 142 deletions

View File

@ -9,9 +9,9 @@ Architecture:
localhost LoRa/mesh localhost LoRa/mesh
The bridge: The bridge:
1. Listens on localhost TCP (default: 8776, Radicle's default port) 1. Allocates a dedicated TCP port per discovered remote bridge
2. Accepts connections from local radicle-node 2. Accepts connections from local radicle-node on those ports
3. Tunnels traffic over Reticulum to remote bridges 3. Tunnels traffic over Reticulum to the correct remote bridge
4. Remote bridges forward to their local radicle-node 4. Remote bridges forward to their local radicle-node
""" """
@ -32,7 +32,7 @@ from radicle_reticulum.identity import RadicleIdentity
# Default ports # Default ports
RADICLE_DEFAULT_PORT = 8776 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 for RNS destinations
APP_NAME = "radicle" APP_NAME = "radicle"
@ -64,21 +64,21 @@ class TunnelConnection:
if self.tcp_socket: if self.tcp_socket:
try: try:
self.tcp_socket.close() self.tcp_socket.close()
except: except OSError:
pass pass
if self.rns_link: if self.rns_link:
try: try:
self.rns_link.teardown() self.rns_link.teardown()
except: except Exception:
pass pass
class RadicleBridge: class RadicleBridge:
"""Bridges Radicle TCP connections over Reticulum. """Bridges Radicle TCP connections over Reticulum.
Modes: Each discovered remote bridge gets its own dedicated TCP listen port so
- Server mode: Listens for TCP from local radicle-node, tunnels to remote bridges that radicle-node connections are always routed to the correct peer.
- Accepts incoming tunnels from remote bridges, forwards to local radicle-node The first bridge gets listen_port; subsequent bridges get OS-assigned ports.
""" """
def __init__( def __init__(
@ -97,7 +97,8 @@ class RadicleBridge:
Args: Args:
identity: RNS identity for this bridge 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_host: Host where radicle-node listens (for incoming tunnels)
radicle_port: Port where radicle-node listens radicle_port: Port where radicle-node listens
config_path: Path to Reticulum config config_path: Path to Reticulum config
@ -138,13 +139,16 @@ class RadicleBridge:
self._remote_bridges: Dict[bytes, float] = {} self._remote_bridges: Dict[bytes, float] = {}
self._remote_bridges_lock = threading.Lock() 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 # Active tunnels
self._tunnels: Dict[int, TunnelConnection] = {} self._tunnels: Dict[int, TunnelConnection] = {}
self._tunnel_counter = 0 self._tunnel_counter = 0
self._tunnels_lock = threading.Lock() self._tunnels_lock = threading.Lock()
# TCP server
self._tcp_server: Optional[socket.socket] = None
self._running = False self._running = False
# Callbacks # Callbacks
@ -155,7 +159,7 @@ class RadicleBridge:
# Local radicle node NID (for announcing to remote bridges) # Local radicle node NID (for announcing to remote bridges)
self._local_radicle_nid: Optional[str] = None 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] = {} self._bridge_nids: Dict[bytes, str] = {}
def start(self): def start(self):
@ -164,35 +168,23 @@ class RadicleBridge:
# Register announce handler to discover other bridges # Register announce handler to discover other bridges
RNS.Transport.register_announce_handler(self._handle_announce) RNS.Transport.register_announce_handler(self._handle_announce)
RNS.log(f"Registered announce handler for bridge discovery", RNS.LOG_INFO) RNS.log("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 # Announce presence; repeat a few times so peers that come up shortly
# after us don't miss it due to interface initialisation timing # after us don't miss it due to interface initialisation timing
self.announce() self.announce()
threading.Thread(target=self._startup_announce_loop, daemon=True).start() threading.Thread(target=self._startup_announce_loop, daemon=True).start()
RNS.log(f"Radicle bridge started", RNS.LOG_INFO) RNS.log("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" 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" 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): def _startup_announce_loop(self):
"""Re-announce after startup to catch peers that come up slightly later. """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: for delay in self.announce_retry_delays:
time.sleep(delay) time.sleep(delay)
if not self._running: if not self._running:
@ -209,43 +201,47 @@ class RadicleBridge:
tunnel.close() tunnel.close()
self._tunnels.clear() self._tunnels.clear()
# Close TCP server # Close all per-bridge TCP servers
if self._tcp_server: with self._servers_lock:
self._tcp_server.close() 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) RNS.log("Radicle bridge stopped", RNS.LOG_INFO)
def set_local_radicle_nid(self, nid: str): def set_local_radicle_nid(self, nid: str):
"""Set the local radicle node's NID for announcement. """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 self._local_radicle_nid = nid
RNS.log(f"Local radicle NID set: {nid[:32]}...", RNS.LOG_INFO) RNS.log(f"Local radicle NID set: {nid[:32]}...", RNS.LOG_INFO)
def get_remote_bridge_nid(self, bridge_hash: bytes) -> Optional[str]: def get_remote_bridge_nid(self, bridge_hash: bytes) -> Optional[str]:
"""Get the radicle NID served by a remote bridge.""" """Get the radicle NID served by a remote bridge."""
with self._remote_bridges_lock:
return self._bridge_nids.get(bridge_hash) return self._bridge_nids.get(bridge_hash)
def announce(self): def announce(self):
"""Announce this bridge on the network.""" """Announce this bridge on the network."""
# Build app_data: magic + optional NID
app_data = BRIDGE_APP_DATA_MAGIC app_data = BRIDGE_APP_DATA_MAGIC
if self._local_radicle_nid: if self._local_radicle_nid:
nid_bytes = self._local_radicle_nid.encode("utf-8") nid_bytes = self._local_radicle_nid.encode("utf-8")
app_data += struct.pack("!H", len(nid_bytes)) + nid_bytes app_data += struct.pack("!H", len(nid_bytes)) + nid_bytes
self.destination.announce(app_data=app_data) 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: 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 Actual per-connection tunneling is set up lazily when radicle-node
local radicle-node connects to our TCP port. connects to the allocated TCP port.
""" """
# Check if we have a path
if not RNS.Transport.has_path(destination_hash): if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash) RNS.Transport.request_path(destination_hash)
deadline = time.time() + timeout deadline = time.time() + timeout
@ -255,7 +251,6 @@ class RadicleBridge:
return False return False
time.sleep(0.1) time.sleep(0.1)
# Remember this bridge
with self._remote_bridges_lock: with self._remote_bridges_lock:
self._remote_bridges[destination_hash] = time.time() self._remote_bridges[destination_hash] = time.time()
@ -267,45 +262,78 @@ class RadicleBridge:
with self._remote_bridges_lock: with self._remote_bridges_lock:
return list(self._remote_bridges.keys()) return list(self._remote_bridges.keys())
def _accept_loop(self): # ── Per-bridge TCP server ────────────────────────────────────────────────
"""Accept incoming TCP connections from local radicle-node."""
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: while self._running:
try: try:
readable, _, _ = select.select([self._tcp_server], [], [], 1.0) readable, _, _ = select.select([server], [], [], 1.0)
if readable: if readable:
client_socket, addr = self._tcp_server.accept() client_socket, addr = server.accept()
RNS.log(f"TCP connection from {addr}", RNS.LOG_DEBUG) RNS.log(
f"TCP from {addr} → bridge {bridge_hash.hex()[:16]}",
# Handle in new thread RNS.LOG_DEBUG,
thread = threading.Thread(
target=self._handle_local_connection,
args=(client_socket,),
daemon=True,
) )
thread.start() threading.Thread(
target=self._handle_local_connection,
args=(client_socket, bridge_hash),
daemon=True,
).start()
except Exception as e: except Exception as e:
if self._running: 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): def _handle_local_connection(self, tcp_socket: socket.socket, bridge_hash: bytes):
"""Handle a connection from local radicle-node. """Handle a connection from local radicle-node, tunneled to bridge_hash."""
remote_identity = RNS.Identity.recall(bridge_hash)
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: 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() tcp_socket.close()
return return
@ -319,11 +347,14 @@ class RadicleBridge:
rns_link = RNS.Link(remote_dest) rns_link = RNS.Link(remote_dest)
# Wait for link establishment # Wait for link establishment (Noise XK handshake)
deadline = time.time() + 30.0 deadline = time.time() + 30.0
while rns_link.status != RNS.Link.ACTIVE: while rns_link.status != RNS.Link.ACTIVE:
if rns_link.status == RNS.Link.CLOSED: if rns_link.status in (RNS.Link.CLOSED, RNS.Link.FAILED):
RNS.log("Link closed before becoming active (remote refused or unreachable)", RNS.LOG_WARNING) RNS.log(
"Link closed/failed before becoming active",
RNS.LOG_WARNING,
)
tcp_socket.close() tcp_socket.close()
return return
if time.time() > deadline: if time.time() > deadline:
@ -332,7 +363,6 @@ class RadicleBridge:
return return
time.sleep(0.1) time.sleep(0.1)
# Create tunnel
with self._tunnels_lock: with self._tunnels_lock:
self._tunnel_counter += 1 self._tunnel_counter += 1
tunnel_id = self._tunnel_counter tunnel_id = self._tunnel_counter
@ -341,18 +371,17 @@ class RadicleBridge:
tunnel_id=tunnel_id, tunnel_id=tunnel_id,
tcp_socket=tcp_socket, tcp_socket=tcp_socket,
rns_link=rns_link, rns_link=rns_link,
remote_destination=remote_hash, remote_destination=bridge_hash,
) )
with self._tunnels_lock: with self._tunnels_lock:
self._tunnels[tunnel_id] = tunnel 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: if self._tunnel_opened_cb:
self._tunnel_opened_cb(tunnel) self._tunnel_opened_cb(tunnel)
# Set up bidirectional forwarding
rns_link.set_packet_callback( rns_link.set_packet_callback(
lambda data, pkt: self._on_rns_data(tunnel_id, data) lambda data, pkt: self._on_rns_data(tunnel_id, data)
) )
@ -360,7 +389,6 @@ class RadicleBridge:
lambda link: self._on_tunnel_closed(tunnel_id) lambda link: self._on_tunnel_closed(tunnel_id)
) )
# Forward TCP to RNS
self._forward_tcp_to_rns(tunnel) self._forward_tcp_to_rns(tunnel)
def _forward_tcp_to_rns(self, tunnel: TunnelConnection): def _forward_tcp_to_rns(self, tunnel: TunnelConnection):
@ -379,9 +407,8 @@ class RadicleBridge:
if readable: if readable:
data = tcp_socket.recv(RNS_BUFFER_SIZE) data = tcp_socket.recv(RNS_BUFFER_SIZE)
if not data: if not data:
break # Connection closed break
# Send over RNS
if rns_link.status == RNS.Link.ACTIVE: if rns_link.status == RNS.Link.ACTIVE:
packet = RNS.Packet(rns_link, data) packet = RNS.Packet(rns_link, data)
packet.send() packet.send()
@ -418,17 +445,17 @@ class RadicleBridge:
if tunnel: if tunnel:
tunnel.close() tunnel.close()
RNS.log( RNS.log(
f"Tunnel {tunnel_id} closed (sent: {tunnel.bytes_sent}, recv: {tunnel.bytes_received})", f"Tunnel {tunnel_id} closed "
RNS.LOG_INFO f"(sent: {tunnel.bytes_sent}, recv: {tunnel.bytes_received})",
RNS.LOG_INFO,
) )
if self._tunnel_closed_cb: if self._tunnel_closed_cb:
self._tunnel_closed_cb(tunnel) self._tunnel_closed_cb(tunnel)
def _on_incoming_link(self, link: RNS.Link): def _on_incoming_link(self, link: RNS.Link):
"""Handle incoming RNS link from remote bridge.""" """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: try:
tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_socket.connect((self.radicle_host, self.radicle_port)) tcp_socket.connect((self.radicle_host, self.radicle_port))
@ -437,7 +464,6 @@ class RadicleBridge:
link.teardown() link.teardown()
return return
# Create tunnel
with self._tunnels_lock: with self._tunnels_lock:
self._tunnel_counter += 1 self._tunnel_counter += 1
tunnel_id = self._tunnel_counter tunnel_id = self._tunnel_counter
@ -454,7 +480,6 @@ class RadicleBridge:
RNS.log(f"Incoming tunnel {tunnel_id} opened", RNS.LOG_INFO) RNS.log(f"Incoming tunnel {tunnel_id} opened", RNS.LOG_INFO)
# Set up bidirectional forwarding
link.set_packet_callback( link.set_packet_callback(
lambda data, pkt: self._on_rns_data(tunnel_id, data) lambda data, pkt: self._on_rns_data(tunnel_id, data)
) )
@ -462,19 +487,20 @@ class RadicleBridge:
lambda l: self._on_tunnel_closed(tunnel_id) lambda l: self._on_tunnel_closed(tunnel_id)
) )
# Forward TCP to RNS in background threading.Thread(
thread = threading.Thread(
target=self._forward_tcp_to_rns, target=self._forward_tcp_to_rns,
args=(tunnel,), args=(tunnel,),
daemon=True, daemon=True,
) ).start()
thread.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. """Set callback for when a new bridge is discovered.
Callback receives (destination_hash, radicle_nid) where radicle_nid Callback: (destination_hash: bytes, radicle_nid: Optional[str])
may be None if the remote bridge hasn't set one.
""" """
self._on_bridge_discovered = callback self._on_bridge_discovered = callback
@ -484,27 +510,29 @@ class RadicleBridge:
announced_identity: RNS.Identity, announced_identity: RNS.Identity,
app_data: Optional[bytes], app_data: Optional[bytes],
): ):
"""Handle announce - only process if it's a bridge announce.""" """Handle RNS announce — process only bridge announces."""
RNS.log(f"Received announce: {destination_hash.hex()[:16]}... app_data={app_data[:20] if app_data else None}", RNS.LOG_VERBOSE) 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: if destination_hash == self.destination.hash:
RNS.log("Ignoring own announcement", RNS.LOG_VERBOSE) RNS.log("Ignoring own announcement", RNS.LOG_VERBOSE)
return 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): if app_data is None or not app_data.startswith(BRIDGE_APP_DATA_MAGIC):
# Not a bridge announce, ignore RNS.log("Ignoring non-bridge announce (no magic)", RNS.LOG_VERBOSE)
RNS.log(f"Ignoring non-bridge announce (no magic)", RNS.LOG_VERBOSE)
return return
# Extract radicle NID if present
radicle_nid = None radicle_nid = None
if len(app_data) > len(BRIDGE_APP_DATA_MAGIC): if len(app_data) > len(BRIDGE_APP_DATA_MAGIC):
try: try:
offset = len(BRIDGE_APP_DATA_MAGIC) 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 offset += 2
available = len(app_data) - offset
nid_len = min(nid_len, available)
nid_str = app_data[offset:offset + nid_len].decode("utf-8") nid_str = app_data[offset:offset + nid_len].decode("utf-8")
radicle_nid = nid_str if nid_str else None radicle_nid = nid_str if nid_str else None
except Exception as e: except Exception as e:
@ -513,20 +541,22 @@ class RadicleBridge:
with self._remote_bridges_lock: with self._remote_bridges_lock:
is_new = destination_hash not in self._remote_bridges is_new = destination_hash not in self._remote_bridges
self._remote_bridges[destination_hash] = time.time() self._remote_bridges[destination_hash] = time.time()
# Check and update NID mapping under the same lock to avoid double-registering nid_is_new = bool(
nid_is_new = bool(radicle_nid and destination_hash not in self._bridge_nids) radicle_nid and destination_hash not in self._bridge_nids
)
if radicle_nid: if radicle_nid:
self._bridge_nids[destination_hash] = radicle_nid self._bridge_nids[destination_hash] = radicle_nid
if is_new: if is_new:
nid_info = f" (NID: {radicle_nid[:32]}...)" if radicle_nid else "" 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: if self._on_bridge_discovered:
self._on_bridge_discovered(destination_hash, radicle_nid) self._on_bridge_discovered(destination_hash, radicle_nid)
# Auto-connect if enabled
if self.auto_connect: if self.auto_connect:
threading.Thread( threading.Thread(
target=self._auto_connect_to_bridge, target=self._auto_connect_to_bridge,
@ -534,12 +564,10 @@ class RadicleBridge:
daemon=True, daemon=True,
).start() ).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: if self.auto_seed and nid_is_new:
threading.Thread( threading.Thread(
target=self._auto_register_seed, target=self._auto_register_seed,
args=(radicle_nid,), args=(radicle_nid, destination_hash),
daemon=True, daemon=True,
).start() ).start()
@ -549,17 +577,23 @@ class RadicleBridge:
if self.connect_to_bridge(destination_hash, timeout=30.0): if self.connect_to_bridge(destination_hash, timeout=30.0):
RNS.log(f"Auto-connected to bridge: {destination_hash.hex()}", RNS.LOG_INFO) RNS.log(f"Auto-connected to bridge: {destination_hash.hex()}", RNS.LOG_INFO)
else: 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: def register_seed(self, radicle_nid: str, port: Optional[int] = None) -> bool:
"""Register a remote radicle NID as a seed through this bridge. """Register a remote radicle NID as reachable through this bridge.
Calls 'rad node connect <NID>@127.0.0.1:<listen_port>' to tell Calls 'rad node connect <NID>@127.0.0.1:<port>' to tell radicle-node
radicle-node that the given NID is reachable through our bridge. 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) RNS.log(f"Registering seed: {addr}", RNS.LOG_INFO)
env = None env = None
@ -582,7 +616,7 @@ class RadicleBridge:
RNS.log(f"Failed to register seed: {result.stderr}", RNS.LOG_WARNING) RNS.log(f"Failed to register seed: {result.stderr}", RNS.LOG_WARNING)
return False return False
except FileNotFoundError: 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 return False
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
RNS.log("Timeout registering seed", RNS.LOG_WARNING) RNS.log("Timeout registering seed", RNS.LOG_WARNING)
@ -591,11 +625,11 @@ class RadicleBridge:
RNS.log(f"Error registering seed: {e}", RNS.LOG_WARNING) RNS.log(f"Error registering seed: {e}", RNS.LOG_WARNING)
return False return False
def _auto_register_seed(self, radicle_nid: str): def _auto_register_seed(self, radicle_nid: str, bridge_hash: bytes):
"""Auto-register a seed in background after discovery.""" """Allocate a dedicated TCP port for this bridge and register its NID."""
# Small delay to allow bridge connection to establish first
time.sleep(2.0) 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: def get_stats(self) -> dict:
"""Get bridge statistics.""" """Get bridge statistics."""

View File

@ -299,15 +299,15 @@ def cmd_gossip(args):
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
last_stats = None last_known_peers = -1
try: try:
while running: while running:
stats = relay.get_stats() 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']}, " print(f"[Status] Peers: {stats['known_peers']}, "
f"Repos: {stats['watched_repos']}, " f"Repos: {stats['watched_repos']}, "
f"Refs: {stats['refs_per_repo']}") f"Refs: {stats['refs_per_repo']}")
last_stats = dict(stats)
time.sleep(5) time.sleep(5)
finally: finally:
relay.stop() relay.stop()
@ -315,7 +315,7 @@ def cmd_gossip(args):
def cmd_seed(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_home = Path(args.seed_home)
seed = SeedNode(seed_home=seed_home, port=args.seed_port) 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) 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 running = True
def signal_handler(sig, frame): def signal_handler(sig, frame):
@ -388,14 +402,16 @@ def cmd_seed(args):
try: try:
bridge.start() bridge.start()
gossip.start()
print() print()
print("Seed node running.") print("Seed node running.")
print(f" Seed NID: {nid}") print(f" Seed NID: {nid}")
print(f" Seed port: {args.seed_port}") print(f" Seed port: {args.seed_port}")
print(f" Bridge hash: {bridge.destination.hexhash}") print(f" Bridge hash: {bridge.destination.hexhash}")
print(f" Gossip hash: {gossip.destination.hexhash}")
print() 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(f" rad node connect {nid}@127.0.0.1:{args.seed_port}")
print() print()
print("Other machines running 'radicle-rns seed' will discover this") 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: if stats["known_bridges"] != last_known_bridges:
last_known_bridges = stats["known_bridges"] last_known_bridges = stats["known_bridges"]
print(f"[Status] Remote seeds: {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) time.sleep(5)
finally: finally:
gossip.stop()
bridge.stop() bridge.stop()
seed.stop() seed.stop()
print("Seed stopped.") print("Seed stopped.")
@ -675,6 +693,13 @@ def main():
metavar="PORT", metavar="PORT",
help="TCP listen port for the seed bridge (default: 8778)", 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( seed_parser.add_argument(
"--announce-retry-delays", "--announce-retry-delays",
default="5,15,30", default="5,15,30",

View File

@ -15,6 +15,7 @@ Flow:
""" """
import json import json
import os
import struct import struct
import subprocess import subprocess
import threading import threading
@ -87,10 +88,12 @@ class GossipRelay:
rids: List[str], rids: List[str],
storage: Optional[Path] = None, storage: Optional[Path] = None,
radicle_nid: Optional[str] = None, radicle_nid: Optional[str] = None,
bridge_port: int = 8777, bridge_port: Optional[int] = 8777,
poll_interval: int = DEFAULT_POLL_INTERVAL, poll_interval: int = DEFAULT_POLL_INTERVAL,
announce_retry_delays: Tuple[int, ...] = (5, 15, 30), announce_retry_delays: Tuple[int, ...] = (5, 15, 30),
config_path: Optional[str] = None, config_path: Optional[str] = None,
auto_discover: bool = False,
rad_home: Optional[str] = None,
): ):
""" """
Args: Args:
@ -98,10 +101,15 @@ class GossipRelay:
rids: List of Radicle repository IDs to watch (e.g. 'rad:z3abc...'). rids: List of Radicle repository IDs to watch (e.g. 'rad:z3abc...').
storage: Path to radicle storage dir. Auto-detected if None. storage: Path to radicle storage dir. Auto-detected if None.
radicle_nid: Local radicle NID to advertise to peers. 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. poll_interval: Seconds between ref polls.
announce_retry_delays: Startup re-announce delays (seconds). announce_retry_delays: Startup re-announce delays (seconds).
config_path: Reticulum config path (None = default). 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.identity = identity
self.rids = list(rids) self.rids = list(rids)
@ -110,6 +118,8 @@ class GossipRelay:
self.bridge_port = bridge_port self.bridge_port = bridge_port
self.poll_interval = poll_interval self.poll_interval = poll_interval
self.announce_retry_delays = announce_retry_delays self.announce_retry_delays = announce_retry_delays
self.auto_discover = auto_discover
self.rad_home = rad_home
self.reticulum = RNS.Reticulum(config_path) self.reticulum = RNS.Reticulum(config_path)
@ -196,8 +206,25 @@ class GossipRelay:
return return
self.announce() 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): def _poll_loop(self):
while self._running: while self._running:
if self.auto_discover:
self._discover_rids()
for rid in self.rids: for rid in self.rids:
try: try:
refs = _read_refs(self.storage, rid) refs = _read_refs(self.storage, rid)
@ -304,15 +331,20 @@ class GossipRelay:
def _trigger_sync(self, rid: str, nid: str): def _trigger_sync(self, rid: str, nid: str):
"""Run rad node connect (if needed) then rad sync --fetch.""" """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( subprocess.run(
["rad", "node", "connect", f"{nid}@127.0.0.1:{self.bridge_port}"], ["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( result = subprocess.run(
["rad", "sync", "--fetch", "--rid", rid], ["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: if result.returncode == 0: