fix(mesh): radio tools ship via OTA + ISO; transport flag gated on daemon support

v1.7.117 broke Reticulum mesh on OTA-only fleet nodes two ways: the
update swaps only the backend binary and frontend tarball, so nodes
kept a stale archy-reticulum-daemon whose argparse exits on the new
--enable-transport flag (mesh session died on every spawn — confirmed
on framework-pt), and they never had archy-rnodeconf at all, so the
in-app Flash LoRa flow failed with a bare "No such file or directory".

Four-part fix:
- The Rust supervisor probes `daemon --help` and only passes
  --enable-transport when the daemon advertises it; unsupported daemons
  run edge-only exactly as pre-1.7.117 (tested against stub daemons
  both ways + missing-binary fail-safe).
- Both PyInstaller tools ride the frontend tarball's runtime payload
  (radio-tools/) and bootstrap.rs promotes them to /usr/local/bin on
  startup when bytes differ — the first OTA path that ever updates
  them. create-release.sh now rebuilds them every release and the
  manifest script hard-fails if they're missing.
- The ISO bundles archy-rnodeconf alongside the daemon (it never did).
- Flash LoRa reports "tool not installed — update the node" instead of
  the bare spawn error when rnodeconf is absent everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 07:51:22 -04:00
parent 04c056acdb
commit 500aebb3e2
6 changed files with 202 additions and 17 deletions

View File

@ -373,6 +373,42 @@ async fn run_runtime_assets() -> Result<bool> {
}
}
// 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() {

View File

@ -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!(

View File

@ -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<String>) {
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];

View File

@ -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..."

View File

@ -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

View File

@ -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"