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
This commit is contained in:
Maciek "mab122" Bator 2026-04-21 15:42:36 +02:00
parent c418cfaccf
commit be25772602
3 changed files with 218 additions and 90 deletions

227
README.md
View File

@ -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: <HASH>
```
**Machine B**:
```sh
uv run radicle-rns bridge --connect <HASH-FROM-A>
```
**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: <hash> (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 <hash> # RTT probe to a peer
radicle-rns identity generate # create/show identity
radicle-rns sync <repo> # LXMF store-and-forward sync
radicle-rns bundle create <repo> # pack a repo into a bundle
radicle-rns bundle apply <bundle> <repo> # unpack a bundle
radicle-rns bundle info <bundle> # inspect a bundle
radicle-rns bundle qr-encode <bundle> # print ASCII QR (≤2953 bytes)
radicle-rns bundle qr-decode <image.png> # 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 <hash> # RTT probe to a peer
radicle-rns identity generate # create/show identity
radicle-rns sync <repo> # LXMF store-and-forward sync
radicle-rns bundle create <repo> # pack repo into a bundle file
radicle-rns bundle apply <bundle> <repo> # unpack a bundle into a repo
radicle-rns bundle info <bundle> # inspect bundle metadata
radicle-rns bundle qr-encode <bundle> # print ASCII QR (≤2953 bytes)
radicle-rns bundle qr-decode <image.png> # 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 <hash>` | — | Manually connect to a remote bridge |
| `--nid <NID>` | auto | Override local radicle NID |
| `-c, --connect <hash>` | — | Connect to a remote bridge by RNS hash |
| `--nid <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]]

View File

@ -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."""

View File

@ -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()