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:
parent
04c056acdb
commit
500aebb3e2
@ -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() {
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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..."
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user