Merge pull request 'Aurora Reticulum TCP interop + mesh/RNode reliability fixes' (#70) from worktree-reticulum-tcp-interop into main
This commit is contained in:
commit
c6b9e5d0c6
@ -422,6 +422,7 @@ pub fn spawn_mesh_listener(
|
|||||||
lora_region: Option<String>,
|
lora_region: Option<String>,
|
||||||
channel_name: Option<String>,
|
channel_name: Option<String>,
|
||||||
device_kind: Option<super::types::DeviceType>,
|
device_kind: Option<super::types::DeviceType>,
|
||||||
|
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||||
) -> tokio::task::JoinHandle<()> {
|
) -> tokio::task::JoinHandle<()> {
|
||||||
@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
|
|||||||
lora_region.as_deref(),
|
lora_region.as_deref(),
|
||||||
channel_name.as_deref(),
|
channel_name.as_deref(),
|
||||||
device_kind,
|
device_kind,
|
||||||
|
reticulum_tcp.clone(),
|
||||||
&mut shutdown,
|
&mut shutdown,
|
||||||
&mut cmd_rx,
|
&mut cmd_rx,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -261,7 +261,7 @@ impl MeshRadioDevice {
|
|||||||
/// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only
|
/// `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
|
/// that one device's probe runs, so a non-matching firmware's init bytes are
|
||||||
/// never injected into the port. `None` keeps the strict
|
/// 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(
|
async fn auto_detect_and_open(
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: &str,
|
our_ed_pubkey_hex: &str,
|
||||||
@ -274,6 +274,34 @@ async fn auto_detect_and_open(
|
|||||||
}
|
}
|
||||||
for path in &paths {
|
for path in &paths {
|
||||||
debug!(path = %path, "Probing for mesh radio device");
|
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) {
|
if device_kind.is_none_or(|k| k == DeviceType::Meshcore) {
|
||||||
match MeshcoreDevice::open(path).await {
|
match MeshcoreDevice::open(path).await {
|
||||||
Ok(mut dev) => match dev.initialize().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"),
|
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!(
|
anyhow::bail!(
|
||||||
"No supported mesh radio found on {} candidate ports: {:?}",
|
"No supported mesh radio found on {} candidate ports: {:?}",
|
||||||
@ -381,20 +385,10 @@ async fn open_preferred_path(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
match MeshcoreDevice::open(path).await {
|
// Reticulum first — see the matching comment on auto_detect_and_open:
|
||||||
Ok(mut dev) => match dev.initialize().await {
|
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while
|
||||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
// trying Meshcore/Meshtastic first was observed leaving a real RNode
|
||||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
|
// board unresponsive by the time Reticulum's turn came.
|
||||||
},
|
|
||||||
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"),
|
|
||||||
}
|
|
||||||
match ReticulumLink::open(
|
match ReticulumLink::open(
|
||||||
path,
|
path,
|
||||||
data_dir,
|
data_dir,
|
||||||
@ -404,11 +398,69 @@ async fn open_preferred_path(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mut dev) => match dev.initialize().await {
|
Ok(mut dev) => match dev.initialize().await {
|
||||||
Ok(info) => Ok((MeshRadioDevice::Reticulum(dev), info)),
|
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
|
||||||
Err(e) => Err(e).context("Preferred path is not a working Reticulum RNode"),
|
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:
|
/// ASCII marker for the original DM-via-channel format:
|
||||||
@ -627,11 +679,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
|||||||
advert_name: contact.advert_name.clone(),
|
advert_name: contact.advert_name.clone(),
|
||||||
did: existing.and_then(|p| p.did.clone()),
|
did: existing.and_then(|p| p.did.clone()),
|
||||||
pubkey_hex: Some(contact.public_key_hex.clone()),
|
pubkey_hex: Some(contact.public_key_hex.clone()),
|
||||||
// Preserve any archipelago identity bound by an earlier
|
// Reticulum carries the archipelago identity in-band with
|
||||||
// identity advert — NEVER overwrite it with the firmware
|
// the contact snapshot itself (see `ParsedContact::
|
||||||
// contact key, or a signed `!ai` query from this peer would
|
// arch_pubkey_hex`'s doc comment) — prefer it when this
|
||||||
// fail authentication after the next contact refresh.
|
// refresh's snapshot has one. Otherwise preserve whatever
|
||||||
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
|
// 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),
|
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
|
||||||
// Meshtastic-only today (see ParsedContact) — falls back to
|
// Meshtastic-only today (see ParsedContact) — falls back to
|
||||||
// whatever was already known if this refresh's contact
|
// whatever was already known if this refresh's contact
|
||||||
@ -787,11 +847,17 @@ pub(super) async fn run_mesh_session(
|
|||||||
lora_region: Option<&str>,
|
lora_region: Option<&str>,
|
||||||
channel_name: Option<&str>,
|
channel_name: Option<&str>,
|
||||||
device_kind: Option<DeviceType>,
|
device_kind: Option<DeviceType>,
|
||||||
|
reticulum_tcp: Option<ReticulumTcpConfig>,
|
||||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Detect device — try preferred path first, fall back to auto-detect
|
// Detect device — TCP Reticulum config (radio-less) takes priority when
|
||||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
// 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(
|
match open_preferred_path(
|
||||||
path,
|
path,
|
||||||
data_dir,
|
data_dir,
|
||||||
|
|||||||
@ -180,6 +180,12 @@ impl MeshtasticDevice {
|
|||||||
"Failed to open serial port {} (permission denied? device busy?)",
|
"Failed to open serial port {} (permission denied? device busy?)",
|
||||||
path
|
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");
|
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@ -976,6 +982,7 @@ impl MeshtasticDevice {
|
|||||||
snr,
|
snr,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
|
arch_pubkey_hex: None,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1048,6 +1055,7 @@ fn packet_to_inbound_frame(
|
|||||||
snr: None,
|
snr: None,
|
||||||
lat: None,
|
lat: None,
|
||||||
lon: None,
|
lon: None,
|
||||||
|
arch_pubkey_hex: None,
|
||||||
});
|
});
|
||||||
if packet.rx_rssi.is_some() {
|
if packet.rx_rssi.is_some() {
|
||||||
contact.rssi = packet.rx_rssi.map(|v| v as i16);
|
contact.rssi = packet.rx_rssi.map(|v| v as i16);
|
||||||
|
|||||||
@ -392,6 +392,13 @@ pub struct MeshConfig {
|
|||||||
/// strict-probe auto-detect.
|
/// strict-probe auto-detect.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub device_kind: Option<types::DeviceType>,
|
pub device_kind: Option<types::DeviceType>,
|
||||||
|
/// 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<types::ReticulumTcpConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_assistant_backend() -> String {
|
fn default_assistant_backend() -> String {
|
||||||
@ -422,6 +429,7 @@ impl Default for MeshConfig {
|
|||||||
assistant_backend: default_assistant_backend(),
|
assistant_backend: default_assistant_backend(),
|
||||||
assistant_allowed_contacts: Vec::new(),
|
assistant_allowed_contacts: Vec::new(),
|
||||||
device_kind: None,
|
device_kind: None,
|
||||||
|
reticulum_tcp: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -704,6 +712,7 @@ impl MeshService {
|
|||||||
self.config.lora_region.clone(),
|
self.config.lora_region.clone(),
|
||||||
self.config.channel_name.clone(),
|
self.config.channel_name.clone(),
|
||||||
self.config.device_kind,
|
self.config.device_kind,
|
||||||
|
self.config.reticulum_tcp.clone(),
|
||||||
shutdown_rx,
|
shutdown_rx,
|
||||||
cmd_rx,
|
cmd_rx,
|
||||||
);
|
);
|
||||||
@ -2223,4 +2232,133 @@ mod tests {
|
|||||||
assert!(loaded.enabled);
|
assert!(loaded.enabled);
|
||||||
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -406,6 +406,15 @@ pub struct ParsedContact {
|
|||||||
/// contact has shared one.
|
/// contact has shared one.
|
||||||
pub lat: Option<f64>,
|
pub lat: Option<f64>,
|
||||||
pub lon: Option<f64>,
|
pub lon: Option<f64>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse RESP_CONTACT (0x03) response.
|
/// Parse RESP_CONTACT (0x03) response.
|
||||||
@ -457,6 +466,7 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
|
|||||||
snr: None,
|
snr: None,
|
||||||
lat: None,
|
lat: None,
|
||||||
lon: None,
|
lon: None,
|
||||||
|
arch_pubkey_hex: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,14 @@ const KISS_CMD_PLATFORM: u8 = 0x48;
|
|||||||
const KISS_CMD_MCU: u8 = 0x49;
|
const KISS_CMD_MCU: u8 = 0x49;
|
||||||
|
|
||||||
const PROBE_BAUD: u32 = 115200;
|
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
|
/// Prefix marking an LXMF `content` string as base64 of a raw binary
|
||||||
/// typed-envelope payload rather than literal text. LXMF `content` travels
|
/// 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
|
/// packaging); during development it can point at the venv's interpreter
|
||||||
/// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging.
|
/// 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
|
/// `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
|
/// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the
|
||||||
/// SAME wire format meshcore/Meshtastic identity adverts use — so a
|
/// 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.
|
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
|
||||||
fn daemon_command(
|
fn daemon_command(
|
||||||
socket_path: &Path,
|
socket_path: &Path,
|
||||||
serial_port: &str,
|
iface: &ReticulumInterface<'_>,
|
||||||
identity_key: &Path,
|
identity_key: &Path,
|
||||||
archy_ed_pubkey_hex: Option<&str>,
|
archy_ed_pubkey_hex: Option<&str>,
|
||||||
archy_x25519_pubkey_hex: Option<&str>,
|
archy_x25519_pubkey_hex: Option<&str>,
|
||||||
@ -94,9 +139,20 @@ fn daemon_command(
|
|||||||
cmd.arg("--identity-key")
|
cmd.arg("--identity-key")
|
||||||
.arg(identity_key)
|
.arg(identity_key)
|
||||||
.arg("--socket")
|
.arg("--socket")
|
||||||
.arg(socket_path)
|
.arg(socket_path);
|
||||||
.arg("--serial-port")
|
match iface {
|
||||||
.arg(serial_port);
|
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) {
|
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
|
||||||
cmd.arg("--archy-ed-pubkey-hex")
|
cmd.arg("--archy-ed-pubkey-hex")
|
||||||
.arg(ed)
|
.arg(ed)
|
||||||
@ -120,9 +176,12 @@ fn daemon_command(
|
|||||||
struct ReticulumPeer {
|
struct ReticulumPeer {
|
||||||
dest_hash: [u8; 16],
|
dest_hash: [u8; 16],
|
||||||
display_name: String,
|
display_name: String,
|
||||||
/// Archy ed25519 identity hex, once carried in a verified announce
|
/// Archy ed25519 identity hex, once carried in this peer's own announce
|
||||||
/// app-data blob. Not yet wired (TODO, lands with the signed-announce
|
/// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm.
|
||||||
/// work) — present so `get_contacts` has a stable shape to extend into.
|
/// 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<String>,
|
arch_pubkey_hex: Option<String>,
|
||||||
reachable: bool,
|
reachable: bool,
|
||||||
}
|
}
|
||||||
@ -189,11 +248,62 @@ impl ReticulumLink {
|
|||||||
our_x25519_pubkey_hex: Option<&str>,
|
our_x25519_pubkey_hex: Option<&str>,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
probe_rnode(path).await.context("RNode KISS detect failed")?;
|
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<Self> {
|
||||||
|
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<Self> {
|
||||||
|
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(
|
async fn spawn(
|
||||||
path: &str,
|
iface: ReticulumInterface<'_>,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
our_ed_pubkey_hex: Option<&str>,
|
our_ed_pubkey_hex: Option<&str>,
|
||||||
our_x25519_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))
|
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
let label = iface.label();
|
||||||
let socket_path = runtime_dir.join(format!(
|
let socket_path = runtime_dir.join(format!(
|
||||||
"{}.sock",
|
"{}.sock",
|
||||||
path.replace(['/', ' '], "_")
|
label.replace(['/', ' ', ':', ','], "_")
|
||||||
));
|
));
|
||||||
if socket_path.exists() {
|
if socket_path.exists() {
|
||||||
let _ = std::fs::remove_file(&socket_path);
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
@ -229,7 +340,7 @@ impl ReticulumLink {
|
|||||||
|
|
||||||
let mut cmd = daemon_command(
|
let mut cmd = daemon_command(
|
||||||
&socket_path,
|
&socket_path,
|
||||||
path,
|
&iface,
|
||||||
&identity_key,
|
&identity_key,
|
||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
our_x25519_pubkey_hex,
|
our_x25519_pubkey_hex,
|
||||||
@ -274,13 +385,13 @@ impl ReticulumLink {
|
|||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
path = %path,
|
iface = %label,
|
||||||
dest_hash = %dest_hash_hex,
|
dest_hash = %dest_hash_hex,
|
||||||
"Reticulum daemon ready"
|
"Reticulum daemon ready"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut link = Self {
|
let mut link = Self {
|
||||||
device_path: path.to_string(),
|
device_path: label,
|
||||||
socket_path,
|
socket_path,
|
||||||
child,
|
child,
|
||||||
writer: write_half,
|
writer: write_half,
|
||||||
@ -544,6 +655,7 @@ impl ReticulumLink {
|
|||||||
snr: None,
|
snr: None,
|
||||||
lat: None,
|
lat: None,
|
||||||
lon: None,
|
lon: None,
|
||||||
|
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@ -622,17 +734,21 @@ impl ReticulumLink {
|
|||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
// If the announce app_data is an ARCHY:n: identity blob (see
|
// If the announce app_data is an ARCHY:n: identity blob (see
|
||||||
// daemon_command's doc comment), bind it onto this peer AND
|
// daemon_command's doc comment), bind the ed25519 hex directly
|
||||||
// surface it through the SAME channel-text path
|
// onto this RNS-hash-keyed peer below (unambiguous — it came
|
||||||
// meshcore/Meshtastic identity adverts use
|
// 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
|
// (frames::handle_channel_payload -> parse_identity_broadcast
|
||||||
// -> handle_identity_received -> bind_federation_twins), so a
|
// -> handle_identity_received), so it also lands on that
|
||||||
// Reticulum-carried identity merges into the same conversation
|
// transport's federation-twin peer for UI/contact purposes.
|
||||||
// as that node's other-transport twins — zero new bind logic.
|
// `group_peer_twins` can then collapse the two rows since both
|
||||||
let is_identity_blob = app_data_text
|
// 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()
|
.as_deref()
|
||||||
.map(|t| protocol::parse_identity_broadcast(t).is_some())
|
.and_then(protocol::parse_identity_broadcast);
|
||||||
.unwrap_or(false);
|
let is_identity_blob = parsed_identity.is_some();
|
||||||
if is_identity_blob {
|
if is_identity_blob {
|
||||||
let text = app_data_text.clone().unwrap();
|
let text = app_data_text.clone().unwrap();
|
||||||
let mut data = Vec::with_capacity(7 + text.len());
|
let mut data = Vec::with_capacity(7 + text.len());
|
||||||
@ -645,6 +761,7 @@ impl ReticulumLink {
|
|||||||
bytes_consumed: 0,
|
bytes_consumed: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
||||||
|
|
||||||
let display_name = app_data_text
|
let display_name = app_data_text
|
||||||
.filter(|_| !is_identity_blob)
|
.filter(|_| !is_identity_blob)
|
||||||
@ -654,11 +771,14 @@ impl ReticulumLink {
|
|||||||
.and_modify(|p| {
|
.and_modify(|p| {
|
||||||
p.display_name = display_name.clone();
|
p.display_name = display_name.clone();
|
||||||
p.reachable = true;
|
p.reachable = true;
|
||||||
|
if arch_pubkey_hex.is_some() {
|
||||||
|
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.or_insert(ReticulumPeer {
|
.or_insert(ReticulumPeer {
|
||||||
dest_hash: hash,
|
dest_hash: hash,
|
||||||
display_name,
|
display_name,
|
||||||
arch_pubkey_hex: None,
|
arch_pubkey_hex,
|
||||||
reachable: true,
|
reachable: true,
|
||||||
});
|
});
|
||||||
self.persist_peers();
|
self.persist_peers();
|
||||||
@ -904,6 +1024,14 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
|
|||||||
async fn probe_rnode(path: &str) -> Result<()> {
|
async fn probe_rnode(path: &str) -> Result<()> {
|
||||||
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
|
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
|
||||||
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
|
.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] = [
|
let probe: [u8; 13] = [
|
||||||
KISS_FEND,
|
KISS_FEND,
|
||||||
KISS_CMD_DETECT,
|
KISS_CMD_DETECT,
|
||||||
|
|||||||
@ -57,6 +57,12 @@ impl MeshcoreDevice {
|
|||||||
"Failed to open serial port {} (permission denied? device busy?)",
|
"Failed to open serial port {} (permission denied? device busy?)",
|
||||||
path
|
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");
|
info!(path = %path, baud = BAUD_RATE, "Opened serial port");
|
||||||
|
|
||||||
|
|||||||
@ -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<String> },
|
||||||
|
}
|
||||||
|
|
||||||
/// The per-message transport pill label for a radio-delivered message: the
|
/// The per-message transport pill label for a radio-delivered message: the
|
||||||
/// active device's own name, since one session owns exactly one device.
|
/// active device's own name, since one session owns exactly one device.
|
||||||
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only
|
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only
|
||||||
|
|||||||
@ -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) |
|
| 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) |
|
| 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) |
|
| 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)
|
## 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
|
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.
|
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.
|
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<ReticulumTcpConfig>` (`#[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.
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build the PyInstaller single-binary for the OTA (plan Phase 1 packaging).
|
# Build the PyInstaller single-binaries for the OTA (plan Phase 1 packaging).
|
||||||
# Output: dist/archy-reticulum-daemon — drop next to /usr/local/bin/archipelago.
|
# Outputs: dist/archy-reticulum-daemon, dist/archy-rnodeconf — drop both next
|
||||||
|
# to /usr/local/bin/archipelago.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
@ -9,7 +10,7 @@ if [ ! -d .venv ]; then
|
|||||||
fi
|
fi
|
||||||
.venv/bin/pip install -q -r requirements.txt -r requirements-build.txt
|
.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
|
# --collect-submodules: RNS/LXMF load most of their own internals dynamically
|
||||||
# (interface drivers, transport backends), which PyInstaller's static import
|
# (interface drivers, transport backends), which PyInstaller's static import
|
||||||
@ -30,3 +31,30 @@ rm -rf build dist archy-reticulum-daemon.spec
|
|||||||
reticulum_daemon.py
|
reticulum_daemon.py
|
||||||
|
|
||||||
echo "Built dist/archy-reticulum-daemon ($(du -h dist/archy-reticulum-daemon | cut -f1))"
|
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
|
||||||
|
|||||||
18
reticulum-daemon/pyi_rthook_exit_builtins.py
Normal file
18
reticulum-daemon/pyi_rthook_exit_builtins.py
Normal file
@ -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
|
||||||
@ -59,9 +59,33 @@ from archy_rns_identity import lxmf_destination_hash, load_identity
|
|||||||
|
|
||||||
# ─────────────────────────── RNS config generation ───────────────────────────
|
# ─────────────────────────── RNS config generation ───────────────────────────
|
||||||
|
|
||||||
def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, no_radio: bool) -> None:
|
def _require_loopback(host: str) -> None:
|
||||||
"""Materialise an RNS config file. RNode interface for real radios; a loopback-
|
"""Bind-scope guard: TCP server mode is dev/verification only for now. WAN/LAN
|
||||||
only config for --selftest so the stack comes up without hardware or network."""
|
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)
|
configdir.mkdir(parents=True, exist_ok=True)
|
||||||
cfg = configdir / "config"
|
cfg = configdir / "config"
|
||||||
if no_radio:
|
if no_radio:
|
||||||
@ -70,7 +94,7 @@ def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, n
|
|||||||
" type = AutoInterface\n"
|
" type = AutoInterface\n"
|
||||||
" enabled = no\n"
|
" enabled = no\n"
|
||||||
)
|
)
|
||||||
else:
|
elif serial_port:
|
||||||
interfaces = (
|
interfaces = (
|
||||||
" [[RNode LoRa]]\n"
|
" [[RNode LoRa]]\n"
|
||||||
" type = RNodeInterface\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" spreadingfactor = {lora['spreadingfactor']}\n"
|
||||||
f" codingrate = {lora['codingrate']}\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(
|
cfg.write_text(
|
||||||
"[reticulum]\n"
|
"[reticulum]\n"
|
||||||
" enable_transport = no\n"
|
" enable_transport = no\n"
|
||||||
@ -137,6 +188,8 @@ class ReticulumDaemon:
|
|||||||
"codingrate": self.args.codingrate,
|
"codingrate": self.args.codingrate,
|
||||||
},
|
},
|
||||||
no_radio=self.args.no_radio,
|
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.reticulum = RNS.Reticulum(configdir=str(configdir))
|
||||||
self.identity = load_identity(self.seed)
|
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("--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("--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("--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("--display-name", default="Archy", help="LXMF display name")
|
||||||
p.add_argument("--archy-ed-pubkey-hex", default=None,
|
p.add_argument("--archy-ed-pubkey-hex", default=None,
|
||||||
help="Archy ed25519 pubkey hex (64 chars) — embedded in the announce "
|
help="Archy ed25519 pubkey hex (64 chars) — embedded in the announce "
|
||||||
@ -530,6 +588,12 @@ def main(argv=None) -> int:
|
|||||||
_install_parent_death_signal()
|
_install_parent_death_signal()
|
||||||
args = _parse_args(argv if argv is not None else sys.argv[1:])
|
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:
|
if args.check:
|
||||||
seed = ReticulumDaemon._read_seed(Path(args.identity_key))
|
seed = ReticulumDaemon._read_seed(Path(args.identity_key))
|
||||||
print(lxmf_destination_hash(seed).hex())
|
print(lxmf_destination_hash(seed).hex())
|
||||||
|
|||||||
@ -257,6 +257,13 @@ ssh $SSH_OPTS "$TARGET_HOST" '
|
|||||||
NEED_INSTALL=""
|
NEED_INSTALL=""
|
||||||
command -v rsync >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL rsync"
|
command -v rsync >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL rsync"
|
||||||
command -v python3 >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL python3"
|
command -v python3 >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL python3"
|
||||||
|
# python3 -m venv exists but cannot bootstrap pip without the matching
|
||||||
|
# <major>.<minor>-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
|
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
|
||||||
echo " Node.js/npm not found — installing..."
|
echo " Node.js/npm not found — installing..."
|
||||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - 2>&1 | tail -3
|
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"
|
echo " ⚠️ Rust not installed on target, skipping backend build"
|
||||||
fi
|
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
|
if [ "$LIVE" = true ]; then
|
||||||
|
|
||||||
# Create rollback backup before deploying
|
# 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/"
|
ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
|
||||||
fi
|
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)
|
# Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build)
|
||||||
progress "Deploying frontend"
|
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 {} +"
|
ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user