diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index f74811ef..2739ff1c 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -373,6 +373,42 @@ async fn run_runtime_assets() -> Result { } } + // Packaged radio tools (v1.7.118+): OTA updates only apply the backend + // binary and frontend tarball, so these PyInstaller binaries ride the + // runtime payload and get promoted here. Without this, fleet nodes keep + // a stale archy-reticulum-daemon (which exits on flags it doesn't know — + // the v1.7.117 --enable-transport rollout killed their mesh sessions) + // and never receive archy-rnodeconf at all (Flash LoRa: "No such file + // or directory"). Skipped when byte-identical; a running daemon is + // unaffected (install replaces the inode) and picks the new binary up + // on its next spawn. + for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] { + let src = runtime_dir.join("radio-tools").join(tool); + if !src.exists() { + continue; + } + let dest = format!("/usr/local/bin/{}", tool); + let same = match (fs::read(&src).await, fs::read(&dest).await) { + (Ok(a), Ok(b)) => a == b, + _ => false, + }; + if same { + continue; + } + let src_s = src.to_string_lossy().to_string(); + let status = host_sudo(&["install", "-m", "755", &src_s, &dest]) + .await + .with_context(|| format!("install {}", tool))?; + if !status.success() { + anyhow::bail!("install {} exited with {}", tool, status); + } + info!( + tool, + "Promoted packaged radio tool from OTA runtime payload" + ); + changed = true; + } + if changed { let _ = host_sudo(&["systemctl", "daemon-reload"]).await; if nginx_src.exists() { diff --git a/core/archipelago/src/mesh/flash.rs b/core/archipelago/src/mesh/flash.rs index 7928c07f..8b8ade67 100644 --- a/core/archipelago/src/mesh/flash.rs +++ b/core/archipelago/src/mesh/flash.rs @@ -823,6 +823,18 @@ fn rnodeconf_bin() -> String { .unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string()) } +/// True when `name` resolves to an executable on PATH. +fn which_on_path(name: &str) -> bool { + std::env::var_os("PATH") + .map(|paths| { + std::env::split_paths(&paths).any(|dir| { + let candidate = dir.join(name); + candidate.is_file() + }) + }) + .unwrap_or(false) +} + /// `--autoinstall`'s "which board is this" step is interactive by design — /// confirmed live against a real Heltec V4 (2026-07-23): even with a board /// given on the command line, rnodeconf can't always tell V3 from V4 apart @@ -877,9 +889,17 @@ async fn rnodeconf_autoinstall( let bin = rnodeconf_bin(); let mut cmd = if Path::new(&bin).exists() { Command::new(bin) - } else { + } else if which_on_path("rnodeconf") { // Dev fallback if only a plain venv/system rnodeconf is on PATH. Command::new("rnodeconf") + } else { + // Older ISOs/OTAs never shipped the tool — say so instead of the + // bare "No such file or directory" the spawn would produce. + anyhow::bail!( + "{bin} is not installed on this node — RNode flashing needs the packaged \ + archy-rnodeconf tool, which ships with the v1.7.118+ update (or can be \ + sideloaded from a dev box). Update the node, then retry." + ); }; cmd.args(["--autoinstall", path]); let stdin = format!( diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index 5f041803..6ce97325 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -115,6 +115,59 @@ fn is_loopback_host(host: &str) -> bool { /// Reticulum-carried identity binds onto the existing Archy contact via the /// existing `parse_identity_broadcast`/`handle_identity_received` path, /// satisfying cross-protocol DM convergence with zero new Rust dispatch code. +/// Resolve the daemon invocation: packaged binary, else the dev venv script. +/// `(program, Some(script_arg))` for the venv fallback. +fn daemon_program() -> (String, Option) { + let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN") + .unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string()); + if Path::new(&bin).exists() { + return (bin, None); + } + let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY") + .unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string()); + let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT") + .unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string()); + (py, Some(script)) +} + +/// Whether the installed daemon understands `--enable-transport`. OTA updates +/// ship the archipelago binary ahead of the packaged daemon tools, so a fleet +/// node can run a new binary against an old daemon — whose argparse EXITS on +/// an unknown flag, killing the mesh session on every spawn (live regression, +/// framework-pt on v1.7.117). Probe `--help` and only pass the flag when the +/// daemon advertises it; an old daemon then runs edge-only exactly as before. +async fn daemon_supports_enable_transport() -> bool { + let (program, script) = daemon_program(); + let mut cmd = Command::new(&program); + if let Some(script) = &script { + cmd.arg(script); + } + cmd.arg("--help") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + let out = tokio::time::timeout(Duration::from_secs(10), async { cmd.output().await }).await; + match out { + Ok(Ok(out)) => { + let supported = String::from_utf8_lossy(&out.stdout).contains("--enable-transport"); + if !supported { + warn!( + program, + "installed reticulum-daemon predates --enable-transport — running edge-only until the daemon tools update" + ); + } + supported + } + _ => { + warn!( + program, + "reticulum-daemon --help probe failed — not passing --enable-transport" + ); + false + } + } +} + fn daemon_command( socket_path: &Path, iface: &ReticulumInterface<'_>, @@ -122,21 +175,13 @@ fn daemon_command( archy_ed_pubkey_hex: Option<&str>, archy_x25519_pubkey_hex: Option<&str>, display_name: Option<&str>, + enable_transport: bool, ) -> Command { - let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN") - .unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string()); - let mut cmd = if Path::new(&bin).exists() { - Command::new(bin) - } else { - // Dev fallback: run the script through its venv interpreter. - let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY") - .unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string()); - let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT") - .unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string()); - let mut c = Command::new(py); - c.arg(script); - c - }; + let (program, script) = daemon_program(); + let mut cmd = Command::new(program); + if let Some(script) = script { + cmd.arg(script); + } cmd.arg("--identity-key") .arg(identity_key) .arg("--socket") @@ -158,8 +203,11 @@ fn daemon_command( // announces, so archy nodes (and Sideband/NomadNet peers) beyond direct RF // range discover and reach each other through any archy node in between. // Edge-only operation (`enable_transport = no`) left every node an island - // limited to its own radio horizon. - cmd.arg("--enable-transport"); + // limited to its own radio horizon. Gated on the installed daemon actually + // supporting the flag — see `daemon_supports_enable_transport`. + if enable_transport { + cmd.arg("--enable-transport"); + } if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) { cmd.arg("--archy-ed-pubkey-hex") .arg(ed) @@ -405,6 +453,7 @@ impl ReticulumLink { ); } + let enable_transport = daemon_supports_enable_transport().await; let mut cmd = daemon_command( &socket_path, &iface, @@ -412,6 +461,7 @@ impl ReticulumLink { our_ed_pubkey_hex, our_x25519_pubkey_hex, display_name, + enable_transport, ); cmd.env("TMPDIR", &tmp_dir); let child = cmd @@ -1500,6 +1550,45 @@ mod tests { assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1); } + // One test covers both directions to avoid two tests racing on the same + // process-global env var under the parallel test runner. + #[tokio::test] + async fn transport_flag_gated_on_daemon_support() { + let dir = std::env::temp_dir().join(format!("archy-daemon-stub-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let old = dir.join("old-daemon"); + std::fs::write(&old, "#!/bin/sh\necho 'usage: --serial-port --no-radio'\n").unwrap(); + let new = dir.join("new-daemon"); + std::fs::write( + &new, + "#!/bin/sh\necho 'usage: --serial-port --enable-transport --no-radio'\n", + ) + .unwrap(); + for p in [&old, &new] { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + // An old daemon whose --help doesn't advertise the flag: never pass it + // (its argparse would exit and kill the mesh session — the v1.7.117 + // fleet regression this guards against). + std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &old); + assert!(!daemon_supports_enable_transport().await); + + std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", &new); + assert!(daemon_supports_enable_transport().await); + + // A missing binary must also fail safe (probe error → no flag). + std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", dir.join("missing")); + // (falls through to the venv dev path, which doesn't exist in the + // test environment either → probe fails → false) + assert!(!daemon_supports_enable_transport().await); + + std::env::remove_var("ARCHY_RETICULUM_DAEMON_BIN"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn synthetic_frame_matches_meshtastic_layout() { let prefix = [1, 2, 3, 4, 5, 6]; diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index ba5e321a..59d89f52 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -1184,6 +1184,19 @@ else echo " ⚠️ archy-reticulum-daemon not found at $RETICULUM_DAEMON — ISO nodes won't support RNode radios until it's sideloaded" fi +# archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware +# (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node +# imaged without it fails every RNode flash with "No such file or directory" +# (framework-pt, 2026-07-29, v1.7.117). +RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}" +if [ -f "$RNODECONF" ]; then + cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf" + chmod +x "$ARCH_DIR/bin/archy-rnodeconf" + echo " ✅ rnodeconf bundled ($(du -h "$ARCH_DIR/bin/archy-rnodeconf" | cut -f1))" +else + echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded" +fi + if [ "$BACKEND_CAPTURED" = "0" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then echo " ⚠️ Could not capture from live server, building from source..." diff --git a/scripts/create-release-manifest.sh b/scripts/create-release-manifest.sh index ed0a62b6..309286d9 100755 --- a/scripts/create-release-manifest.sh +++ b/scripts/create-release-manifest.sh @@ -115,6 +115,23 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then cp "$PROJECT_ROOT/image-recipe/configs/nginx-archipelago.conf" \ "$RUNTIME_DIR/image-recipe/configs/nginx-archipelago.conf" fi + # Packaged radio tools ride the runtime payload: OTA-only nodes never + # get them any other way — the v1.7.117 rollout left fleet nodes with + # a stale archy-reticulum-daemon (exits on the new --enable-transport + # flag → mesh dead) and no archy-rnodeconf at all (Flash LoRa fails + # with "No such file or directory"). bootstrap.rs promotes these to + # /usr/local/bin on first startup after the update. + for tool in archy-reticulum-daemon archy-rnodeconf; do + if [ -f "$PROJECT_ROOT/reticulum-daemon/dist/$tool" ]; then + mkdir -p "$RUNTIME_DIR/radio-tools" + echo " Including radio tool $tool" + cp "$PROJECT_ROOT/reticulum-daemon/dist/$tool" "$RUNTIME_DIR/radio-tools/$tool" + else + echo " ERROR: reticulum-daemon/dist/$tool missing — run reticulum-daemon/build.sh first" >&2 + rm -rf "$STAGING_DIR" + exit 1 + fi + done rm -rf "$RUNTIME_DIR/scripts/resilience/reports" find "$RUNTIME_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} + find "$RUNTIME_DIR" -type f \( -name '*.bak' -o -name '*.bak-*' -o -name '._*' -o -name '*.log' -o -name '*.pyc' \) -delete diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 3835033d..fedd5c3c 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -167,6 +167,16 @@ if ! grep -rqo "${VERSION}" "$PROJECT_ROOT"/web/dist/neode-ui/assets/*.js; then exit 1 fi +echo "[4b/8] Building packaged radio tools (archy-reticulum-daemon, archy-rnodeconf)..." +# These ride the frontend tarball's runtime payload (radio-tools/) and are +# promoted to /usr/local/bin by bootstrap.rs — the ONLY path that updates them +# on OTA-only nodes. Stale-dist releases re-broke fleet mesh once (v1.7.117), +# so always rebuild here; the manifest script hard-fails if they're missing. +(cd "$PROJECT_ROOT/reticulum-daemon" && ./build.sh) || { + echo "Error: reticulum-daemon/build.sh failed — radio tools are release-critical" >&2 + exit 1 +} + echo "[5/8] Validating curated changelog..." CHANGELOG_FILE="$PROJECT_ROOT/CHANGELOG.md"