From be257726020175ed7f3328b81aebda0019c00d36 Mon Sep 17 00:00:00 2001 From: "Maciek \"mab122\" Bator" Date: Tue, 21 Apr 2026 15:42:36 +0200 Subject: [PATCH] fix: bridge auto-discovery, tunnel bugs, and LoRa-aware announces bridge.py: - Fix _on_tunnel_opened/_on_tunnel_closed instance attrs shadowing methods of the same name, causing NoneType-not-callable on every tunnel close - Fix auto_seed not firing on --connect path: NID registration now triggers whenever the NID is first learned from an announce, not only on is_new - Move nid_is_new check inside _remote_bridges_lock to prevent double rad-node-connect on concurrent announces - Distinguish link CLOSED vs timeout in link establishment log message - Add startup re-announce loop so peers that start slightly later are discovered without manual --connect; delays are configurable - Add announce_retry_delays parameter (default 5,15,30s; use 60,300,900s on LoRa to respect duty cycle limits) cli.py: - Expose --announce-retry-delays flag with validation and helpful error README.md: - Rewrite setup section based on real end-to-end test (two machines, radicle-node listen config, rad remote add workflow) - Remove --connect and rad node connect from required steps; both are now automatic via auto-discovery and auto-seed - Add LoRa duty cycle note for announce delays - Add full git workflow: init, push, clone, fetch across mesh --- README.md | 227 ++++++++++++++++++++++---------- src/radicle_reticulum/bridge.py | 63 ++++++--- src/radicle_reticulum/cli.py | 18 +++ 3 files changed, 218 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index db521f4..32671a1 100644 --- a/README.md +++ b/README.md @@ -4,118 +4,205 @@ Bridges [Radicle](https://radicle.xyz) (decentralized Git) over [Reticulum](http **Why:** Radicle requires publicly reachable seed nodes; Reticulum routes over any physical medium. Both use Ed25519 keys — a natural fit. +--- + +## Prerequisites + +Both machines need: +- [Radicle](https://radicle.xyz/install) (`rad` CLI + `radicle-node`) +- [uv](https://docs.astral.sh/uv/getting-started/installation/) (Python package manager) +- Git + +--- + ## Install +On **each machine**, clone this repo and install: + ```sh -pip install uv # once -uv sync # install deps into .venv +git clone rad:z4NMdcKbw2TETQ56fbQfbibFHtZqZ # via radicle +# or: git clone https://github.com/youruser/radicle-reticulum +cd radicle-reticulum +uv sync ``` -QR encoding (optional): -```sh -uv sync --extra qr # ASCII QR output -pip install pillow pyzbar # image encode/decode +--- + +## Setup: connect two machines over mesh + +Do this once on each machine. + +### Step 1 — Configure radicle-node to listen on localhost + +Edit `~/.radicle/config.json`, find the `"node"` section, set `"listen"`: + +```json +"node": { + "listen": ["127.0.0.1:8776"], + ... +} ``` -## Quickstart: bridge two machines over mesh +Then (re)start radicle-node: -**Machine A** (or any node on the mesh): -```sh -uv run radicle-rns bridge -# prints: RNS address: -``` - -**Machine B**: -```sh -uv run radicle-rns bridge --connect -``` - -**Both machines** — configure radicle-node to use the bridge as a seed: -```toml -# ~/.radicle/node/config.toml -[[seeds]] -address = "127.0.0.1:8777" -``` - -Then start radicle-node and use it normally: ```sh rad node start -rad clone rad:z3xyz... -rad push / rad pull +rad node status # confirm: "listening for inbound connections on 127.0.0.1:8776" ``` -The bridge auto-detects your local NID via `rad self` and auto-registers discovered remote NIDs with radicle-node (`--no-auto-seed` to disable). +### Step 2 — Start the bridge on both machines + +Run on each machine: + +```sh +uv run radicle-rns bridge +``` + +Within ~30 seconds the bridges discover each other via RNS announce, connect automatically, and register each other's NIDs with radicle-node. You'll see: + +``` +[+] Discovered bridge: (NID: z6Mk...) +[Status] Tunnels: 0, Remote bridges: 1, TX: 0, RX: 0 +``` + +Once radicle-node connects through the bridge, tunnels open automatically: + +``` +Tunnel 1 opened / Incoming tunnel 1 opened +[Status] Tunnels: 1, Remote bridges: 1, TX: 1551, RX: 1831 +``` + +Bytes in TX/RX confirm radicle gossip is flowing over the mesh. + +> **LoRa note:** The bridge re-announces at t+5s, t+15s, t+30s after startup. On LoRa, +> use `--announce-retry-delays 60,300,900` to respect duty cycle limits. + +--- + +## Share a repository + +### Machine A — init and push a repo + +```sh +mkdir myproject && cd myproject +git init +git commit --allow-empty -m "init" +rad init --name myproject --description "my project" --default-branch main +``` + +`rad init` prints the repository ID (`rad:z3...`). Share it with Machine B. + +```sh +# make a commit and push +echo "hello" > hello.txt +git add . && git commit -m "hello" +rad push +``` + +### Machine B — clone it + +```sh +rad clone rad:z3... # use the RID from Machine A +cd myproject +cat hello.txt # hello +``` + +### Machine A — fetch updates from Machine B + +```sh +# Machine B: edit, commit, push +echo "world" >> hello.txt +git add . && git commit -m "world" +rad push + +# Machine A: +rad fetch +git pull +``` + +--- ## Commands ``` -radicle-rns bridge # TCP↔RNS bridge (main command) -radicle-rns node # lightweight peer-announce node -radicle-rns peers # discover peers on the mesh -radicle-rns ping # RTT probe to a peer -radicle-rns identity generate # create/show identity -radicle-rns sync # LXMF store-and-forward sync -radicle-rns bundle create # pack a repo into a bundle -radicle-rns bundle apply # unpack a bundle -radicle-rns bundle info # inspect a bundle -radicle-rns bundle qr-encode # print ASCII QR (≤2953 bytes) -radicle-rns bundle qr-decode # decode QR back to bundle +radicle-rns bridge # TCP↔RNS bridge (main command) +radicle-rns node # lightweight peer-announce node +radicle-rns peers # discover peers on the mesh +radicle-rns ping # RTT probe to a peer +radicle-rns identity generate # create/show identity +radicle-rns sync # LXMF store-and-forward sync +radicle-rns bundle create # pack repo into a bundle file +radicle-rns bundle apply # unpack a bundle into a repo +radicle-rns bundle info # inspect bundle metadata +radicle-rns bundle qr-encode # print ASCII QR (≤2953 bytes) +radicle-rns bundle qr-decode # decode QR back to bundle ``` -Global flags: `-v` verbose, `--identity PATH` (default `~/.radicle-rns/identity`). +Global flags: `-v` verbose logging, `--identity PATH` (default `~/.radicle-rns/identity`). -## Air-gapped / QR transfer - -For truly offline transfers (tiny incremental bundles ≤ 2953 bytes): - -```sh -# Sender -radicle-rns bundle create myrepo --incremental --basis prev.refs.json -radicle-rns bundle qr-encode myrepo-*.radicle-bundle - -# Receiver (photograph the QR, then:) -radicle-rns bundle qr-decode qr-photo.png -o received.radicle-bundle -radicle-rns bundle apply received.radicle-bundle ./myrepo -``` - -## Bridge flags +### Bridge flags | Flag | Default | Description | |------|---------|-------------| | `-l, --listen-port` | 8777 | TCP port radicle-node connects to | | `--radicle-port` | 8776 | Port radicle-node listens on | -| `-c, --connect ` | — | Manually connect to a remote bridge | -| `--nid ` | auto | Override local radicle NID | +| `-c, --connect ` | — | Connect to a remote bridge by RNS hash | +| `--nid ` | auto-detect | Override local radicle NID | | `--no-auto-connect` | — | Disable auto-connect on discovery | | `--no-auto-seed` | — | Disable auto-registering remote NIDs | +--- + +## Air-gapped / QR transfer + +For links too slow even for Reticulum, transfer tiny incremental bundles via QR code (max 2953 bytes): + +```sh +# Sender — create a small incremental bundle and encode it +uv run radicle-rns bundle create ./myrepo --incremental --basis myrepo.refs.json +uv run radicle-rns bundle qr-encode myrepo-*.radicle-bundle # prints ASCII QR to terminal + +# Receiver — photograph the QR, then decode and apply +uv run radicle-rns bundle qr-decode qr-photo.png -o received.radicle-bundle +uv run radicle-rns bundle apply received.radicle-bundle ./myrepo +``` + +QR image output (PNG) and image decoding require optional deps: +```sh +uv sync --extra qr # qrcode for PNG output +pip install pillow pyzbar # for qr-decode from image file +``` + +--- + ## Architecture ``` radicle-node ──TCP:8777── RadicleBridge ──RNS Link── RadicleBridge ──TCP:8776── radicle-node - │ │ - RNS announce RNS announce - (auto-discovery) (auto-discovery) + (Machine A) (Machine A) (Machine B) (Machine B) ``` -- **Identity** (`identity.py`) — Ed25519 DID ↔ RNS destination mapping; persisted to `~/.radicle-rns/identity` -- **Adapter** (`adapter.py`) — peer discovery via RNS announces -- **Link** (`link.py`) — buffered RNS Link with state machine -- **SyncManager** (`sync.py`) — LXMF store-and-forward bundles; auto-push on refs announce -- **AdaptiveSyncManager** (`adaptive.py`) — picks FULL/INCREMENTAL/MINIMAL/QR by RTT + throughput -- **GitBundle** (`git_bundle.py`) — full and incremental Git bundles +- **Identity** (`identity.py`) — Ed25519 DID ↔ RNS destination; saved to `~/.radicle-rns/identity` +- **Bridge** (`bridge.py`) — TCP↔RNS tunnel, announces itself, discovers peers +- **SyncManager** (`sync.py`) — LXMF store-and-forward bundles; auto-pushes on refs announce +- **AdaptiveSyncManager** (`adaptive.py`) — selects FULL/INCREMENTAL/MINIMAL/QR by RTT + throughput +- **GitBundle** (`git_bundle.py`) — full and incremental Git bundles for delay-tolerant transfer - **QR** (`qr.py`) — visual air-gap transfer for tiny bundles +--- + ## Development ```sh -uv run pytest # 158 tests -uv run pytest -x -q # stop on first failure +uv run pytest # 158 tests +uv run pytest -x -q # stop on first failure ``` +--- + ## Reticulum interfaces -Reticulum auto-discovers local peers via UDP multicast. For LoRa / serial / I2P, configure `~/.reticulum/config`: +On the same LAN, Reticulum auto-discovers peers via UDP multicast — no config needed. For LoRa / serial / I2P, edit `~/.reticulum/config`: ```ini [[lora_interface]] diff --git a/src/radicle_reticulum/bridge.py b/src/radicle_reticulum/bridge.py index dd29179..acd30fa 100644 --- a/src/radicle_reticulum/bridge.py +++ b/src/radicle_reticulum/bridge.py @@ -89,6 +89,7 @@ class RadicleBridge: config_path: Optional[str] = None, auto_connect: bool = True, auto_seed: bool = True, + announce_retry_delays: Tuple[int, ...] = (5, 15, 30), ): """Initialize the bridge. @@ -100,12 +101,15 @@ class RadicleBridge: config_path: Path to Reticulum config auto_connect: Automatically connect to discovered bridges auto_seed: Automatically register discovered NIDs with radicle-node + announce_retry_delays: Seconds between startup re-announces. Use longer + intervals on LoRa to respect duty cycle limits, e.g. (60, 300, 900). """ self.listen_port = listen_port self.radicle_host = radicle_host self.radicle_port = radicle_port self.auto_connect = auto_connect self.auto_seed = auto_seed + self.announce_retry_delays = announce_retry_delays # Initialize Reticulum self.reticulum = RNS.Reticulum(config_path) @@ -139,8 +143,8 @@ class RadicleBridge: self._running = False # Callbacks - self._on_tunnel_opened: Optional[Callable[[TunnelConnection], None]] = None - self._on_tunnel_closed: Optional[Callable[[TunnelConnection], None]] = None + self._tunnel_opened_cb: Optional[Callable[[TunnelConnection], None]] = None + self._tunnel_closed_cb: Optional[Callable[[TunnelConnection], None]] = None self._on_bridge_discovered: Optional[Callable[[bytes, Optional[str]], None]] = None # Local radicle node NID (for announcing to remote bridges) @@ -168,14 +172,28 @@ class RadicleBridge: self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True) self._accept_thread.start() - # Announce presence + # 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(f" RNS hash: {self.destination.hexhash}", RNS.LOG_INFO) RNS.log(f" Radicle target: {self.radicle_host}:{self.radicle_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. + """ + for delay in self.announce_retry_delays: + time.sleep(delay) + if not self._running: + return + self.announce() + def stop(self): """Stop the bridge.""" self._running = False @@ -299,8 +317,12 @@ class RadicleBridge: # Wait for link establishment deadline = time.time() + 30.0 while rns_link.status != RNS.Link.ACTIVE: - if time.time() > deadline or rns_link.status == RNS.Link.CLOSED: - RNS.log("Link establishment timeout", RNS.LOG_WARNING) + if rns_link.status == RNS.Link.CLOSED: + RNS.log("Link closed before becoming active (remote refused or unreachable)", RNS.LOG_WARNING) + tcp_socket.close() + return + if time.time() > deadline: + RNS.log("Link establishment timed out after 30s", RNS.LOG_WARNING) tcp_socket.close() return time.sleep(0.1) @@ -322,8 +344,8 @@ class RadicleBridge: RNS.log(f"Tunnel {tunnel_id} opened to {remote_hash.hex()[:16]}", RNS.LOG_INFO) - if self._on_tunnel_opened: - self._on_tunnel_opened(tunnel) + if self._tunnel_opened_cb: + self._tunnel_opened_cb(tunnel) # Set up bidirectional forwarding rns_link.set_packet_callback( @@ -394,8 +416,8 @@ class RadicleBridge: f"Tunnel {tunnel_id} closed (sent: {tunnel.bytes_sent}, recv: {tunnel.bytes_received})", RNS.LOG_INFO ) - if self._on_tunnel_closed: - self._on_tunnel_closed(tunnel) + 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.""" @@ -486,10 +508,10 @@ class RadicleBridge: with self._remote_bridges_lock: is_new = destination_hash not in self._remote_bridges self._remote_bridges[destination_hash] = time.time() - - # Store NID mapping - if radicle_nid: - self._bridge_nids[destination_hash] = radicle_nid + # 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) + if radicle_nid: + self._bridge_nids[destination_hash] = radicle_nid if is_new: nid_info = f" (NID: {radicle_nid[:32]}...)" if radicle_nid else "" @@ -507,13 +529,14 @@ class RadicleBridge: daemon=True, ).start() - # Auto-register seed if enabled and NID is known - if self.auto_seed and radicle_nid: - threading.Thread( - target=self._auto_register_seed, - args=(radicle_nid,), - 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,), + daemon=True, + ).start() def _auto_connect_to_bridge(self, destination_hash: bytes): """Auto-connect to a discovered bridge in background.""" diff --git a/src/radicle_reticulum/cli.py b/src/radicle_reticulum/cli.py index b01c63e..2d2a2c3 100644 --- a/src/radicle_reticulum/cli.py +++ b/src/radicle_reticulum/cli.py @@ -561,6 +561,14 @@ def cmd_bridge(args): 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, @@ -568,6 +576,7 @@ def cmd_bridge(args): 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' @@ -818,6 +827,15 @@ def main(): "--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()