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..04f89bb1 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -261,7 +261,7 @@ impl MeshRadioDevice { /// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only /// that one device's probe runs, so a non-matching firmware's init bytes are /// never injected into the port. `None` keeps the strict -/// Meshcore→Meshtastic→Reticulum probe order. +/// Reticulum→Meshcore→Meshtastic probe order. async fn auto_detect_and_open( data_dir: &Path, our_ed_pubkey_hex: &str, @@ -274,6 +274,34 @@ async fn auto_detect_and_open( } for path in &paths { debug!(path = %path, "Probing for mesh radio device"); + // Tried FIRST: `ReticulumLink::open()` gates its expensive daemon + // spawn behind a cheap (~1s worst case) RNode KISS-detect probe, so a + // failed match here costs about as much as the Meshcore/Meshtastic + // probes below. Trying it first matters in practice: on a real RNode + // board, Meshcore's and Meshtastic's handshake bytes (~5s timeout + // each, ~10.6s combined) sitting on the wire before Reticulum ever + // gets a turn was observed to leave the RNode firmware unresponsive + // by the time its turn came — confirmed on hardware that responds + // correctly to the same KISS probe on a truly fresh port. + if device_kind.is_none_or(|k| k == DeviceType::Reticulum) { + match ReticulumLink::open( + path, + data_dir, + Some(our_ed_pubkey_hex), + Some(our_x25519_pubkey_hex), + ) + .await + { + Ok(mut dev) => match dev.initialize().await { + Ok(info) => { + info!(path = %path, "Found Reticulum (RNode) device via auto-detect"); + return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info)); + } + Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"), + }, + Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"), + } + } if device_kind.is_none_or(|k| k == DeviceType::Meshcore) { match MeshcoreDevice::open(path).await { Ok(mut dev) => match dev.initialize().await { @@ -298,30 +326,6 @@ async fn auto_detect_and_open( Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"), } } - // Tried LAST: the same reflashable board (e.g. Heltec V3) can run - // Meshcore, Meshtastic, or RNode firmware, so each probe must fail - // strictly before the next is attempted. The RNode KISS-detect probe - // is the most expensive (spawns the supervised daemon on a match), so - // it goes after the two cheap firmware-specific handshakes above. - if device_kind.is_none_or(|k| k == DeviceType::Reticulum) { - match ReticulumLink::open( - path, - data_dir, - Some(our_ed_pubkey_hex), - Some(our_x25519_pubkey_hex), - ) - .await - { - Ok(mut dev) => match dev.initialize().await { - Ok(info) => { - info!(path = %path, "Found Reticulum (RNode) device via auto-detect"); - return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info)); - } - Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"), - }, - Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"), - } - } } anyhow::bail!( "No supported mesh radio found on {} candidate ports: {:?}", @@ -381,20 +385,10 @@ async fn open_preferred_path( }; } - match MeshcoreDevice::open(path).await { - Ok(mut dev) => match dev.initialize().await { - Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)), - Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"), - }, - Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"), - } - match MeshtasticDevice::open(path).await { - Ok(mut dev) => match dev.initialize().await { - Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)), - Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshtastic"), - }, - Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshtastic"), - } + // Reticulum first — see the matching comment on auto_detect_and_open: + // its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while + // trying Meshcore/Meshtastic first was observed leaving a real RNode + // board unresponsive by the time Reticulum's turn came. match ReticulumLink::open( path, data_dir, @@ -404,11 +398,69 @@ async fn open_preferred_path( .await { Ok(mut dev) => match dev.initialize().await { - Ok(info) => Ok((MeshRadioDevice::Reticulum(dev), info)), - Err(e) => Err(e).context("Preferred path is not a working Reticulum RNode"), + Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)), + Err(e) => debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode"), }, - Err(e) => Err(e).context("Could not open preferred path as Reticulum"), + Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"), } + match MeshcoreDevice::open(path).await { + Ok(mut dev) => match dev.initialize().await { + Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)), + Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"), + }, + Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"), + } + match MeshtasticDevice::open(path).await { + Ok(mut dev) => match dev.initialize().await { + Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)), + Err(e) => Err(e).context("Preferred path is not a working Meshtastic device"), + }, + Err(e) => Err(e).context("Could not open preferred path as Meshtastic"), + } +} + +/// 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: @@ -627,11 +679,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) advert_name: contact.advert_name.clone(), did: existing.and_then(|p| p.did.clone()), pubkey_hex: Some(contact.public_key_hex.clone()), - // Preserve any archipelago identity bound by an earlier - // identity advert — NEVER overwrite it with the firmware - // contact key, or a signed `!ai` query from this peer would - // fail authentication after the next contact refresh. - arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()), + // Reticulum carries the archipelago identity in-band with + // the contact snapshot itself (see `ParsedContact:: + // arch_pubkey_hex`'s doc comment) — prefer it when this + // refresh's snapshot has one. Otherwise preserve whatever + // was bound by an earlier identity advert (Meshcore/ + // Meshtastic's `bind_federation_twins` path): NEVER + // overwrite a known identity with the firmware contact + // key, or a signed `!ai` query from this peer would fail + // authentication after the next contact refresh. + arch_pubkey_hex: contact + .arch_pubkey_hex + .clone() + .or_else(|| existing.and_then(|p| p.arch_pubkey_hex.clone())), x25519_pubkey: existing.and_then(|p| p.x25519_pubkey), // Meshtastic-only today (see ParsedContact) — falls back to // whatever was already known if this refresh's contact @@ -787,11 +847,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/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 2692e483..bebb461b 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -180,6 +180,12 @@ impl MeshtasticDevice { "Failed to open serial port {} (permission denied? device busy?)", path ))?; + // See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB + // boards reset on a DTR/RTS transition, so deassert both and settle + // before the handshake below. + let _ = port.set_dtr(false); + let _ = port.set_rts(false); + tokio::time::sleep(Duration::from_millis(300)).await; info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port"); Ok(Self { @@ -976,6 +982,7 @@ impl MeshtasticDevice { snr, lat, lon, + arch_pubkey_hex: None, }, ); } @@ -1048,6 +1055,7 @@ fn packet_to_inbound_frame( snr: None, lat: None, lon: None, + arch_pubkey_hex: None, }); if packet.rx_rssi.is_some() { contact.rssi = packet.rx_rssi.map(|v| v as i16); 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/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 7835d95f..807a07d1 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -406,6 +406,15 @@ pub struct ParsedContact { /// contact has shared one. pub lat: Option, pub lon: Option, + /// Archipelago ed25519 identity hex, when this transport carried it + /// in-band with the contact announce itself (Reticulum only today — the + /// RNS announce's app_data can embed an `ARCHY:n:` identity blob + /// alongside the destination hash in the same event, so there's no + /// ambiguity about which physical peer it belongs to). Meshcore/ + /// Meshtastic identity adverts go out on a separate channel and are + /// correlated after the fact by `bind_federation_twins`'s advert_name + /// matching instead, so they always leave this `None`. + pub arch_pubkey_hex: Option, } /// Parse RESP_CONTACT (0x03) response. @@ -457,6 +466,7 @@ pub fn parse_contact(data: &[u8]) -> Result { snr: None, lat: None, lon: None, + arch_pubkey_hex: None, }) } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index bdcebafd..5af0b667 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -47,7 +47,14 @@ const KISS_CMD_PLATFORM: u8 = 0x48; const KISS_CMD_MCU: u8 = 0x49; const PROBE_BAUD: u32 = 115200; -const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800); +// 800ms was too tight for real hardware: confirmed on a genuine Heltec V4 +// RNode (firmware 1.86, verified via `rnodeconf --info`) that prints extra +// boot/status chatter on the same serial line before answering KISS +// commands — DETECT_RESP measured arriving ~1.05s after the probe write in +// that case. 2.5s leaves comfortable margin without meaningfully slowing +// down detection of non-RNode devices (Meshcore/Meshtastic already budget +// ~5s each). +const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(2500); /// Prefix marking an LXMF `content` string as base64 of a raw binary /// typed-envelope payload rather than literal text. LXMF `content` travels @@ -64,6 +71,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 +117,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 +139,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) @@ -120,9 +176,12 @@ fn daemon_command( struct ReticulumPeer { dest_hash: [u8; 16], display_name: String, - /// Archy ed25519 identity hex, once carried in a verified announce - /// app-data blob. Not yet wired (TODO, lands with the signed-announce - /// work) — present so `get_contacts` has a stable shape to extend into. + /// Archy ed25519 identity hex, once carried in this peer's own announce + /// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm. + /// Unlike Meshcore/Meshtastic, this arrives in-band with the same event + /// as the destination hash, so it can be bound directly onto this + /// RNS-hash-keyed peer with no name-matching ambiguity (contrast + /// `bind_federation_twins`, which those two transports rely on instead). arch_pubkey_hex: Option, reachable: bool, } @@ -189,11 +248,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 +322,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 +340,7 @@ impl ReticulumLink { let mut cmd = daemon_command( &socket_path, - path, + &iface, &identity_key, our_ed_pubkey_hex, our_x25519_pubkey_hex, @@ -274,13 +385,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, @@ -544,6 +655,7 @@ impl ReticulumLink { snr: None, lat: None, lon: None, + arch_pubkey_hex: p.arch_pubkey_hex.clone(), }) .collect()) } @@ -622,17 +734,21 @@ impl ReticulumLink { .filter(|s| !s.is_empty()); // If the announce app_data is an ARCHY:n: identity blob (see - // daemon_command's doc comment), bind it onto this peer AND - // surface it through the SAME channel-text path - // meshcore/Meshtastic identity adverts use + // daemon_command's doc comment), bind the ed25519 hex directly + // onto this RNS-hash-keyed peer below (unambiguous — it came + // in the same event as `hash`) AND surface it through the same + // channel-text path meshcore/Meshtastic identity adverts use // (frames::handle_channel_payload -> parse_identity_broadcast - // -> handle_identity_received -> bind_federation_twins), so a - // Reticulum-carried identity merges into the same conversation - // as that node's other-transport twins — zero new bind logic. - let is_identity_blob = app_data_text + // -> handle_identity_received), so it also lands on that + // transport's federation-twin peer for UI/contact purposes. + // `group_peer_twins` can then collapse the two rows since both + // now carry the same `arch_pubkey_hex`, instead of relying on + // `bind_federation_twins`'s advert_name matching, which never + // matches here — see `display_name` below. + let parsed_identity = app_data_text .as_deref() - .map(|t| protocol::parse_identity_broadcast(t).is_some()) - .unwrap_or(false); + .and_then(protocol::parse_identity_broadcast); + let is_identity_blob = parsed_identity.is_some(); if is_identity_blob { let text = app_data_text.clone().unwrap(); let mut data = Vec::with_capacity(7 + text.len()); @@ -645,6 +761,7 @@ impl ReticulumLink { bytes_consumed: 0, }); } + let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey); let display_name = app_data_text .filter(|_| !is_identity_blob) @@ -654,11 +771,14 @@ impl ReticulumLink { .and_modify(|p| { p.display_name = display_name.clone(); p.reachable = true; + if arch_pubkey_hex.is_some() { + p.arch_pubkey_hex = arch_pubkey_hex.clone(); + } }) .or_insert(ReticulumPeer { dest_hash: hash, display_name, - arch_pubkey_hex: None, + arch_pubkey_hex, reachable: true, }); self.persist_peers(); @@ -904,6 +1024,14 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> { async fn probe_rnode(path: &str) -> Result<()> { let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD) .with_context(|| format!("Failed to open {} for Reticulum probe", path))?; + // ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART + // bridge chip) treat a DTR/RTS transition on open as a reset signal, the + // same mechanism esptool uses to force bootloader entry. Deassert both + // and let the board settle before writing the probe, or the reboot eats + // the DETECT_RESP window below. + let _ = port.set_dtr(false); + let _ = port.set_rts(false); + tokio::time::sleep(Duration::from_millis(300)).await; let probe: [u8; 13] = [ KISS_FEND, KISS_CMD_DETECT, diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 5eaac130..5358ff0f 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -57,6 +57,12 @@ impl MeshcoreDevice { "Failed to open serial port {} (permission denied? device busy?)", path ))?; + // See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB + // boards reset on a DTR/RTS transition, so deassert both and settle + // before the handshake below. + let _ = port.set_dtr(false); + let _ = port.set_rts(false); + tokio::time::sleep(Duration::from_millis(300)).await; info!(path = %path, baud = BAUD_RATE, "Opened serial port"); 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/build.sh b/reticulum-daemon/build.sh index ad5964ee..f99642d3 100755 --- a/reticulum-daemon/build.sh +++ b/reticulum-daemon/build.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Build the PyInstaller single-binary for the OTA (plan Phase 1 packaging). -# Output: dist/archy-reticulum-daemon — drop next to /usr/local/bin/archipelago. +# Build the PyInstaller single-binaries for the OTA (plan Phase 1 packaging). +# Outputs: dist/archy-reticulum-daemon, dist/archy-rnodeconf — drop both next +# to /usr/local/bin/archipelago. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" @@ -9,7 +10,7 @@ if [ ! -d .venv ]; then fi .venv/bin/pip install -q -r requirements.txt -r requirements-build.txt -rm -rf build dist archy-reticulum-daemon.spec +rm -rf build dist archy-reticulum-daemon.spec archy-rnodeconf.spec # --collect-submodules: RNS/LXMF load most of their own internals dynamically # (interface drivers, transport backends), which PyInstaller's static import @@ -30,3 +31,30 @@ rm -rf build dist archy-reticulum-daemon.spec reticulum_daemon.py echo "Built dist/archy-reticulum-daemon ($(du -h dist/archy-reticulum-daemon | cut -f1))" + +# archy-rnodeconf: RNS's own official RNode config/diagnostic tool +# (RNS.Utilities.rnodeconf — reads/sets frequency, bandwidth, spreading +# factor, coding rate, TX power on an attached RNode; also verifies firmware +# signatures and can flash/bootstrap a board). Shipped alongside the daemon +# so every node can inspect and reconfigure its own radio without needing a +# full RNS/Python dev environment set up by hand — see the checkpoint in +# docs/RETICULUM-TRANSPORT-PROGRESS.md for the incident (two nodes silently +# running at different spreading factors, invisible without this tool) that +# motivated shipping it as a first-class OS tool rather than an ad hoc script. +RNODECONF_SRC="$(find .venv/lib -maxdepth 5 -path '*/site-packages/RNS/Utilities/rnodeconf.py' -print -quit)" +if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then + # --runtime-hook: rnodeconf's own graceful_exit() calls the bare + # exit()/quit() builtins, which only exist in interactive Python (site.py + # injects them) — a frozen app hits NameError right as it tries to quit + # cleanly, after all the real work already succeeded. See + # pyi_rthook_exit_builtins.py. + .venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \ + --collect-submodules RNS \ + --collect-data RNS \ + --runtime-hook pyi_rthook_exit_builtins.py \ + -d noarchive \ + "$RNODECONF_SRC" + echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))" +else + echo "WARNING: rnodeconf.py not found at $RNODECONF_SRC (RNS version mismatch?) — skipping archy-rnodeconf build" >&2 +fi diff --git a/reticulum-daemon/pyi_rthook_exit_builtins.py b/reticulum-daemon/pyi_rthook_exit_builtins.py new file mode 100644 index 00000000..0b999229 --- /dev/null +++ b/reticulum-daemon/pyi_rthook_exit_builtins.py @@ -0,0 +1,18 @@ +# PyInstaller runtime hook — see build.sh. +# +# `exit()`/`quit()` aren't part of the language; they're `site.Quitter` +# instances the interactive interpreter injects into builtins at startup +# (site.py). A frozen PyInstaller app never runs that interactive-mode +# init, so any bundled script that calls bare `exit()` (RNS's own +# rnodeconf.py does, in its graceful_exit() cleanup path) hits +# `NameError: name 'exit' is not defined` right as it tries to quit +# cleanly — the real work above it already completed, but the process +# still exits 1, which is a foot-gun for anything scripting off the exit +# code. Pre-define both as sys.exit so that path is a no-op crash-wise. +import builtins +import sys + +if not hasattr(builtins, "exit"): + builtins.exit = sys.exit +if not hasattr(builtins, "quit"): + builtins.quit = sys.exit 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()) diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index eb95c29b..c93f3a50 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -257,6 +257,13 @@ ssh $SSH_OPTS "$TARGET_HOST" ' NEED_INSTALL="" command -v rsync >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL rsync" command -v python3 >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL python3" + # python3 -m venv exists but cannot bootstrap pip without the matching + # .-venv package on Debian — needed for reticulum-daemon/ + # build.sh (archy-reticulum-daemon / archy-rnodeconf packaging). + if command -v python3 >/dev/null 2>&1 && ! python3 -c "import ensurepip" >/dev/null 2>&1; then + PYVER=$(python3 -c "import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")") + NEED_INSTALL="$NEED_INSTALL python3.${PYVER#*.}-venv" + fi if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then echo " Node.js/npm not found — installing..." curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - 2>&1 | tail -3 @@ -585,6 +592,20 @@ else echo " ⚠️ Rust not installed on target, skipping backend build" fi +# reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf) — skip with +# --frontend-only. Non-fatal on failure: these are supplementary mesh/radio +# tools, not required for the rest of the deploy to succeed, and build.sh +# handles its own venv/pip setup so a first run here is slower than later ones. +if [ "$FRONTEND_ONLY" = true ]; then + echo " Skipping reticulum-daemon tools build (--frontend-only)" +else + progress "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)" + section_start + ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/reticulum-daemon && ./build.sh 2>&1" | sed 's/^/ /' \ + || echo " ⚠️ reticulum-daemon tools build failed — continuing without updating them" + section_end +fi + if [ "$LIVE" = true ]; then # Create rollback backup before deploying @@ -608,6 +629,26 @@ if [ "$LIVE" = true ]; then ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/" fi + # Deploy reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf) — + # skip with --frontend-only. Non-fatal: archipelago falls back to its dev + # venv path (ARCHY_RETICULUM_DAEMON_PY/_SCRIPT) if the packaged binary + # isn't present, so a missing/failed build here degrades rather than + # breaks mesh. archipelago is already stopped from the backend-binary + # step above, so this is a clean window to swap both binaries. + if [ "$FRONTEND_ONLY" = true ]; then + echo " Skipping reticulum-daemon tools deploy (--frontend-only)" + else + progress "Deploying reticulum-daemon tools" + for tool in archy-reticulum-daemon archy-rnodeconf; do + if ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/reticulum-daemon/dist/$tool ]" 2>/dev/null; then + ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/reticulum-daemon/dist/$tool /usr/local/bin/ && sudo chmod +x /usr/local/bin/$tool" + echo " $tool deployed" + else + echo " ⚠️ $tool not built — leaving existing /usr/local/bin/$tool (if any) in place" + fi + done + fi + # Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build) progress "Deploying frontend" ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"