"""Radicle seed node manager. Starts and manages a dedicated radicle-node configured as a seed server (storage.policy = allow). The seed runs under its own RAD_HOME so it doesn't interfere with the user's own radicle identity. Users add the seed as a peer once: rad node connect @127.0.0.1: The bridge then connects this seed to remote seeds over Reticulum. """ import json import os import socket import subprocess import time from pathlib import Path from typing import Optional DEFAULT_SEED_HOME = Path.home() / ".radicle-rns" / "seed" DEFAULT_SEED_PORT = 8779 SEED_CONFIG = { "node": { "alias": "radicle-rns-seed", "listen": [], # filled in by write_config() "connect": [], "externalAddresses": [], "seedingPolicy": { "default": "allow", "repos": {} }, "db": {"journalMode": "wal"}, } } class SeedNode: """Manages a dedicated radicle-node process configured as a seed.""" def __init__( self, seed_home: Path = DEFAULT_SEED_HOME, port: int = DEFAULT_SEED_PORT, ): self.seed_home = Path(seed_home) self.port = port self._process: Optional[subprocess.Popen] = None # ── Setup ──────────────────────────────────────────────────────────────── def is_initialized(self) -> bool: """Return True if rad auth has been run for this seed home.""" try: result = subprocess.run( ["rad", "self"], env=self._env(), capture_output=True, text=True, timeout=5, ) return result.returncode == 0 and "NID" in result.stdout except Exception: return False def write_config(self): """Write config.json only if it does not already exist.""" self.seed_home.mkdir(parents=True, exist_ok=True) config_path = self.seed_home / "config.json" if config_path.exists(): return config = json.loads(json.dumps(SEED_CONFIG)) # deep copy config["node"]["listen"] = [f"127.0.0.1:{self.port}"] config_path.write_text(json.dumps(config, indent=2)) # ── Lifecycle ───────────────────────────────────────────────────────────── def start(self): """Start radicle-node for the seed. Raises if not initialized.""" if not self.is_initialized(): raise RuntimeError( f"Seed identity not found. Initialize it with:\n" f" RAD_HOME={self.seed_home} rad auth\n" f"Then run 'radicle-rns seed' again." ) self.write_config() # Discard stdout/stderr — if kept as PIPE the buffer fills and the # process deadlocks once radicle-node produces more than ~64 KB of logs. self._process = subprocess.Popen( ["radicle-node"], env=self._env(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) # Wait up to 10s for the port to open. deadline = time.time() + 10 while time.time() < deadline: if self._process.poll() is not None: raise RuntimeError( f"Seed radicle-node exited immediately after launch. " f"Check: RAD_HOME={self.seed_home} radicle-node" ) if self._port_open(): return time.sleep(0.3) if self._process.poll() is not None: raise RuntimeError("Seed radicle-node exited before port opened.") # Still running but port not open — may be slow on first run; continue. print(f"Warning: seed port {self.port} not yet open after 10s, continuing anyway.") def stop(self): """Stop the seed radicle-node.""" if self._process and self._process.poll() is None: self._process.terminate() try: self._process.wait(timeout=5) except subprocess.TimeoutExpired: self._process.kill() self._process.wait() # reap zombie def is_running(self) -> bool: return self._process is not None and self._process.poll() is None # ── Queries ─────────────────────────────────────────────────────────────── def get_nid(self) -> Optional[str]: """Return the seed's radicle NID (z6Mk...).""" result = subprocess.run( ["rad", "self"], env=self._env(), 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] return None def connect_peer(self, nid: str, addr: str) -> bool: """Tell the seed to connect to a remote peer.""" result = subprocess.run( ["rad", "node", "connect", f"{nid}@{addr}"], env=self._env(), capture_output=True, text=True, timeout=30, ) return result.returncode == 0 # ── Internal ────────────────────────────────────────────────────────────── def _env(self) -> dict: env = os.environ.copy() env["RAD_HOME"] = str(self.seed_home) return env def _port_open(self) -> bool: try: with socket.create_connection(("127.0.0.1", self.port), timeout=0.5): return True except OSError: return False