radicle-reticulum/src/radicle_reticulum/cli.py

789 lines
24 KiB
Python

"""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 <path> 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 <YOUR_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()