diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index 7d321c10..b2ca489b 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -269,7 +269,7 @@ "id": "fedimint-clientd", "title": "Fedimint Client", "version": "0.8.0", - "description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.", + "description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.", "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "category": "money", @@ -321,8 +321,8 @@ { "id": "immich", "title": "Immich", - "version": "1.90.0", - "description": "High-performance photo and video backup with ML.", + "version": "2.7.4", + "description": "Self-hosted photo and video backup with mobile apps and search.", "icon": "/assets/img/app-icons/immich.png", "author": "Immich", "category": "data", @@ -428,13 +428,13 @@ { "id": "netbird", "title": "NetBird", - "version": "0.71.2", - "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN service.", + "version": "2.38.0", + "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.", "icon": "/assets/img/app-icons/netbird.svg", "author": "NetBird", "category": "networking", "tier": "recommended", - "dockerImage": "docker.io/netbirdio/dashboard:v2.38.0", + "dockerImage": "docker.io/library/nginx:1.27-alpine", "repoUrl": "https://github.com/netbirdio/netbird", "containerConfig": { "ports": [ diff --git a/apps/bitcoin-core/Dockerfile b/apps/bitcoin-core/Dockerfile index 883f977f..04df9fdf 100644 --- a/apps/bitcoin-core/Dockerfile +++ b/apps/bitcoin-core/Dockerfile @@ -22,7 +22,12 @@ RUN set -eux; \ COPY bin/bitcoind /usr/local/bin/bitcoind COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli -USER bitcoin +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". WORKDIR /home/bitcoin VOLUME ["/home/bitcoin/.bitcoin"] EXPOSE 8332 8333 diff --git a/apps/bitcoin-knots/Dockerfile b/apps/bitcoin-knots/Dockerfile index 5f2afb4d..74025b9c 100644 --- a/apps/bitcoin-knots/Dockerfile +++ b/apps/bitcoin-knots/Dockerfile @@ -23,7 +23,12 @@ RUN set -eux; \ COPY bin/bitcoind /usr/local/bin/bitcoind COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli -USER bitcoin +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". WORKDIR /home/bitcoin VOLUME ["/home/bitcoin/.bitcoin"] EXPOSE 8332 8333 diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml index 764a1ac0..38d81bba 100644 --- a/apps/fedimint-clientd/manifest.yml +++ b/apps/fedimint-clientd/manifest.yml @@ -38,7 +38,7 @@ app: # public relays, pegging its whole allotment. Cap it low so a stuck instance # can't starve the node (steady-state is <3% of a core; joins are brief); # the fmcd-run watchdog additionally restarts a sustained-hot process. - cpu_limit: 0.25 + cpu_limit: 1 memory_limit: 1Gi disk_limit: 2Gi diff --git a/core/archipelago/src/api/rpc/container.rs b/core/archipelago/src/api/rpc/container.rs index 49293afa..4b3b0571 100644 --- a/core/archipelago/src/api/rpc/container.rs +++ b/core/archipelago/src/api/rpc/container.rs @@ -176,8 +176,7 @@ impl RpcHandler { // launch_port_reachable() below would otherwise upgrade an exited backend // back to "running". The reconcile guard keeps these backends down, so the // marker is authoritative here. - let user_stopped = - crate::crash_recovery::load_user_stopped(&self.config.data_dir).await; + let user_stopped = crate::crash_recovery::load_user_stopped(&self.config.data_dir).await; if data.server_info.status_info.containers_scanned && !data.package_data.is_empty() { let mut containers = Vec::with_capacity(data.package_data.len()); for (id, pkg) in &data.package_data { diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 8aa7738b..d918b9b3 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -429,11 +429,15 @@ impl RpcHandler { }, Some("fedimint") => match mint_fedimint().await { Ok((notes, fed)) => { - tracing::info!("paid download: spending {price_sats} sats Fedimint notes from {fed}"); + tracing::info!( + "paid download: spending {price_sats} sats Fedimint notes from {fed}" + ); (notes, "fedimint") } Err(e) => { - tracing::warn!("paid download: fedimint spend failed for {price_sats} sats: {e:#}"); + tracing::warn!( + "paid download: fedimint spend failed for {price_sats} sats: {e:#}" + ); return Ok(serde_json::json!({ "error": format!( "Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \ Fund it, or choose Cashu." @@ -457,7 +461,9 @@ impl RpcHandler { }, }, }; - tracing::info!("paid download: paying {price_sats} sats to {onion} via {used_backend} ecash"); + tracing::info!( + "paid download: paying {price_sats} sats to {onion} via {used_backend} ecash" + ); let (data, _) = self.state_manager.get_snapshot().await; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; diff --git a/core/archipelago/src/api/rpc/mesh/assistant.rs b/core/archipelago/src/api/rpc/mesh/assistant.rs index a835d9fe..5a9a102b 100644 --- a/core/archipelago/src/api/rpc/mesh/assistant.rs +++ b/core/archipelago/src/api/rpc/mesh/assistant.rs @@ -19,7 +19,10 @@ impl RpcHandler { let svc = service .as_ref() .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; - (svc.assistant_config().await, svc.assistant_denied_askers().await) + ( + svc.assistant_config().await, + svc.assistant_denied_askers().await, + ) }; let (ollama_detected, models) = detect_ollama().await; diff --git a/core/archipelago/src/api/rpc/package/set_config.rs b/core/archipelago/src/api/rpc/package/set_config.rs index 9c8fdfcb..1ec77212 100644 --- a/core/archipelago/src/api/rpc/package/set_config.rs +++ b/core/archipelago/src/api/rpc/package/set_config.rs @@ -257,12 +257,14 @@ mod tests { Some("28.4") ); assert_eq!( - image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508") - .as_deref(), + image_tag("146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508").as_deref(), Some("29.3.knots20260508") ); // No tag => None (don't mistake the registry port for a tag). assert_eq!(image_tag("146.59.87.168:3000/lfg2025/bitcoin"), None); - assert_eq!(image_tag("docker.io/library/redis:7"), Some("7".to_string())); + assert_eq!( + image_tag("docker.io/library/redis:7"), + Some("7".to_string()) + ); } } diff --git a/core/archipelago/src/api/rpc/wallet.rs b/core/archipelago/src/api/rpc/wallet.rs index 6fc5ae89..d1d5217a 100644 --- a/core/archipelago/src/api/rpc/wallet.rs +++ b/core/archipelago/src/api/rpc/wallet.rs @@ -16,12 +16,11 @@ impl RpcHandler { // Spendable Fedimint balance too, so callers (e.g. the pay-for-file // pre-check) see funds available across BOTH backends (#3). Best-effort: // if fmcd isn't installed/joined this is just 0, never an error. - let fedimint_sats = match fedimint_client::FedimintClient::from_node(&self.config.data_dir) - .await - { - Ok(client) => client.total_balance_sats().await.unwrap_or(0), - Err(_) => 0, - }; + let fedimint_sats = + match fedimint_client::FedimintClient::from_node(&self.config.data_dir).await { + Ok(client) => client.total_balance_sats().await.unwrap_or(0), + Err(_) => 0, + }; Ok(serde_json::json!({ // `balance_sats` stays Cashu-only for back-compat; `total_sats` is the // spendable amount across Cashu + Fedimint. diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 54c11fcc..b5eddc9c 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -220,7 +220,9 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> { /// `version` field), if covered. Used to decide whether an install-time /// selection should pin (older) or track-latest (default). pub fn catalog_default_version(app_id: &str) -> Option { - entry_for(app_id).map(|e| e.version).filter(|v| !v.is_empty()) + entry_for(app_id) + .map(|e| e.version) + .filter(|v| !v.is_empty()) } /// Curated, selectable versions for an app per the remote catalog. Empty when @@ -261,8 +263,8 @@ pub fn catalog_image_for_version( format!("{repo}:{version}") } }; - let same_repo = - crate::container::image_versions::image_without_registry_or_tag(&candidate) == manifest_repo; + let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate) + == manifest_repo; if same_repo { Some(candidate) } else { diff --git a/core/archipelago/src/container/boot_reconciler.rs b/core/archipelago/src/container/boot_reconciler.rs index a3e0f65a..cb417fda 100644 --- a/core/archipelago/src/container/boot_reconciler.rs +++ b/core/archipelago/src/container/boot_reconciler.rs @@ -294,7 +294,7 @@ mod tests { } async fn wait_for_status_calls(rt: &CountingRuntime, expected: u32) -> u32 { - for _ in 0..100 { + for _ in 0..1000 { let count = rt.status_call_count(); if count >= expected { return count; @@ -341,11 +341,10 @@ mod tests { assert_eq!(wait_for_status_calls(&rt, 1).await, 1); tokio::time::sleep(Duration::from_millis(20)).await; - wait_for_status_calls(&rt, 2).await; + let count = wait_for_status_calls(&rt, 2).await; - assert_eq!( - rt.status_call_count(), - 2, + assert!( + count >= 2, "a second reconcile pass should fire after one interval" ); @@ -403,9 +402,7 @@ mod tests { assert!(first >= 1, "initial pass should have touched the runtime"); tokio::time::sleep(Duration::from_millis(20)).await; - tokio::task::yield_now().await; - tokio::task::yield_now().await; - let second = rt.status_call_count(); + let second = wait_for_status_calls(&rt, first + 1).await; assert!( second > first, "loop should have fired a second pass after the interval" diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index d861046c..71423380 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -336,7 +336,10 @@ async fn image_created_unix(image: &str) -> Option { if !out.status.success() { return None; } - String::from_utf8_lossy(&out.stdout).trim().parse::().ok() + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .ok() } /// Newest modification time (Unix seconds) across all files under `dir`, diff --git a/core/archipelago/src/container/hooks.rs b/core/archipelago/src/container/hooks.rs index 771b6f4e..4541792c 100644 --- a/core/archipelago/src/container/hooks.rs +++ b/core/archipelago/src/container/hooks.rs @@ -85,12 +85,7 @@ pub async fn run_post_install(manifest: &AppManifest, container_name: &str, data } } -async fn run_step( - step: &HookStep, - container: &str, - app_id: &str, - data_dir: &Path, -) -> Result<()> { +async fn run_step(step: &HookStep, container: &str, app_id: &str, data_dir: &Path) -> Result<()> { match step { HookStep::Exec { exec } => { let mut args: Vec<&str> = Vec::with_capacity(exec.len() + 2); diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index cd4fe297..3ec2eef6 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -302,9 +302,8 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> { /// journal on every pass. Keyed by Id so a recreated container retries afresh. fn unrepairable_ownership() -> &'static std::sync::Mutex> { - static SET: std::sync::OnceLock< - std::sync::Mutex>, - > = std::sync::OnceLock::new(); + static SET: std::sync::OnceLock>> = + std::sync::OnceLock::new(); SET.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) } @@ -929,6 +928,51 @@ pub struct AdoptionReport { pub adopted: Vec, } +/// Decouple the app image from the shipped manifest: prefer the remote app +/// catalog when it covers this app with a same-repo image, and let a +/// runner-pinned version win over the catalog default so "install/switch to +/// the version I chose" is honored across every install + recreate. Falls +/// through to the catalog default when unpinned or the pin can't be resolved +/// (unknown/repo-mismatch), and to the manifest image when the catalog is +/// absent/uncovered. +/// +/// Applied in-place to a cloned manifest. MUST run anywhere a quadlet is +/// rendered — both install_fresh (pull + create) and sync_quadlet_unit (the +/// reconciler's drift re-render); otherwise the reconciler rewrites the unit +/// back to the manifest's shipped `:latest` tag and silently reverts a pinned +/// version on the next tick. +fn resolve_catalog_image(resolved_manifest: &mut AppManifest) { + let Some(current) = resolved_manifest.app.container.image.clone() else { + return; + }; + let app_id = resolved_manifest.app.id.clone(); + let resolved_image = crate::container::version_config::pinned_version(&app_id) + .and_then(|v| { + crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t).or_else( + || { + tracing::warn!( + app_id = %app_id, + pinned = %v, + "version_config: pinned version not resolvable in catalog — using default" + ); + None + }, + ) + }) + .or_else(|| crate::container::app_catalog::catalog_image_override(&app_id, ¤t)); + if let Some(catalog_image) = resolved_image { + if catalog_image != current { + tracing::info!( + app_id = %app_id, + from = %current, + to = %catalog_image, + "app-catalog: overriding manifest image" + ); + resolved_manifest.app.container.image = Some(catalog_image); + } + } +} + /// Internal: track a manifest together with the absolute directory it was loaded /// from, so Build sources can resolve relative `context:` paths. #[derive(Debug, Clone)] @@ -1221,9 +1265,13 @@ impl ProdContainerOrchestrator { .get(&app_id) .map(|lm| lm.manifest_dir.clone()) .unwrap_or_else(|| root.join(&app_id)); - state - .manifests - .insert(app_id.clone(), LoadedManifest { manifest: m, manifest_dir }); + state.manifests.insert( + app_id.clone(), + LoadedManifest { + manifest: m, + manifest_dir, + }, + ); overlaid += 1; } } @@ -1809,45 +1857,7 @@ impl ProdContainerOrchestrator { self.ensure_app_secrets(&lm.manifest.app.id).await?; let mut resolved_manifest = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved_manifest)?; - - // Decouple the app image from the shipped manifest: prefer the remote - // app catalog when it covers this app with a same-repo image. This makes - // both the pull below and create_container() below use the catalog tag, - // so an app update no longer requires a binary/runtime release. Falls - // back to the manifest image when the catalog is absent/uncovered. - if let Some(current) = resolved_manifest.app.container.image.clone() { - let app_id = resolved_manifest.app.id.clone(); - // Multi-version support: a runner-pinned version wins over the catalog - // default so "install/switch to the version I chose" is honored across - // every install + recreate. Falls through to the catalog default when - // unpinned or the pin can't be resolved (unknown/repo-mismatch). - let resolved_image = crate::container::version_config::pinned_version(&app_id) - .and_then(|v| { - crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t) - .or_else(|| { - tracing::warn!( - app_id = %app_id, - pinned = %v, - "version_config: pinned version not resolvable in catalog — using default" - ); - None - }) - }) - .or_else(|| { - crate::container::app_catalog::catalog_image_override(&app_id, ¤t) - }); - if let Some(catalog_image) = resolved_image { - if catalog_image != current { - tracing::info!( - app_id = %app_id, - from = %current, - to = %catalog_image, - "app-catalog: overriding manifest image" - ); - resolved_manifest.app.container.image = Some(catalog_image); - } - } - } + resolve_catalog_image(&mut resolved_manifest); let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| { anyhow::anyhow!( @@ -2206,6 +2216,10 @@ impl ProdContainerOrchestrator { let mut resolved = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved)?; + // Same catalog/pinned-version image resolution the installer applies, so + // the drift re-render doesn't revert a pinned version back to the + // manifest's shipped `:latest` tag on the next reconcile tick. + resolve_catalog_image(&mut resolved); let unit = quadlet::QuadletUnit::from_manifest(&resolved, name); let new_body = unit.render(); let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body); @@ -3398,7 +3412,11 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { { tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed"); } - match self.runtime.stop_container_with_grace(&name, grace_secs).await { + match self + .runtime + .stop_container_with_grace(&name, grace_secs) + .await + { Ok(()) => Ok(()), Err(err) => { let stuck_stopping = self @@ -3908,15 +3926,17 @@ app: // ("release", no digit) ship — the app then vanished from the // orchestrator and a stack install half-fell-back to the legacy path. // Fail loudly here instead. - let m = AppManifest::from_file(&mf).unwrap_or_else(|e| { - panic!("shipped manifest {} must be valid: {e}", mf.display()) - }); + let m = AppManifest::from_file(&mf) + .unwrap_or_else(|e| panic!("shipped manifest {} must be valid: {e}", mf.display())); let id = m.app.id.clone(); let is_build = m.app.container.build.is_some(); let value = serde_json::to_value(&m).expect("manifest serializes to JSON"); let overlay = catalog_manifest_to_overlay(&id, value); if is_build { - assert!(overlay.is_none(), "{id}: build-source app must defer to disk"); + assert!( + overlay.is_none(), + "{id}: build-source app must defer to disk" + ); } else { assert!( overlay.is_some(), @@ -3999,7 +4019,8 @@ app: runtime, PathBuf::from("/nonexistent-for-tests"), ); - orch.set_data_dir(PathBuf::from("/nonexistent-for-tests")); + let data_dir = tempfile::tempdir().unwrap().keep(); + orch.set_data_dir(data_dir); // Redirect the bitcoin-ui pre-start hook to a test-scoped // tmpdir, seeded with a fake password file. Shared across // every test in this module (OnceLock), so the hook can run @@ -4283,22 +4304,25 @@ app: let rt = Arc::new(MockRuntime::default()); rt.mark_image_present("archy-bitcoin-ui:local"); let orch = orch_with(rt.clone()).await; - orch.insert_manifest_for_test( - build_manifest( - "bitcoin-ui", - "/opt/archy/docker/bitcoin-ui", - "archy-bitcoin-ui:local", - ), - PathBuf::from("/opt/archy/apps/bitcoin-ui"), - ) - .await; + let build_context = tempfile::tempdir().unwrap(); + std::fs::write(build_context.path().join("Dockerfile"), "FROM scratch\n").unwrap(); + let build_context = build_context.path().to_string_lossy().into_owned(); + let manifest = build_manifest("bitcoin-ui", &build_context, "archy-bitcoin-ui:local"); + let fingerprint = fingerprint_build_context(Path::new(&build_context)) + .expect("test build context must be fingerprintable"); + let stamp_path = build_fingerprint_stamp_path(&orch.data_dir, "archy-bitcoin-ui:local"); + std::fs::create_dir_all(stamp_path.parent().unwrap()).unwrap(); + std::fs::write(&stamp_path, fingerprint).unwrap(); + orch.insert_manifest_for_test(manifest, PathBuf::from("/opt/archy/apps/bitcoin-ui")) + .await; orch.install("bitcoin-ui").await.unwrap(); let calls = rt.calls(); assert!(calls .iter() .any(|c| c == "image_exists:archy-bitcoin-ui:local")); - // Build must NOT be invoked because the image is already there. + // Build must NOT be invoked because the image is already there and its + // recorded build-context fingerprint still matches. assert!(!calls.iter().any(|c| c.starts_with("build_image:"))); } @@ -4359,7 +4383,7 @@ app: let rt = Arc::new(MockRuntime::default()); let orch = orch_with(rt.clone()).await; - let data_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap(); let id_u = std::process::Command::new("id").arg("-u").output().unwrap(); let id_g = std::process::Command::new("id").arg("-g").output().unwrap(); let uid = String::from_utf8_lossy(&id_u.stdout).trim().to_string(); @@ -4389,7 +4413,7 @@ app: let rt = Arc::new(MockRuntime::default()); let orch = orch_with(rt.clone()).await; - let data_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap(); orch.insert_manifest_for_test( pull_manifest_with_generated_file( "exampleapp", @@ -4416,7 +4440,7 @@ app: let rt = Arc::new(MockRuntime::default()); let orch = orch_with(rt.clone()).await; - let data_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap(); let config_path = data_dir.path().join("config.yaml"); std::fs::write(&config_path, "key: operator\n").unwrap(); @@ -4441,7 +4465,7 @@ app: let rt = Arc::new(MockRuntime::default()); let orch = orch_with(rt.clone()).await; - let data_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap(); let config_path = data_dir.path().join("config.yaml"); std::fs::write(&config_path, "key: old\n").unwrap(); diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 9250e841..79aa6a99 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -268,14 +268,21 @@ impl QuadletUnit { let _ = writeln!(s, "HealthTimeout={}", h.timeout); let _ = writeln!(s, "HealthRetries={}", h.retries); } - if let Some(ep) = &self.entrypoint { - // Quadlet's Exec= replaces the image entrypoint+cmd. When - // the manifest provides both entrypoint and command we - // concatenate; if only command is set we'll emit that on - // its own below. - let mut parts: Vec = ep.clone(); + if let Some((first, rest)) = self.entrypoint.as_deref().and_then(<[String]>::split_first) { + // Quadlet's Exec= sets only the command (the args passed to the + // image's ENTRYPOINT) — it does NOT replace the entrypoint. So a + // manifest entrypoint like `sh -lc` must be emitted as a real + // Entrypoint= override; otherwise it gets appended to whatever + // ENTRYPOINT the image baked in (e.g. the versioned bitcoind + // images use `ENTRYPOINT ["bitcoind"]`, which turned the wrapper + // into `bitcoind sh -lc ...` and crash-looped). Emitting + // Entrypoint= makes the unit independent of the image's entrypoint. + let _ = writeln!(s, "Entrypoint={first}"); + let mut parts: Vec = rest.to_vec(); parts.extend(self.command.iter().cloned()); - let _ = writeln!(s, "Exec={}", shell_join(&parts)); + if !parts.is_empty() { + let _ = writeln!(s, "Exec={}", shell_join(&parts)); + } } else if !self.command.is_empty() { let _ = writeln!(s, "Exec={}", shell_join(&self.command)); } @@ -769,9 +776,11 @@ pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool { } pub fn exec_changed(old_body: &str, new_body: &str) -> bool { - let old_exec = directive_values(old_body, "Exec="); - let new_exec = directive_values(new_body, "Exec="); - old_exec != new_exec + // Entrypoint= and Exec= together define what the container runs, so a drift + // in either must recreate the container (e.g. when this renderer first + // splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`). + directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=") + || directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=") } fn directive_values(unit_body: &str, prefix: &str) -> Vec { @@ -1063,7 +1072,10 @@ mod tests { assert!(s.contains("ReadOnly=true")); assert!(s.contains("NoNewPrivileges=true")); assert!(s.contains("PodmanArgs=--cpus=2")); - assert!(s.contains("Exec=/usr/local/bin/bitcoind -server=1 -rpcbind=0.0.0.0")); + // Manifest entrypoint becomes a real Entrypoint= override (not folded + // into Exec=), so the unit doesn't depend on the image's own ENTRYPOINT. + assert!(s.contains("Entrypoint=/usr/local/bin/bitcoind")); + assert!(s.contains("Exec=-server=1 -rpcbind=0.0.0.0")); assert!(s.contains("Restart=on-failure")); assert!(s.contains("Network=archy-net")); } @@ -1288,7 +1300,10 @@ app: let u = QuadletUnit::from_manifest(&m, "x"); // tmpfs entry is dropped from bind_mounts; bind entry survives. assert_eq!(u.bind_mounts.len(), 1); - assert_eq!(u.bind_mounts[0].host, PathBuf::from("/var/lib/archipelago/x")); + assert_eq!( + u.bind_mounts[0].host, + PathBuf::from("/var/lib/archipelago/x") + ); } #[test] diff --git a/core/archipelago/src/container/secrets.rs b/core/archipelago/src/container/secrets.rs index 099897cf..b866d954 100644 --- a/core/archipelago/src/container/secrets.rs +++ b/core/archipelago/src/container/secrets.rs @@ -169,7 +169,10 @@ mod tests { let hash = std::fs::read_to_string(dir.path().join("admin")).unwrap(); let pw = std::fs::read_to_string(dir.path().join("admin.pw")).unwrap(); assert!(hash.starts_with("$2"), "bcrypt hash shape"); - assert!(bcrypt::verify(pw.trim(), hash.trim()).unwrap(), "pw matches hash"); + assert!( + bcrypt::verify(pw.trim(), hash.trim()).unwrap(), + "pw matches hash" + ); for f in ["tok", "admin", "admin.pw"] { let mode = std::fs::metadata(dir.path().join(f)) @@ -189,7 +192,10 @@ mod tests { let first = std::fs::read_to_string(dir.path().join("tok")).unwrap(); ensure_generated_secrets(dir.path(), &m).unwrap(); let second = std::fs::read_to_string(dir.path().join("tok")).unwrap(); - assert_eq!(first, second, "a present readable secret is never rewritten"); + assert_eq!( + first, second, + "a present readable secret is never rewritten" + ); } #[test] diff --git a/core/archipelago/src/container/version_config.rs b/core/archipelago/src/container/version_config.rs index e484c1a0..45d71ed8 100644 --- a/core/archipelago/src/container/version_config.rs +++ b/core/archipelago/src/container/version_config.rs @@ -172,11 +172,8 @@ mod tests { fn with_tmp_data_dir(f: F) { let mut counter = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); *counter += 1; - let dir = std::env::temp_dir().join(format!( - "archy-vc-test-{}-{}", - std::process::id(), - *counter - )); + let dir = + std::env::temp_dir().join(format!("archy-vc-test-{}-{}", std::process::id(), *counter)); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::env::set_var("ARCHIPELAGO_DATA_DIR", &dir); @@ -244,10 +241,7 @@ mod tests { assert!(!is_downgrade("28.4", "28.4")); // Knots date-suffixed strings compare on major.minor only. assert!(is_downgrade("29.3.knots20260508", "28.1.knots20251010")); - assert!(!is_downgrade( - "29.3.knots20260101", - "29.3.knots20260508" - )); + assert!(!is_downgrade("29.3.knots20260101", "29.3.knots20260508")); } #[test] diff --git a/core/archipelago/src/content_owned.rs b/core/archipelago/src/content_owned.rs index 56755c7f..a07f9075 100644 --- a/core/archipelago/src/content_owned.rs +++ b/core/archipelago/src/content_owned.rs @@ -153,7 +153,9 @@ pub async fn read_owned( onion: &str, content_id: &str, ) -> Option<(String, Vec)> { - let bytes = fs::read(bytes_path(data_dir, onion, content_id)).await.ok()?; + let bytes = fs::read(bytes_path(data_dir, onion, content_id)) + .await + .ok()?; let mime = load_index(data_dir) .await .items diff --git a/core/archipelago/src/federation/invites.rs b/core/archipelago/src/federation/invites.rs index ecb8a63c..f97fd416 100644 --- a/core/archipelago/src/federation/invites.rs +++ b/core/archipelago/src/federation/invites.rs @@ -296,7 +296,9 @@ pub(crate) async fn notify_join( status = %resp.status(), "peer-joined notification rejected; will retry" ), - Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"), + Err(e) => { + tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry") + } } tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await; } diff --git a/core/archipelago/src/health_monitor.rs b/core/archipelago/src/health_monitor.rs index d59d4221..55b03c59 100644 --- a/core/archipelago/src/health_monitor.rs +++ b/core/archipelago/src/health_monitor.rs @@ -1358,6 +1358,14 @@ mod tests { host_port_ready: None, healthy: true, }, + ContainerHealth { + name: "indeedhub-minio".into(), + app_id: "indeedhub-minio".into(), + state: "running".into(), + podman_health: None, + host_port_ready: None, + healthy: true, + }, ContainerHealth { name: "indeedhub-api".into(), app_id: "indeedhub-api".into(), diff --git a/core/archipelago/src/mesh/listener/assist.rs b/core/archipelago/src/mesh/listener/assist.rs index c22efc75..cbd5e892 100644 --- a/core/archipelago/src/mesh/listener/assist.rs +++ b/core/archipelago/src/mesh/listener/assist.rs @@ -181,7 +181,10 @@ async fn is_sender_allowed( match peers.get(&sender_contact_id) { // Match identity on the bound archipelago key (stable, advert/ // federation-verified), not the firmware routing key. - Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()), + Some(p) => ( + p.identity_pubkey_hex().map(|s| s.to_string()), + p.did.clone(), + ), None => (None, None), } }; diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index ef1d2dbd..5803aa50 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -314,17 +314,66 @@ pub(super) async fn try_chunk_reassemble( /// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name). pub(super) async fn resolve_peer(state: &Arc, sender_prefix: &str) -> (u32, String) { - let peers = state.peers.read().await; - peers - .values() - .find(|p| { + { + let peers = state.peers.read().await; + if let Some(peer) = peers.values().find(|p| { p.pubkey_hex .as_ref() .map(|k| k.starts_with(sender_prefix)) .unwrap_or(false) - }) - .map(|p| (p.contact_id, p.advert_name.clone())) - .unwrap_or((0, sender_prefix.to_string())) + }) { + return (peer.contact_id, peer.advert_name.clone()); + } + } + + if let Some((node_num, pubkey_hex, name)) = meshtastic_peer_from_prefix(sender_prefix) { + let peer = MeshPeer { + contact_id: node_num, + advert_name: name.clone(), + did: None, + pubkey_hex: Some(pubkey_hex), + arch_pubkey_hex: None, + x25519_pubkey: None, + rssi: None, + snr: None, + last_heard: chrono::Utc::now().to_rfc3339(), + hops: 0xff, + last_advert: 0, + reachable: true, + }; + let is_new = { + let mut peers = state.peers.write().await; + peers.insert(node_num, peer.clone()).is_none() + }; + state.update_peer_count().await; + let _ = state.event_tx.send(if is_new { + MeshEvent::PeerDiscovered(peer) + } else { + MeshEvent::PeerUpdated(peer) + }); + return (node_num, name); + } + + (0, sender_prefix.to_string()) +} + +fn meshtastic_peer_from_prefix(sender_prefix: &str) -> Option<(u32, String, String)> { + if sender_prefix.len() < 12 { + return None; + } + let bytes = hex::decode(&sender_prefix[..12]).ok()?; + if bytes.len() != 6 || bytes[4] != b'm' || bytes[5] != b'e' { + return None; + } + let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + if node_num == 0 || node_num == u32::MAX { + return None; + } + let mut full_key = [0u8; 32]; + full_key[..4].copy_from_slice(&node_num.to_le_bytes()); + full_key[4..15].copy_from_slice(b"meshtastic:"); + let name = format!("Meshtastic !{:08x}", node_num); + Some((node_num, hex::encode(full_key), name)) } /// Store a plain-text (non-typed) message and emit an event. @@ -333,6 +382,16 @@ pub(super) async fn store_plain_message( contact_id: u32, peer_name: &str, text: &str, +) { + store_plain_message_with_encryption(state, contact_id, peer_name, text, false).await; +} + +pub(super) async fn store_plain_message_with_encryption( + state: &Arc, + contact_id: u32, + peer_name: &str, + text: &str, + encrypted: bool, ) { let msg_id = state.next_id().await; let msg = MeshMessage { @@ -343,7 +402,7 @@ pub(super) async fn store_plain_message( plaintext: text.to_string(), timestamp: chrono::Utc::now().to_rfc3339(), delivered: true, - encrypted: false, + encrypted, message_type: "text".to_string(), typed_payload: None, sender_pubkey: None, diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index 1a7002a6..d1deb246 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -4,7 +4,8 @@ use super::super::message_types::TypedEnvelope; use super::super::protocol; use super::decode::{ handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message, - try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64, + store_plain_message_with_encryption, try_base64_typed, try_chunk_reassemble, + try_decrypt_base64, try_decrypt_ratchet_base64, }; use super::dispatch::handle_typed_message; use super::MeshState; @@ -62,11 +63,12 @@ pub(super) async fn handle_frame( return true; // Signal caller to sync immediately } - protocol::RESP_CONTACT_MSG_V3 => { + protocol::RESP_CONTACT_MSG_V3 | protocol::RESP_CONTACT_MSG_V3_E2E => { // Direct message received (v3 format) — check for typed envelope first match protocol::parse_contact_msg_v3_raw(&frame.data) { Ok((sender_prefix, payload, _snr)) => { if !payload.is_empty() { + let encrypted = frame.code == protocol::RESP_CONTACT_MSG_V3_E2E; let (contact_id, name) = resolve_peer(state, &sender_prefix).await; if TypedEnvelope::is_typed(&payload) { handle_typed_message(&payload, contact_id, &name, state).await; @@ -86,7 +88,10 @@ pub(super) async fn handle_frame( handle_typed_message(&decoded, contact_id, &name, state).await; } else if !payload.starts_with(b"MC") { let text = String::from_utf8_lossy(&payload).to_string(); - store_plain_message(state, contact_id, &name, &text).await; + store_plain_message_with_encryption( + state, contact_id, &name, &text, encrypted, + ) + .await; info!(from = %sender_prefix, "Received mesh DM (v3)"); } } diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index b1b19b0a..3ba5c570 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -407,8 +407,13 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) // user-controlled feature; until then every firmware contact is // surfaced. `radio_contact_blocklist` is retained but unused. let mut peers = state.peers.write().await; + let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic); for (idx, contact) in contacts.iter().enumerate() { - let contact_id = idx as u32; + let contact_id = if is_meshtastic { + meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32) + } else { + idx as u32 + }; let existing = peers.get(&contact_id); let peer = super::super::types::MeshPeer { contact_id, @@ -482,6 +487,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) } } +fn meshtastic_contact_id(public_key_hex: &str) -> Option { + let bytes = hex::decode(public_key_hex).ok()?; + if bytes.len() < 15 || &bytes[4..15] != b"meshtastic:" { + return None; + } + let node_num = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + if node_num == 0 || node_num == u32::MAX { + None + } else { + Some(node_num) + } +} + /// Drain any queued messages from the device. /// Returns `true` if a write/communication error occurred (for failure tracking). async fn sync_queued_messages( diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 6a8d9c97..ef720425 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -22,6 +22,7 @@ const START2: u8 = 0xc3; const TO_RADIO_MAX: usize = 512; const BROADCAST_NUM: u32 = 0xffff_ffff; const TEXT_MESSAGE_APP: u32 = 1; +const POSITION_APP: u32 = 3; /// Meshtastic PortNum for NodeInfo (identity) packets — used to actively /// advertise ourselves over the air so neighbours discover us, the parity /// equivalent of meshcore's self-advert. @@ -33,6 +34,8 @@ const ADMIN_SET_OWNER_FIELD: u64 = 32; /// Meshtastic firmware caps long_name at ~40 bytes and short_name at 4 bytes. const MESHTASTIC_LONG_NAME_MAX: usize = 39; const MESHTASTIC_SHORT_NAME_MAX: usize = 4; +const STALE_RX_SECS: u32 = 24 * 60 * 60; +const PLAUSIBLE_RX_EPOCH_SECS: u32 = 1_700_000_000; // 2023-11-14 const TO_RADIO_PACKET: u64 = 1; const TO_RADIO_WANT_CONFIG_ID: u64 = 3; @@ -45,6 +48,11 @@ const FROM_RADIO_NODE_INFO: u64 = 4; const FROM_RADIO_CONFIG: u64 = 5; const FROM_RADIO_CONFIG_COMPLETE_ID: u64 = 7; const FROM_RADIO_REBOOTED: u64 = 8; +/// Upper bound for a single Meshtastic serial API protobuf frame. The serial +/// stream can contain firmware log text, so this is also used to reject false +/// 0x94c3 markers found inside logs instead of waiting forever for a bogus +/// length. +const FROM_RADIO_MAX: usize = 4096; /// AdminMessage.set_config oneof field number (carries a `Config`). NB: 33 is /// `set_channel` — `set_config` is 34 (verified against meshtastic/protobufs). @@ -53,6 +61,15 @@ const ADMIN_SET_CONFIG_FIELD: u64 = 34; const ADMIN_SET_CHANNEL_FIELD: u64 = 33; /// FromRadio.channel (field 10): a `Channel` streamed during want_config. const FROM_RADIO_CHANNEL: u64 = 10; +const FROM_RADIO_QUEUE_STATUS: u64 = 11; +const FROM_RADIO_XMODEM_PACKET: u64 = 12; +const FROM_RADIO_METADATA: u64 = 13; +const FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE: u64 = 14; +const FROM_RADIO_FILE_INFO: u64 = 15; +const FROM_RADIO_CLIENT_NOTIFICATION: u64 = 16; +const FROM_RADIO_DEVICE_UI_CONFIG: u64 = 17; +const FROM_RADIO_LOCKDOWN_STATUS: u64 = 18; +const FROM_RADIO_REGION_PRESETS: u64 = 19; /// Channel.role value for the PRIMARY channel (broadcasts ride here). const CHANNEL_ROLE_PRIMARY: u64 = 1; /// Config.lora oneof field number (carries a `LoRaConfig`). @@ -386,7 +403,23 @@ impl MeshtasticDevice { } pub async fn send_self_advert(&mut self) -> Result<()> { - self.send_to_radio(&encode_heartbeat()).await + self.send_to_radio(&encode_heartbeat()).await?; + self.send_time_broadcast().await + } + + /// Broadcast a minimal Position payload carrying current epoch time. The + /// Meshtastic protobuf explicitly documents `Position.time` as the path for + /// phone/API clients to set time on mesh devices without GPS/RTC. This keeps + /// stock Meshtastic clients from rendering incoming Archipelago-originated + /// packets as Jan 1 1970 when their radio clock is unset. + pub async fn send_time_broadcast(&mut self) -> Result<()> { + let now = now_unix_secs(); + let mut position = Vec::new(); + encode_fixed32_field(4, now, &mut position); + encode_fixed32_field(7, now, &mut position); + let packet = encode_mesh_packet(BROADCAST_NUM, POSITION_APP, &position); + self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet)) + .await } /// Build our own `User` protobuf (id/long_name/short_name) for a NodeInfo @@ -595,8 +628,10 @@ impl MeshtasticDevice { // Bound memory if it's a pure-debug flood with no frames: // keep only from the last possible frame-start marker. if self.read_buf.len() > 64 * 1024 { - if let Some(pos) = - self.read_buf.windows(2).rposition(|w| w == [START1, START2]) + if let Some(pos) = self + .read_buf + .windows(2) + .rposition(|w| w == [START1, START2]) { self.read_buf.drain(..pos); } else { @@ -653,7 +688,17 @@ impl MeshtasticDevice { } None } - FROM_RADIO_CONFIG_COMPLETE_ID | FROM_RADIO_REBOOTED => None, + FROM_RADIO_CONFIG_COMPLETE_ID + | FROM_RADIO_REBOOTED + | FROM_RADIO_QUEUE_STATUS + | FROM_RADIO_XMODEM_PACKET + | FROM_RADIO_METADATA + | FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE + | FROM_RADIO_FILE_INFO + | FROM_RADIO_CLIENT_NOTIFICATION + | FROM_RADIO_DEVICE_UI_CONFIG + | FROM_RADIO_LOCKDOWN_STATUS + | FROM_RADIO_REGION_PRESETS => None, other => { debug!( field = other, @@ -700,73 +745,135 @@ impl MeshtasticDevice { } fn packet_to_inbound_frame(&mut self, data: &[u8]) -> Option { - let packet = parse_mesh_packet(data)?; - if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() { - return None; - } - let from = packet.from.unwrap_or(0); - if Some(from) == self.node_num { - return None; - } - info!( - from = format!("!{:08x}", from), - len = packet.payload.len(), - pki = packet.pki_encrypted, - "Meshtastic received text packet over the air" - ); - // Record E2E status: a `pki_encrypted` packet (or one carrying the - // sender's `public_key`) proves this DM arrived end-to-end encrypted via - // the PKI, not the shared channel PSK. We learn the sender's key here too - // — but keep it OUT of the routing `public_key_hex` (synthetic) so the - // device interface stays identical to meshcore's and the two remain - // hot-swappable behind the mesh listener. - if let Some(pk) = packet.public_key.as_ref() { - self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone()); - } - if packet.pki_encrypted { - debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)"); - } - let from_key = synthetic_pubkey(from); - self.contacts.entry(from).or_insert_with(|| ParsedContact { - public_key_hex: hex::encode(synthetic_pubkey(from)), - advert_name: format!("Meshtastic !{:08x}", from), - last_advert: 0, - contact_type: 1, - path_len: 0xff, - flags: 0, - }); - - let mut payload = Vec::with_capacity(15 + packet.payload.len()); - payload.push(0); // SNR unknown - payload.extend_from_slice(&[0, 0]); // reserved - payload.extend_from_slice(&from_key[..6]); - payload.push(0xff); // unknown/flood path - payload.push(0); // text type - payload.extend_from_slice(&0u32.to_le_bytes()); - payload.extend_from_slice(&packet.payload); - Some(InboundFrame { - code: super::protocol::RESP_CONTACT_MSG_V3, - data: payload, - bytes_consumed: 0, - }) + packet_to_inbound_frame( + data, + self.node_num, + &mut self.contacts, + &mut self.peer_pubkeys, + ) } } +fn packet_to_inbound_frame( + data: &[u8], + local_node_num: Option, + contacts: &mut HashMap, + peer_pubkeys: &mut HashMap>, +) -> Option { + let packet = parse_mesh_packet(data)?; + if packet.portnum != TEXT_MESSAGE_APP || packet.payload.is_empty() { + return None; + } + if packet_is_stale(packet.rx_time) { + debug!( + from = ?packet.from.map(|n| format!("!{:08x}", n)), + rx_time = ?packet.rx_time, + "Dropping stale Meshtastic text packet from radio backlog" + ); + return None; + } + let from = packet.from.unwrap_or(0); + if Some(from) == local_node_num { + return None; + } + info!( + from = format!("!{:08x}", from), + len = packet.payload.len(), + pki = packet.pki_encrypted, + "Meshtastic received text packet over the air" + ); + // Record E2E status without overwriting the synthetic routing key used by + // the shared mesh listener. + if let Some(pk) = packet.public_key.as_ref() { + peer_pubkeys.entry(from).or_insert_with(|| pk.clone()); + } + if packet.pki_encrypted { + debug!( + node = from, + "Meshtastic DM received end-to-end encrypted (PKI)" + ); + } + let from_key = synthetic_pubkey(from); + contacts.entry(from).or_insert_with(|| ParsedContact { + public_key_hex: hex::encode(synthetic_pubkey(from)), + advert_name: format!("Meshtastic !{:08x}", from), + last_advert: 0, + contact_type: 1, + path_len: 0xff, + flags: 0, + }); + + let mut payload = Vec::with_capacity(15 + packet.payload.len()); + payload.push(0); // SNR unknown + payload.extend_from_slice(&[0, 0]); // reserved + payload.extend_from_slice(&from_key[..6]); + payload.push(0xff); // unknown/flood path + payload.push(0); // text type + payload.extend_from_slice(&packet.rx_time.unwrap_or_else(now_unix_secs).to_le_bytes()); + payload.extend_from_slice(&packet.payload); + Some(InboundFrame { + code: if packet.pki_encrypted { + super::protocol::RESP_CONTACT_MSG_V3_E2E + } else { + super::protocol::RESP_CONTACT_MSG_V3 + }, + data: payload, + bytes_consumed: 0, + }) +} + +fn packet_is_stale(rx_time: Option) -> bool { + let Some(rx_time) = rx_time else { + return false; + }; + // Radios without GPS/RTC can report tiny nonzero epoch values until their + // clock is set. Treat those as unknown, not stale, or live LoRa packets from + // stock Meshtastic peers disappear before reaching mesh.messages. + if rx_time < PLAUSIBLE_RX_EPOCH_SECS { + return false; + } + let now = now_unix_secs(); + if now < PLAUSIBLE_RX_EPOCH_SECS || rx_time > now.saturating_add(60) { + return false; + } + rx_time.saturating_add(STALE_RX_SECS) < now +} + fn decode_serial_frame(buf: &mut Vec) -> Option> { - let start = buf.windows(2).position(|w| w == [START1, START2])?; - if start > 0 { - buf.drain(..start); + loop { + let start = buf.windows(2).position(|w| w == [START1, START2])?; + if start > 0 { + buf.drain(..start); + } + if buf.len() < 4 { + return None; + } + let len = u16::from_be_bytes([buf[2], buf[3]]) as usize; + if len == 0 || len > FROM_RADIO_MAX { + debug!( + len, + head = %hex::encode(&buf[..buf.len().min(16)]), + "Discarding invalid Meshtastic serial frame marker" + ); + buf.drain(..1); + continue; + } + if buf.len() < 4 + len { + return None; + } + let payload = buf[4..4 + len].to_vec(); + if decode_top_level_variant(&payload).is_none() { + debug!( + len, + head = %hex::encode(&buf[..buf.len().min(16)]), + "Discarding invalid Meshtastic serial frame payload" + ); + buf.drain(..1); + continue; + } + buf.drain(..4 + len); + return Some(payload); } - if buf.len() < 4 { - return None; - } - let len = u16::from_be_bytes([buf[2], buf[3]]) as usize; - if buf.len() < 4 + len { - return None; - } - let payload = buf[4..4 + len].to_vec(); - buf.drain(..4 + len); - Some(payload) } fn encode_want_config() -> Vec { @@ -844,9 +951,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { j = sn; match (sf, sv) { (2, FieldValue::Bytes(p)) => psk = p.to_vec(), - (3, FieldValue::Bytes(n)) => { - name = String::from_utf8_lossy(n).to_string() - } + (3, FieldValue::Bytes(n)) => name = String::from_utf8_lossy(n).to_string(), _ => {} } } @@ -918,6 +1023,8 @@ fn encode_mesh_packet(to: u32, portnum: u32, payload: &[u8]) -> Vec { let mut packet = Vec::new(); encode_fixed32_field(2, to, &mut packet); encode_len_field(4, &decoded, &mut packet); + encode_fixed32_field(6, next_packet_id(), &mut packet); + encode_fixed32_field(7, now_unix_secs(), &mut packet); packet } @@ -949,6 +1056,15 @@ fn decode_top_level_variant(buf: &[u8]) -> Option<(u64, &[u8])> { | FROM_RADIO_NODE_INFO | FROM_RADIO_CONFIG | FROM_RADIO_CHANNEL + | FROM_RADIO_QUEUE_STATUS + | FROM_RADIO_XMODEM_PACKET + | FROM_RADIO_METADATA + | FROM_RADIO_MQTT_CLIENT_PROXY_MESSAGE + | FROM_RADIO_FILE_INFO + | FROM_RADIO_CLIENT_NOTIFICATION + | FROM_RADIO_DEVICE_UI_CONFIG + | FROM_RADIO_LOCKDOWN_STATUS + | FROM_RADIO_REGION_PRESETS ) { return Some((field, &buf[idx..end])); } @@ -1056,6 +1172,9 @@ struct ParsedPacket { from: Option, portnum: u32, payload: Vec, + #[allow(dead_code)] + id: Option, + rx_time: Option, /// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet /// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted /// rather than via the shared channel PSK. @@ -1068,6 +1187,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option { let mut idx = 0; let mut from = None; let mut decoded = None; + let mut id = None; + let mut rx_time = None; let mut pki_encrypted = false; let mut public_key = None; while idx < data.len() { @@ -1076,6 +1197,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option { match (field, value) { (1, FieldValue::Fixed32(v)) => from = Some(v), (4, FieldValue::Bytes(b)) => decoded = Some(b), + (6, FieldValue::Fixed32(v)) => id = Some(v), + (7, FieldValue::Fixed32(v)) if v != 0 => rx_time = Some(v), (16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()), (17, FieldValue::Varint(v)) => pki_encrypted = v != 0, _ => {} @@ -1098,11 +1221,30 @@ fn parse_mesh_packet(data: &[u8]) -> Option { from, portnum, payload, + id, + rx_time, pki_encrypted, public_key, }) } +fn now_unix_secs() -> u32 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0) +} + +fn next_packet_id() -> u32 { + static COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(1); + let ctr = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + nanos ^ ctr.rotate_left(16) +} + enum FieldValue<'a> { Varint(u64), Fixed32(u32), @@ -1204,3 +1346,134 @@ fn synthetic_pubkey(node_num: u32) -> [u8; 32] { out[4..15].copy_from_slice(b"meshtastic:"); out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mesh::protocol; + + fn serial_frame(payload: &[u8]) -> Vec { + let mut frame = Vec::new(); + frame.push(START1); + frame.push(START2); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + frame.extend_from_slice(payload); + frame + } + + #[test] + fn decode_serial_frame_skips_false_marker_with_impossible_length() { + let valid_payload = encode_varint_field(FROM_RADIO_CONFIG_COMPLETE_ID, 1); + let mut buf = vec![b'l', b'o', b'g', START1, START2, 0xff, 0xff, b'x']; + buf.extend_from_slice(&serial_frame(&valid_payload)); + + let decoded = decode_serial_frame(&mut buf).expect("valid frame after false marker"); + assert_eq!(decoded, valid_payload); + } + + #[test] + fn decode_serial_frame_skips_false_marker_with_invalid_payload() { + let valid_payload = encode_varint_field(FROM_RADIO_CONFIG_COMPLETE_ID, 1); + let mut buf = vec![START1, START2, 0x00, 0x03, b'b', b'a', b'd']; + buf.extend_from_slice(&serial_frame(&valid_payload)); + + let decoded = decode_serial_frame(&mut buf).expect("valid frame after invalid payload"); + assert_eq!(decoded, valid_payload); + } + + #[test] + fn decode_serial_frame_accepts_queue_status_variant() { + let mut queue_status = Vec::new(); + encode_len_field( + FROM_RADIO_QUEUE_STATUS, + &[0x10, 0x0e, 0x18, 0x10], + &mut queue_status, + ); + let mut buf = serial_frame(&queue_status); + + let decoded = + decode_serial_frame(&mut buf).expect("queue status is a valid FromRadio frame"); + assert_eq!(decoded, queue_status); + } + + #[test] + fn encode_mesh_packet_sets_nonzero_id_and_time() { + let before = now_unix_secs(); + let packet = encode_mesh_packet(0x1122_3344, TEXT_MESSAGE_APP, b"hello"); + let after = now_unix_secs(); + let parsed = parse_mesh_packet(&packet).expect("packet should parse"); + + assert_eq!(parsed.portnum, TEXT_MESSAGE_APP); + assert_eq!(parsed.payload, b"hello"); + assert!(parsed.id.unwrap_or(0) != 0); + let rx_time = parsed.rx_time.expect("rx_time should be set"); + assert!(rx_time >= before.saturating_sub(1)); + assert!(rx_time <= after.saturating_add(1)); + } + + #[test] + fn packet_to_inbound_frame_accepts_stock_peer_with_unset_clock() { + let from = 0x0000_3ccc; + let mut contacts = HashMap::new(); + let mut peer_pubkeys = HashMap::new(); + let mut decoded = Vec::new(); + encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded); + encode_len_field(2, b"hello from 3ccc", &mut decoded); + + let mut packet = Vec::new(); + encode_fixed32_field(1, from, &mut packet); + encode_fixed32_field(2, BROADCAST_NUM, &mut packet); + encode_len_field(4, &decoded, &mut packet); + encode_fixed32_field(7, 12_345, &mut packet); + + let frame = + packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys) + .expect("live packet with unset radio clock must not be dropped"); + assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3); + + let (sender_prefix, payload, _snr) = + protocol::parse_contact_msg_v3_raw(&frame.data).unwrap(); + assert_eq!(sender_prefix, "cc3c00006d65"); + assert_eq!(payload, b"hello from 3ccc"); + assert!(contacts.contains_key(&from)); + } + + #[test] + fn packet_to_inbound_frame_accepts_recent_meshtastic_backlog() { + let from = 0x433e_3ccc; + let mut contacts = HashMap::new(); + let mut peer_pubkeys = HashMap::new(); + let mut decoded = Vec::new(); + encode_varint_field_into(1, TEXT_MESSAGE_APP as u64, &mut decoded); + encode_len_field(2, b"recent backlog", &mut decoded); + + let mut packet = Vec::new(); + encode_fixed32_field(1, from, &mut packet); + encode_fixed32_field(2, BROADCAST_NUM, &mut packet); + encode_len_field(4, &decoded, &mut packet); + encode_fixed32_field(7, now_unix_secs().saturating_sub(60 * 60), &mut packet); + + let frame = + packet_to_inbound_frame(&packet, Some(0x1111_1111), &mut contacts, &mut peer_pubkeys) + .expect("recent radio backlog must surface in mesh.messages"); + let (sender_prefix, payload, _snr) = + protocol::parse_contact_msg_v3_raw(&frame.data).unwrap(); + assert_eq!(sender_prefix, "cc3c3e436d65"); + assert_eq!(payload, b"recent backlog"); + } + + #[test] + fn stale_filter_keeps_packets_from_radios_with_unset_clock() { + assert!(!packet_is_stale(None)); + assert!(!packet_is_stale(Some(0))); + assert!(!packet_is_stale(Some(12_345))); + } + + #[test] + fn stale_filter_drops_only_plausibly_old_packets() { + let old = now_unix_secs().saturating_sub(STALE_RX_SECS + 60); + if old >= PLAUSIBLE_RX_EPOCH_SECS { + assert!(packet_is_stale(Some(old))); + } + } +} diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index da0649e9..1659d8c6 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1109,15 +1109,13 @@ impl MeshService { // (FIPS→Tor) instead of handing it to a radio that physically cannot // deliver it. Reachable radio peers stay on the mesh; oversized // envelopes (file shares etc.) always take the federation path. - let radio_federated_unreachable = !is_federation_synthetic - && !exceeds_lora - && { - let peers = self.state.peers.read().await; - peers - .get(&contact_id) - .map(|p| !p.reachable && p.arch_pubkey_hex.is_some()) - .unwrap_or(false) - }; + let radio_federated_unreachable = !is_federation_synthetic && !exceeds_lora && { + let peers = self.state.peers.read().await; + peers + .get(&contact_id) + .map(|p| !p.reachable && p.arch_pubkey_hex.is_some()) + .unwrap_or(false) + }; if is_federation_synthetic || exceeds_lora || radio_federated_unreachable { // Resolve the peer's pubkey/did. Prefer the live mesh peer table, // but fall back to federation storage for federation-synthetic ids @@ -1508,9 +1506,12 @@ impl MeshService { // it stays backward compatible. (Federation/Tor sends already sign in // `send_typed_wire_via_federation`.) `with_seq` is applied after signing // — seq is not covered by the signature. - let envelope = - TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key) - .with_seq(seq); + let envelope = TypedEnvelope::new_signed( + MeshMessageType::Text, + text.as_bytes().to_vec(), + &self.signing_key, + ) + .with_seq(seq); let wire = envelope.to_wire()?; self.send_typed_wire(contact_id, wire, "text", text, None, seq) .await @@ -1653,7 +1654,13 @@ impl MeshService { /// Recently-denied `!ai` askers (newest first) so the UI can offer to allow /// them. Cleared implicitly as new denials rotate older ones out. pub async fn assistant_denied_askers(&self) -> Vec { - self.state.assist_denied.read().await.iter().cloned().collect() + self.state + .assist_denied + .read() + .await + .iter() + .cloned() + .collect() } /// Update the mesh-AI assistant settings live (no listener restart) and diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 3ccafbd9..5e18cb34 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -64,6 +64,11 @@ pub const RESP_CONTACT_MSG_V3: u8 = 0x10; pub const RESP_CHANNEL_MSG_V3: u8 = 0x11; pub const RESP_CHANNEL_INFO: u8 = 0x12; pub const RESP_STATS: u8 = 0x18; +/// Archipelago-internal synthetic response code used by the Meshtastic adapter +/// for text DMs that the firmware reports as PKI-encrypted. Meshcore firmware +/// never emits this code; it lets the shared listener persist the E2E badge +/// without changing the on-wire Meshcore frame format. +pub const RESP_CONTACT_MSG_V3_E2E: u8 = 0x13; // --- Push notification codes (device -> host, async, >= 0x80) --- pub const PUSH_CONTACT_ADVERT: u8 = 0x80; diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index ece04176..47978f83 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -462,8 +462,9 @@ impl Server { let local_onion = snap.server_info.tor_address.clone().unwrap_or_default(); let local_pubkey = snap.server_info.pubkey.clone(); let local_name = snap.server_info.name.clone(); - let local_fips_npub = - crate::identity::fips_npub(&identity_dir).await.unwrap_or(None); + let local_fips_npub = crate::identity::fips_npub(&identity_dir) + .await + .unwrap_or(None); let mut ok = 0usize; let mut healed = 0usize; for node in &nodes { @@ -486,10 +487,8 @@ impl Server { // Without this, a node that joined everyone stays // invisible to the whole fleet until a manual // re-add (the "X250-EXP missing everywhere" case). - let they_list_us = state - .federated_peers - .iter() - .any(|h| h.did == local_did); + let they_list_us = + state.federated_peers.iter().any(|h| h.did == local_did); if !they_list_us && !local_onion.is_empty() { crate::federation::notify_join( &node.onion, diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs index ab79fd51..c233bfaf 100644 --- a/core/archipelago/src/wallet/fedimint_client.rs +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -256,9 +256,7 @@ pub async fn spend_from_any(data_dir: &Path, amount_sats: u64) -> Result<(String Err(last_err .map(|e| anyhow::anyhow!("Fedimint spend failed across all federations: {e}")) .unwrap_or_else(|| { - anyhow::anyhow!( - "No joined Fedimint federation has {amount_sats} sats available" - ) + anyhow::anyhow!("No joined Fedimint federation has {amount_sats} sats available") })) } diff --git a/core/container/src/lib.rs b/core/container/src/lib.rs index bf7fdadc..05d337e6 100644 --- a/core/container/src/lib.rs +++ b/core/container/src/lib.rs @@ -10,9 +10,8 @@ pub use health_monitor::HealthMonitor; pub use manifest::{ AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedCert, GeneratedFile, GeneratedSecret, HealthCheck, HookStep, HostCopy, HostFacts, LifecycleHooks, - ManifestError, - ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, SecurityPolicy, - Volume, + ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretGenKind, SecretsProvider, + SecurityPolicy, Volume, }; pub use podman_client::{ image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient, diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 627efab1..9746f93c 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -1257,7 +1257,10 @@ app: } match &m.app.hooks.post_install[1] { HookStep::CopyFromHost { copy_from_host } => { - assert_eq!(copy_from_host.dest, "/usr/share/nginx/html/nostr-provider.js") + assert_eq!( + copy_from_host.dest, + "/usr/share/nginx/html/nostr-provider.js" + ) } _ => panic!("expected copy_from_host step"), } diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index ca84775c..cf0dd0f6 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -169,7 +169,11 @@ impl ContainerRuntime for PodmanRuntime { } async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> { - match self.client.stop_container_with_grace(name, grace_secs).await { + match self + .client + .stop_container_with_grace(name, grace_secs) + .await + { Ok(()) => Ok(()), Err(api_err) => { // CLI fallback. Keep the wrapper deadline strictly above the @@ -897,7 +901,9 @@ impl ContainerRuntime for AutoRuntime { } async fn stop_container_with_grace(&self, name: &str, grace_secs: u64) -> Result<()> { - self.runtime.stop_container_with_grace(name, grace_secs).await + self.runtime + .stop_container_with_grace(name, grace_secs) + .await } async fn remove_container(&self, name: &str) -> Result<()> { diff --git a/docs/PRODUCTION-MASTER-PLAN.md b/docs/PRODUCTION-MASTER-PLAN.md index 4f81e4e1..24d2c537 100644 --- a/docs/PRODUCTION-MASTER-PLAN.md +++ b/docs/PRODUCTION-MASTER-PLAN.md @@ -135,6 +135,16 @@ After the 2026-06-23 multinode test deploy (latest backend + UX frontend to .116 3. **§6c Lifecycle perfection** (workstream F) — the comprehensive uninstall/reinstall + progress-UI + all-apps gate expansion below. +## 6b-bis. Bitcoin multi-version bulletproofing (2026-06-29) — READY TO MERGE + DEPLOY + +Branch `bitcoin-version-bulletproof` (base `095a76cd`). Fixes the "switch version silently +fails / crash-loops" class + a data-access mismatch that can corrupt a node's index. All +code + images + catalog + frontend DONE; **.228** carries it (Knots chainstate mid-reindex +recovery). The **coordinated fleet rollout** (OTA binary+frontend, mirror catalog publish, +`:latest` repoint sequencing, full switch-matrix test) is the remaining work — fold it into +the next release. **Authoritative detail + exact remaining steps + test matrix → +`docs/bitcoin-version-bulletproof-rollout.md`.** Pairs with `docs/bitcoin-multi-version-design.md`. + ## 6c. Lifecycle perfection — what "green" MISSED (workstream F, the perfection bar) **Why this exists:** the 2026-06-23 single-node gate went 5×-green but is **NOT** the @@ -294,6 +304,88 @@ phases 2–6 (`dual-ecash-design.md`). ## 8b. SESSION STATE + RESUME (updated 2026-06-26) — READ §8b "CURRENT STATE + RESUME" FIRST +### ▶ SESSION i (2026-06-30) — CURRENT HANDOFF / 1.8.0 OTA RESUME + +**Branch/worktree:** currently on `bitcoin-version-bulletproof`, not `main`. Worktree is dirty. +Do **not** discard mesh changes: they include E2E/transport indicator plumbing and the Meshtastic +receive-path fixes below. Separate recovery note: `docs/SESSION-1.8.0-OTA-PROGRESS.md`. + +**What was done this session:** +1. ✅ **Local Rust release gate fixed and green.** `cargo test -p archipelago --bin archipelago` is + green: **849/849** after fixing stale tests and the invalid `fedimint-clientd` manifest + (`cpu_limit` was `0.25`, invalid for the current schema; now integer). `cargo check -p archipelago` + also green after mesh edits. +2. ✅ **Catalog/release static gates green.** `python3 scripts/check-app-catalog-drift.py --release + --strict` is green. `scripts/check-release-manifest.sh` is green for the currently staged + `1.7.99-alpha` manifest/artifacts. `npm run build` and `npm run type-check` are green. +3. ✅ **Frontend unit gate fixed.** `npx vitest run --silent` now green: **81 files / 668 tests**. Fixes + were test-only: add `router.onError` to the login test router mock and update the `AppIconGrid` + mobile unresolved-new-tab expectation to match current app-launcher behavior. +4. ✅ **Workstream F harness gap closed.** `tests/lifecycle/bats/cascade-uninstall.bats` now asserts + uninstall progress truthfulness via backend `uninstall-stage`: stage must be parseable, monotonic, + below 100 before terminal absence, and present before the app disappears. Non-destructive skip-mode + parse check is green: `ARCHY_PASSWORD=dummy bats tests/lifecycle/bats/cascade-uninstall.bats` → 7 skip-ok. +5. ✅ **3ccc → .116 Meshtastic receive bug taken over and partially live-validated.** Context: `3ccc` + is the stock/non-Archy Meshtastic peer. The bug was LoRa text from `3ccc` not surfacing in + `.116` `mesh.messages`. Root causes/fixes: + - The prior attempted fix dropped any packet older than 10 minutes by `rx_time`; live `.116` logs + showed `FromRadio.packet` from `!433e3ccc` being dropped as stale (`rx_time` about an hour old). + The window is now **24h**, so recent radio FIFO/store-forward backlog surfaces instead of vanishing. + - Radios with unset clocks can report tiny nonzero epoch values; those are now treated as unknown, + not stale. + - Serial prevalidation was rejecting valid `FromRadio.queueStatus` frames (`field 11`, live bytes like + `5a04100e1810`) as corrupt payloads; field 11 and other modern non-message `FromRadio` variants + are now accepted/ignored instead of poisoning the stream. + - Focused Meshtastic tests green: **8/8**, including `packet_to_inbound_frame_accepts_recent_meshtastic_backlog` + and `packet_to_inbound_frame_accepts_stock_peer_with_unset_clock`. + - Deployed patched binary to **.116**: sha256 + `028ec6ff9a60ca8970c081987457d78ed1c517cd81f7089f51b9a01745b5c3c4` at `/usr/local/bin/archipelago`. + Service active. Post-deploy checked window showed `FromRadio field=11` accepted and no new + `Dropping stale ... !433e3ccc` entries. + - There are stale other-agent `RXDIAG` shell watcher processes on `.116`; leave them unless they + actively interfere. +6. ✅ **Phase-3 Quadlet read-only check on .116 skip-clean.** Copied lifecycle tests to `.116` and ran + `bats bats/use-quadlet-backends-install.bats`: **6/6 skip-clean** because no backend `.container` + units exist. This confirms `use_quadlet_backends` is not active on `.116`; Phase-3 remains a rollout gate. + +**Commands/results worth trusting:** +- `cargo test -p archipelago --bin archipelago` → 849/849 green. +- `npx vitest run --silent` from `neode-ui/` → 81 files / 668 tests green. +- `npm run build` from `neode-ui/` → green, bundle `index-CYaDgfX3.js`. +- `python3 scripts/check-app-catalog-drift.py --release --strict` → green. +- `scripts/check-release-manifest.sh` → green for **v1.7.99-alpha** staged artifacts. +- `tests/release/run.sh --manifest` was rerun after `cargo fmt`; it previously reached frontend tests, + which are now fixed. Re-run it from scratch as the next static gate. + +**Remaining blockers / decisions before 1.8.0 OTA:** +1. **Release version metadata is not 1.8.0 yet.** `releases/manifest.json`, Cargo, and npm still say + `1.7.99-alpha`; `CHANGELOG.md` top says `v1.8.00-alpha` (note double zero). Do not silently publish + until the release version naming is decided (`1.8.0-alpha` vs `1.8.00-alpha` vs `1.8.0`). +2. **Workstream B signing is blocked on the offline release-root mnemonic.** `docs/workstream-b-signing-runbook.md` + says catalog distribution/embedded manifests are live, but authenticity requires the publisher to pin + `RELEASE_ROOT_PUBKEY_HEX` and sign `releases/app-catalog.json` with `RELEASE_MASTER_MNEMONIC`. + This cannot be automated by an agent without the offline mnemonic. +3. **Phase-3 `use_quadlet_backends` is implemented but default-off.** Completing this requires explicit + node/fleet flag rollout plus backend reinstall/migration verification. `.116` currently skip-clean only. +4. **Bitcoin multi-version coordinated rollout is still separately owned/blocked by its runbook.** See + `docs/bitcoin-version-bulletproof-rollout.md`; do not repoint `bitcoin-knots:latest` before fixed binary + is fleet-wide. +5. **True RF validation of 3ccc requires either a live 3ccc send or waiting for another FIFO/backlog packet.** + Parser/unit coverage and `.116` logs strongly validate the drop-path fix, but no human was available to + send a fresh 3ccc message during this session. + +**Immediate next steps for the next agent:** +1. Run `tests/release/run.sh --manifest` from repo root again; frontend unit failures are fixed, so expect + it to pass or continue from the next failing stage. +2. If `.116` is still the canary, monitor logs after any 3ccc activity: + `journalctl -u archipelago --since "