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:
parent
c418cfaccf
commit
be25772602
227
README.md
227
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: <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]]
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue