"""Command-line interface for Radicle-Reticulum adapter.""" import argparse import os import subprocess import sys import time import signal from pathlib import Path from typing import Optional DEFAULT_IDENTITY_PATH = Path.home() / ".radicle-rns" / "identity" import RNS from radicle_reticulum.adapter import RNSTransportAdapter, PeerInfo from radicle_reticulum.identity import RadicleIdentity from radicle_reticulum.bridge import RadicleBridge from radicle_reticulum.gossip import GossipRelay from radicle_reticulum.seed import SeedNode, DEFAULT_SEED_HOME, DEFAULT_SEED_PORT def detect_radicle_nid() -> Optional[str]: """Try to detect the local radicle NID by running 'rad self'. Returns the NID string (e.g. 'z6Mk...') or None if rad is not available or the NID cannot be parsed. """ try: result = subprocess.run( ["rad", "self"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return None for line in result.stdout.splitlines(): parts = line.split() if len(parts) >= 2 and parts[0] == "NID": return parts[1] # Older versions may print DID format if len(parts) >= 2 and parts[0] == "DID": did = parts[1] if did.startswith("did:key:"): return did[len("did:key:"):] return None except (FileNotFoundError, subprocess.TimeoutExpired, Exception): return None def on_peer_discovered(peer: PeerInfo): """Callback when a new peer is discovered.""" print(f"[+] Discovered peer: {peer.identity.did}") print(f" RNS hash: {peer.destination_hash.hex()}") def cmd_node(args): """Run a Radicle-RNS node.""" print("Starting Radicle-RNS node...") identity = RadicleIdentity.load_or_generate(args.identity) _print_identity_info(args.identity) # Create adapter adapter = RNSTransportAdapter(identity=identity) adapter.set_on_peer_discovered(on_peer_discovered) print(f"Node ID: {identity.did}") print(f"RNS Hash: {adapter.node_hash_hex}") print() # Start adapter adapter.start() print("Node running. Press Ctrl+C to stop.") print("Announcing every 60 seconds...") print() # Handle graceful shutdown running = True def signal_handler(sig, frame): nonlocal running print("\nShutting down...") running = False signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Main loop last_announce = 0 try: while running: now = time.time() # Periodic announce if now - last_announce > 60: adapter.announce() last_announce = now time.sleep(0.5) finally: adapter.stop() print("Node stopped.") def _print_identity_info(identity_path: Path): """Print identity file location (new or loaded).""" path = Path(identity_path) status = "loaded" if path.exists() else "generated" print(f"Identity {status}: {path}") def cmd_identity(args): """Generate or display identity information.""" if args.action == "generate": path = Path(args.identity) if path.exists() and not args.force: print(f"Identity already exists: {path}") print("Use --force to overwrite, or --identity for a different path.") sys.exit(1) identity = RadicleIdentity.generate() identity.save(path) print(f"Generated new identity: {path}") print(f" DID: {identity.did}") print(f" RNS Hash: {identity.rns_identity_hash_hex}") elif args.action == "info": path = Path(args.identity) if path.exists(): try: identity = RadicleIdentity.load(path) print(f"Identity: {path}") print(f" DID: {identity.did}") print(f" RNS Hash: {identity.rns_identity_hash_hex}") print(f" Public key (hex): {identity.public_key_bytes.hex()}") except Exception as e: print(f"Error loading identity: {e}", file=sys.stderr) sys.exit(1) elif args.did: try: identity = RadicleIdentity.from_did(args.did) print(f"DID: {identity.did}") print(f"Public key (hex): {identity.public_key_bytes.hex()}") except Exception as e: print(f"Error parsing DID: {e}", file=sys.stderr) sys.exit(1) else: print(f"No identity file found at {path} and no --did provided.", file=sys.stderr) sys.exit(1) def cmd_ping(args): """Ping a peer by RNS hash.""" print(f"Connecting to {args.destination}...") identity = RadicleIdentity.load_or_generate(args.identity) adapter = RNSTransportAdapter(identity=identity) adapter.start() try: dest_hash = bytes.fromhex(args.destination) except ValueError: print("Error: Invalid destination hash (must be hex)", file=sys.stderr) sys.exit(1) link = adapter.connect(dest_hash, timeout=args.timeout) if link is None: print("Failed to connect") sys.exit(1) print(f"Connected! RTT: {link.rtt:.3f}s" if link.rtt else "Connected!") # Send ping from radicle_reticulum.messages import Ping, Pong, decode_message, MessageType import struct ping = Ping() ping_time = time.time() link.send(ping.to_message()) print("Ping sent, waiting for pong...") response = link.recv(timeout=10.0) if response: header, msg = decode_message(response) if header.msg_type == MessageType.PONG: rtt = (time.time() - ping_time) * 1000 print(f"Pong received! RTT: {rtt:.1f}ms") else: print(f"Unexpected response: {header.msg_type}") else: print("No response (timeout)") link.close() adapter.stop() def cmd_peers(args): """List discovered peers.""" identity = RadicleIdentity.load_or_generate(args.identity) adapter = RNSTransportAdapter(identity=identity) adapter.set_on_peer_discovered(on_peer_discovered) adapter.start() print(f"Listening for peers for {args.timeout} seconds...") print() time.sleep(args.timeout) peers = adapter.get_peers() if peers: print(f"\nDiscovered {len(peers)} peer(s):") for peer in peers: print(f" {peer.identity.did}") print(f" Hash: {peer.destination_hash.hex()}") print(f" Age: {peer.age:.1f}s") else: print("\nNo peers discovered.") adapter.stop() def _detect_rid(repo_path: Path) -> Optional[str]: """Detect the Radicle RID for the repo at repo_path via 'rad inspect'.""" try: result = subprocess.run( ["rad", "inspect", "--rid"], cwd=repo_path, capture_output=True, text=True, timeout=5, ) if result.returncode == 0: rid = result.stdout.strip() if rid.startswith("rad:"): return rid except Exception: pass return None def cmd_gossip(args): """Run the gossip relay daemon.""" identity = RadicleIdentity.load_or_generate(args.identity) _print_identity_info(args.identity) # Collect RIDs: explicit args + auto-detect from CWD rids = list(args.rids) if not rids: rid = _detect_rid(Path.cwd()) if rid: print(f"Auto-detected RID: {rid}") rids.append(rid) else: print("Error: no RIDs given and could not auto-detect from current directory.", file=sys.stderr) print("Pass one or more RIDs as arguments, or run from inside a radicle repo.", file=sys.stderr) sys.exit(1) nid = args.nid or detect_radicle_nid() if nid: print(f"Local NID: {nid}") try: announce_retry_delays = tuple( int(x.strip()) for x in args.announce_retry_delays.split(",") if x.strip() ) except ValueError: print("Error: --announce-retry-delays must be comma-separated integers.", file=sys.stderr) sys.exit(1) relay = GossipRelay( identity=identity, rids=rids, radicle_nid=nid, bridge_port=args.bridge_port, poll_interval=args.poll_interval, announce_retry_delays=announce_retry_delays, ) def on_sync(rid, peer_nid): print(f"[sync] {rid[:24]}... from {peer_nid[:24] if peer_nid else 'peer'}") relay.set_on_sync_triggered(on_sync) relay.start() print() print(f"Gossip relay running:") print(f" RNS address: {relay.destination.hexhash}") print(f" Repos: {', '.join(r[:28] for r in rids)}") print(f" Poll: every {args.poll_interval}s") print() print("Press Ctrl+C to stop.") print() running = True def signal_handler(sig, frame): nonlocal running print("\nShutting down...") running = False signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) last_known_peers = -1 try: while running: stats = relay.get_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']}") time.sleep(5) finally: relay.stop() print("Gossip relay stopped.") def cmd_seed(args): """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) # First-time setup: guide the user if not seed.is_initialized(): seed.write_config() print(f"Seed home: {seed_home}") print() print("Seed identity not found. Initialize it with:") print(f" RAD_HOME={seed_home} rad auth") print() print("Then run 'radicle-rns seed' again.") sys.exit(1) # Start seed radicle-node print(f"Starting seed radicle-node (port {args.seed_port})...") try: seed.start() except RuntimeError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) nid = seed.get_nid() if not nid: print("Error: could not get seed NID.", file=sys.stderr) seed.stop() sys.exit(1) # Start bridge pointing at the seed identity = RadicleIdentity.load_or_generate(args.identity) _print_identity_info(args.identity) try: announce_retry_delays = tuple( int(x.strip()) for x in args.announce_retry_delays.split(",") if x.strip() ) except ValueError: print("Error: --announce-retry-delays must be comma-separated integers.", file=sys.stderr) seed.stop() sys.exit(1) bridge = RadicleBridge( identity=identity, listen_port=args.bridge_port, radicle_host="127.0.0.1", radicle_port=args.seed_port, auto_connect=True, auto_seed=True, announce_retry_delays=announce_retry_delays, rad_home=str(seed_home), ) bridge.set_local_radicle_nid(nid) def on_peer_seed_discovered(dest_hash, remote_nid=None): nid_info = f" (NID: {remote_nid[:32]})" if remote_nid else "" print(f"[+] Discovered remote seed: {dest_hash.hex()[:16]}{nid_info}") 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): nonlocal running print("\nShutting down...") running = False signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) 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 (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") print("seed automatically and sync over the mesh.") print() print("Press Ctrl+C to stop.") print() last_known_bridges = -1 while running: if not seed.is_running(): print("Seed radicle-node exited unexpectedly.", file=sys.stderr) break stats = bridge.get_stats() 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"Gossip peers: {gossip.get_stats()['known_peers']}") time.sleep(5) finally: gossip.stop() bridge.stop() seed.stop() print("Seed stopped.") def cmd_bridge(args): """Run Radicle-Reticulum bridge.""" print("Starting Radicle-Reticulum bridge...") identity = RadicleIdentity.load_or_generate(args.identity) _print_identity_info(args.identity) # Determine auto modes auto_connect = not args.no_auto_connect and not args.connect auto_seed = not args.no_auto_seed try: announce_retry_delays = tuple( int(x.strip()) for x in args.announce_retry_delays.split(",") if x.strip() ) except ValueError: print("Error: --announce-retry-delays must be comma-separated integers, e.g. 5,15,30", file=sys.stderr) sys.exit(1) bridge = RadicleBridge( identity=identity, listen_port=args.listen_port, radicle_host=args.radicle_host, radicle_port=args.radicle_port, auto_connect=auto_connect, auto_seed=auto_seed, announce_retry_delays=announce_retry_delays, ) # Resolve local radicle NID: explicit flag > auto-detect from 'rad self' nid = args.nid if not nid: nid = detect_radicle_nid() if nid: print(f"Auto-detected radicle NID: {nid}") else: print("Could not auto-detect NID (is rad installed and initialized?)") print("Run with --nid to set it manually.") if nid: bridge.set_local_radicle_nid(nid) # Set up discovery callback def on_bridge_discovered(dest_hash: bytes, radicle_nid: str = None): nid_info = "" if radicle_nid: nid_info = f"\n Radicle NID: {radicle_nid}" print(f"[+] Discovered bridge: {dest_hash.hex()}{nid_info}") if auto_connect: print(f" Auto-connecting...") if auto_seed and radicle_nid: print(f" Auto-registering seed with radicle-node...") bridge.set_on_bridge_discovered(on_bridge_discovered) bridge.start() print() print(f"Bridge running:") print(f" RNS address: {bridge.destination.hexhash}") print(f" TCP listen: 127.0.0.1:{args.listen_port}") print(f" Radicle: {args.radicle_host}:{args.radicle_port}") print(f" Auto-connect: {'enabled' if auto_connect else 'disabled'}") print(f" Auto-seed: {'enabled' if auto_seed else 'disabled'}") if args.nid: print(f" Local NID: {args.nid}") print() # Connect to remote bridge if specified if args.connect: try: remote_hash = bytes.fromhex(args.connect) print(f"Connecting to remote bridge: {args.connect}") if bridge.connect_to_bridge(remote_hash): print("Connected!") else: print("Failed to connect to remote bridge") except ValueError: print(f"Invalid remote hash: {args.connect}", file=sys.stderr) elif auto_connect: print("Waiting for bridge announcements (auto-discovery enabled)...") if nid and auto_seed: print() print("When remote bridges are discovered, their NIDs will be") print("automatically registered with radicle-node. You can then") print("use radicle normally (clone, push, pull, etc.)") print() print("Press Ctrl+C to stop.") print() # Handle shutdown running = True def signal_handler(sig, frame): nonlocal running print("\nShutting down...") running = False signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) # Status loop last_announce = 0 last_stats = None try: while running: now = time.time() # Periodic announce if now - last_announce > 120: bridge.announce() last_announce = now # Show status changes stats = bridge.get_stats() if stats != last_stats: print(f"[Status] Tunnels: {stats['active_tunnels']}, " f"Remote bridges: {stats['known_bridges']}, " f"TX: {stats['bytes_sent']}, RX: {stats['bytes_received']}") last_stats = stats.copy() time.sleep(1.0) finally: bridge.stop() print("Bridge stopped.") def main(): """Main entry point.""" parser = argparse.ArgumentParser( description="Radicle-Reticulum transport adapter" ) parser.add_argument( "-v", "--verbose", action="store_true", help="Enable verbose logging" ) subparsers = parser.add_subparsers(dest="command", help="Commands") def add_identity_arg(p): p.add_argument( "--identity", type=Path, default=DEFAULT_IDENTITY_PATH, metavar="PATH", help=f"Identity file (default: {DEFAULT_IDENTITY_PATH})", ) # node command node_parser = subparsers.add_parser("node", help="Run a Radicle-RNS node") add_identity_arg(node_parser) # identity command id_parser = subparsers.add_parser("identity", help="Identity operations") id_parser.add_argument( "action", choices=["generate", "info"], help="Action to perform" ) add_identity_arg(id_parser) id_parser.add_argument("--did", help="DID string for info action (if no identity file)") id_parser.add_argument( "--force", action="store_true", help="Overwrite existing identity file (for generate)" ) # ping command ping_parser = subparsers.add_parser("ping", help="Ping a peer") ping_parser.add_argument("destination", help="RNS destination hash (hex)") ping_parser.add_argument( "-t", "--timeout", type=float, default=30.0, help="Connection timeout (seconds)" ) add_identity_arg(ping_parser) # peers command peers_parser = subparsers.add_parser("peers", help="Discover peers") peers_parser.add_argument( "-t", "--timeout", type=float, default=10.0, help="Discovery timeout (seconds)" ) add_identity_arg(peers_parser) # gossip command gossip_parser = subparsers.add_parser( "gossip", help="Run gossip relay: watch refs, notify peers, trigger rad sync", ) gossip_parser.add_argument( "rids", nargs="*", metavar="RID", help="Radicle repo IDs to watch (auto-detected from CWD if omitted)", ) gossip_parser.add_argument( "--nid", help="Local radicle NID to advertise (auto-detected if omitted)", ) gossip_parser.add_argument( "--bridge-port", type=int, default=8777, metavar="PORT", help="TCP port of the local bridge (default: 8777)", ) gossip_parser.add_argument( "--poll-interval", type=int, default=30, metavar="SECONDS", help="Seconds between ref polls (default: 30)", ) gossip_parser.add_argument( "--announce-retry-delays", default="5,15,30", metavar="SECONDS", help="Startup re-announce delays, comma-separated (default: 5,15,30). " "Use longer values on LoRa, e.g. 60,300,900", ) add_identity_arg(gossip_parser) # seed command seed_parser = subparsers.add_parser( "seed", help="Start a Radicle seed node and bridge it to the mesh over Reticulum", ) seed_parser.add_argument( "--seed-home", default=str(DEFAULT_SEED_HOME), metavar="PATH", help=f"RAD_HOME for the seed node (default: {DEFAULT_SEED_HOME})", ) seed_parser.add_argument( "--seed-port", type=int, default=DEFAULT_SEED_PORT, metavar="PORT", help=f"TCP port for the seed radicle-node (default: {DEFAULT_SEED_PORT})", ) seed_parser.add_argument( "--bridge-port", type=int, default=8778, 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", metavar="SECONDS", help="Startup re-announce delays, comma-separated (default: 5,15,30). " "Use longer values on LoRa, e.g. 60,300,900", ) add_identity_arg(seed_parser) bridge_parser = subparsers.add_parser("bridge", help="Run Radicle-Reticulum bridge") bridge_parser.add_argument( "-l", "--listen-port", type=int, default=8777, help="TCP port to listen on (default: 8777)" ) bridge_parser.add_argument( "--radicle-host", default="127.0.0.1", help="Radicle node host (default: 127.0.0.1)" ) bridge_parser.add_argument( "--radicle-port", type=int, default=8776, help="Radicle node port (default: 8776)" ) bridge_parser.add_argument( "-c", "--connect", help="Connect to remote bridge (RNS hash)" ) bridge_parser.add_argument( "--no-auto-connect", action="store_true", help="Disable auto-discovery and auto-connect to bridges" ) bridge_parser.add_argument( "--no-auto-seed", action="store_true", help="Disable auto-registering discovered NIDs with radicle-node" ) bridge_parser.add_argument( "--nid", help="Local radicle node NID to announce (from 'rad self')" ) bridge_parser.add_argument( "--announce-retry-delays", default="5,15,30", metavar="SECONDS", help=( "Comma-separated delays for startup re-announces (default: 5,15,30). " "On LoRa use longer values to respect duty cycle, e.g. 60,300,900" ), ) add_identity_arg(bridge_parser) args = parser.parse_args() # Configure logging if args.verbose: RNS.loglevel = RNS.LOG_VERBOSE else: RNS.loglevel = RNS.LOG_INFO # Dispatch command if args.command == "node": cmd_node(args) elif args.command == "identity": cmd_identity(args) elif args.command == "ping": cmd_ping(args) elif args.command == "peers": cmd_peers(args) elif args.command == "gossip": cmd_gossip(args) elif args.command == "seed": cmd_seed(args) elif args.command == "bridge": cmd_bridge(args) else: parser.print_help() sys.exit(1) if __name__ == "__main__": main()