merge: bitcoin version bulletproof and OTA work
This commit is contained in:
commit
4b7cbf2b5e
@ -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": [
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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, ¤t).or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
pinned = %v,
|
||||
"version_config: pinned version not resolvable in catalog — using default"
|
||||
);
|
||||
None
|
||||
},
|
||||
)
|
||||
})
|
||||
.or_else(|| crate::container::app_catalog::catalog_image_override(&app_id, ¤t));
|
||||
if let Some(catalog_image) = resolved_image {
|
||||
if catalog_image != current {
|
||||
tracing::info!(
|
||||
app_id = %app_id,
|
||||
from = %current,
|
||||
to = %catalog_image,
|
||||
"app-catalog: overriding manifest image"
|
||||
);
|
||||
resolved_manifest.app.container.image = Some(catalog_image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: track a manifest together with the absolute directory it was loaded
|
||||
/// from, so Build sources can resolve relative `context:` paths.
|
||||
#[derive(Debug, Clone)]
|
||||
@ -1221,9 +1265,13 @@ impl ProdContainerOrchestrator {
|
||||
.get(&app_id)
|
||||
.map(|lm| lm.manifest_dir.clone())
|
||||
.unwrap_or_else(|| root.join(&app_id));
|
||||
state
|
||||
.manifests
|
||||
.insert(app_id.clone(), LoadedManifest { manifest: m, manifest_dir });
|
||||
state.manifests.insert(
|
||||
app_id.clone(),
|
||||
LoadedManifest {
|
||||
manifest: m,
|
||||
manifest_dir,
|
||||
},
|
||||
);
|
||||
overlaid += 1;
|
||||
}
|
||||
}
|
||||
@ -1809,45 +1857,7 @@ impl ProdContainerOrchestrator {
|
||||
self.ensure_app_secrets(&lm.manifest.app.id).await?;
|
||||
let mut resolved_manifest = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved_manifest)?;
|
||||
|
||||
// Decouple the app image from the shipped manifest: prefer the remote
|
||||
// app catalog when it covers this app with a same-repo image. This makes
|
||||
// both the pull below and create_container() below use the catalog tag,
|
||||
// so an app update no longer requires a binary/runtime release. Falls
|
||||
// back to the manifest image when the catalog is absent/uncovered.
|
||||
if let Some(current) = resolved_manifest.app.container.image.clone() {
|
||||
let app_id = resolved_manifest.app.id.clone();
|
||||
// Multi-version support: a runner-pinned version wins over the catalog
|
||||
// default so "install/switch to the version I chose" is honored across
|
||||
// every install + recreate. Falls through to the catalog default when
|
||||
// unpinned or the pin can't be resolved (unknown/repo-mismatch).
|
||||
let resolved_image = crate::container::version_config::pinned_version(&app_id)
|
||||
.and_then(|v| {
|
||||
crate::container::app_catalog::catalog_image_for_version(&app_id, &v, ¤t)
|
||||
.or_else(|| {
|
||||
tracing::warn!(
|
||||
app_id = %app_id,
|
||||
pinned = %v,
|
||||
"version_config: pinned version not resolvable in catalog — using default"
|
||||
);
|
||||
None
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
crate::container::app_catalog::catalog_image_override(&app_id, ¤t)
|
||||
});
|
||||
if let Some(catalog_image) = resolved_image {
|
||||
if catalog_image != current {
|
||||
tracing::info!(
|
||||
app_id = %app_id,
|
||||
from = %current,
|
||||
to = %catalog_image,
|
||||
"app-catalog: overriding manifest image"
|
||||
);
|
||||
resolved_manifest.app.container.image = Some(catalog_image);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve_catalog_image(&mut resolved_manifest);
|
||||
|
||||
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
@ -2206,6 +2216,10 @@ impl ProdContainerOrchestrator {
|
||||
|
||||
let mut resolved = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved)?;
|
||||
// Same catalog/pinned-version image resolution the installer applies, so
|
||||
// the drift re-render doesn't revert a pinned version back to the
|
||||
// manifest's shipped `:latest` tag on the next reconcile tick.
|
||||
resolve_catalog_image(&mut resolved);
|
||||
let unit = quadlet::QuadletUnit::from_manifest(&resolved, name);
|
||||
let new_body = unit.render();
|
||||
let restart_for_port_change = quadlet::publish_ports_changed(&old_body, &new_body);
|
||||
@ -3398,7 +3412,11 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
|
||||
{
|
||||
tracing::debug!(container = %name, error = %err, "quadlet stop skipped/failed");
|
||||
}
|
||||
match self.runtime.stop_container_with_grace(&name, grace_secs).await {
|
||||
match self
|
||||
.runtime
|
||||
.stop_container_with_grace(&name, grace_secs)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
let stuck_stopping = self
|
||||
@ -3908,15 +3926,17 @@ app:
|
||||
// ("release", no digit) ship — the app then vanished from the
|
||||
// orchestrator and a stack install half-fell-back to the legacy path.
|
||||
// Fail loudly here instead.
|
||||
let m = AppManifest::from_file(&mf).unwrap_or_else(|e| {
|
||||
panic!("shipped manifest {} must be valid: {e}", mf.display())
|
||||
});
|
||||
let m = AppManifest::from_file(&mf)
|
||||
.unwrap_or_else(|e| panic!("shipped manifest {} must be valid: {e}", mf.display()));
|
||||
let id = m.app.id.clone();
|
||||
let is_build = m.app.container.build.is_some();
|
||||
let value = serde_json::to_value(&m).expect("manifest serializes to JSON");
|
||||
let overlay = catalog_manifest_to_overlay(&id, value);
|
||||
if is_build {
|
||||
assert!(overlay.is_none(), "{id}: build-source app must defer to disk");
|
||||
assert!(
|
||||
overlay.is_none(),
|
||||
"{id}: build-source app must defer to disk"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
overlay.is_some(),
|
||||
@ -3999,7 +4019,8 @@ app:
|
||||
runtime,
|
||||
PathBuf::from("/nonexistent-for-tests"),
|
||||
);
|
||||
orch.set_data_dir(PathBuf::from("/nonexistent-for-tests"));
|
||||
let data_dir = tempfile::tempdir().unwrap().keep();
|
||||
orch.set_data_dir(data_dir);
|
||||
// Redirect the bitcoin-ui pre-start hook to a test-scoped
|
||||
// tmpdir, seeded with a fake password file. Shared across
|
||||
// every test in this module (OnceLock), so the hook can run
|
||||
@ -4283,22 +4304,25 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
rt.mark_image_present("archy-bitcoin-ui:local");
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
orch.insert_manifest_for_test(
|
||||
build_manifest(
|
||||
"bitcoin-ui",
|
||||
"/opt/archy/docker/bitcoin-ui",
|
||||
"archy-bitcoin-ui:local",
|
||||
),
|
||||
PathBuf::from("/opt/archy/apps/bitcoin-ui"),
|
||||
)
|
||||
.await;
|
||||
let build_context = tempfile::tempdir().unwrap();
|
||||
std::fs::write(build_context.path().join("Dockerfile"), "FROM scratch\n").unwrap();
|
||||
let build_context = build_context.path().to_string_lossy().into_owned();
|
||||
let manifest = build_manifest("bitcoin-ui", &build_context, "archy-bitcoin-ui:local");
|
||||
let fingerprint = fingerprint_build_context(Path::new(&build_context))
|
||||
.expect("test build context must be fingerprintable");
|
||||
let stamp_path = build_fingerprint_stamp_path(&orch.data_dir, "archy-bitcoin-ui:local");
|
||||
std::fs::create_dir_all(stamp_path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&stamp_path, fingerprint).unwrap();
|
||||
orch.insert_manifest_for_test(manifest, PathBuf::from("/opt/archy/apps/bitcoin-ui"))
|
||||
.await;
|
||||
|
||||
orch.install("bitcoin-ui").await.unwrap();
|
||||
let calls = rt.calls();
|
||||
assert!(calls
|
||||
.iter()
|
||||
.any(|c| c == "image_exists:archy-bitcoin-ui:local"));
|
||||
// Build must NOT be invoked because the image is already there.
|
||||
// Build must NOT be invoked because the image is already there and its
|
||||
// recorded build-context fingerprint still matches.
|
||||
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
|
||||
}
|
||||
|
||||
@ -4359,7 +4383,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
let id_u = std::process::Command::new("id").arg("-u").output().unwrap();
|
||||
let id_g = std::process::Command::new("id").arg("-g").output().unwrap();
|
||||
let uid = String::from_utf8_lossy(&id_u.stdout).trim().to_string();
|
||||
@ -4389,7 +4413,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
orch.insert_manifest_for_test(
|
||||
pull_manifest_with_generated_file(
|
||||
"exampleapp",
|
||||
@ -4416,7 +4440,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
let config_path = data_dir.path().join("config.yaml");
|
||||
std::fs::write(&config_path, "key: operator\n").unwrap();
|
||||
|
||||
@ -4441,7 +4465,7 @@ app:
|
||||
let rt = Arc::new(MockRuntime::default());
|
||||
let orch = orch_with(rt.clone()).await;
|
||||
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir_in("/var/lib/archipelago").unwrap();
|
||||
let config_path = data_dir.path().join("config.yaml");
|
||||
std::fs::write(&config_path, "key: old\n").unwrap();
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
|
||||
@ -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<()> {
|
||||
|
||||
@ -135,6 +135,16 @@ After the 2026-06-23 multinode test deploy (latest backend + UX frontend to .116
|
||||
3. **§6c Lifecycle perfection** (workstream F) — the comprehensive uninstall/reinstall +
|
||||
progress-UI + all-apps gate expansion below.
|
||||
|
||||
## 6b-bis. Bitcoin multi-version bulletproofing (2026-06-29) — READY TO MERGE + DEPLOY
|
||||
|
||||
Branch `bitcoin-version-bulletproof` (base `095a76cd`). Fixes the "switch version silently
|
||||
fails / crash-loops" class + a data-access mismatch that can corrupt a node's index. All
|
||||
code + images + catalog + frontend DONE; **.228** carries it (Knots chainstate mid-reindex
|
||||
recovery). The **coordinated fleet rollout** (OTA binary+frontend, mirror catalog publish,
|
||||
`:latest` repoint sequencing, full switch-matrix test) is the remaining work — fold it into
|
||||
the next release. **Authoritative detail + exact remaining steps + test matrix →
|
||||
`docs/bitcoin-version-bulletproof-rollout.md`.** Pairs with `docs/bitcoin-multi-version-design.md`.
|
||||
|
||||
## 6c. Lifecycle perfection — what "green" MISSED (workstream F, the perfection bar)
|
||||
|
||||
**Why this exists:** the 2026-06-23 single-node gate went 5×-green but is **NOT** the
|
||||
@ -294,6 +304,88 @@ phases 2–6 (`dual-ecash-design.md`).
|
||||
|
||||
## 8b. SESSION STATE + RESUME (updated 2026-06-26) — READ §8b "CURRENT STATE + RESUME" FIRST
|
||||
|
||||
### ▶ SESSION i (2026-06-30) — CURRENT HANDOFF / 1.8.0 OTA RESUME
|
||||
|
||||
**Branch/worktree:** currently on `bitcoin-version-bulletproof`, not `main`. Worktree is dirty.
|
||||
Do **not** discard mesh changes: they include E2E/transport indicator plumbing and the Meshtastic
|
||||
receive-path fixes below. Separate recovery note: `docs/SESSION-1.8.0-OTA-PROGRESS.md`.
|
||||
|
||||
**What was done this session:**
|
||||
1. ✅ **Local Rust release gate fixed and green.** `cargo test -p archipelago --bin archipelago` is
|
||||
green: **849/849** after fixing stale tests and the invalid `fedimint-clientd` manifest
|
||||
(`cpu_limit` was `0.25`, invalid for the current schema; now integer). `cargo check -p archipelago`
|
||||
also green after mesh edits.
|
||||
2. ✅ **Catalog/release static gates green.** `python3 scripts/check-app-catalog-drift.py --release
|
||||
--strict` is green. `scripts/check-release-manifest.sh` is green for the currently staged
|
||||
`1.7.99-alpha` manifest/artifacts. `npm run build` and `npm run type-check` are green.
|
||||
3. ✅ **Frontend unit gate fixed.** `npx vitest run --silent` now green: **81 files / 668 tests**. Fixes
|
||||
were test-only: add `router.onError` to the login test router mock and update the `AppIconGrid`
|
||||
mobile unresolved-new-tab expectation to match current app-launcher behavior.
|
||||
4. ✅ **Workstream F harness gap closed.** `tests/lifecycle/bats/cascade-uninstall.bats` now asserts
|
||||
uninstall progress truthfulness via backend `uninstall-stage`: stage must be parseable, monotonic,
|
||||
below 100 before terminal absence, and present before the app disappears. Non-destructive skip-mode
|
||||
parse check is green: `ARCHY_PASSWORD=dummy bats tests/lifecycle/bats/cascade-uninstall.bats` → 7 skip-ok.
|
||||
5. ✅ **3ccc → .116 Meshtastic receive bug taken over and partially live-validated.** Context: `3ccc`
|
||||
is the stock/non-Archy Meshtastic peer. The bug was LoRa text from `3ccc` not surfacing in
|
||||
`.116` `mesh.messages`. Root causes/fixes:
|
||||
- The prior attempted fix dropped any packet older than 10 minutes by `rx_time`; live `.116` logs
|
||||
showed `FromRadio.packet` from `!433e3ccc` being dropped as stale (`rx_time` about an hour old).
|
||||
The window is now **24h**, so recent radio FIFO/store-forward backlog surfaces instead of vanishing.
|
||||
- Radios with unset clocks can report tiny nonzero epoch values; those are now treated as unknown,
|
||||
not stale.
|
||||
- Serial prevalidation was rejecting valid `FromRadio.queueStatus` frames (`field 11`, live bytes like
|
||||
`5a04100e1810`) as corrupt payloads; field 11 and other modern non-message `FromRadio` variants
|
||||
are now accepted/ignored instead of poisoning the stream.
|
||||
- Focused Meshtastic tests green: **8/8**, including `packet_to_inbound_frame_accepts_recent_meshtastic_backlog`
|
||||
and `packet_to_inbound_frame_accepts_stock_peer_with_unset_clock`.
|
||||
- Deployed patched binary to **.116**: sha256
|
||||
`028ec6ff9a60ca8970c081987457d78ed1c517cd81f7089f51b9a01745b5c3c4` at `/usr/local/bin/archipelago`.
|
||||
Service active. Post-deploy checked window showed `FromRadio field=11` accepted and no new
|
||||
`Dropping stale ... !433e3ccc` entries.
|
||||
- There are stale other-agent `RXDIAG` shell watcher processes on `.116`; leave them unless they
|
||||
actively interfere.
|
||||
6. ✅ **Phase-3 Quadlet read-only check on .116 skip-clean.** Copied lifecycle tests to `.116` and ran
|
||||
`bats bats/use-quadlet-backends-install.bats`: **6/6 skip-clean** because no backend `.container`
|
||||
units exist. This confirms `use_quadlet_backends` is not active on `.116`; Phase-3 remains a rollout gate.
|
||||
|
||||
**Commands/results worth trusting:**
|
||||
- `cargo test -p archipelago --bin archipelago` → 849/849 green.
|
||||
- `npx vitest run --silent` from `neode-ui/` → 81 files / 668 tests green.
|
||||
- `npm run build` from `neode-ui/` → green, bundle `index-CYaDgfX3.js`.
|
||||
- `python3 scripts/check-app-catalog-drift.py --release --strict` → green.
|
||||
- `scripts/check-release-manifest.sh` → green for **v1.7.99-alpha** staged artifacts.
|
||||
- `tests/release/run.sh --manifest` was rerun after `cargo fmt`; it previously reached frontend tests,
|
||||
which are now fixed. Re-run it from scratch as the next static gate.
|
||||
|
||||
**Remaining blockers / decisions before 1.8.0 OTA:**
|
||||
1. **Release version metadata is not 1.8.0 yet.** `releases/manifest.json`, Cargo, and npm still say
|
||||
`1.7.99-alpha`; `CHANGELOG.md` top says `v1.8.00-alpha` (note double zero). Do not silently publish
|
||||
until the release version naming is decided (`1.8.0-alpha` vs `1.8.00-alpha` vs `1.8.0`).
|
||||
2. **Workstream B signing is blocked on the offline release-root mnemonic.** `docs/workstream-b-signing-runbook.md`
|
||||
says catalog distribution/embedded manifests are live, but authenticity requires the publisher to pin
|
||||
`RELEASE_ROOT_PUBKEY_HEX` and sign `releases/app-catalog.json` with `RELEASE_MASTER_MNEMONIC`.
|
||||
This cannot be automated by an agent without the offline mnemonic.
|
||||
3. **Phase-3 `use_quadlet_backends` is implemented but default-off.** Completing this requires explicit
|
||||
node/fleet flag rollout plus backend reinstall/migration verification. `.116` currently skip-clean only.
|
||||
4. **Bitcoin multi-version coordinated rollout is still separately owned/blocked by its runbook.** See
|
||||
`docs/bitcoin-version-bulletproof-rollout.md`; do not repoint `bitcoin-knots:latest` before fixed binary
|
||||
is fleet-wide.
|
||||
5. **True RF validation of 3ccc requires either a live 3ccc send or waiting for another FIFO/backlog packet.**
|
||||
Parser/unit coverage and `.116` logs strongly validate the drop-path fix, but no human was available to
|
||||
send a fresh 3ccc message during this session.
|
||||
|
||||
**Immediate next steps for the next agent:**
|
||||
1. Run `tests/release/run.sh --manifest` from repo root again; frontend unit failures are fixed, so expect
|
||||
it to pass or continue from the next failing stage.
|
||||
2. If `.116` is still the canary, monitor logs after any 3ccc activity:
|
||||
`journalctl -u archipelago --since "<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).**
|
||||
|
||||
45
docs/SESSION-1.8.0-OTA-PROGRESS.md
Normal file
45
docs/SESSION-1.8.0-OTA-PROGRESS.md
Normal 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.
|
||||
131
docs/bitcoin-version-bulletproof-rollout.md
Normal file
131
docs/bitcoin-version-bulletproof-rollout.md
Normal 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@`.
|
||||
171
docs/hardware-signer-design.md
Normal file
171
docs/hardware-signer-design.md
Normal 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. ~$2–4.
|
||||
|
||||
### 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. ~$8–12.
|
||||
- 1.69" rounded-rect IPS ST7789 + CST816 cap touch — best size/compactness balance.
|
||||
~$7–10.
|
||||
- 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 | $6–8 |
|
||||
| OV2640 camera | $2–4 |
|
||||
| 2.0" cap-touch IPS | $8–12 |
|
||||
| TROPIC01 Mini Board | €9.50 |
|
||||
| (Dev only) TROPIC01 USB DevKit | €50 |
|
||||
|
||||
**Core device BOM ≈ $20–30** + 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.
|
||||
74
docs/workstream-b-signing-runbook.md
Normal file
74
docs/workstream-b-signing-runbook.md
Normal 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`).
|
||||
@ -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": [
|
||||
|
||||
120
neode-ui/src/components/InstallVersionModal.vue
Normal file
120
neode-ui/src/components/InstallVersionModal.vue
Normal 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>
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}))
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user