Commit Graph

20 Commits

Author SHA1 Message Date
Maciek "mab122" Bator 5658042a7e fix: wrap announce handlers in _AnnounceHandler objects
RNS.Transport.register_announce_handler silently ignores raw callables —
it only accepts objects with both `aspect_filter` attribute and
`received_announce` method.  Both bridge and gossip were registering
bound methods directly, so no announces ever fired.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:57:44 +02:00
Maciek "mab122" Bator fd6c1b4f05 docs: add Reticulum connectivity step to quickstart
Add step 1 covering Reticulum setup: LAN works automatically, for
other transports generate the example config with rnsd --exampleconfig
and configure the appropriate interface. Fixes missing instructions
that caused bridges not to discover each other on fresh installs.
Standardise on uv throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:36:18 +02:00
Maciek "mab122" Bator d670b096f2 refactor: remove seed mode, simplify setup and README
Delete seed.py and cmd_seed — managing a separate radicle-node process
is radicle configuration, not this project's job. Simplify cmd_setup
to check only bridge prerequisites (rad, radicle-node, RNS, identity,
localhost listen). Update README prerequisites to cover all four user
types (from scratch, Radicle only, Reticulum only, both) with inline
install hints for each missing piece.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:06:42 +02:00
Maciek "mab122" Bator 6a11905500 docs: simplify README — one mode, remove seed section
Remove the "two modes" framing and always-on seed quickstart; seed
setup is standard radicle-node configuration, not part of this
codebase. Remove --lora references and dead commands. Keep quickstart,
interface config, and architecture note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:01:15 +02:00
Maciek "mab122" Bator 63267e5789 refactor: use RNS.Buffer for tunnel, drop dead code and --lora flag
Switch bridge TCP↔RNS tunnel from fire-and-forget RNS.Packet to
RNS.Buffer over RNS.Channel, which provides ordered reliable delivery
with automatic retransmission. A dropped packet no longer silently
corrupts Radicle's Noise session.

Delete adapter.py, link.py, messages.py (and their tests) — these
implemented a parallel peer-discovery and binary gossip layer that
duplicates what Radicle handles natively over the bridge session.
Remove the cmd_node, cmd_ping, cmd_peers CLI commands that used them.

Remove --lora flag: Reticulum caps announce bandwidth at 2% per
interface automatically, so application-level duty-cycle management
is unnecessary. --announce-retry-delays remains for tuning startup
timing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 10:54:25 +02:00
Maciek "mab122" Bator aff4719910 docs: rewrite README to match current implementation
- Remove "Why:" line and air-gapped/QR section (those modules were deleted)
- Remove stale commands: sync, bundle create/apply/info, qr-encode/decode
- Add seed mode quick-start and setup instructions
- Add gossip relay section with delta broadcast note
- Add --lora flag to all command flag tables
- Update architecture diagram: per-bridge port routing, gossip relay, 383 B MTU note
- Update module list: bridge/gossip/seed/adapter (remove sync/adaptive/git_bundle/qr)
- Update test count: 149

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:59:08 +02:00
Maciek "mab122" Bator d7b124e830 fix: chunk RNS packets to ENCRYPTED_MDU (383 B) — unblocks real LoRa use
bridge.py:
- _send_over_link: splits TCP data into RNS.Packet.ENCRYPTED_MDU-sized chunks
  before sending. RNS.Packet.pack() raises IOError on oversized data; a 32 KB
  TCP read would silently kill the tunnel on any LoRa or constrained interface.
  Order is safe — link is point-to-point, single sender per tunnel.
- Renamed RNS_BUFFER_SIZE → TCP_READ_SIZE (reads stay large for TCP efficiency;
  only outbound RNS direction is chunked).

gossip.py:
- _build_ref_payloads: packs refs into JSON payloads that each fit within
  ENCRYPTED_MDU. For >5 refs (>383 B), produces multiple packets. The receiver
  handles each independently — each triggers a change check and potential sync.
- _broadcast and _send_initial_refs now use _build_ref_payloads instead of
  building a single possibly-oversized payload.

tests:
- test_integration: set mock_pkt_cls.ENCRYPTED_MDU=383 so chunk size is correct
  under patch; assert single-packet delivery for small payloads
- test_gossip: TestBuildRefPayloads — small fits in 1 packet, 20 refs split
  across multiple packets all ≤ MDU, delta flag propagated to all chunks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:42:35 +02:00
Maciek "mab122" Bator 959eed17d2 feat: seed auto-track and delta ref broadcasts
gossip.py:
- auto_seed param: when refs arrive for an unknown RID, calls 'rad seed <RID>'
  then triggers a sync; adds the RID to self.rids so it gets polled going
  forward. Combined with auto_discover, the seed becomes fully self-populating.
- Delta broadcasts: _broadcast now accepts old_refs and sends only the changed
  subset with "delta": true in the packet. A 50-ref repo push shrinks from
  ~2.5 KB to ~120 B on LoRa — 95% bandwidth reduction.
- _on_packet: handles "delta": true by merging incoming refs onto local state
  instead of replacing; correctly detects changes after merge.
- _auto_seed_and_sync: calls rad seed, adds rid to watchlist, then delegates
  to _trigger_sync. No-ops cleanly if rad seed fails.
- _send_initial_refs still sends full refs (new peer has no prior state).

cli.py:
- cmd_seed: passes auto_seed=True to GossipRelay so the seed self-populates
  from the mesh as remote seeds announce their repos.

tests/test_gossip.py:
- Delta packet tests: merge, no-sync-on-known, full vs delta flag
- Auto-seed tests: seeds+syncs unknown repo, no-sync on failure, no dup rid
- Broadcast delta tests: full when no old_refs, only changed when delta,
  _send_initial_refs always sends full

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:21:48 +02:00
Maciek "mab122" Bator eb0a669801 fix: gossip correctness — debounce, non-blocking peer init, targeted sync
gossip.py:
- _poll_loop: run initial baseline poll at startup, then debounce 2s after
  watchdog events so a 20-commit push triggers one broadcast not twenty
- _on_announce: move inline _send_packet calls to _send_initial_refs() on a
  daemon thread — _send_packet blocks up to 15s waiting for a path, which
  was stalling the RNS announce handler when called inline
- _trigger_sync: pass --seed NID@127.0.0.1:PORT to rad sync --fetch when
  rad node connect succeeded, targeting the specific peer that sent the
  gossip instead of syncing with all known seeds; log clearly when connect
  fails and fall back to untagged fetch

tests/test_gossip.py:
- test_seed_flag_added_when_connect_succeeds
- test_no_seed_flag_when_connect_fails
- test_debounce_clears_event_on_early_wakeup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:09:59 +02:00
Maciek "mab122" Bator f71b87e34a fix: reuse RNS instance when already running; setup detects seed liveness
bridge.py / gossip.py:
- Use RNS.Reticulum.get_instance() to reuse the running singleton instead
  of raising OSError when cmd_seed instantiates both RadicleBridge and
  GossipRelay with the same config_path

cli.py (cmd_setup):
- Added TCP port check via seed_node._port_open() to report whether the
  seed radicle-node is currently listening; prints start command if not
- Adjusted final summary: distinguishes "all checks passed + running" from
  "all checks passed but not started"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:02:35 +02:00
Maciek "mab122" Bator 0226c00de0 feat: --lora flag for LoRa-safe announce/poll defaults
Adds --lora to bridge, seed, and gossip commands. When set, overrides
announce-retry-delays to 60,300,900s and poll-interval to 120s, matching
LoRa duty-cycle limits. Explicit user flags always take precedence over the
LoRa defaults, so --lora --announce-retry-delays=30,60 still works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 14:00:14 +02:00
Maciek "mab122" Bator 3ee1fa8c58 fix: persist bridge NIDs across restarts; fix missing Tuple import
bridge.py:
- state_path param: optional JSON file for persisting discovered bridge NIDs
- _load_state: on startup, re-registers each saved NID at its allocated port
  so radicle-node reconnects fast without waiting for a re-announce cycle
- _save_state: called whenever a new NID is first discovered
- Added json/Path imports

cli.py:
- cmd_seed: passes state_path={seed_home}/bridge_state.json
- cmd_bridge: passes state_path=~/.radicle-rns/bridge_state.json
- Fixed missing Tuple import (used by _parse_delays return type annotation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:35:58 +02:00
Maciek "mab122" Bator 4d3fdcf5f9 fix: reconnect after tunnel drop — path maintenance + link re-establishment
- _path_maintenance_loop: runs every 60s, re-requests stale paths to all
  known bridges so radicle-node retries are fast after a LoRa glitch
- _reconnect_link: attempts to re-establish an RNS link after it drops
  mid-transfer; splits 20s timeout between path recovery and handshake
- _forward_tcp_to_rns: on link CLOSED/FAILED, tries _reconnect_link once
  before closing the TCP socket — preserves the TCP connection on brief
  glitches, re-registers packet/close callbacks on the new link
- _stop_event wakes the maintenance loop immediately on stop()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:31:42 +02:00
Maciek "mab122" Bator 05dc078f31 fix: iteration race, NID bounds check, and DRY delay parsing
gossip: _poll_loop_once iterated self.rids directly while _discover_rids
could append to it from the same call — replaced with list(self.rids)
snapshot to avoid RuntimeError on concurrent modification.

gossip: _on_announce was missing the nid_len bounds clamp that bridge.py
has, allowing a malformed truncated packet to slice beyond available bytes.
Now consistent with bridge.py: nid_len = min(nid_len, available).

cli: announce_retry_delays string→tuple parsing was copy-pasted three times
across cmd_gossip, cmd_seed, and cmd_bridge. Extracted to _parse_delays().
cmd_seed now validates delays before starting any processes so it never
needs to stop a running seed just to report a bad argument.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:23:45 +02:00
Maciek "mab122" Bator 2ab81b525f test: integration tests for gossip, bridge discovery, and TCP tunneling
FakeRNSNetwork routes announces and packets between instances in-process,
replacing the need for real RNS hardware in CI.  Covers:

- Gossip: A broadcasts new refs → B receives packet → B triggers sync
- Gossip: repeated identical refs do not trigger a spurious sync
- Gossip: peer discovery via announce causes initial ref exchange
- Bridge: mutual discovery, NID extraction, de-duplication of auto_seed
- TCP tunnel: data forwarded from TCP socket to RNS Packet
- TCP tunnel: data received from RNS written back to TCP socket

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:19:01 +02:00
Maciek "mab122" Bator 8f4f732dca feat: watchdog push detection, setup command, and gossip refactor
Gossip relay now reacts to rad push instantly when watchdog is installed.
A threading.Event replaces the interruptible sleep loop: watchdog fires
the event on any filesystem change in the seed's storage directory, waking
the poll loop immediately.  Without watchdog the relay falls back to the
configured poll interval with a clear log message.

New command 'radicle-rns setup' checks all prerequisites and prints exact
fix instructions for anything missing: rad/radicle-node binaries, seed
identity, watchdog, and whether the seed is registered in the user's
radicle node.

Other changes:
- gossip: _poll_loop_once() extracted so tests can drive one iteration
- gossip: stop() sets poll_event so the thread exits without waiting out
  the full poll interval
- gossip: _start_watcher() creates storage dir if absent (watchdog requires
  the watched path to exist)
- pyproject.toml: watchdog>=3.0 added as [watch] optional dep and dev dep
- 5 new tests for watchdog/event/auto-discover behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:28:42 +02:00
Maciek "mab122" Bator 5fa6f890b4 fix: per-bridge TCP routing, gossip in seed mode, and thread-safety
Multi-bridge routing was broken: all incoming radicle-node connections
were routed to remote_bridges[0] regardless of which NID was being
reached. Fixed by allocating a dedicated OS-assigned TCP port per
discovered remote bridge; auto_seed registers each NID at its specific
port so radicle-node connections always reach the correct peer.

Gossip relay integrated into 'radicle-rns seed': watches the seed's
storage, auto-discovers repos as they are seeded, notifies remote gossip
peers of ref changes.  bridge_port=None skips the redundant rad-node-
connect step (bridge's auto_seed already registered NIDs correctly).

Other fixes:
- bridge.py: link FAILED state now breaks the wait loop immediately
  instead of waiting out the full 30 s timeout
- bridge.py: get_remote_bridge_nid now reads _bridge_nids under lock
- bridge.py: NID length bounds-checked before slice in _handle_announce
- gossip.py: add rad_home param so seed's rad calls use correct RAD_HOME
- gossip.py: add auto_discover flag to scan storage for new repos each cycle
- gossip.py: import os moved to top-level
- cli.py: gossip status line only prints when peer count changes (not
  every 5 s on any stats change)
- cli.py: add --poll-interval to seed command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 19:33:19 +02:00
Maciek "mab122" Bator e051d82af1 feat: replace parallel git-bundle layer with real Radicle seed bridging
Rips out the custom git-bundle/sync/adaptive/qr parallel sync stack and
replaces it with a proper seed-to-seed architecture: two real radicle-node
instances (each with seedingPolicy=allow) bridge over Reticulum/LoRa.
Users connect their own radicle-node to the local seed once; all mesh
sync happens in the background using Radicle's own protocol, so atomicity,
Noise XK encryption, and pack verification are all handled natively.

New modules:
- seed.py: SeedNode — manages a dedicated radicle-node subprocess under its
  own RAD_HOME (~/.radicle-rns/seed/), writes a seed config only on first
  run, uses DEVNULL I/O to prevent pipe-buffer deadlocks
- gossip.py: GossipRelay — polls ~/.radicle/storage/<rid>/ for ref changes,
  broadcasts ~200-500 byte RNS packets to known peers, on receipt calls
  `rad sync --fetch --rid X`; thread-safe via separate peers/refs locks

New CLI commands:
- `radicle-rns seed`: starts seed radicle-node + bridge as one command;
  auto-discovers remote seeds over the mesh and connects them
- `radicle-rns gossip`: runs ref-watching notification relay

bridge.py: added rad_home parameter so `rad node connect` is called with
the seed's RAD_HOME when auto-connecting remote seeds.

Bug fixes applied during review:
- seed.py: PIPE→DEVNULL (64 KB pipe buffer deadlock)
- seed.py: missing wait() after kill() (zombie process)
- seed.py: write_config() now idempotent (preserves user customisations)
- seed.py: is_initialized() wraps TimeoutExpired in except Exception
- seed.py: removed hardcoded "network":"main" (breaks testnet)
- seed.py: moved `import socket` to top-level
- cli.py: bridge.start() inside try/finally (orphaned seed on start error)
- cli.py: status line gated on known_bridges change (not every 5 s)
- cli.py: removed dead get_remote_bridges() call in bridge status loop
- gossip.py: added _refs_lock protecting _known_refs across threads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 15:44:59 +02:00
Maciek "mab122" Bator be25772602 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
2026-04-21 15:42:36 +02:00
Maciek "mab122" Bator c418cfaccf feat: initial implementation of radicle-reticulum bridge
Python adapter bridging Radicle (decentralized Git) over Reticulum mesh
  networking (LoRa, packet radio, serial, I2P). Enables offline-first code
  collaboration without internet infrastructure or public seed nodes.

  - Identity mapping: Radicle Ed25519 DIDs ↔ RNS destinations, with persistence
  - TCP↔RNS bridge: tunnels radicle-node traffic over mesh, auto-discovers peers
  - LXMF sync: store-and-forward bundle delivery for offline peers, auto-push
  - Adaptive strategy: selects FULL/INCREMENTAL/MINIMAL/QR by RTT + throughput
  - Git bundles: full and incremental, delay-tolerant transfer
  - QR air-gap: encode/decode bundles as QR codes (≤2953 bytes)
  - CLI: radicle-rns bridge/node/sync/bundle/ping/peers/identity commands
  - 158 tests
2026-04-21 12:14:57 +02:00