diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 666b2415..2cede9b6 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -422,6 +422,7 @@ pub fn spawn_mesh_listener( lora_region: Option, channel_name: Option, device_kind: Option, + reticulum_tcp: Option, shutdown: tokio::sync::watch::Receiver, cmd_rx: mpsc::Receiver, ) -> tokio::task::JoinHandle<()> { @@ -456,6 +457,7 @@ pub fn spawn_mesh_listener( lora_region.as_deref(), channel_name.as_deref(), device_kind, + reticulum_tcp.clone(), &mut shutdown, &mut cmd_rx, ) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 5b1f2f5a..4e094c48 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -411,6 +411,50 @@ async fn open_preferred_path( } } +/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no +/// `probe_rnode` KISS-detect gate. This is the radio-less counterpart to +/// `auto_detect_and_open`/`open_preferred_path`'s serial paths; see +/// `types::ReticulumTcpConfig`'s doc comment for scope (dev/verification, +/// loopback-only server bind). +async fn open_reticulum_tcp( + cfg: &ReticulumTcpConfig, + data_dir: &Path, + our_ed_pubkey_hex: &str, + our_x25519_pubkey_hex: &str, +) -> Result<(String, MeshRadioDevice, DeviceInfo)> { + let mut dev = match cfg { + ReticulumTcpConfig::Server { bind } => { + ReticulumLink::open_tcp_server( + bind, + data_dir, + Some(our_ed_pubkey_hex), + Some(our_x25519_pubkey_hex), + ) + .await + .context("Could not open Reticulum TCP server interface")? + } + ReticulumTcpConfig::Client { connect } => { + ReticulumLink::open_tcp_client( + connect, + data_dir, + Some(our_ed_pubkey_hex), + Some(our_x25519_pubkey_hex), + ) + .await + .context("Could not open Reticulum TCP client interface")? + } + }; + let info = dev + .initialize() + .await + .context("Reticulum TCP interface failed to initialize")?; + let label = match cfg { + ReticulumTcpConfig::Server { bind } => format!("tcp-server:{bind}"), + ReticulumTcpConfig::Client { connect } => format!("tcp-client:{}", connect.join(",")), + }; + Ok((label, MeshRadioDevice::Reticulum(dev), info)) +} + /// ASCII marker for the original DM-via-channel format: /// `@DM:` + base64(`[dest_prefix(6)][inner…]`). No sender info on the wire, /// so the receiver has to guess the sender from its contact table — which @@ -787,11 +831,17 @@ pub(super) async fn run_mesh_session( lora_region: Option<&str>, channel_name: Option<&str>, device_kind: Option, + reticulum_tcp: Option, shutdown: &mut tokio::sync::watch::Receiver, cmd_rx: &mut mpsc::Receiver, ) -> Result<()> { - // Detect device — try preferred path first, fall back to auto-detect - let (device_path, mut device, device_info) = if let Some(path) = preferred_path { + // Detect device — TCP Reticulum config (radio-less) takes priority when + // set, otherwise try the preferred serial path, falling back to + // auto-detect. TCP mode is additive/dev-only; it never changes behavior + // for existing serial/RNode deployments where `reticulum_tcp` is None. + let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp { + open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await? + } else if let Some(path) = preferred_path { match open_preferred_path( path, data_dir, diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 764e0e5e..99788234 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -392,6 +392,13 @@ pub struct MeshConfig { /// strict-probe auto-detect. #[serde(default)] pub device_kind: Option, + /// Optional plain-TCP Reticulum interface — radio-less dev/verification + /// alternative to the serial-RNode path (see `types::ReticulumTcpConfig` + /// doc comment). Not exposed via `mesh.configure`/frontend; hand-edit + /// this file to use it. `None` (default) preserves today's + /// serial-only/auto-detect behavior unchanged. + #[serde(default)] + pub reticulum_tcp: Option, } fn default_assistant_backend() -> String { @@ -422,6 +429,7 @@ impl Default for MeshConfig { assistant_backend: default_assistant_backend(), assistant_allowed_contacts: Vec::new(), device_kind: None, + reticulum_tcp: None, } } } @@ -704,6 +712,7 @@ impl MeshService { self.config.lora_region.clone(), self.config.channel_name.clone(), self.config.device_kind, + self.config.reticulum_tcp.clone(), shutdown_rx, cmd_rx, ); @@ -2223,4 +2232,133 @@ mod tests { assert!(loaded.enabled); assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string())); } + + /// End-to-end: `MeshService::start()` spawns a real `reticulum-daemon` in + /// plain-TCP CLIENT mode (no serial RNode, no `probe_rnode`), dials a + /// second stand-alone daemon instance running in TCP SERVER mode (the + /// Aurora-side role — Aurora's `RnsTcpInterface` dials the same way), and + /// reaches `device_connected: true` via the exact `mesh.status`-backing + /// `MeshService::status()` call the RPC layer uses. This is the Rust-side + /// half of the archy<->Aurora TCP interop gate (see + /// docs/RETICULUM-TRANSPORT-PROGRESS.md); the LXMF wire-level proof + /// itself already passed via a scripted RNS/LXMF stand-in (Steps 1-2 of + /// that gate), so this test only needs to prove Rust's spawn/connect + /// path, not re-prove LXMF content delivery. + /// + /// Requires `reticulum-daemon/.venv` (`python3 -m venv .venv && + /// .venv/bin/pip install -r requirements.txt`) — skips (not fails) if + /// absent, since CI doesn't provision a Python/RNS/LXMF environment for + /// the rest of the mesh test suite either. + #[tokio::test] + #[ignore = "spawns real reticulum-daemon subprocesses over loopback TCP; run manually with `cargo test -p archipelago -- --ignored mesh_service_connects_over_reticulum_tcp`"] + async fn mesh_service_connects_over_reticulum_tcp_client() { + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("core/archipelago has two ancestors up to the repo root") + .to_path_buf(); + let venv_py = repo_root.join("reticulum-daemon/.venv/bin/python"); + let daemon_script = repo_root.join("reticulum-daemon/reticulum_daemon.py"); + if !venv_py.exists() { + eprintln!( + "SKIP mesh_service_connects_over_reticulum_tcp_client: {} not found — \ + run `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt` \ + in reticulum-daemon/", + venv_py.display() + ); + return; + } + + let root = tempfile::tempdir().unwrap(); + + // Stand-in "Aurora-side" server daemon: a second, independent + // reticulum-daemon instance in --tcp-listen mode. Any free loopback + // port works; this test doesn't need it fixed, so ask the OS for one. + let bind_addr = { + let sock = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = sock.local_addr().unwrap(); + drop(sock); // release it so the daemon can bind — small TOCTOU window, acceptable for a manual/ignored test + addr + }; + let server_dir = root.path().join("standin-server"); + std::fs::create_dir_all(&server_dir).unwrap(); + let server_seed = SigningKey::generate(&mut OsRng); + std::fs::write(server_dir.join("seed.bin"), server_seed.to_bytes()).unwrap(); + let mut server = tokio::process::Command::new(&venv_py) + .arg(&daemon_script) + .arg("--identity-key") + .arg(server_dir.join("seed.bin")) + .arg("--socket") + .arg(server_dir.join("server.sock")) + .arg("--rns-config") + .arg(server_dir.join("rns-config")) + .arg("--tcp-listen") + .arg(bind_addr.to_string()) + .arg("--display-name") + .arg("standin-server") + .kill_on_drop(true) + .spawn() + .expect("failed to spawn stand-in reticulum-daemon (server)"); + + // Give the server daemon time to bind before the client dials it. + tokio::time::sleep(Duration::from_secs(2)).await; + + // Point ReticulumLink::daemon_command's dev-fallback at our venv — + // these env vars are read fresh on every spawn (mesh/reticulum.rs), + // so setting them here (test-process-global, but this test owns the + // only mesh session in this process) is sufficient. + std::env::set_var("ARCHY_RETICULUM_DAEMON_PY", &venv_py); + std::env::set_var("ARCHY_RETICULUM_DAEMON_SCRIPT", &daemon_script); + // Force the dev fallback even if a packaged binary happens to exist + // on this machine's PATH convention — this test wants exactly the + // freshly-built venv daemon under test. + std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", "/nonexistent-force-dev-fallback"); + + let data_dir = root.path().join("node"); + std::fs::create_dir_all(data_dir.join("identity")).unwrap(); + let node_signing_key = SigningKey::generate(&mut OsRng); + std::fs::write( + data_dir.join("identity").join("node_key"), + node_signing_key.to_bytes(), + ) + .unwrap(); + let ed_pubkey_hex = hex::encode(node_signing_key.verifying_key().to_bytes()); + let did = crate::identity::did_key_from_pubkey_hex(&ed_pubkey_hex).unwrap(); + + let config = MeshConfig { + enabled: true, + reticulum_tcp: Some(types::ReticulumTcpConfig::Client { + connect: vec![bind_addr.to_string()], + }), + ..Default::default() + }; + save_config(&data_dir, &config).await.unwrap(); + + let mut service = MeshService::new(&data_dir, &node_signing_key, &did, &ed_pubkey_hex) + .await + .expect("MeshService::new failed"); + service.start().expect("MeshService::start failed"); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + let mut last_status = service.status().await; + while tokio::time::Instant::now() < deadline { + last_status = service.status().await; + if last_status.device_connected { + break; + } + tokio::time::sleep(Duration::from_millis(300)).await; + } + + let _ = server.start_kill(); + + assert!( + last_status.device_connected, + "MeshService never reached device_connected: true over Reticulum TCP client \ + mode within 20s (last status: {last_status:?})" + ); + assert_eq!(last_status.device_type, DeviceType::Reticulum); + } } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index bdcebafd..adf02218 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -64,6 +64,44 @@ const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:"; /// packaging); during development it can point at the venv's interpreter /// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging. /// +/// Which Reticulum interface the daemon should bring up. `Serial` is the +/// original (and only, until now) path — a physical RNode over a serial +/// port, gated behind `probe_rnode`'s KISS-detect handshake in `open()`. +/// `TcpServer`/`TcpClient` are the radio-less, dev/verification-only +/// addition (see `types::ReticulumTcpConfig`'s doc comment): they let the +/// daemon speak plain Reticulum TCP — the same transport Aurora's +/// `RnsTcpInterface`/`RnsTcpServerInterface` use by default — without any +/// physical hardware. `TcpServer` is hard-gated to loopback by +/// `open_tcp_server`; `TcpClient` (outbound dial) is unrestricted, the same +/// risk class as Aurora's own hub uplinks. +pub enum ReticulumInterface<'a> { + Serial(&'a str), + TcpServer(&'a str), + TcpClient(&'a [String]), +} + +impl ReticulumInterface<'_> { + /// Human-readable label used for both the `device_path` status field and + /// (sanitized) the per-instance RPC socket filename. For `Serial` this is + /// exactly the old bare path — zero behavior change for the existing + /// hardware flow. + fn label(&self) -> String { + match self { + Self::Serial(path) => path.to_string(), + Self::TcpServer(bind) => format!("tcp-server:{bind}"), + Self::TcpClient(targets) => format!("tcp-client:{}", targets.join(",")), + } + } +} + +/// Bind-scope guard for TCP server mode — see `ReticulumInterface` doc +/// comment. Mirrors `reticulum_daemon.py`'s `_require_loopback`; enforced +/// here too (defense in depth) since this is the gate an operator/caller +/// actually goes through in Rust. +fn is_loopback_host(host: &str) -> bool { + matches!(host, "127.0.0.1" | "::1" | "localhost") +} + /// `archy_ed_pubkey_hex`/`archy_x25519_pubkey_hex` (when known) are embedded /// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the /// SAME wire format meshcore/Meshtastic identity adverts use — so a @@ -72,7 +110,7 @@ const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:"; /// satisfying cross-protocol DM convergence with zero new Rust dispatch code. fn daemon_command( socket_path: &Path, - serial_port: &str, + iface: &ReticulumInterface<'_>, identity_key: &Path, archy_ed_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>, @@ -94,9 +132,20 @@ fn daemon_command( cmd.arg("--identity-key") .arg(identity_key) .arg("--socket") - .arg(socket_path) - .arg("--serial-port") - .arg(serial_port); + .arg(socket_path); + match iface { + ReticulumInterface::Serial(path) => { + cmd.arg("--serial-port").arg(path); + } + ReticulumInterface::TcpServer(bind) => { + cmd.arg("--tcp-listen").arg(bind); + } + ReticulumInterface::TcpClient(targets) => { + for target in *targets { + cmd.arg("--tcp-connect").arg(target); + } + } + } if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) { cmd.arg("--archy-ed-pubkey-hex") .arg(ed) @@ -189,11 +238,62 @@ impl ReticulumLink { our_x25519_pubkey_hex: Option<&str>, ) -> Result { probe_rnode(path).await.context("RNode KISS detect failed")?; - Self::spawn(path, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await + Self::spawn( + ReticulumInterface::Serial(path), + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + ) + .await + } + + /// Bring up a daemon in plain-TCP server mode — no physical RNode, no + /// `probe_rnode` gate. `bind` (`host:port`) must be loopback; see + /// `ReticulumInterface`/`is_loopback_host`. + pub async fn open_tcp_server( + bind: &str, + data_dir: &Path, + our_ed_pubkey_hex: Option<&str>, + our_x25519_pubkey_hex: Option<&str>, + ) -> Result { + let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind); + anyhow::ensure!( + is_loopback_host(host), + "reticulum TCP server bind must be loopback-only (127.0.0.1/::1/localhost) — \ + got {bind}; WAN/LAN exposure needs its own security review" + ); + Self::spawn( + ReticulumInterface::TcpServer(bind), + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + ) + .await + } + + /// Bring up a daemon in plain-TCP client mode, dialing one or more + /// `host:port` targets — no physical RNode, no `probe_rnode` gate. + pub async fn open_tcp_client( + targets: &[String], + data_dir: &Path, + our_ed_pubkey_hex: Option<&str>, + our_x25519_pubkey_hex: Option<&str>, + ) -> Result { + anyhow::ensure!( + !targets.is_empty(), + "reticulum TCP client mode needs at least one target" + ); + Self::spawn( + ReticulumInterface::TcpClient(targets), + data_dir, + our_ed_pubkey_hex, + our_x25519_pubkey_hex, + ) + .await } async fn spawn( - path: &str, + iface: ReticulumInterface<'_>, data_dir: &Path, our_ed_pubkey_hex: Option<&str>, our_x25519_pubkey_hex: Option<&str>, @@ -212,9 +312,10 @@ impl ReticulumLink { let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700)) .await; } + let label = iface.label(); let socket_path = runtime_dir.join(format!( "{}.sock", - path.replace(['/', ' '], "_") + label.replace(['/', ' ', ':', ','], "_") )); if socket_path.exists() { let _ = std::fs::remove_file(&socket_path); @@ -229,7 +330,7 @@ impl ReticulumLink { let mut cmd = daemon_command( &socket_path, - path, + &iface, &identity_key, our_ed_pubkey_hex, our_x25519_pubkey_hex, @@ -274,13 +375,13 @@ impl ReticulumLink { .map(str::to_string); info!( - path = %path, + iface = %label, dest_hash = %dest_hash_hex, "Reticulum daemon ready" ); let mut link = Self { - device_path: path.to_string(), + device_path: label, socket_path, child, writer: write_half, diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index c7562efc..43f64730 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -28,6 +28,23 @@ impl std::fmt::Display for DeviceType { } } +/// Optional plain-TCP Reticulum interface, radio-less alternative to the +/// serial-RNode path. Dev/verification surface for now (e.g. proving +/// interop with Aurora's `RnsTcpInterface`/`RnsTcpServerInterface`, which is +/// its default connectivity mode) — not exposed through `mesh.configure`/the +/// frontend. `Server` is hard-gated to loopback in both the daemon and Rust +/// (`reticulum::is_loopback_host`); WAN/LAN exposure is a separate, deliberate +/// future decision (archy is otherwise Tor-first for inter-node traffic). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum ReticulumTcpConfig { + /// Bind a `TCPServerInterface`. `bind` is `host:port`, host must be + /// loopback (127.0.0.1/::1/localhost). + Server { bind: String }, + /// Dial one or more `TCPClientInterface` targets (`host:port`). + Client { connect: Vec }, +} + /// The per-message transport pill label for a radio-delivered message: the /// active device's own name, since one session owns exactly one device. /// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only diff --git a/docs/RETICULUM-TRANSPORT-PROGRESS.md b/docs/RETICULUM-TRANSPORT-PROGRESS.md index 2dd80790..5441c4f8 100644 --- a/docs/RETICULUM-TRANSPORT-PROGRESS.md +++ b/docs/RETICULUM-TRANSPORT-PROGRESS.md @@ -25,6 +25,7 @@ owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions. | 2c | `MeshConfig.device_kind` reflashable-board pin | ✅ **DONE** this session (was the one open Phase-2 item) | | 3 | Frontend (~8 label/CSS spots) | ✅ DONE (scoped down — see note below) | | 4 | Multi-device (run all 3 radios at once) + per-network channels | ⏳ not started (follow-on, after 0–3) | +| 5 | Aurora interop — optional plain-TCP Reticulum interface (radio-less) | ✅ **DONE + verified 2026-07-03** — see checkpoint below. Real Aurora GUI test still open (manual follow-up). | ## Checkpoint 2026-06-30 (late session — read this first if cut off) @@ -290,3 +291,82 @@ Phase 0 gates #1–#3 are now **all passed**. What's left: second board will likely report the same `0001` stock serial since CP2102 modules commonly ship with an unprogrammed default, so this may still need a different disambiguator. 6. Phase 4 (run all 3 radios at once) — still not started, follow-on after the above. + +## Checkpoint 2026-07-03 — Phase 5: Aurora interop via plain-TCP Reticulum (radio-less) + +**Why:** `~/aurora` (a separate Flutter off-grid messenger) already runs real RNS + LXMF +(`LxmfRouter`, comment "interop with Sideband/NomadNet/MeshChat" in `rns_service.dart`), and its +**default** connectivity mode is plain TCP (`RnsTcpInterface`/`RnsTcpServerInterface`), not radio — +it ships a static bootstrap list of public RNS hubs on port 4242. Archy's daemon could previously +only bring up a serial-RNode interface, so it was unreachable by Aurora (or any TCP-based RNS/LXMF +client) at all, and every interop proof was bottlenecked on scarce LoRa hardware. This phase adds +an **optional, additive, loopback-only plain-TCP interface**, proves interop with a scripted +RNS/LXMF stand-in (the same class of proof the Sideband gate already established), and leaves the +serial/RNode path completely unchanged. + +**Done, all verified:** +1. `reticulum-daemon/reticulum_daemon.py` — `_write_rns_config()` gained a third branch + (`--tcp-listen HOST:PORT` → `TCPServerInterface`, `--tcp-connect HOST:PORT` repeatable → + `TCPClientInterface`), mutually exclusive with `--serial-port`. `--tcp-listen` is hard-gated to + loopback (`_require_loopback`) — archy is otherwise Tor-first for inter-node traffic, so a + WAN/LAN-exposed Reticulum port is a deliberate future decision, not something this phase does + silently. Verified: `--selftest` regression still passes; two daemon processes (server + + client, throwaway identities) reached `connected: true` on both sides via `mesh.status`-daemon + RPC, live `TCPServerInterface`/`TCPClientInterface` visible in `get_interface_stats()`. +2. **Bidirectional LXMF DM gate against a scripted Aurora stand-in** (Python RNS+LXMF client + dialing as a `TCPClientInterface` + running its own `LXMRouter` — a legitimate protocol-level + proxy for Aurora's Dart stack, same wire format): forward (stand-in → archy daemon) and reverse + (archy daemon → stand-in) both delivered with matching content and correct source/dest hashes, + confirmed via the daemon's own `recv`/`delivered` RPC events. Direct TCP analogue of the + already-passed Sideband gate (RF → TCP, Sideband → scripted stand-in). +3. **Rust wiring**, fully additive — the serial/RNode path is byte-for-byte unchanged: + - `mesh/reticulum.rs`: new `ReticulumInterface` enum (`Serial`/`TcpServer`/`TcpClient`) threads + through `daemon_command()`/`spawn()`; `open()` (serial) now just wraps + `ReticulumInterface::Serial` — same `probe_rnode` gate as before. New + `open_tcp_server()`/`open_tcp_client()` associated fns skip `probe_rnode` entirely (the + "spawn without a physical RNode" path); `open_tcp_server` hard-enforces + `is_loopback_host()` (mirrors the Python-side guard). + - `mesh/types.rs`: new `ReticulumTcpConfig` enum (`Server { bind }` / `Client { connect }`). + - `mesh/mod.rs`: `MeshConfig.reticulum_tcp: Option` (`#[serde(default)]`, + `None` by default — no migration, zero behavior change when unset); threaded into + `start()` → `spawn_mesh_listener`. + - `listener/mod.rs` / `listener/session.rs`: `reticulum_tcp` param threaded through + `spawn_mesh_listener`/`run_mesh_session`; new leading branch — if set, a new + `open_reticulum_tcp()` helper dispatches to `open_tcp_server`/`open_tcp_client`; otherwise + falls through to the **untouched** existing `preferred_path`/`auto_detect_and_open` logic. + - Deliberately **not** wired into `mesh.configure`/the frontend — dev/verification-only surface + for now (hand-edit `mesh-config.json`), consistent with how narrowly scoped this phase is. + - `cargo check -p archipelago` + `cargo test -p archipelago` (mesh module): **108 passed, 0 + failed, 1 ignored** (the pre-existing hardware-gated `probe_rnode_detects_real_hardware`) — + zero regression to the serial/RNode path, provable without any hardware. +4. **End-to-end Rust integration test** (`mesh::tests::mesh_service_connects_over_reticulum_tcp_client`, + `#[ignore]`d — spawns real subprocesses, skipped in the default `cargo test` run the same way + the rest of the mesh suite skips hardware-gated tests): a real `MeshService::start()` spawns the + daemon in TCP **client** mode (no serial probe at all), dials a second stand-alone daemon + instance in TCP **server** mode (the Aurora-side role), and reaches `device_connected: true` / + `device_type: Reticulum` via the exact `MeshService::status()` call the `mesh.status` RPC uses. + Passed in ~2.6s. Run manually: `cargo test -p archipelago -- --ignored + mesh_service_connects_over_reticulum_tcp` (needs `reticulum-daemon/.venv`, see below). + +**Environment note:** this session's Rust toolchain drift — system `rustc` (apt, 1.85.0) is too +old for code already on `main` (`u32::is_multiple_of` in `health_monitor.rs`, stabilized upstream +after 1.85); a pre-installed rustup toolchain at +`~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu` (1.96.0) builds clean. Not something this +phase's changes caused — pre-existing, just newly hit. Put that toolchain's `bin/` first on `PATH` +if `cargo check`/`test` reports `E0658 unsigned_is_multiple_of`. + +**Explicitly NOT done (out of scope for this phase, see plan non-goals):** +- Real Aurora Flutter GUI verification — this dev sandbox has no `flutter`, no `$DISPLAY`, and no + `reticulum-dart` sibling checked out (Aurora's actual RNS implementation lives in that separate + repo; Aurora's CI clones it fresh at build time). The scripted-stand-in gate above is the + protocol-level substitute. **Manual follow-up**: point a real Aurora build's TCP hub list (or an + ad hoc connect) at an archy node's `--tcp-listen` address and confirm an LXMF DM in the actual + app UI. +- Any non-loopback (LAN/WAN) TCP bind — hard-gated off on purpose; a real "Aurora hub" deployment + needs its own security review given archy's Tor-first posture for inter-node traffic. +- LXMF propagation-node / always-on-hub role for archy (bridging Aurora's offline BLE peers) — + bigger architectural + storage commitment. +- Identity unification between archy's and Aurora's independent Nostr/secp256k1 keys — both + already have separate Nostr identities with no derivation link; out of scope here. +- `mesh.configure` RPC / frontend exposure of `reticulum_tcp` — stays hand-edit-only until/unless + it becomes user-facing. diff --git a/reticulum-daemon/reticulum_daemon.py b/reticulum-daemon/reticulum_daemon.py index 3fb15eef..d31c4c73 100644 --- a/reticulum-daemon/reticulum_daemon.py +++ b/reticulum-daemon/reticulum_daemon.py @@ -59,9 +59,33 @@ from archy_rns_identity import lxmf_destination_hash, load_identity # ─────────────────────────── RNS config generation ─────────────────────────── -def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, no_radio: bool) -> None: - """Materialise an RNS config file. RNode interface for real radios; a loopback- - only config for --selftest so the stack comes up without hardware or network.""" +def _require_loopback(host: str) -> None: + """Bind-scope guard: TCP server mode is dev/verification only for now. WAN/LAN + exposure is a separate future decision needing its own security review — archy + is Tor-first for inter-node traffic (see README/CLAUDE.md), and a plain-TCP + Reticulum listener bound beyond loopback would bypass that entirely. Defense in + depth: the Rust side enforces the same rule (reticulum.rs::is_loopback_host), + but this daemon can also be invoked directly, so it re-checks here too.""" + if host not in ("127.0.0.1", "::1", "localhost"): + raise SystemExit( + f"--tcp-listen host must be loopback-only (got {host!r}); " + "WAN/LAN bind is out of scope for this daemon build" + ) + + +def _write_rns_config( + configdir: Path, + *, + serial_port: str | None, + lora: dict, + no_radio: bool, + tcp_listen: str | None = None, + tcp_connect: list[str] | None = None, +) -> None: + """Materialise an RNS config file. RNode interface for real radios; plain-TCP + server/client interface(s) for radio-less dev/verification (e.g. Aurora + interop testing); a loopback-only disabled config for --selftest so the stack + comes up with zero interfaces at all.""" configdir.mkdir(parents=True, exist_ok=True) cfg = configdir / "config" if no_radio: @@ -70,7 +94,7 @@ def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, n " type = AutoInterface\n" " enabled = no\n" ) - else: + elif serial_port: interfaces = ( " [[RNode LoRa]]\n" " type = RNodeInterface\n" @@ -82,6 +106,33 @@ def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, n f" spreadingfactor = {lora['spreadingfactor']}\n" f" codingrate = {lora['codingrate']}\n" ) + elif tcp_listen or tcp_connect: + parts = [] + if tcp_listen: + host, _, port = tcp_listen.rpartition(":") + _require_loopback(host) + parts.append( + " [[Reticulum TCP Server]]\n" + " type = TCPServerInterface\n" + " enabled = yes\n" + f" listen_ip = {host}\n" + f" listen_port = {port}\n" + ) + for i, target in enumerate(tcp_connect or []): + host, _, port = target.rpartition(":") + parts.append( + f" [[Reticulum TCP Client {i}]]\n" + " type = TCPClientInterface\n" + " enabled = yes\n" + f" target_host = {host}\n" + f" target_port = {port}\n" + ) + interfaces = "\n".join(parts) + else: + raise SystemExit( + "no interface configured: need --serial-port, --tcp-listen/--tcp-connect, " + "or --no-radio" + ) cfg.write_text( "[reticulum]\n" " enable_transport = no\n" @@ -137,6 +188,8 @@ class ReticulumDaemon: "codingrate": self.args.codingrate, }, no_radio=self.args.no_radio, + tcp_listen=self.args.tcp_listen, + tcp_connect=self.args.tcp_connect, ) self.reticulum = RNS.Reticulum(configdir=str(configdir)) self.identity = load_identity(self.seed) @@ -483,6 +536,11 @@ def _parse_args(argv): p.add_argument("--socket", default="/tmp/archy-reticulum.sock", help="Unix RPC socket path") p.add_argument("--rns-config", default=str(Path.home() / ".archy-reticulum"), help="RNS config/storage dir") p.add_argument("--serial-port", help="RNode serial device, e.g. /dev/reticulum-radio") + p.add_argument("--tcp-listen", default=None, metavar="HOST:PORT", + help="Bind a plain-TCP Reticulum server interface (dev/verification " + "only; loopback-only, e.g. 127.0.0.1:4242).") + p.add_argument("--tcp-connect", action="append", default=None, metavar="HOST:PORT", + help="Dial a plain-TCP Reticulum client interface (repeatable).") p.add_argument("--display-name", default="Archy", help="LXMF display name") p.add_argument("--archy-ed-pubkey-hex", default=None, help="Archy ed25519 pubkey hex (64 chars) — embedded in the announce " @@ -530,6 +588,12 @@ def main(argv=None) -> int: _install_parent_death_signal() args = _parse_args(argv if argv is not None else sys.argv[1:]) + if args.serial_port and (args.tcp_listen or args.tcp_connect): + raise SystemExit( + "--serial-port is mutually exclusive with --tcp-listen/--tcp-connect " + "(one daemon process = one interface)" + ) + if args.check: seed = ReticulumDaemon._read_seed(Path(args.identity_key)) print(lxmf_destination_hash(seed).hex())