merge: bitcoin version bulletproof and OTA work

This commit is contained in:
archipelago 2026-06-30 05:08:27 -04:00
commit 4b7cbf2b5e
49 changed files with 1544 additions and 279 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)?;

View File

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

View File

@ -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())
);
}
}

View File

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

View File

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

View File

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

View File

@ -336,7 +336,10 @@ async fn image_created_unix(image: &str) -> Option<i64> {
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse::<i64>().ok()
String::from_utf8_lossy(&out.stdout)
.trim()
.parse::<i64>()
.ok()
}
/// Newest modification time (Unix seconds) across all files under `dir`,

View File

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

View File

@ -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<std::collections::HashSet<(String, String)>>
{
static SET: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashSet<(String, String)>>,
> = std::sync::OnceLock::new();
static SET: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<(String, String)>>> =
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<String>,
}
/// 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, &current).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, &current));
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, &current)
.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, &current)
});
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();

View File

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

View File

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

View File

@ -172,11 +172,8 @@ mod tests {
fn with_tmp_data_dir<F: FnOnce()>(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]

View File

@ -153,7 +153,9 @@ pub async fn read_owned(
onion: &str,
content_id: &str,
) -> Option<(String, Vec<u8>)> {
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

View File

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

View File

@ -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(),

View File

@ -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),
}
};

View File

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

View File

@ -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)");
}
}

View File

@ -407,8 +407,13 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// 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<MeshState>)
}
}
fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
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(

View File

@ -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<InboundFrame> {
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<u32>,
contacts: &mut HashMap<u32, ParsedContact>,
peer_pubkeys: &mut HashMap<u32, Vec<u8>>,
) -> Option<InboundFrame> {
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<u32>) -> 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<u8>) -> Option<Vec<u8>> {
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<u8> {
@ -844,9 +951,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec<u8>)> {
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<u8> {
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<u32>,
portnum: u32,
payload: Vec<u8>,
#[allow(dead_code)]
id: Option<u32>,
rx_time: Option<u32>,
/// 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<ParsedPacket> {
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<ParsedPacket> {
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<ParsedPacket> {
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<u8> {
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)));
}
}
}

View File

@ -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<listener::DeniedAsker> {
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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"),
}

View File

@ -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<()> {

View File

@ -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 26 (`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 "<time>" | grep -Ei "!433e3ccc|3ccc|Dropping stale|Meshtastic received text|FromRadio field field=2"`.
3. Decide/reconcile version naming for the actual 1.8.0 OTA, then use the release scripts intentionally
(do not run `create-release.sh` casually: it commits/tags and requires `main` + clean tree).
4. If pursuing Workstream B completion, get the offline release mnemonic from the publisher and follow
`docs/workstream-b-signing-runbook.md` exactly.
5. If pursuing Phase-3 Quadlet, enable `ARCHY_USE_QUADLET_BACKENDS=1` only on a canary first and run the
Quadlet/lifecycle gates before considering fleet rollout.
### ▶ SESSION h (2026-06-26) — LATEST, RESUME FROM HERE
**Canonical resume detail: memory `project_session_resume_2026_06_23b` (▶️ top of MEMORY.md).**

View File

@ -0,0 +1,45 @@
# 1.8.0 OTA Session Progress
Updated: 2026-06-29
Current scope:
- Preserve existing mesh work: E2E indicators, FIPS/Tor transport indicators, typed-message paths, Meshtastic region/channel provisioning, and dirty Meshtastic receive-attempt changes.
- Take over the `3ccc` stock Meshtastic peer bug: LoRa text from `3ccc` to Archipelago `.116` does not surface in `mesh.messages`.
- Keep release-gate fixes already made in this session.
Local gate status so far:
- `cargo test -p archipelago --bin archipelago`: green, 849/849 after Meshtastic fixes.
- `python3 scripts/check-app-catalog-drift.py --release --strict`: green.
- `npm run type-check`: green.
Key changes made so far:
- Added cascade uninstall progress truthfulness assertion to `tests/lifecycle/bats/cascade-uninstall.bats`.
- Fixed release catalog drift filters and regenerated catalog metadata.
- Fixed invalid `apps/fedimint-clientd/manifest.yml` `cpu_limit` schema value.
- Updated stale/tight Rust tests without changing production behavior.
Remaining non-automatable / operational gates:
- Workstream B signing is blocked on the offline `RELEASE_MASTER_MNEMONIC`; code + runbook exist, but the publisher must pin/sign the release-root catalog.
- Phase-3 Quadlet backend rollout is implemented behind `use_quadlet_backends` and default-off. The gate skip-passes until explicitly enabled on a node; flipping it fleet-wide requires a coordinated flag rollout plus backend reinstall/migration verification.
- `.116` read-only `use-quadlet-backends-install.bats`: 6/6 skip-clean; no backend `.container` units, so Phase-3 is not active on that node.
- Release metadata still says `1.7.99-alpha` in `releases/manifest.json`; changelog top is `v1.8.00-alpha`. Cutting an actual 1.8.0 OTA requires an explicit version/manifest update.
Do not discard:
- `core/archipelago/src/mesh/listener/decode.rs`
- `core/archipelago/src/mesh/listener/session.rs`
- `core/archipelago/src/mesh/meshtastic.rs`
3ccc bug current hypothesis:
- The prior attempted Meshtastic fix added a hard stale-packet filter using `rx_time`.
- Stock Meshtastic radios without GPS/RTC can report tiny nonzero epoch values until time sync.
- That would make live `3ccc` packets look older than 10 minutes and get dropped before `mesh.messages`.
- Current patch treats implausibly early `rx_time` values as unknown rather than stale.
.116 live validation:
- `.116` reachable by SSH; `archipelago` active; `/dev/mesh-radio -> ttyUSB0` attached.
- Recent logs show repeated `FromRadio.queueStatus` frames (`field 11`, bytes like `5a04100e1810`) being rejected by the serial frame prevalidator as invalid payloads.
- Current patch accepts `FromRadio.queueStatus` as a valid ignored frame so non-message status frames no longer look like corrupt serial data.
- Focused Meshtastic tests: green, 7/7.
- Updated patch deployed to `.116` as binary sha `028ec6ff9a60ca8970c081987457d78ed1c517cd81f7089f51b9a01745b5c3c4`.
- After redeploy, logs show `FromRadio field=11` accepted and no new `Dropping stale ... !433e3ccc` entries in the checked post-deploy window.
- There are stale other-agent shell watcher processes on `.116` referencing `RXDIAG`; leave alone unless they interfere.

View File

@ -0,0 +1,131 @@
# Bitcoin Multi-Version — Bulletproofing & Rollout (handoff)
> **Status 2026-06-29:** code + images + catalog + frontend DONE on branch
> `bitcoin-version-bulletproof` (base commit `095a76cd`, plus the catalog-generator
> + handoff follow-ups). **.228 is the test node**: binary + frontend + catalog are
> live there; its Knots chainstate is mid-**reindex recovery** (see §5). The fleet
> rollout (OTA binary+frontend, mirror catalog publish, `:latest` repoint) is the
> **coordinated step the other agent owns** — see §4. Pairs with
> `docs/bitcoin-multi-version-design.md` (the original design).
## 1. What was broken (root causes)
User report: "switched Knots to `v29.3.knots20260508`, version didn't update in the UI."
Three **stacked** bugs, plus a data-corruption hazard:
1. **Reconciler reverted the pin.** `prod_orchestrator::sync_quadlet_unit` re-rendered the
quadlet every reconcile tick using the manifest's `:latest`, ignoring the per-app
pinned version → any switch silently reverted within one tick.
2. **Entrypoint render bug.** The renderer folded the manifest `entrypoint: ["sh","-lc"]`
into `Exec=`. That only works when the image ENTRYPOINT is a passthrough shell wrapper.
The versioned images use `ENTRYPOINT ["bitcoind"]`, so `Exec=sh -lc …` became
`bitcoind sh -lc …``unexpected token 'sh'` → crash loop.
3. **Image USER divergence.** The versioned images were built `USER bitcoin` (uid 1000);
the legacy `:latest` ran as **root**. Chain data is owned by the `data_uid`
(host 100101 / container uid 102). Root reads it via `CAP_DAC_OVERRIDE` (granted in the
manifest); uid-1000 cannot → `Error initializing block database`.
4. **Data hazard (already hit on .228).** Repeated failed starts under mixed UIDs left
bitcoind's two LevelDBs (`blocks/index/` + `chainstate/`) truncated to KB stubs while
the raw `blocks/blk*.dat` (797 GB) stayed intact. Recovery = `bitcoind -reindex` from
local blocks (no re-download). The uniform-root image fix (below) removes the mixed-UID
cause going forward; the proper switch flow was already data-safe (600s stop grace,
clean stop→rm→recreate, conflict-stops the other impl — they share port 8332 + datadir
`/var/lib/archipelago/bitcoin`).
## 2. What was fixed (all on the branch)
- **Renderer** (`core/archipelago/src/container/`):
- `prod_orchestrator.rs`: factored `resolve_catalog_image()` (catalog/pinned-version →
image) and call it in BOTH `install_fresh` and `sync_quadlet_unit` — the pin now
survives reconcile.
- `quadlet.rs`: emit a real `Entrypoint=<first>` + `Exec=<rest+cmd>` instead of folding;
`exec_changed` now also diffs `Entrypoint=` so the recreate fires. Validated against
the live podman 5.4.2 quadlet generator.
- **Images** (`scripts/build-bitcoin-image.sh`, `apps/bitcoin-{knots,core}/Dockerfile`):
removed `USER bitcoin` → run as **container-root** like legacy (still 100% rootless:
container-root maps to the unprivileged host service user; `CAP_DAC_OVERRIDE` from the
manifest lets bitcoind read the `data_uid`-owned datadir). **All** images rebuilt root +
pushed to the mirror (`146.59.87.168:3000/lfg2025`):
- Knots: `29.3.knots20260508`, `29.3.knots20260507`, `29.3.knots20260210`, `29.2.knots20251110`
- Core: `25.2 26.2 27.2 28.4 29.2 29.3 30.2 31.0` + `latest` (→31.0)
- **Catalog** (`scripts/generate-app-catalog.sh` VERSIONS map + regenerated
`releases/app-catalog.json`): Knots & Core `versions[]` populated; the generator now
forces top-level `version` == the `default` entry's version (the `169ff2e2` invariant)
regardless of the manifest version. Knots `latest` entry points at the newest **dated**
image (`29.3.knots20260508`) so "Always use latest" = newest on fixed-binary nodes.
- **Frontend** (`neode-ui/`):
- `AppSidebar.vue`: rename the latest option to **"Always use the latest version"**
(no `v` prefix), fix right padding, and `pickSelection()` guarantees the bound value is
a real option (fixes the blank dropdown).
- New `components/InstallVersionModal.vue`: full-screen version chooser shown from the
App Store / Discover **card** install button for multi-version apps — app icon +
"Install <name>", latest pre-selected. Wired in `Discover.vue handleInstall`.
- i18n keys: `appDetails.alwaysUseLatestVersion`, `marketplace.installModalTitle/Hint`.
## 3. Current live state on .228 (test node)
- Binary with both renderer fixes: **deployed** (`/usr/local/bin/archipelago`).
- New frontend bundle: **deployed** to `/opt/archipelago/web-ui` (hard-refresh to see it).
- Updated catalog: placed at `/var/lib/archipelago/app-catalog.json` (local override —
will refresh from the mirror's OLDER copy at the next hourly fetch until §4 publishes it).
- Knots: `bitcoin-knots` service held **stopped** (`package.stop`, user_stopped);
a detached `bitcoin-knots-reindex` container is rebuilding the index+UTXO (§5).
## 4. Remaining — coordinated fleet rollout (OTHER AGENT)
Do this together with the other workstream's release, AFTER both are ready:
1. **Merge** branch `bitcoin-version-bulletproof` into the release line.
2. **Build + OTA** the binary + frontend (these carry the renderer fix + UI). The renderer
fix is a **hard prerequisite** for the new images everywhere — see fleet-safety below.
3. **Publish the catalog** to the mirror (push `releases/app-catalog.json` to gitea-vps2
`main`, the raw URL nodes fetch hourly). The current catalog is **fleet-safe even before
the binary lands**: unpinned/auto-update nodes resolve via the manifest's floating
`:latest` (still the legacy image); only explicit version selection (needs the new UI)
uses the new root images.
4. **Only AFTER the binary is fleet-wide:** optionally repoint the `bitcoin-knots:latest`
tag → `29.3.knots20260508` (root) and simplify the catalog `latest` entry back to the
`:latest` tag. **Do NOT repoint `:latest` before then** — old-binary nodes fold
`Exec=sh -lc …` and would crash on an `ENTRYPOINT ["bitcoind"]` image. (Core never
worked on old binaries — it always shipped `ENTRYPOINT ["bitcoind"]` — so Core has no
such constraint.)
5. **Verify the full switch matrix** on a healthy node (§6).
## 5. Finishing .228's reindex (OTHER AGENT owns this — not babysat by the original author)
The detached `bitcoin-knots-reindex` container runs the new **root** `29.3.knots20260508`
image with `-reindex -server=0` against `/var/lib/archipelago/bitcoin`. It holds the datadir
lock, so the managed service (held stopped) can't collide. When it has connected blocks up
to ~the prior tip (height ≥ ~955800) it's done; then:
```sh
# on .228 (SSH/sudo/UI pw all: ThisIsWeb54321@)
podman stop -t 600 bitcoin-knots-reindex && podman rm bitcoin-knots-reindex
# start the managed service via RPC (sets desired=running, clears user_stopped):
# package.start {id: bitcoin-knots} (POST https://127.0.0.1/rpc/v1, CSRF: echo csrf_token cookie as X-CSRF-Token)
# verify:
podman exec bitcoin-knots sh -lc '$(command -v bitcoind) --version | head -1' # → v29.3.knots20260508
# RPC up → the Bitcoin UI populates; it syncs the gap to tip.
```
The "Bitcoin RPC connection refused (127.0.0.1:8332)" the UI shows is EXPECTED until this
swap (reindex runs with RPC off).
## 6. Switch-matrix test plan (what "bulletproof" must prove)
On a healthy node, each step must end with bitcoind running + RPC answering + syncing, with
NO `Error initializing block database` and NO data loss:
- Knots: switch `latest``29.3.knots20260507``29.3.knots20260210` → back to `latest`.
- Core: install `latest`; switch `31.0``28.4.0`.
- **Knots ↔ Core** (shared datadir/port): Knots→Core upgrade path (Core ≥ data version) and
the reverse. **Cross-major DOWNGRADES** (e.g. 29.x data → Core 28.4) legitimately need a
reindex — the UI already surfaces a downgrade warning; confirm it does and that confirming
reindexes cleanly rather than crash-looping.
- Reboot survival after each switch.
## 7. Notes / assumptions
- **"29.2"** in the request doesn't exist as a Knots build (404 upstream); added as **Bitcoin
Core 29.2** (exists). Revisit if a Knots 29.2 was meant.
- Reindex is unavoidable ONLY because .228's index was already corrupted by the pre-fix
crash loop; a normal switch on the fixed binary does NOT reindex.
- Creds for .228: SSH/sudo + UI/RPC all `ThisIsWeb54321@`.

View File

@ -0,0 +1,171 @@
# Archipelago Hardware Signer — Design Notes (PSBT + Nostr)
> Status: **exploratory / spec stub** (2026-06-24). No code yet. This captures the
> hardware-selection reasoning and architecture for a small, air-gapped, super-secure
> signing device built around the Tropic Square **TROPIC01** secure element, intended
> to integrate with Archipelago as an external signer.
## 1. Goal
A small, super-secure, air-gapped handheld device that:
- Signs **Bitcoin PSBTs** for the Archipelago wallet.
- (Stretch / dual-function) Signs **Nostr events** for the node's sovereign identity.
- Communicates **only via QR** (camera in, screen out) — no USB data path, no radio in
use. Pure air-gap, same threat model as SeedSigner but with a real audited secure element.
- Anchors key-at-rest security and RNG in the **TROPIC01** open-source secure element.
## 2. The critical curve caveat
**TROPIC01's signing engine supports P-256 (ECDSA) and Ed25519 (EdDSA) — NOT secp256k1.**
Bitcoin and Nostr both require secp256k1. Therefore:
- The secure element is the **vault + RNG + attestation**, not the signer.
- The seed lives encrypted inside TROPIC01 (tamper mesh, pairing, secure channel).
- The host MCU does the actual **secp256k1 ECDSA (Bitcoin)** and **Schnorr / BIP-340
(Taproot + Nostr)** signing in software.
- TODO before committing: re-check whether a firmware revision adds secp256k1 — it's
open RISC-V silicon and has been a community ask. If/when it lands, this design gets
materially stronger (signing in-silicon).
## 3. Architecture (two chips)
```
[ QR in ] --> Camera (OV2640)
|
Host MCU (ESP32-S3) <--SPI--> TROPIC01 (Mini Board)
| (seed vault, RNG,
Touch screen secure channel, attest)
|
[ QR out ] <-- Display (signed PSBT / signed event)
```
- **Host MCU** drives camera, touch screen, QR parse/render, PSBT + Nostr logic, and
the secp256k1/Schnorr signing.
- **TROPIC01** protects the seed at rest and supplies the TRNG + secure boot/attestation
over an authenticated+encrypted SPI channel.
## 4. Hardware selection
### 4.1 MCU — the camera-ease vs radio-purity fork
| | **ESP32-S3** (recommended) | **RP2350** |
|---|---|---|
| Camera | Native DVP interface; huge QR-scan code ecosystem | No camera peripheral — bit-bang over PIO (harder) |
| Radios on die | WiFi + BLE present (con for air-gap purists) | **None** |
| Security | Secure boot, flash encryption | Cortex-M33 + TrustZone, signed boot, OTP |
| secp256k1 in SW | Fine (240 MHz dual-core) | Fine (150 MHz dual-core M33) |
| Price (chip / board) | ~$3 / ~$6 | ~$1.20 / ~$5 |
**Pick: ESP32-S3 (N16R8 — 16MB flash / 8MB PSRAM).** The camera is the hard part of the
build and the S3 is the only cheap MCU with a native camera interface. PSRAM matters for
holding camera frames during QR decode. The on-die radio is the one downside — acceptable
because trust is anchored in the TROPIC01, not the MCU. If radio-on-die is a hard no,
switch to RP2350 and accept harder camera bring-up. (SeedSigner deliberately chose a
no-WiFi Pi Zero 1.3 for exactly this reason — the concern is legitimate.)
### 4.2 Camera
- **OV2640** 2MP module — standard ESP32-cam sensor, code everywhere. ~$24.
### 4.3 Thin touch screen
Pick by review legibility (the whole security value is the human verifying address +
amount before tap-to-approve):
- **2.0" IPS ST7789 capacitive, 240×320 — recommended.** Easiest to read a full Bitcoin
address/amount. ~$812.
- 1.69" rounded-rect IPS ST7789 + CST816 cap touch — best size/compactness balance.
~$710.
- 1.28" round (GC9A01 + CST816) — smallest/thinnest but **too cramped** for address
verification; skip for a signer.
**Do not go below ~1.69".** Use capacitive (not resistive) touch for a thin glass-front
tap-to-confirm feel.
### 4.4 TROPIC01 board (from the Tropic Square order form)
All options speak SPI (wires to the S3 the same way). Two-board plan:
- **Development: TROPIC01 USB DevKit (€50)** — STM32 + USB-to-SPI stick. Bring up the
secure-element stack (pairing, key gen, secure channel) on a PC first, independent of
the camera/screen work.
- **Final device: TROPIC01 Mini Board (€9.50)** — small easy-to-solder module exposing
SPI; solder straight to the S3's SPI bus inside the enclosure.
- Skip: Standalone Sample (€5, bare QFN — needs hot-air), Raspberry Pi / Arduino Shields
(wrong host form factor), MIKROE Click (€20, only if you have a mikroBUS rig).
### 4.5 Rough BOM
| Item | ~Cost |
|---|---|
| ESP32-S3 N16R8 board | $68 |
| OV2640 camera | $24 |
| 2.0" cap-touch IPS | $812 |
| TROPIC01 Mini Board | €9.50 |
| (Dev only) TROPIC01 USB DevKit | €50 |
**Core device BOM ≈ $2030** + TROPIC01 Mini Board, before enclosure/battery.
## 5. Dual-function: Nostr signer
Genuinely viable and a natural fit — **Nostr signs with Schnorr/BIP-340 over secp256k1,
the same scheme as Bitcoin Taproot.** So Nostr signing reuses the secp256k1+Schnorr code
already needed for Bitcoin — near-zero marginal firmware cost.
### 5.1 One seed → two separated keys
From the single seed in the TROPIC01:
- **Bitcoin:** BIP-32/39/84 HD derivation.
- **Nostr:** **NIP-06** deterministic derivation (`m/44'/1237'/…`) → `nsec`/`npub`.
One backup, two independent identities, no cross-contamination.
### 5.2 Cold vs hot tension
| | Bitcoin | Nostr |
|---|---|---|
| Frequency | Rare, high-value | Frequent, often interactive |
| Natural transport | QR / PSBT — air-gap perfect | Apps want real-time signing |
| Air-gap comfort | Excellent | Fine for occasional events, painful for chat |
Two possible modes:
1. **Air-gapped QR Nostr signer (recommended):** app shows unsigned-event QR → camera
scan → touch approve → signed-event QR back. Great for high-value/infrequent events
(root identity, profile/metadata, key rotation, announcements). Keeps 100% air-gap.
2. **Connected NIP-46 "bunker" over USB/serial:** enables interactive real-time signing
but **breaks the air-gap** and reintroduces the USB/radio attack surface. Not
recommended for this device.
### 5.3 Recommendation
Keep it **cold for both roles.** The device guards the Bitcoin spending key *and* the
high-value Nostr **identity** key — neither ever touches a network. Day-to-day Nostr
chatter uses a separate hot software key; the hardware device protects only the
identity-defining key you can't afford to leak. Avoids putting a hot key next to cold
Bitcoin funds.
## 6. Archipelago integration
- Slots in as an **external signer** path alongside the existing wallet flow — does not
touch the orchestrator. Archipelago builds PSBT → renders QR (animated QR for large
txs) → device scans → touch review → returns signed-PSBT QR → Archipelago broadcasts.
- Especially apt given Archipelago's Nostr/Blossom catalog + node-identity direction
(see `dht-distribution-design.md`): the device becomes the **hardware root of trust**
for both halves of a node's identity — its `npub`/DID and its Bitcoin keys — aligning
with the sovereign/secure/rootless north star.
## 7. Open items / next steps
- [ ] **Pin budget:** confirm the S3 GPIO/SPI budget fits camera DVP + display SPI +
TROPIC01 SPI simultaneously. (Biggest unknown before buying.)
- [ ] Confirm current TROPIC01 firmware secp256k1 status (could remove the §2 caveat).
- [ ] Define QR payload formats for both roles (PSBT vs unsigned Nostr-event JSON) so a
single scan→approve→return firmware loop handles either transparently.
- [ ] Animated/multi-part QR strategy for large PSBTs.
- [ ] Seed provisioning ceremony into the TROPIC01 (gen on-device via its TRNG; never
import in clear).
- [ ] Enclosure + power (battery vs USB-power-only-while-airgapped).
- [ ] Decide: ESP32-S3 (radio present) vs RP2350 (no radio, harder camera) — final call.

View File

@ -0,0 +1,74 @@
# Workstream B — Signed app-catalog: completion runbook
**Status (2026-06-28):** The registry-distributed manifest pipeline is live — nodes fetch
`releases/app-catalog.json` from the OTA mirror and embed manifests (origin-wins, disk
fallback). What remains for Workstream B is **authenticity**: pin the release-root anchor and
ship a *signed* catalog so nodes can cryptographically verify the publisher.
Today the catalog is **accepted unsigned** ("migration window") and the anchor is **unpinned**
(`core/archipelago/src/trust/anchor.rs:21``RELEASE_ROOT_PUBKEY_HEX = None`). Completing B is
a coordinated ceremony that **only the publisher can run** — it needs the offline
`RELEASE_MASTER_MNEMONIC`, which is not (and must not be) stored on any node or build host.
## Why this is gated on you (not automatable)
- The signing key is an **offline mnemonic** you hold (`archipelago ceremony gen` output, backed
up offline / via `seed.reveal`). It is intentionally absent from the repo and all hosts.
- Order matters: once a binary **pins** the anchor, a catalog carrying a signature from the
*wrong* key is **hard-rejected fleet-wide** (`trust/signed_doc.rs:79`). Unsigned and
correctly-signed catalogs are both accepted; only a *mismatched* signature breaks nodes.
- So the pinned pubkey and the signature MUST come from the same key, shipped consistently.
## The ceremony (run from `core/`, with your mnemonic)
```bash
# 0. (only if you don't already have a release-root key) generate one and back the
# mnemonic up OFFLINE. Prints the pubkey hex + signer did:key.
cargo run --release -p archipelago -- ceremony gen
# 1. Print the release-root pubkey hex for the anchor (idempotent; same mnemonic → same key)
RELEASE_MASTER_MNEMONIC="word1 word2 …" cargo run --release -p archipelago -- ceremony pubkey
# → copy the 64-char hex.
# 2. Pin it in code:
# core/archipelago/src/trust/anchor.rs:21
# - pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None;
# + pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = Some("<64-char-hex-from-step-1>");
# 3. Sign the published catalog in place (inserts `signature` + `signed_by` over the
# canonical JSON — re-run after ANY catalog regen, since signing covers the exact bytes):
RELEASE_MASTER_MNEMONIC="word1 word2 …" \
cargo run --release -p archipelago -- ceremony sign releases/app-catalog.json
# 4. Verify locally before shipping (optional sanity): a node build with the pinned anchor
# should log "app-catalog: release-root signature verified (<did>)" rather than
# "self-consistent but anchor not pinned".
```
## Ship order (backward-compatible)
1. Commit the **signed** `releases/app-catalog.json` + the `anchor.rs` change together.
2. Push the signed catalog to the OTA mirror (gitea-vps2 `main`) — old binaries (no pinned
anchor) still accept it (verified-but-unconfirmed); nothing breaks.
3. Build + OTA the binary with the pinned anchor. New nodes now **verify** the catalog against
the anchor. (This is the normal release path — gate the tag per the ship-ritual.)
4. **Later / optional hardening:** once the whole fleet is on the pinned-anchor binary, flip
the policy from "accept unsigned (migration window)" to "reject unsigned" in
`container/app_catalog.rs` (the `SignatureStatus::Unsigned` arm). Do this LAST — while any
node still runs an unsigned catalog it must keep being accepted.
## Env-override escape hatch (no rebuild)
For staging/canary you can pin the anchor without editing code via
`ARCHY_RELEASE_ROOT_PUBKEY=<hex>` (`trust/anchor.rs:23`) on a single node, then sign the catalog
and confirm that node verifies it before baking the constant in.
## What's already done (so this is the only remaining step)
- Catalog distribution + manifest embedding: live (this session's `169ff2e2` published the
corrected catalog to the mirror).
- `ceremony gen|pubkey|sign` tooling: shipped (`core/archipelago/src/ceremony.rs`).
- Verify path: `trust::verify_detached` accepts unsigned, verifies signed against the anchor,
hard-rejects mismatches (`trust/signed_doc.rs`).
- Detached-signature schema fields (`signature`/`signed_by`) already part of the signed
preimage (`container/app_catalog.rs`).

View File

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

View File

@ -0,0 +1,120 @@
<template>
<BaseModal :show="show" title="" max-width="max-w-lg" @close="emit('close')">
<!-- Header: app icon + "Install Bitcoin Knots/Core" -->
<div class="flex items-center gap-4 mb-5 -mt-2">
<img
v-if="app?.icon"
:src="app.icon"
:alt="app?.title || ''"
class="w-14 h-14 rounded-xl shadow-lg shrink-0"
/>
<div v-else class="w-14 h-14 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<svg class="w-7 h-7 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
<h3 class="text-xl font-semibold text-white leading-snug">
{{ t('marketplace.installModalTitle', { name: app?.title || appId }) }}
</h3>
</div>
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
<div v-else class="space-y-2">
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<select
v-model="selected"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
</select>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div>
<template #footer>
<div class="flex gap-2 mt-6">
<button
type="button"
class="flex-1 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-sm font-semibold py-2.5 transition-colors"
:disabled="loading || !selected"
@click="confirm"
>
{{ t('common.install') }}
</button>
<button
type="button"
class="rounded-lg bg-white/10 hover:bg-white/20 text-white text-sm font-medium px-5 py-2.5 transition-colors"
@click="emit('close')"
>
{{ t('common.cancel') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from './BaseModal.vue'
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
import { displayVersion } from '@/utils/version'
const props = defineProps<{
show: boolean
appId: string
app: { id: string; title?: string; icon?: string } | null
}>()
const emit = defineEmits<{
close: []
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
confirm: [version: string]
}>()
const { t } = useI18n()
const loading = ref(false)
const versions = ref<CatalogVersionInfo[]>([])
const selected = ref('')
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
function optionLabel(v: CatalogVersionInfo): string {
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
let label = displayVersion(v.version)
if (v.deprecated) label += ' (deprecated)'
if (v.eol) label += ` · EOL ${v.eol}`
return label
}
async function load() {
loading.value = true
versions.value = []
selected.value = ''
try {
const info = await rpcClient.getPackageVersions(props.appId)
// catalog_versions() returns the list default(=latest)-first, so versions[0]
// is the latest pre-select it.
versions.value = info.versions || []
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
} catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed.
selected.value = 'latest'
} finally {
loading.value = false
}
}
function confirm() {
if (!selected.value) return
emit('confirm', selected.value)
}
watch(
() => props.show,
(open) => {
if (open) void load()
},
{ immediate: true },
)
</script>

View File

@ -330,6 +330,8 @@
"marketplace": {
"title": "App Store",
"subtitle": "Discover and install apps for your new sovereign life",
"installModalTitle": "Install {name}",
"installModalHint": "Choose a version to install. The latest is recommended.",
"curatedTab": "Curated",
"communityTab": "Community",
"nostrCommunityTab": "Nostr Community",
@ -572,6 +574,7 @@
"versionUpdates": "Version & Updates",
"runningVersion": "Running version",
"selectVersion": "Version",
"alwaysUseLatestVersion": "Always use the latest version",
"autoUpdateLatest": "Auto-update to latest",
"autoUpdatePinnedNote": "Auto-update is disabled while a version is pinned.",
"confirmDowngrade": "Downgrade anyway",

View File

@ -207,6 +207,15 @@
<p class="text-white/60 text-xl mt-4 font-mono">// Cypherpunks write code. We run nodes.</p>
</div>
<!-- First-install version chooser (Bitcoin Knots / Core) -->
<InstallVersionModal
:show="showInstallModal"
:app-id="installModalApp?.id || ''"
:app="installModalApp"
@close="showInstallModal = false; installModalApp = null"
@confirm="onInstallModalConfirm"
/>
</div>
</template>
@ -228,6 +237,7 @@ import { APP_STORE_SECTIONS } from './appStoreCategories'
import DiscoverHero from './discover/DiscoverHero.vue'
import FeaturedApps from './discover/FeaturedApps.vue'
import AppGrid from './discover/AppGrid.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import type { MarketplaceApp, FeaturedApp } from './discover/types'
import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp, fetchAppCatalog, type CatalogFeatured } from './discover/curatedApps'
@ -262,6 +272,10 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
// been removed in favour of the store's phase-aware mapping.
const installingApps = serverStore.installingApps
// First-install version-choice modal (multi-version apps: Bitcoin Knots / Core)
const showInstallModal = ref(false)
const installModalApp = ref<MarketplaceApp | null>(null)
function navigateToMarketplace(categoryId: string) {
router.push({ name: 'marketplace', query: { category: categoryId } })
}
@ -427,19 +441,42 @@ function launchInstalledApp(app: MarketplaceApp) {
appLauncher.openSession(app.id)
}
function handleInstall(app: MarketplaceApp) {
async function handleInstall(app: MarketplaceApp) {
const blocked = installBlockedReason(app.id)
if (blocked) {
toast.error(blocked)
return
}
if (installingApps.has(app.id) || isInstalled(app.id)) return
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
// front via a full-screen modal (latest pre-selected) instead of silently
// installing the default. Best-effort if the lookup fails we install directly.
try {
const info = await rpcClient.getPackageVersions(app.id)
if (info.supportsVersions && info.versions.length > 1) {
installModalApp.value = app
showInstallModal.value = true
return
}
} catch { /* no catalog versions — fall through to direct install */ }
startInstall(app)
}
function startInstall(app: MarketplaceApp, versionOverride?: string) {
if (app.source === 'local') {
installApp(app)
installApp(app, versionOverride)
} else {
installCommunityApp(app)
installCommunityApp(app, versionOverride)
}
}
function onInstallModalConfirm(version: string) {
const app = installModalApp.value
showInstallModal.value = false
installModalApp.value = null
if (app) startInstall(app, version)
}
function viewAppDetails(app: MarketplaceApp) {
try {
if (isInstalled(app.id)) {
@ -514,27 +551,27 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
}
async function installApp(app: MarketplaceApp) {
async function installApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version }, timeout: 600000 })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
failInstall(app, err)
}
}
async function installCommunityApp(app: MarketplaceApp) {
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
queueInstall(app)
toast.info("Installing " + (app.title ?? app.id) + " - check My Apps")
router.push('/dashboard/apps').catch(() => {})
try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
if ((app as Record<string, unknown>).containerConfig) {
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
}

View File

@ -47,7 +47,7 @@ const pushMock = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
useRoute: () => ({ query: {} }),
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), onError: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
createWebHistory: vi.fn(),
}))

View File

@ -48,11 +48,9 @@
<select
v-model="selectedVersion"
:disabled="versionBusy"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-blue-400/60"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}{{ v.eol ? ` · EOL ${v.eol}` : '' }}
</option>
<option v-for="v in versionInfo.versions" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
</select>
</div>
@ -252,7 +250,8 @@
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AppCredentialsResponse } from '@/types/api'
import { rpcClient, type PackageVersionsResponse } from '../../api/rpc-client'
import { rpcClient, type PackageVersionsResponse, type CatalogVersionInfo } from '../../api/rpc-client'
import { displayVersion } from '@/utils/version'
const { t } = useI18n()
const copiedCredential = ref('')
@ -334,10 +333,33 @@ const isPinned = computed(() => !!versionInfo.value?.pinnedVersion)
const versionDirty = computed(() => {
const info = versionInfo.value
if (!info) return false
const currentSelection = info.pinnedVersion || info.default || info.installedVersion || ''
return selectedVersion.value !== currentSelection || autoUpdate.value !== info.autoUpdate
return selectedVersion.value !== pickSelection(info) || autoUpdate.value !== info.autoUpdate
})
// Option label: the floating "latest" entry reads as a sentence (no "v"
// prefix); every concrete version is normalized via $ver + status suffixes.
function versionOptionLabel(v: CatalogVersionInfo): string {
if (v.version === 'latest') return t('appDetails.alwaysUseLatestVersion')
let label = displayVersion(v.version)
if (v.deprecated) label += ' (deprecated)'
if (v.eol) label += ` · EOL ${v.eol}`
return label
}
// Resolve the dropdown's current value and GUARANTEE it matches a real option,
// otherwise the native <select> renders blank. Prefer the pin, then the catalog
// default, then the running version but fall back to the default/first option
// when none of those is actually in the list (e.g. a stale installedVersion the
// catalog no longer carries, which is what left the control blank).
function pickSelection(info: PackageVersionsResponse): string {
const options = info.versions.map((v) => v.version)
const preferred = info.pinnedVersion || info.default || info.installedVersion || ''
if (preferred && options.includes(preferred)) return preferred
return info.default && options.includes(info.default)
? info.default
: info.versions[0]?.version || ''
}
async function loadVersions(appId: string) {
versionInfo.value = null
versionError.value = ''
@ -346,7 +368,7 @@ async function loadVersions(appId: string) {
const info = await rpcClient.getPackageVersions(appId)
if (!info.supportsVersions || !info.versions.length) return
versionInfo.value = info
selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || info.versions[0]?.version || ''
selectedVersion.value = pickSelection(info)
autoUpdate.value = info.autoUpdate
} catch (err) {
if (import.meta.env.DEV) console.warn('[AppSidebar] getPackageVersions failed:', err)
@ -381,7 +403,7 @@ function cancelDowngrade() {
downgradeWarning.value = ''
// Reset the dropdown to the current selection.
const info = versionInfo.value
if (info) selectedVersion.value = info.pinnedVersion || info.default || info.installedVersion || ''
if (info) selectedVersion.value = pickSelection(info)
}
watch(

View File

@ -90,7 +90,7 @@ describe('AppIconGrid', () => {
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
})
it('routes desktop new-tab apps through app session on mobile', async () => {
it('opens unresolved new-tab apps externally on mobile', async () => {
Object.defineProperty(window, 'innerWidth', {
value: 390,
writable: true,
@ -107,7 +107,11 @@ describe('AppIconGrid', () => {
await wrapper.get('.app-icon-item').trigger('click')
await flushPromises()
expect(mockWindowOpen).not.toHaveBeenCalled()
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.198:3001',
'_blank',
'noopener,noreferrer',
)
expect(useAppLauncherStore(pinia).panelAppId).toBeNull()
})

View File

@ -1,6 +1,6 @@
{
"schema": 1,
"updated": "2026-06-28",
"updated": "2026-06-29",
"apps": {
"adguardhome": {
"version": "v0.107.55",
@ -334,7 +334,7 @@
}
},
"bitcoin-core": {
"version": "28.4.0",
"version": "latest",
"manifest": {
"app": {
"id": "bitcoin-core",
@ -433,6 +433,11 @@
}
},
"versions": [
{
"version": "latest",
"image": "146.59.87.168:3000/lfg2025/bitcoin:latest",
"default": true
},
{
"version": "31.0",
"image": "146.59.87.168:3000/lfg2025/bitcoin:31.0"
@ -445,10 +450,13 @@
"version": "29.3",
"image": "146.59.87.168:3000/lfg2025/bitcoin:29.3"
},
{
"version": "29.2",
"image": "146.59.87.168:3000/lfg2025/bitcoin:29.2"
},
{
"version": "28.4.0",
"image": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
"default": true
"image": "146.59.87.168:3000/lfg2025/bitcoin:28.4"
},
{
"version": "27.2",
@ -569,12 +577,24 @@
"versions": [
{
"version": "latest",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508",
"default": true
},
{
"version": "29.3.knots20260508",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260508"
},
{
"version": "29.3.knots20260507",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260507"
},
{
"version": "29.3.knots20260210",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.3.knots20260210"
},
{
"version": "29.2.knots20251110",
"image": "146.59.87.168:3000/lfg2025/bitcoin-knots:29.2.knots20251110"
}
]
},

View File

@ -144,7 +144,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, exactly like the legacy hand-built :latest image.
# Rootless Podman maps container-root to the unprivileged host service user, and
# 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 here 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

View File

@ -21,6 +21,7 @@ INTERNAL_MANIFEST_IDS = {
"core-lightning",
"did-wallet",
"electrs-ui",
"fips-ui",
"lightning-stack",
"lnd-ui",
"mempool-api",
@ -28,6 +29,16 @@ INTERNAL_MANIFEST_IDS = {
"router",
"strfry",
"web5-dwn",
"immich-postgres",
"immich-redis",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-minio",
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-relay",
"netbird-dashboard",
"netbird-server",
}
LEGACY_STACK_CATALOG_IDS = {

View File

@ -182,10 +182,12 @@ VERSIONS = {
# "Version & Updates" card. Add the next release by building its image then
# prepending it here.
"bitcoin-core": [
{"version": "latest", "image": f"{REGISTRY}/bitcoin:latest", "default": True},
{"version": "31.0", "image": f"{REGISTRY}/bitcoin:31.0"},
{"version": "30.2", "image": f"{REGISTRY}/bitcoin:30.2"},
{"version": "29.3", "image": f"{REGISTRY}/bitcoin:29.3"},
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4", "default": True},
{"version": "29.2", "image": f"{REGISTRY}/bitcoin:29.2"},
{"version": "28.4.0", "image": f"{REGISTRY}/bitcoin:28.4"},
{"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2"},
{"version": "26.2", "image": f"{REGISTRY}/bitcoin:26.2", "deprecated": True},
{"version": "25.2", "image": f"{REGISTRY}/bitcoin:25.2", "deprecated": True},
@ -196,16 +198,35 @@ VERSIONS = {
# top-level catalog version (L167-168) or the card can't reach "latest" and
# selecting the highlighted default would instead pin+recreate. Pinning
# 29.3.knots20260508 moves a runner off the floating tag.
# `latest` is the default and points at the NEWEST published dated image
# (not the bare :latest tag) so "Always use the latest version" installs the
# newest build on fixed-binary nodes, while UNPINNED nodes still resolve via
# the manifest's floating :latest tag (kept on the legacy image until the
# entrypoint-render fix is fleet-deployed — see
# docs/bitcoin-version-bulletproof-rollout.md).
"bitcoin-knots": [
{"version": "latest",
"image": f"{REGISTRY}/bitcoin-knots:latest", "default": True},
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "default": True},
{"version": "29.3.knots20260508",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508"},
{"version": "29.3.knots20260507",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260507"},
{"version": "29.3.knots20260210",
"image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260210"},
{"version": "29.2.knots20251110",
"image": f"{REGISTRY}/bitcoin-knots:29.2.knots20251110"},
],
}
for app_id, versions in VERSIONS.items():
if app_id in apps and versions:
apps[app_id]["versions"] = versions
# The default/latest entry MUST equal the app's top-level catalog
# `version` (commit 169ff2e2) so selecting the highlighted default
# un-pins / tracks latest instead of pinning+recreating. Enforce it here
# rather than relying on the manifest version matching.
default_entry = next((v for v in versions if v.get("default")), None)
if default_entry:
apps[app_id]["version"] = default_entry["version"]
catalog = {
"schema": 1,

View File

@ -29,6 +29,7 @@ CASCADE_CONFIG="${ARCHY_CASCADE_CONFIG:-{\"ports\":[\"3000:3000\"],\"volumes\":[
CASCADE_DATA_DIR="${ARCHY_CASCADE_DATA_DIR:-/var/lib/archipelago/${CASCADE_APP}}"
setup_file() {
cascade_enabled || return 0
: "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}"
export ARCHY_FORCE_LOGIN=1
rpc_login
@ -55,6 +56,71 @@ app_state() {
| jq -r --arg id "$CASCADE_APP" '.data["package-data"][$id].state // "absent"'
}
# Live uninstall stage shown by My Apps, or an empty string when the entry is gone
# or the backend has not emitted a stage yet.
app_uninstall_stage() {
rpc_result server.get-state '{}' 2>/dev/null \
| jq -r --arg id "$CASCADE_APP" '.data["package-data"][$id]["uninstall-stage"] // ""'
}
# Mirror the frontend's AppCard.vue mapping so the gate proves the UI has
# backend data that can render as a monotonic, non-fake progress bar.
uninstall_stage_percent() {
local stage="$1"
if [[ "$stage" =~ \(([0-9]+)[[:space:]]*/[[:space:]]*([0-9]+)\) ]]; then
local done="${BASH_REMATCH[1]}" total="${BASH_REMATCH[2]}"
if (( total > 0 )); then
(( done > total )) && done="$total"
echo $(( 10 + (done * 40 / total) ))
return 0
fi
fi
if [[ "$stage" =~ [Vv]olume ]]; then echo 70; return 0; fi
if [[ "$stage" =~ [Dd]ata ]]; then echo 90; return 0; fi
return 1
}
# Poll until CASCADE_APP disappears while enforcing the progress contract:
# stages must be parseable, monotonic, below 100 before terminal absence, and
# the operation must emit at least one visible stage instead of silently hanging.
wait_absent_with_truthful_uninstall_progress() {
local timeout="${1:-180}"
local deadline=$(( $(date +%s) + timeout ))
local saw_stage=0 last_percent=0
while (( $(date +%s) < deadline )); do
local state stage percent
state="$(app_state)"
[[ "$state" == "absent" ]] && {
(( saw_stage == 1 )) || {
echo "uninstall progress: no uninstall-stage observed before terminal absence" >&2
return 1
}
return 0
}
stage="$(app_uninstall_stage)"
if [[ -n "$stage" ]]; then
if ! percent="$(uninstall_stage_percent "$stage")"; then
echo "uninstall progress: unparseable stage '$stage'" >&2
return 1
fi
(( percent >= last_percent )) || {
echo "uninstall progress regressed: ${percent}% after ${last_percent}% (stage '$stage')" >&2
return 1
}
(( percent < 100 )) || {
echo "uninstall progress reached ${percent}% before terminal absence (stage '$stage')" >&2
return 1
}
saw_stage=1
last_percent="$percent"
fi
sleep 2
done
echo "wait_absent_with_truthful_uninstall_progress: $CASCADE_APP did not disappear within ${timeout}s (last='$(app_state)', stage='$(app_uninstall_stage)')" >&2
return 1
}
# Poll My Apps until CASCADE_APP reaches $1 (a state, or "absent").
wait_app_state() {
local target="$1" timeout="${2:-180}"
@ -102,7 +168,7 @@ wait_app_state() {
[ "$saw_transitional" -eq 1 ] || echo "# note: no transitional install state observed (image likely cached)" >&3
}
@test "uninstall ${CASCADE_APP} clears it from My Apps — NO ghost (#13)" {
@test "uninstall ${CASCADE_APP} reports truthful progress and clears My Apps — NO ghost (#13)" {
cascade_enabled || skip "ARCHY_ALLOW_CASCADE_DESTRUCTIVE not set"
app_in_my_apps || skip "${CASCADE_APP} not installed (install step must have failed)"
@ -114,8 +180,9 @@ wait_app_state() {
[ "$status" -eq 0 ]
# …AND the My Apps entry must be GONE — the #13 ghost was the entry lingering
# with a stale state / stuck uninstall stage. Poll: removal trails teardown.
run wait_app_state absent 120
# with a stale state / stuck uninstall stage. While polling, prove the backend
# emits stage data the UI can render as monotonic, non-full progress.
run wait_absent_with_truthful_uninstall_progress 120
[ "$status" -eq 0 ]
# Belt-and-suspenders: the key is truly absent from package-data.
@ -148,6 +215,6 @@ wait_app_state() {
[ "$status" -eq 0 ]
run wait_for_container_status "$CASCADE_APP" absent 180
[ "$status" -eq 0 ]
run wait_app_state absent 120
run wait_absent_with_truthful_uninstall_progress 120
[ "$status" -eq 0 ]
}