diff --git a/CHANGELOG.md b/CHANGELOG.md index c0932e6f..89b01e77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,23 @@ # Changelog -## v1.8.23-alpha (2026-10-01) +## v1.9.0-alpha (2026-10-05) +Unpublished release candidate; qualification is still in progress. + +- Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce. +- Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices. +- Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions. +- Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations. +- Restrict public management access while retaining configured public apps and ACME certificate validation. +- Serve the Mempool explorer on the Angor indexer origin alongside its API. +- Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads. - Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache. - Return explicit payment-status errors with safe retry guidance when verification is unavailable. -- Show compact upload progress across screens, retain the original destination, and cancel active and queued uploads. +- Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads. +- Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion. +- Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files. +- Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility. +- Allow apps to opt in to a validated public-key list of user identities without granting signing access. - Make transaction filters transparent and horizontally scrollable on mobile. - Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps. - Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dbdf38f6..f89f3b5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,7 +95,7 @@ python3 scripts/check-git-mirrors.py --local Before publishing release artifacts, also check the actual release tag: ```bash -python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0 +python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0-alpha ``` Use the release's actual tag name. Missing refs, inaccessible mirrors or differing diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index e7d14094..b37c4146 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -436,13 +436,13 @@ { "id": "nginx-proxy-manager", "title": "Nginx Proxy Manager", - "version": "2.12.1", - "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", + "version": "2.14.0", + "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.", "icon": "/assets/img/app-icons/nginx.svg", "author": "Nginx Proxy Manager", "category": "networking", "tier": "optional", - "dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest", + "dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08", "repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager" }, { @@ -648,9 +648,9 @@ { "id": "angor-indexer", "title": "Angor Indexer", - "version": "1.0.1", - "description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.", - "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1", + "version": "1.0.2", + "description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.", + "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2", "author": "Angor / Archipelago", "requires": [ "Mempool API", diff --git a/apps/nginx-proxy-manager/manifest.yml b/apps/nginx-proxy-manager/manifest.yml index 9935bd4c..acf1887e 100644 --- a/apps/nginx-proxy-manager/manifest.yml +++ b/apps/nginx-proxy-manager/manifest.yml @@ -1,20 +1,23 @@ app: id: nginx-proxy-manager name: Nginx Proxy Manager - version: 2.12.1 + version: 2.14.0 upstream: kind: github repo: NginxProxyManager/nginx-proxy-manager description: >- Reverse proxy with SSL. Beautiful web interface for managing proxies. - On a node, this manages its admin UI and upstream configuration — the - proxy's own :80/:443 listeners are not published (the node's web server - owns those ports). + The node's public web server forwards configured domains through this + service, preserving its access lists, certificates and custom routes. + backup_before_runtime_change: true container: - image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest + image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08 pull_policy: if-not-present - network: pasta + # Rootless pasta copies the LAN IP, preventing requests back to this node. + # Retain the old pasta host gateway used by saved NPM upstreams, plus + # host.containers.internal. This subnet stays inside the private rootless namespace. + network: slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24 dependencies: - storage: 1Gi @@ -49,6 +52,17 @@ app: Nginx Proxy Manager enforces its own admin account on every page; the initial setup wizard also has to answer before any account exists. + - host: 8088 + container: 80 + protocol: tcp + bind: 127.0.0.1 + auth: local + - host: 8444 + container: 443 + protocol: tcp + bind: 127.0.0.1 + auth: local + volumes: - type: bind source: /var/lib/archipelago/nginx-proxy-manager diff --git a/core/Cargo.lock b/core/Cargo.lock index 7a6109f2..47521506 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -104,7 +104,7 @@ dependencies = [ [[package]] name = "archipelago" -version = "1.8.22-alpha" +version = "1.9.0-alpha" dependencies = [ "anyhow", "archipelago-container", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index de02f930..46edac95 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "archipelago" -version = "1.8.22-alpha" +version = "1.9.0-alpha" edition = "2021" license.workspace = true description = "Archipelago Bitcoin Node OS - Native backend" diff --git a/core/archipelago/src/api/rpc/bitcoin.rs b/core/archipelago/src/api/rpc/bitcoin.rs index 453f9281..1c0d677e 100644 --- a/core/archipelago/src/api/rpc/bitcoin.rs +++ b/core/archipelago/src/api/rpc/bitcoin.rs @@ -136,7 +136,7 @@ impl RpcHandler { /// ~30% of UI calls error out even though the node is perfectly healthy. /// With retry + backoff, the UI sees a uniform slow-but-successful /// response instead of intermittent failures. - async fn bitcoin_rpc_call( + pub(in crate::api::rpc) async fn bitcoin_rpc_call( &self, client: &reqwest::Client, method: &str, diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 443f306b..eb1353ad 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -73,6 +73,34 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json }) } +// Resolve known purchases BEFORE any mint/spend. Missing bytes or an unreadable +// index require recovery; neither is authorization to charge the buyer again. +async fn existing_paid_content( + data_dir: &std::path::Path, + onion: &str, + content_id: &str, + filename: Option<&str>, +) -> Result> { + let owned = crate::content_owned::list_owned_checked(data_dir) + .await + .context("Could not verify previous purchases; no new payment was sent")?; + let Some(item) = owned.iter().find(|o| { + o.onion == onion + && (o.content_id == content_id + || filename.is_some_and(|f| { + !f.is_empty() && o.filename.trim_start_matches('/') == f.trim_start_matches('/') + })) + }) else { + return Ok(None); + }; + let (mime, bytes) = crate::content_owned::read_owned(data_dir, &item.onion, &item.content_id) + .await.context("This purchase is recorded, but its cached file is unavailable. No new payment was sent. Restore the cached file or contact the seller.")?; + let mut response = paid_content_response(&bytes, &mime, 0); + response["already_owned"] = serde_json::json!(true); + response["filename"] = serde_json::json!(item.filename); + Ok(Some(response)) +} + // Updated clients open the persisted file through the Range-capable HTTP // endpoint. Avoid putting two base64 copies of a large video in a JSON reply. // Keep older clients compatible until both sides have upgraded. @@ -517,36 +545,15 @@ impl RpcHandler { // by exact (onion, content_id) and by (onion, filename) — the latter // catches duplicate ids pointing at the same file on the same // seller. The owned copy is served from the local cache instead. + if let Some(cached) = existing_paid_content( + &self.config.data_dir, + onion, + content_id, + params.get("filename").and_then(|v| v.as_str()), + ) + .await? { - let filename = params.get("filename").and_then(|v| v.as_str()); - let owned = crate::content_owned::list_owned(&self.config.data_dir).await; - let already = owned.iter().find(|o| { - o.onion == onion - && (o.content_id == content_id - || filename.is_some_and(|f| { - !f.is_empty() - && o.filename.trim_start_matches('/') == f.trim_start_matches('/') - })) - }); - if let Some(o) = already { - tracing::info!( - onion, - content_id, - owned_as = %o.content_id, - "paid download: already owned — serving cached copy, NOT paying again" - ); - if let Some((mime, bytes)) = - crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id) - .await - { - let mut result = paid_content_response(&bytes, &mime, 0); - result["already_owned"] = serde_json::json!(true); - result["filename"] = serde_json::json!(o.filename); - return Ok(result); - } - // Cache record exists but bytes are gone — fall through and - // repurchase rather than stranding the user. - } + return Ok(cached); } // `method` pins the backend the user confirmed in the UI ("cashu" | diff --git a/core/archipelago/src/api/rpc/content_tests.rs b/core/archipelago/src/api/rpc/content_tests.rs index 7df1742b..fd5efebe 100644 --- a/core/archipelago/src/api/rpc/content_tests.rs +++ b/core/archipelago/src/api/rpc/content_tests.rs @@ -71,3 +71,68 @@ fn seller_errors_are_bounded_printable_and_identified_as_peer_text() { ); } } + +#[tokio::test] +async fn known_purchase_never_becomes_a_new_spend_when_cache_or_index_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + assert!( + existing_paid_content(dir.path(), "seller.onion", "id", None) + .await + .unwrap() + .is_none() + ); + crate::content_owned::record_purchase( + dir.path(), + "seller.onion", + "id", + "file.txt", + "text/plain", + b"paid", + 1, + "cashu", + "now", + ) + .await + .unwrap(); + for (id, filename) in [("id", None), ("duplicate-id", Some("/file.txt"))] { + let cached = existing_paid_content(dir.path(), "seller.onion", id, filename) + .await + .unwrap() + .unwrap(); + assert_eq!(cached["paid_sats"], 0); + assert_eq!(cached["already_owned"], true); + assert_eq!(cached["data"], "cGFpZA=="); + } + assert!( + existing_paid_content(dir.path(), "different.onion", "id", None) + .await + .unwrap() + .is_none() + ); + tokio::fs::remove_file(dir.path().join("purchased-content/seller.onion/id")) + .await + .unwrap(); + assert!( + existing_paid_content(dir.path(), "seller.onion", "id", None) + .await + .unwrap_err() + .to_string() + .contains("No new payment") + ); + tokio::fs::write(dir.path().join("purchased-content/owned.json"), b"damaged") + .await + .unwrap(); + assert!( + existing_paid_content(dir.path(), "seller.onion", "other-id", None) + .await + .unwrap_err() + .to_string() + .contains("no new payment") + ); + assert_eq!( + tokio::fs::read(dir.path().join("purchased-content/owned.json")) + .await + .unwrap(), + b"damaged" + ); +} diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index d7fbf067..f7426b02 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -132,6 +132,9 @@ impl RpcHandler { "lnd.newaddress" => self.handle_lnd_newaddress().await, "lnd.sendcoins" => self.handle_lnd_sendcoins(params).await, "lnd.estimatefee" => self.handle_lnd_estimatefee(params).await, + "lnd.bump-quote" => self.handle_lnd_bump_quote(params).await, + "lnd.bump-submit" => self.handle_lnd_bump_submit(params).await, + "lnd.bump-status" => self.handle_lnd_bump_status(params).await, "lnd.createinvoice" => self.handle_lnd_createinvoice(params).await, "lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await, "lnd.payinvoice" => self.handle_lnd_payinvoice(params).await, diff --git a/core/archipelago/src/api/rpc/lnd/channels.rs b/core/archipelago/src/api/rpc/lnd/channels.rs index b0b94359..ef69a422 100644 --- a/core/archipelago/src/api/rpc/lnd/channels.rs +++ b/core/archipelago/src/api/rpc/lnd/channels.rs @@ -272,28 +272,8 @@ impl RpcHandler { .and_then(|v| v.as_bool()) .unwrap_or(false); - // Fee control: either a confirmation target or an explicit fee rate - let target_conf = params.get("target_conf").and_then(|v| v.as_i64()); - let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64()); - if target_conf.is_some() && sat_per_vbyte.is_some() { - return Err(anyhow::anyhow!( - "Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both" - )); - } - if let Some(tc) = target_conf { - if !(1..=1008).contains(&tc) { - return Err(anyhow::anyhow!( - "Invalid target_conf: must be between 1 and 1008 blocks" - )); - } - } - if let Some(rate) = sat_per_vbyte { - if !(1..=5000).contains(&rate) { - return Err(anyhow::anyhow!( - "Invalid sat_per_vbyte: must be between 1 and 5000" - )); - } - } + // Omitted fees target the next block; explicit slower/custom choices win. + let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?; info!( peer = pubkey, @@ -574,7 +554,7 @@ impl RpcHandler { /// LND's CloseChannel REST endpoint takes fee selection as query parameters. /// With neither parameter LND uses a lax target; keep legacy clients on our -/// explicit Standard target rather than silently accepting that default. +/// explicit next-block target rather than silently accepting that default. fn close_channel_fee_query(params: &serde_json::Value) -> Result> { let force = match params.get("force") { None | Some(serde_json::Value::Null) => false, @@ -609,7 +589,12 @@ fn close_channel_fee_query(params: &serde_json::Value) -> Result>> = LazyLock::new(Default::default); +// Serialize check/register/persist across dashboard clients. The create_new receipt +// additionally survives process restarts and prevents retries of ambiguous results. +static SUBMIT: Mutex<()> = Mutex::const_new(()); +const QUOTE_SECONDS: u64 = 60; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +struct Plan { + txid: String, + method: String, + input_txid: String, + input_index: u32, + parent_txid: String, + recipient_sats: u64, + rate_sat_vb: u64, + current_fee_sats: u64, + additional_fee_sats: u64, + total_fee_sats: u64, + budget_sats: u64, + input_sats: u64, + parent_vsize: u64, + sweep_vsize_bound: u64, + tip: String, +} +#[derive(Clone, Serialize, Deserialize)] +struct Quote { + quote_id: String, + expires_at: u64, + custom_rate: Option, + #[serde(flatten)] + plan: Plan, +} +#[derive(Serialize, Deserialize)] +struct Operation { + quote: Quote, + status: String, + message: String, +} +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn number(v: &Value) -> Result { + v.as_u64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + .context("Missing or invalid wallet amount") +} +fn txid_param(p: &Value) -> Result { + let s = p["txid"].as_str().context("Missing transaction ID")?; + ensure!( + s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()), + "Invalid transaction ID" + ); + Ok(s.to_ascii_lowercase()) +} +fn btc_sats(v: &Value) -> Result { + let n = v.as_f64().context("Missing Bitcoin fee")? * 100_000_000.0; + ensure!( + n.is_finite() && n >= 0.0 && n <= 2_100_000_000_000_000.0, + "Invalid Bitcoin fee" + ); + Ok(n.round() as u64) +} +fn outpoint_matches(v: &Value, txid: &str, index: u32) -> bool { + v["txid_str"].as_str() == Some(txid) && v["output_index"].as_u64() == Some(index as u64) +} +fn array<'a>(v: &'a Value, key: &str) -> Result<&'a Vec> { + v[key] + .as_array() + .with_context(|| format!("Missing wallet field: {key}")) +} +fn sweep_size(output: &Value) -> Result { + // One native input, one wallet taproot output, including signature rounding. + match output["output_type"].as_str() { + Some("SCRIPT_TYPE_WITNESS_V1_TAPROOT") => Ok(112), + Some("SCRIPT_TYPE_WITNESS_V0_PUBKEY_HASH") => Ok(123), + _ => bail!("This output type is not supported for fee bumping yet"), + } +} +fn fee_budget( + rate: u64, + parent_size: u64, + parent_fee: u64, + size: u64, + old_fee: u64, + relay: u64, + input: u64, +) -> Result { + ensure!( + (1..=5000).contains(&rate), + "Fee rate must be a whole number from 1 to 5000 sat/vB" + ); + ensure!( + parent_size <= 100_000 && size <= 100_000 && relay <= 5000, + "Unsupported package size or relay fee" + ); + let required = rate * (parent_size + size); + let mut budget = required.saturating_sub(parent_fee).max(relay * size); + if old_fee > 0 { + budget = budget.max(old_fee + relay * size + 1); + } + ensure!(budget > old_fee, "Choose a higher fee rate"); + // Conservative dust buffer; never attach unrelated wallet inputs to fund fees. + ensure!( + budget.checked_add(1000).is_some_and(|v| v <= input), + "Not enough wallet change for this fee; choose a lower rate" + ); + Ok(budget) +} + +async fn lnd( + client: &reqwest::Client, + macaroon: &str, + path: &str, + body: Option, +) -> Result { + let url = format!("{LND_REST_BASE_URL}{path}"); + let req = match body { + Some(v) => client.post(url).json(&v), + None => client.get(url), + }; + let response = req + .header("Grpc-Metadata-macaroon", macaroon) + .send() + .await?; + let status = response.status(); + let value: Value = response.json().await.context("Invalid LND response")?; + ensure!( + status.is_success() && value.get("code").is_none(), + "{}", + value["message"].as_str().unwrap_or("LND request failed") + ); + Ok(value) +} + +fn validate_quote(quote: &Quote, fresh: &Plan, timestamp: u64) -> Result<()> { + ensure!( + quote.expires_at > timestamp && quote.plan == *fresh, + "Transaction or fees changed; review a fresh quote" + ); + Ok(()) +} +fn bump_body(plan: &Plan) -> Value { + json!({"outpoint":{"txid_str":plan.input_txid,"output_index":plan.input_index}, + "sat_per_vbyte":plan.rate_sat_vb.to_string(), "budget":plan.budget_sats.to_string(), + "deadline_delta":1, "immediate":true}) +} + +async fn reserve(path: &Path, op: &Operation) -> Result<()> { + let parent = path.parent().context("Invalid operation path")?; + tokio::fs::create_dir_all(parent).await?; + let mut f = tokio::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .await + .context("A bump already exists for this transaction; check its status")?; + f.write_all(&serde_json::to_vec(op)?).await?; + f.sync_all().await?; + // Sync directory entry too: a crash must not make a submitted operation vanish. + tokio::fs::File::open(parent).await?.sync_all().await?; + Ok(()) +} + +// Only a recorded Archy CPFP with one owned input and no external outputs may +// be folded into a payment. Labels and a fee-sized delta alone are not evidence. +fn fee_child_matches(tx: &Value, plan: &Plan) -> bool { + let input = format!("{}:{}", plan.input_txid, plan.input_index); + let amount = tx["amount"] + .as_i64() + .or_else(|| tx["amount"].as_str()?.parse().ok()); + let fee = number(&tx["total_fees"]) + .ok() + .and_then(|n| i64::try_from(n).ok()); + tx["tx_hash"] + .as_str() + .is_some_and(|id| id.len() == 64 && id.bytes().all(|c| c.is_ascii_hexdigit())) + && amount + .zip(fee) + .is_some_and(|(amount, fee)| fee > 0 && amount == -fee) + && tx["previous_outpoints"].as_array().is_some_and(|inputs| { + inputs.len() == 1 + && inputs[0]["outpoint"] == input + && inputs[0]["is_our_output"] == true + }) + && tx["output_details"].as_array().is_some_and(|outputs| { + !outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true) + }) +} + +impl RpcHandler { + pub(super) async fn group_fee_bump_history( + &self, + raw: &[Value], + normalized: &mut Vec, + client: &reqwest::Client, + ) { + let mut hidden = std::collections::HashSet::new(); + for parent in normalized.iter_mut() { + if parent["direction"] != "outgoing" { + continue; + } + let Some(id) = parent["tx_hash"].as_str().map(str::to_owned) else { + continue; + }; + if id.len() != 64 || !id.bytes().all(|c| c.is_ascii_hexdigit()) { + continue; + } + let path = self + .config + .data_dir + .join("wallet/fee-bumps") + .join(format!("{id}.json")); + let Ok(bytes) = tokio::fs::read(path).await else { + continue; + }; + let Ok(op) = serde_json::from_slice::(&bytes) else { + continue; + }; + let plan = &op.quote.plan; + if plan.method != "cpfp" + || plan.txid != id + || plan.parent_txid != id + || plan.input_txid != id + { + continue; + } + let candidates: Vec<_> = raw + .iter() + .filter(|tx| fee_child_matches(tx, plan)) + .collect(); + let mut active = Vec::new(); + for child in &candidates { + let child_id = child["tx_hash"].as_str().unwrap(); + if child["num_confirmations"].as_i64().unwrap_or(0) > 0 + || self + .bitcoin_rpc_call::(client, "getmempoolentry", &[json!(child_id)]) + .await + .is_ok() + { + active.push(*child); + } + } + // Ambiguous or unavailable chain state must not hide wallet history. + if active.len() != 1 { + continue; + } + let current = active[0]; + parent["bump_fee_sats"] = json!(number(¤t["total_fees"]).unwrap()); + parent["fee_bump_txid"] = current["tx_hash"].clone(); + parent["fee_bump_confirmations"] = current["num_confirmations"].clone(); + parent["fee_bump_history"] = json!(candidates.iter().map(|child| { + let child_id = child["tx_hash"].as_str().unwrap(); + hidden.insert(child_id.to_owned()); + json!({"tx_hash":child_id,"fee_sats":number(&child["total_fees"]).unwrap(), + "status":if child["tx_hash"] != current["tx_hash"] { "replaced" } + else if child["num_confirmations"].as_i64().unwrap_or(0) > 0 { "confirmed" } else { "mempool" }}) + }).collect::>()); + } + normalized.retain(|tx| !tx["tx_hash"].as_str().is_some_and(|id| hidden.contains(id))); + } + + async fn bump_plan(&self, txid: &str, custom_rate: Option) -> Result { + let (client, macaroon) = self.lnd_client().await?; + let info = lnd(&client, &macaroon, "/v1/getinfo", None).await?; + ensure!( + info["synced_to_chain"] == true, + "Wait for the wallet to finish syncing" + ); + let version = info["version"] + .as_str() + .context("LND version is unavailable")?; + let mut parts = version.trim_start_matches('v').split('.'); + let major: u32 = parts + .next() + .unwrap_or("") + .parse() + .context("Invalid LND version")?; + let minor: u32 = parts + .next() + .unwrap_or("") + .parse() + .context("Invalid LND version")?; + ensure!( + major > 0 || minor >= 21, + "This fee-bump interface requires LND 0.21 or newer" + ); + let history = lnd(&client, &macaroon, "/v1/transactions", None).await?; + let txs = array(&history, "transactions")?; + let tx = txs + .iter() + .find(|t| t["tx_hash"] == txid) + .context("Transaction is not in this wallet")?; + ensure!( + tx["num_confirmations"].as_i64() == Some(0), + "This transaction is no longer pending" + ); + let entry: Value = self + .bitcoin_rpc_call(&client, "getmempoolentry", &[json!(txid)]) + .await + .context("Transaction is not currently in the node's mempool")?; + ensure!( + number(&entry["descendantcount"])? == 1, + "This transaction already has a child; open the child's Bump options instead" + ); + let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?; + let sweeps = array(&pending, "pending_sweeps")?; + let published = lnd( + &client, + &macaroon, + "/v2/wallet/sweeps?verbose=false&start_height=-1", + None, + ) + .await?; + let is_sweep = published["transaction_ids"]["transaction_ids"] + .as_array() + .is_some_and(|ids| ids.iter().any(|id| id == txid)); + let outputs = array(tx, "output_details")?; + ensure!( + tx["amount"] + .as_str() + .and_then(|v| v.parse::().ok()) + .or_else(|| tx["amount"].as_i64()) + .is_some_and(|v| v < 0), + "Bump is available for outgoing payments and wallet fee sweeps" + ); + let ( + method, + input_txid, + input_index, + input_sats, + parent_txid, + parent_size, + parent_fee, + old_fee, + size, + recipient_sats, + ) = if is_sweep { + // Only a simple wallet CPFP sweep is replaceable here. Anchor/HTLC, + // batched sweeps and arbitrary signed payments need different previews. + let raw: Value = self + .bitcoin_rpc_call(&client, "getrawtransaction", &[json!(txid), json!(true)]) + .await?; + let inputs = array(&raw, "vin")?; + ensure!( + inputs.len() == 1 && outputs.len() == 1 && outputs[0]["is_our_address"] == true, + "RBF for batched or channel sweeps is not supported here yet" + ); + let input_txid = inputs[0]["txid"] + .as_str() + .context("Missing sweep input")? + .to_string(); + let index = u32::try_from(number(&inputs[0]["vout"])?)?; + ensure!( + sweeps.len() == 1 && outpoint_matches(&sweeps[0]["outpoint"], &input_txid, index), + "RBF is unavailable while other wallet sweeps are active" + ); + let parent = txs + .iter() + .find(|t| t["tx_hash"] == input_txid) + .context("Sweep parent is unavailable")?; + let parent_output = array(parent, "output_details")? + .iter() + .find(|o| { + number(&o["output_index"]).ok() == Some(index as u64) + && o["is_our_address"] == true + }) + .context("RBF requires a wallet-owned change input")?; + let parent_entry: Value = self + .bitcoin_rpc_call(&client, "getmempoolentry", &[json!(input_txid)]) + .await + .context("Only unconfirmed CPFP sweep replacements are supported here")?; + ensure!( + number(&parent_entry["ancestorcount"])? == 1 + && number(&parent_entry["descendantcount"])? == 2, + "Complex sweep package cannot be quoted safely" + ); + let recipients = recipient_amount(parent)?; + ( + "rbf", + input_txid.clone(), + index, + number(&parent_output["amount"])?, + input_txid, + number(&parent_entry["vsize"])?, + btc_sats(&parent_entry["fees"]["base"])?, + btc_sats(&entry["fees"]["base"])?, + sweep_size(parent_output)?.max(number(&entry["vsize"])?), + recipients, + ) + } else { + ensure!( + sweeps.is_empty(), + "Another wallet sweep is active; wait for it before creating a CPFP bump" + ); + ensure!( + number(&entry["ancestorcount"])? == 1, + "Fee bumping a chain of unconfirmed payments is not supported yet" + ); + let unspent = lnd( + &client, + &macaroon, + "/v2/wallet/utxos", + Some(json!({"unconfirmed_only":true})), + ) + .await?; + let utxos = array(&unspent, "utxos")?; + let leases = lnd( + &client, + &macaroon, + "/v2/wallet/utxos/leases", + Some(json!({})), + ) + .await?; + let locked = array(&leases, "locked_utxos")?; + let output = outputs + .iter() + .filter(|o| o["is_our_address"] == true && sweep_size(o).is_ok()) + .filter(|o| { + number(&o["output_index"]).ok().is_some_and(|i| { + utxos + .iter() + .any(|u| outpoint_matches(&u["outpoint"], txid, i as u32)) + && !locked + .iter() + .any(|u| outpoint_matches(&u["outpoint"], txid, i as u32)) + }) + }) + .max_by_key(|o| number(&o["amount"]).unwrap_or(0)) + .context( + "RBF is unavailable for this payment. CPFP needs spendable wallet-owned change", + )?; + let index = u32::try_from(number(&output["output_index"])?)?; + let available: Value = self + .bitcoin_rpc_call( + &client, + "gettxout", + &[json!(txid), json!(index), json!(true)], + ) + .await?; + ensure!( + available.is_object() + && number(&available["confirmations"])? == 0 + && btc_sats(&available["value"])? == number(&output["amount"])?, + "Change is no longer available" + ); + ( + "cpfp", + txid.to_string(), + index, + number(&output["amount"])?, + txid.to_string(), + number(&entry["vsize"])?, + btc_sats(&entry["fees"]["base"])?, + 0, + sweep_size(output)?, + recipient_amount(tx)?, + ) + }; + let mempool: Value = self + .bitcoin_rpc_call(&client, "getmempoolinfo", &[]) + .await?; + let relay = btc_sats(&mempool["incrementalrelayfee"])? + .div_ceil(1000) + .max(1); + let floor = btc_sats(&mempool["mempoolminfee"])? + .max(btc_sats(&mempool["minrelaytxfee"])?) + .div_ceil(1000) + .max(1); + let rate = match custom_rate { + Some(rate) => { + ensure!( + rate >= floor, + "Custom rate is below the current mempool minimum" + ); + rate + } + None => { + let estimate = lnd(&client, &macaroon, "/v2/wallet/estimatefee/1", None).await?; + number(&estimate["sat_per_kw"])?.div_ceil(250).max(floor) + } + }; + let budget = fee_budget( + rate, + parent_size, + parent_fee, + size, + old_fee, + relay.max(floor), + input_sats, + )?; + let tip: String = self + .bitcoin_rpc_call(&client, "getbestblockhash", &[]) + .await?; + Ok(Plan { + txid: txid.to_string(), + method: method.into(), + input_txid, + input_index, + parent_txid, + recipient_sats, + rate_sat_vb: rate, + current_fee_sats: parent_fee + old_fee, + additional_fee_sats: budget - old_fee, + total_fee_sats: parent_fee + budget, + budget_sats: budget, + input_sats, + parent_vsize: parent_size, + sweep_vsize_bound: size, + tip, + }) + } + + pub(in crate::api::rpc) async fn handle_lnd_bump_quote( + &self, + params: Option, + ) -> Result { + let p = params.unwrap_or_default(); + let txid = txid_param(&p)?; + let path = self + .config + .data_dir + .join("wallet/fee-bumps") + .join(format!("{txid}.json")); + ensure!( + !path.try_exists()?, + "A bump was already submitted for this transaction. Check its status" + ); + let custom = p + .get("sat_per_vbyte") + .map(|v| v.as_u64().context("Custom rate must be a whole number")) + .transpose()?; + if let Some(rate) = custom { + ensure!( + (1..=5000).contains(&rate), + "Custom rate must be 1–5000 sat/vB" + ); + } + let plan = self.bump_plan(&txid, custom).await?; + let quote = Quote { + quote_id: uuid::Uuid::new_v4().to_string(), + expires_at: now() + QUOTE_SECONDS, + custom_rate: custom, + plan, + }; + let mut quotes = QUOTES.lock().await; + quotes.retain(|_, q| q.expires_at > now()); + ensure!(quotes.len() < 128, "Too many fee quotes; try again shortly"); + quotes.insert(quote.quote_id.clone(), quote.clone()); + Ok(serde_json::to_value(quote)?) + } + + pub(in crate::api::rpc) async fn handle_lnd_bump_submit( + &self, + params: Option, + ) -> Result { + let p = params.unwrap_or_default(); + let txid = txid_param(&p)?; + let _guard = SUBMIT.lock().await; + let path = self + .config + .data_dir + .join("wallet/fee-bumps") + .join(format!("{txid}.json")); + if path.try_exists()? { + return self + .handle_lnd_bump_status(Some(json!({"txid":txid}))) + .await; + } + let id = p["quote_id"] + .as_str() + .context("A reviewed fee quote is required")?; + let quote = QUOTES + .lock() + .await + .get(id) + .cloned() + .context("Quote expired; review the fee again")?; + ensure!( + quote.plan.txid == txid && quote.expires_at > now(), + "Quote expired; review the fee again" + ); + let fresh = self.bump_plan(&txid, quote.custom_rate).await?; + validate_quote("e, &fresh, now())?; + let op = Operation { + quote: quote.clone(), + status: "unknown".into(), + message: "Submission recorded; checking the wallet. Do not submit another bump.".into(), + }; + reserve(&path, &op).await?; + QUOTES.lock().await.remove(id); + let (client, macaroon) = self.lnd_client().await?; + // At a one-block deadline LND may spend ALL this explicitly previewed + // budget. It is always below input value, so no extra funding is requested. + let result = lnd( + &client, + &macaroon, + "/v2/wallet/bumpfee", + Some(bump_body(&fresh)), + ) + .await; + // Keep the write-ahead record even for an RPC error: a lost response can + // conceal an accepted bump. Status reconciles from wallet/mempool evidence. + match result { + Ok(_) => Ok( + json!({"status":"registered", "message":"Bump registered with the wallet. Waiting for broadcast.", "quote":quote}), + ), + Err(_) => Ok( + json!({"status":"unknown", "message":"The wallet response was not confirmed. Check status; do not submit again.", "quote":quote}), + ), + } + } + + pub(in crate::api::rpc) async fn handle_lnd_bump_status( + &self, + params: Option, + ) -> Result { + let txid = txid_param(¶ms.unwrap_or_default())?; + let path = self + .config + .data_dir + .join("wallet/fee-bumps") + .join(format!("{txid}.json")); + let bytes = match tokio::fs::read(path).await { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Ok(json!({"status":"none"})) + } + Err(e) => return Err(e.into()), + }; + let op: Operation = serde_json::from_slice(&bytes) + .context("Bump receipt needs recovery; do not resubmit")?; + let (client, macaroon) = self.lnd_client().await?; + let history = lnd(&client, &macaroon, "/v1/transactions", None).await?; + let plan = &op.quote.plan; + let input = format!("{}:{}", plan.input_txid, plan.input_index); + let mut candidates: Vec<&Value> = array(&history, "transactions")? + .iter() + .filter(|t| { + t["tx_hash"] != txid + && t["output_details"].as_array().is_some_and(|outputs| { + !outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true) + }) + && t["previous_outpoints"] + .as_array() + .is_some_and(|inputs| inputs.iter().any(|i| i["outpoint"] == input)) + }) + .collect(); + candidates.sort_by_key(|t| std::cmp::Reverse(number(&t["time_stamp"]).unwrap_or(0))); + for t in candidates { + let id = t["tx_hash"] + .as_str() + .context("Missing bump transaction ID")?; + let confirmed = t["num_confirmations"].as_i64().unwrap_or(0) > 0; + let accepted = if confirmed { + false + } else { + self.bitcoin_rpc_call::(&client, "getmempoolentry", &[json!(id)]) + .await + .is_ok() + }; + if confirmed || accepted { + return Ok(json!({"status":if confirmed {"confirmed"} else {"mempool"}, + "message":if confirmed {"Fee bump confirmed."} else {"Fee bump accepted in the node's mempool; awaiting confirmation."}, + "bump_txid":id,"confirmations":t["num_confirmations"],"actual_sweep_fee_sats":number(&t["total_fees"])?,"quote":op.quote})); + } + } + let pending = lnd(&client, &macaroon, "/v2/wallet/sweeps/pending", None).await?; + let registered = array(&pending, "pending_sweeps")?.iter().any(|s| { + outpoint_matches(&s["outpoint"], &plan.input_txid, plan.input_index) + && number(&s["budget"]).ok() == Some(plan.budget_sats) + && number(&s["requested_sat_per_vbyte"]).ok() == Some(plan.rate_sat_vb) + }); + Ok( + json!({"status":if registered {"registered"} else {"unknown"}, + "message":if registered {"Bump registered; waiting for a verified broadcast."} else {"Submission outcome is unknown. Do not submit again; check wallet status."}, "quote":op.quote}), + ) + } +} +fn recipient_amount(tx: &Value) -> Result { + array(tx, "output_details")? + .iter() + .filter(|o| o["is_our_address"] == false) + .try_fold(0u64, |sum, o| { + sum.checked_add(number(&o["amount"])?) + .context("Recipient amount overflow") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn sample_plan() -> Plan { + serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":161650,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap() + } + #[test] + fn history_requires_owned_simple_fee_only_child() { + let plan = sample_plan(); + let tx = json!({"tx_hash":"b".repeat(64),"amount":"-200","total_fees":"200", + "previous_outpoints":[{"outpoint":"a:0","is_our_output":true}], + "output_details":[{"is_our_address":true}]}); + assert!(fee_child_matches(&tx, &plan)); + for bad in [ + json!({"amount":"-201"}), + json!({"amount":"200"}), + json!({"total_fees":"0"}), + json!({"previous_outpoints":[{"outpoint":"a:1","is_our_output":true}]}), + json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":false}]}), + json!({"previous_outpoints":[{"outpoint":"a:0","is_our_output":true},{"outpoint":"c:0","is_our_output":true}]}), + json!({"output_details":[{"is_our_address":false}]}), + json!({"output_details":[]}), + json!({"tx_hash":"../../invalid"}), + ] { + let mut changed = tx.clone(); + for (key, value) in bad.as_object().unwrap() { + changed[key] = value.clone(); + } + assert!(!fee_child_matches(&changed, &plan), "{bad}"); + } + } + #[test] + fn stale_quotes_cannot_silently_change_approved_fee_or_transaction() { + let plan = sample_plan(); + let q = Quote { + quote_id: "q".into(), + expires_at: 100, + custom_rate: None, + plan: plan.clone(), + }; + assert!(validate_quote(&q, &plan, 99).is_ok()); + assert!(validate_quote(&q, &plan, 100).is_err()); + let mut changed = plan.clone(); + changed.budget_sats += 1; + assert!(validate_quote(&q, &changed, 99).is_err()); + changed = plan.clone(); + changed.input_index += 1; + assert!(validate_quote(&q, &changed, 99).is_err()); + changed = plan.clone(); + changed.recipient_sats -= 1; + assert!(validate_quote(&q, &changed, 99).is_err()); + changed = plan.clone(); + changed.tip = "new block".into(); + assert!(validate_quote(&q, &changed, 99).is_err()); + } + #[test] + fn mutation_always_has_explicit_budget_and_does_not_send_a_second_payment() { + assert_eq!( + bump_body(&sample_plan()), + json!({"outpoint":{"txid_str":"a","output_index":0},"sat_per_vbyte":"3","budget":"618","deadline_delta":1,"immediate":true}) + ); + let mut rbf = sample_plan(); + rbf.method = "rbf".into(); + rbf.txid = "child".into(); + // RBF uses the already-registered input, not the child's output. + assert_eq!(bump_body(&rbf)["outpoint"]["txid_str"], "a"); + } + #[test] + fn outpoint_ownership_and_recipient_exclude_wallet_change() { + assert!(outpoint_matches( + &json!({"txid_str":"a","output_index":2}), + "a", + 2 + )); + assert!(!outpoint_matches( + &json!({"txid_str":"b","output_index":2}), + "a", + 2 + )); + assert!(!outpoint_matches( + &json!({"txid_str":"a","output_index":3}), + "a", + 2 + )); + assert_eq!(recipient_amount(&json!({"output_details":[{"is_our_address":true,"amount":"21126"},{"is_our_address":false,"amount":"161650"}]})).unwrap(), 161650); + assert!(recipient_amount( + &json!({"output_details":[{"is_our_address":false,"amount":"bad"}]}) + ) + .is_err()); + } + #[test] + fn cpfp_budget_covers_parent_and_preserves_change() { + assert_eq!(fee_budget(3, 142, 144, 112, 0, 1, 21126).unwrap(), 618); + assert!(fee_budget(5000, 142, 144, 112, 0, 1, 21126).is_err()); + assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err()); + } + #[test] + fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() { + let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap(); + assert_eq!(fee, 763); + assert_eq!(fee - 650, 113); + } + #[test] + fn unsupported_outputs_and_malformed_ids_fail_closed() { + assert!(sweep_size(&json!({"output_type":"SCRIPT_TYPE_WITNESS_V0_SCRIPT_HASH"})).is_err()); + assert!(txid_param(&json!({"txid":"../../file"})).is_err()); + assert!(number(&json!(-1)).is_err()); + assert!(btc_sats(&json!(-0.1)).is_err()); + assert_eq!(btc_sats(&json!(0.00000650)).unwrap(), 650); + } + #[tokio::test] + async fn receipt_prevents_duplicate_submission_after_restart() { + let dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string()); + let path = dir.join("receipt.json"); + let plan: Plan = serde_json::from_value(json!({"txid":"a","method":"cpfp","input_txid":"a","input_index":0,"parent_txid":"a","recipient_sats":1000,"rate_sat_vb":3,"current_fee_sats":144,"additional_fee_sats":618,"total_fee_sats":762,"budget_sats":618,"input_sats":21126,"parent_vsize":142,"sweep_vsize_bound":112,"tip":"tip"})).unwrap(); + let op = Operation { + quote: Quote { + quote_id: "q".into(), + expires_at: now() + 60, + custom_rate: None, + plan, + }, + status: "unknown".into(), + message: "pending".into(), + }; + reserve(&path, &op).await.unwrap(); + assert!(reserve(&path, &op).await.is_err()); + let restored: Operation = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(restored.quote.plan.budget_sats, 618); + tokio::fs::remove_dir_all(dir).await.unwrap(); + } +} diff --git a/core/archipelago/src/api/rpc/lnd/fee_policy.rs b/core/archipelago/src/api/rpc/lnd/fee_policy.rs new file mode 100644 index 00000000..960fe575 --- /dev/null +++ b/core/archipelago/src/api/rpc/lnd/fee_policy.rs @@ -0,0 +1,120 @@ +//! Explicit on-chain fee choices retain priority; omitted choices target the next block. +use anyhow::{ensure, Context, Result}; +use serde_json::Value; + +pub(super) const DEFAULT_TARGET: i64 = 1; + +pub(super) fn estimated_sat_per_vbyte(value: &Value) -> Result { + let per_kw = value["sat_per_kw"] + .as_u64() + .or_else(|| value["sat_per_kw"].as_str().and_then(|s| s.parse().ok())) + .context("Next-block fee estimate is unavailable")?; + let rate = per_kw.div_ceil(250); + ensure!( + (1..=5000).contains(&rate), + "Next-block fee estimate is outside supported bounds; choose an explicit fee" + ); + Ok(rate) +} + +pub(super) fn fee_options(params: &Value) -> Result<(Option, Option)> { + let integer = |key: &str, max: i64| -> Result> { + match params.get(key) { + None | Some(Value::Null) => Ok(None), + Some(value) => { + let n = value + .as_i64() + .with_context(|| format!("{key} must be a positive whole number"))?; + ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}"); + Ok(Some(n)) + } + } + }; + let target = integer("target_conf", 1008)?; + let rate = integer("sat_per_vbyte", 5000)?; + ensure!( + target.is_none() || rate.is_none(), + "Specify either target_conf or sat_per_vbyte, not both" + ); + Ok(( + if rate.is_none() { + Some(target.unwrap_or(DEFAULT_TARGET)) + } else { + None + }, + rate, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn estimates_round_up_and_missing_or_extreme_estimates_fail_closed() { + assert_eq!( + estimated_sat_per_vbyte(&json!({"sat_per_kw":"501"})).unwrap(), + 3 + ); + assert_eq!( + estimated_sat_per_vbyte(&json!({"sat_per_kw":250})).unwrap(), + 1 + ); + for v in [ + json!({}), + json!({"sat_per_kw":0}), + json!({"sat_per_kw":-1}), + json!({"sat_per_kw":1250001}), + ] { + assert!(estimated_sat_per_vbyte(&v).is_err()); + } + } + + #[test] + fn next_block_default_preserves_explicit_slower_and_custom_choices() { + assert_eq!(fee_options(&json!({})).unwrap(), (Some(1), None)); + assert_eq!( + fee_options(&json!({"target_conf":null})).unwrap(), + (Some(1), None) + ); + for target in [1, 3, 6, 144, 1008] { + assert_eq!( + fee_options(&json!({"target_conf":target})).unwrap(), + (Some(target), None) + ); + } + for rate in [1, 17, 5000] { + assert_eq!( + fee_options(&json!({"sat_per_vbyte":rate})).unwrap(), + (None, Some(rate)) + ); + } + } + + #[test] + fn malformed_explicit_fees_never_silently_become_fast() { + for value in [ + json!(0), + json!(-1), + json!(1.5), + json!("6"), + json!(true), + json!({}), + json!(1009), + ] { + assert!(fee_options(&json!({"target_conf":value})).is_err()); + } + for value in [ + json!(0), + json!(-1), + json!(1.5), + json!("6"), + json!(true), + json!(5001), + ] { + assert!(fee_options(&json!({"sat_per_vbyte":value})).is_err()); + } + assert!(fee_options(&json!({"target_conf":1,"sat_per_vbyte":2})).is_err()); + } +} diff --git a/core/archipelago/src/api/rpc/lnd/mod.rs b/core/archipelago/src/api/rpc/lnd/mod.rs index ef734ccf..9e26822b 100644 --- a/core/archipelago/src/api/rpc/lnd/mod.rs +++ b/core/archipelago/src/api/rpc/lnd/mod.rs @@ -1,4 +1,6 @@ mod channels; +mod fee_bump; +mod fee_policy; mod info; mod macaroons; mod payments; diff --git a/core/archipelago/src/api/rpc/lnd/payments.rs b/core/archipelago/src/api/rpc/lnd/payments.rs index 9d08c094..23c5aecb 100644 --- a/core/archipelago/src/api/rpc/lnd/payments.rs +++ b/core/archipelago/src/api/rpc/lnd/payments.rs @@ -402,6 +402,9 @@ impl RpcHandler { })); } + self.group_fee_bump_history(raw_txs, &mut transactions, &client) + .await; + // Sort by timestamp descending (most recent first) transactions.sort_by(|a, b| { let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 31fc7c67..c7f79e1e 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -124,28 +124,8 @@ impl RpcHandler { return Err(anyhow::anyhow!("Invalid Bitcoin address format")); } - // Fee control: either a confirmation target or an explicit fee rate - let target_conf = params.get("target_conf").and_then(|v| v.as_i64()); - let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64()); - if target_conf.is_some() && sat_per_vbyte.is_some() { - return Err(anyhow::anyhow!( - "Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both" - )); - } - if let Some(tc) = target_conf { - if !(1..=1008).contains(&tc) { - return Err(anyhow::anyhow!( - "Invalid target_conf: must be between 1 and 1008 blocks" - )); - } - } - if let Some(rate) = sat_per_vbyte { - if !(1..=5000).contains(&rate) { - return Err(anyhow::anyhow!( - "Invalid sat_per_vbyte: must be between 1 and 5000" - )); - } - } + // Omitted fees target the next block; explicit slower/custom choices win. + let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?; info!( addr = addr, @@ -238,15 +218,12 @@ impl RpcHandler { if !(546..=21_000_000 * 100_000_000).contains(&amount) { return Err(anyhow::anyhow!("Invalid amount")); } - let target_conf = params - .get("target_conf") - .and_then(|v| v.as_i64()) - .unwrap_or(6); - if !(1..=1008).contains(&target_conf) { - return Err(anyhow::anyhow!( - "Invalid target_conf: must be between 1 and 1008 blocks" - )); - } + let (target_conf, custom_rate) = super::fee_policy::fee_options(¶ms)?; + anyhow::ensure!( + custom_rate.is_none(), + "Fee estimation requires a confirmation target" + ); + let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET); let (client, macaroon_hex) = self.lnd_client().await?; @@ -782,10 +759,24 @@ impl RpcHandler { total_amount += amount; } - let sat_per_vbyte = params - .get("fee_rate_sat_per_vbyte") - .and_then(|v| v.as_u64()) - .unwrap_or(10); + let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({ + "sat_per_vbyte": params.get("fee_rate_sat_per_vbyte") + }))?; + let (client, macaroon_hex) = self.lnd_client().await?; + let sat_per_vbyte = if let Some(rate) = explicit_rate { + rate as u64 + } else { + let response = client + .get(format!("{LND_REST_BASE_URL}/v2/wallet/estimatefee/1")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("Cannot estimate the next-block fee")? + .error_for_status() + .context("Next-block fee estimate rejected")?; + let estimate: serde_json::Value = response.json().await?; + super::fee_policy::estimated_sat_per_vbyte(&estimate)? + }; info!( total_amount = total_amount, @@ -793,8 +784,6 @@ impl RpcHandler { "Creating PSBT for hardware wallet signing" ); - let (client, macaroon_hex) = self.lnd_client().await?; - let fund_body = serde_json::json!({ "raw": { "outputs": lnd_outputs, diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index d66e529c..42e444c4 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -221,6 +221,10 @@ impl RpcHandler { params: Option, ) -> Result { let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + if let Some(job) = self.flash_job.read().await.as_ref() { + anyhow::ensure!(job.snapshot().await.done, + "A firmware flash is in progress; wait before reconnecting or changing radio settings"); + } let mut config = mesh::load_config(&self.config.data_dir).await?; @@ -325,7 +329,16 @@ impl RpcHandler { { let service_arc = Arc::clone(&self.mesh_service); let config_for_apply = config.clone(); + let flash_jobs = Arc::clone(&self.flash_job); tokio::spawn(async move { + // Serialize against flash registration. If a flash started + // after this RPC saved settings, its completion applies them. + let flash_guard = flash_jobs.read().await; + if let Some(job) = flash_guard.as_ref() { + if !job.snapshot().await.done { + return; + } + } let mut service = service_arc.write().await; if let Some(svc) = service.as_mut() { if let Err(e) = svc.configure(config_for_apply).await { diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 77087623..3c2e9223 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -110,7 +110,8 @@ impl RpcHandler { // `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe) // from opening the identical port at the same time and corrupting // both operations' handshakes. - if let Some(job) = self.flash_job.read().await.as_ref() { + let flash_guard = self.flash_job.read().await; + if let Some(job) = flash_guard.as_ref() { anyhow::ensure!( job.snapshot().await.done, "A firmware flash is in progress — refusing to probe the serial port until it finishes" @@ -131,6 +132,7 @@ impl RpcHandler { } } let probe = mesh::listener::probe_device(&path).await?; + drop(flash_guard); Ok(serde_json::to_value(probe)?) } diff --git a/core/archipelago/src/api/rpc/package/config.rs b/core/archipelago/src/api/rpc/package/config.rs index 5bd81419..20cf592d 100644 --- a/core/archipelago/src/api/rpc/package/config.rs +++ b/core/archipelago/src/api/rpc/package/config.rs @@ -985,28 +985,26 @@ pub(super) async fn get_app_config( ) } "nginx-proxy-manager" => { + let storage = crate::container::npm::resolve_storage().await?; let admin_port = allocator .allocate_or_get(app_id, 8081, 81) .await .unwrap_or(8081); let http_port = allocator - .allocate_or_get("nginx-proxy-manager-http", 8084, 80) + .allocate_or_get("nginx-proxy-manager-http", 8088, 80) .await - .unwrap_or(8084); + .unwrap_or(8088); let https_port = allocator .allocate_or_get("nginx-proxy-manager-https", 8444, 443) .await .unwrap_or(8444); ( vec![ - format!("{}:81", admin_port), - format!("{}:80", http_port), - format!("{}:443", https_port), - ], - vec![ - "/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(), - "/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(), + format!("127.0.0.1:{}:81", admin_port), + format!("127.0.0.1:{}:80", http_port), + format!("127.0.0.1:{}:443", https_port), ], + storage.bind_mounts(), vec![], None, None, diff --git a/core/archipelago/src/api/rpc/package/install.rs b/core/archipelago/src/api/rpc/package/install.rs index 126912d9..ae2570e2 100644 --- a/core/archipelago/src/api/rpc/package/install.rs +++ b/core/archipelago/src/api/rpc/package/install.rs @@ -693,7 +693,11 @@ impl RpcHandler { // These standalone web UIs have repeatedly lost host listeners // under Podman's rootless pasta backend while staying healthy internally. // Use slirp4netns/rootlessport for this standalone web UI. - run_args.push("--network=slirp4netns:allow_host_loopback=true"); + run_args.push(if package_id == "nginx-proxy-manager" { + "--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + } else { + "--network=slirp4netns:allow_host_loopback=true" + }); } else if needs_archy_net(package_id) { // Create archy-net if it doesn't exist (idempotent — "already exists" is fine) match tokio::process::Command::new("podman") @@ -1568,88 +1572,8 @@ autopilot.active=false\n", super::pine_ha::restart_home_assistant_if_running().await; } } - if package_id == "filebrowser" { - // Generate a random password (32 bytes, hex-encoded) - let mut buf = [0u8; 32]; - rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf); - let password = hex::encode(buf); - - let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - { - Ok(c) => c, - Err(e) => { - tracing::warn!("Failed to create HTTP client for FileBrowser hook: {}", e); - return; - } - }; - - // Retry loop: FileBrowser may take time to initialize its SQLite database - let mut password_changed = false; - for attempt in 0..6u32 { - let delay = if attempt == 0 { 5 } else { 10 }; - tokio::time::sleep(std::time::Duration::from_secs(delay)).await; - - // Try to log in with default credentials - let login_res = client - .post("http://127.0.0.1:8083/api/login") - .json(&serde_json::json!({"username": "admin", "password": "admin"})) - .send() - .await; - - let token = match login_res { - Ok(resp) if resp.status().is_success() => match resp.text().await { - Ok(t) => t.trim_matches('"').to_string(), - Err(_) => continue, - }, - _ => { - debug!("FileBrowser not ready (attempt {}/6)", attempt + 1); - continue; - } - }; - - // Change admin password - let change_res = client - .put("http://127.0.0.1:8083/api/users/1") - .header("X-Auth", &token) - .json(&serde_json::json!({"password": password})) - .send() - .await; - - match change_res { - Ok(resp) if resp.status().is_success() => { - let secret_dir = "/var/lib/archipelago/secrets/filebrowser"; - if let Err(e) = tokio::fs::create_dir_all(secret_dir).await { - tracing::warn!("Failed to create filebrowser secrets dir: {}", e); - } - let pw_path = format!("{}/password", secret_dir); - if let Err(e) = tokio::fs::write(&pw_path, &password).await { - tracing::warn!("Failed to write filebrowser password: {}", e); - } - // Set restrictive permissions on the password file - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions( - &pw_path, - std::fs::Permissions::from_mode(0o600), - ); - } - info!("FileBrowser admin password secured (default credentials replaced)"); - password_changed = true; - break; - } - _ => continue, - } - } - if !password_changed { - tracing::warn!( - "FileBrowser password could not be changed after 6 attempts — \ - default credentials (admin/admin) remain active" - ); - } - } + // File Browser credentials are provisioned and verified before the + // server starts. Never attempt a default-password change after launch. // Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin) { @@ -1912,10 +1836,10 @@ autopilot.active=false\n", } pub(in crate::api::rpc) async fn handle_filebrowser_token(&self) -> Result { - let secret_path = "/var/lib/archipelago/secrets/filebrowser/password"; - let password = tokio::fs::read_to_string(secret_path) - .await - .unwrap_or_else(|_| "admin".to_string()); + let credentials = crate::container::filebrowser::cloud_credentials(std::path::Path::new( + "/var/lib/archipelago/secrets/filebrowser", + )) + .await?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) @@ -1924,7 +1848,7 @@ autopilot.active=false\n", let resp = client .post("http://127.0.0.1:8083/api/login") - .json(&serde_json::json!({"username": "admin", "password": password})) + .json(&serde_json::json!({"username": credentials.username, "password": credentials.password})) .send() .await .context("Failed to connect to FileBrowser")?; @@ -1954,17 +1878,16 @@ autopilot.active=false\n", super::validation::validate_app_id(app_id)?; if app_id == "filebrowser" { - let password = - tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password") - .await - .map(|p| p.trim().to_string()) - .unwrap_or_else(|_| "admin".to_string()); + let credentials = crate::container::filebrowser::cloud_credentials( + std::path::Path::new("/var/lib/archipelago/secrets/filebrowser"), + ) + .await?; return Ok(serde_json::json!({ "title": "File Browser credentials", "description": "Use these credentials when File Browser asks you to sign in.", "credentials": [ - { "label": "Username", "value": "admin" }, - { "label": "Password", "value": password, "sensitive": true } + { "label": "Username", "value": credentials.username }, + { "label": "Password", "value": credentials.password, "sensitive": true } ] })); } diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index ac65bc94..ca081db9 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -1576,41 +1576,110 @@ async fn repair_netbird_network() { } async fn repair_nginx_proxy_manager_container() { - repair_nginx_proxy_manager_dirs().await; + // Quadlet owns managed containers; its backed-up reconciliation applies + // port and mount changes. Never remove a systemd-owned container here. + if crate::container::quadlet::unit_exists("nginx-proxy-manager").await { + return; + } + // Serialize repair so a second caller cannot overlap a replacement. + static REPAIR: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + let _repair = REPAIR.lock().await; if !nginx_proxy_manager_has_legacy_admin_port().await { return; } - - install_log( - "START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081", - ) - .await; - let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await; - crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await; - if let Err(err) = recreate_nginx_proxy_manager_container().await { - tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container"); + if let Err(error) = repair_legacy_nginx_proxy_manager().await { + tracing::warn!(error = %error, "NPM legacy repair failed; persistent state preserved"); } } -async fn repair_nginx_proxy_manager_dirs() { - let _ = tokio::process::Command::new("sudo") - .args([ - "mkdir", - "-p", - "/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge", - "/var/lib/archipelago/nginx-proxy-manager/letsencrypt", - ]) - .output() - .await; - let _ = tokio::process::Command::new("sudo") - .args([ - "chown", - "-R", - "1000:1000", - "/var/lib/archipelago/nginx-proxy-manager", - ]) - .output() - .await; +const NPM_PREVIOUS_CONTAINER: &str = "archy-npm-upgrade-previous"; + +async fn restore_failed_npm_repair() -> Result<()> { + let removed = podman_control(&["rm", "-f", "--ignore", "nginx-proxy-manager"]).await?; + anyhow::ensure!( + removed.status.success(), + "cannot remove failed NPM replacement; previous container retained" + ); + let renamed = + podman_control(&["rename", NPM_PREVIOUS_CONTAINER, "nginx-proxy-manager"]).await?; + anyhow::ensure!( + renamed.status.success(), + "cannot restore previous NPM container name" + ); + let started = podman_control(&["start", "nginx-proxy-manager"]).await?; + anyhow::ensure!( + started.status.success(), + "previous NPM container restored but failed to start" + ); + Ok(()) +} + +async fn repair_legacy_nginx_proxy_manager() -> Result<()> { + let previous = podman_control(&["container", "exists", NPM_PREVIOUS_CONTAINER]).await?; + anyhow::ensure!(previous.status.code() == Some(1), + "NPM previous-container slot is occupied or cannot be inspected; preserve it and review interrupted repair before proceeding"); + let inspection = podman_control(&[ + "inspect", + "nginx-proxy-manager", + "--format", + "{{json .Config.Env}}", + ]) + .await?; + anyhow::ensure!( + inspection.status.success(), + "cannot preserve NPM environment before repair" + ); + let environment: Vec = + serde_json::from_slice(&inspection.stdout).context("invalid original NPM environment")?; + let environment = npm_repair_environment(&environment)?; + let storage = crate::container::npm::resolve_storage().await?; + let mut manifest: archipelago_container::AppManifest = serde_yaml::from_str(include_str!( + "../../../../../../apps/nginx-proxy-manager/manifest.yml" + ))?; + storage.apply(&mut manifest)?; + let stopped = podman_control(&["stop", "--time", "30", "nginx-proxy-manager"]).await?; + anyhow::ensure!( + stopped.status.success(), + "could not stop NPM for a consistent backup" + ); + if let Err(error) = crate::container::migration_backup::snapshot( + &manifest, + std::path::Path::new("/var/lib/archipelago"), + None, + ) + .await + { + let _ = podman_control(&["start", "nginx-proxy-manager"]).await; + return Err(error); + } + // Keep the original runtime definition for rollback, including its operator + // options. Never delete it before the replacement has become ready. + let renamed = podman_control(&["rename", "nginx-proxy-manager", NPM_PREVIOUS_CONTAINER]).await; + if !renamed.as_ref().is_ok_and(|out| out.status.success()) { + let _ = podman_control(&["start", "nginx-proxy-manager"]).await; + anyhow::bail!("could not retain legacy NPM runtime; state backup preserved, inspect both container names before retrying"); + } + let replacement = async { + recreate_nginx_proxy_manager_container(&storage, &environment).await?; + anyhow::ensure!( + wait_for_runtime_host_port("nginx-proxy-manager", 8081, 180).await, + "replacement NPM admin listener did not become ready" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + if let Err(error) = replacement { + restore_failed_npm_repair() + .await + .context("restoring previous NPM after replacement failure")?; + return Err(error); + } + let removed = podman_control(&["rm", NPM_PREVIOUS_CONTAINER]).await?; + anyhow::ensure!( + removed.status.success(), + "replacement ready but previous NPM cleanup failed; rollback container retained" + ); + Ok(()) } async fn nginx_proxy_manager_has_legacy_admin_port() -> bool { @@ -1645,36 +1714,75 @@ async fn nginx_proxy_manager_has_legacy_admin_port() -> bool { ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp") } -async fn recreate_nginx_proxy_manager_container() -> Result<()> { - tokio::process::Command::new("sudo") - .args([ - "mkdir", - "-p", - "/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge", - "/var/lib/archipelago/nginx-proxy-manager/letsencrypt", - ]) - .output() - .await - .context("failed to create nginx-proxy-manager data directories")?; - let _ = tokio::process::Command::new("sudo") - .args([ - "chown", - "-R", - "1000:1000", - "/var/lib/archipelago/nginx-proxy-manager", - ]) - .output() - .await; +fn npm_repair_environment(values: &[String]) -> Result> { + values.iter().map(|value| { + let (key, value) = value.split_once('=').context("invalid NPM environment entry")?; + anyhow::ensure!(!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), + "unsupported NPM environment name; original container preserved"); + anyhow::ensure!(!value.contains(['\n', '\r', '\0']), + "NPM environment requires explicit migration of a multiline value; original container preserved"); + Ok((key.to_owned(), value.to_owned())) + }).collect() +} - let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager") - .unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string()); +struct NpmRepairEnvironmentFile(std::path::PathBuf); + +impl NpmRepairEnvironmentFile { + fn create(environment: &[(String, String)]) -> Result { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let path = std::env::temp_dir().join(format!(".archy-npm-env-{}", uuid::Uuid::new_v4())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path)?; + let guard = Self(path); + for (key, value) in environment { + writeln!(file, "{key}={value}")?; + } + file.sync_all()?; + Ok(guard) + } +} + +impl Drop for NpmRepairEnvironmentFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +async fn recreate_nginx_proxy_manager_container( + storage: &crate::container::npm::Storage, + environment: &[(String, String)], +) -> Result<()> { + // Existing directories and ownership came from the active runtime. Never + // create a second database tree or recursively rewrite data permissions. + for directory in [&storage.data, &storage.certificates] { + anyhow::ensure!( + std::path::Path::new(directory).is_dir(), + "NPM persistent directory is missing" + ); + } + + // This repair changes connectivity, not NPM's application version. Keep + // the exact old image so rollback never starts an older binary against a + // database that an incidental mutable-tag update may have migrated. + let image_output = + podman_control(&["inspect", NPM_PREVIOUS_CONTAINER, "--format", "{{.Image}}"]).await?; + anyhow::ensure!( + image_output.status.success(), + "cannot resolve original NPM image for repair" + ); + let image = String::from_utf8(image_output.stdout)?.trim().to_string(); + anyhow::ensure!(!image.is_empty(), "original NPM image is missing"); let mut args = vec![ "run".to_string(), "-d".to_string(), "--name".to_string(), "nginx-proxy-manager".to_string(), "--restart=unless-stopped".to_string(), - "--network=slirp4netns:allow_host_loopback=true".to_string(), + "--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24".to_string(), "--cap-drop=ALL".to_string(), "--security-opt=no-new-privileges:true".to_string(), "--pids-limit=4096".to_string(), @@ -1682,24 +1790,32 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> { args.extend(get_app_capabilities("nginx-proxy-manager")); args.extend([ "-p".to_string(), - "8081:81".to_string(), + "127.0.0.1:8081:81".to_string(), "-p".to_string(), - "8084:80".to_string(), + "127.0.0.1:8088:80".to_string(), "-p".to_string(), - "8444:443".to_string(), + "127.0.0.1:8444:443".to_string(), "-v".to_string(), - "/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(), + format!("{}:/data", storage.data), "-v".to_string(), - "/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(), + format!("{}:/etc/letsencrypt", storage.certificates), "--memory".to_string(), get_memory_limit("nginx-proxy-manager").to_string(), "--cpus=2".to_string(), ]); args.extend(get_health_check_args("nginx-proxy-manager", "")); + // Keep values out of argv/logs AND out of Podman's host environment + // (a container's PATH or LD_PRELOAD must never alter the host command). + let env_file = NpmRepairEnvironmentFile::create(environment)?; + args.extend([ + "--env-file".to_string(), + env_file.0.to_string_lossy().into_owned(), + ]); args.push(image); - let refs = args.iter().map(String::as_str).collect::>(); - let output = podman_control(&refs).await?; + let mut command = tokio::process::Command::new("podman"); + command.args(&args); + let output = command_with_timeout(command, Duration::from_secs(120), "NPM replacement").await?; if !output.status.success() { anyhow::bail!( "podman run nginx-proxy-manager failed: {}", @@ -1767,7 +1883,7 @@ fn runtime_host_ports(container_name: &str) -> Vec { "vaultwarden" => vec![8082], "gitea" => vec![3001, 2222, 3000], "nextcloud" => vec![8085], - "nginx-proxy-manager" => vec![8081, 8084, 8444], + "nginx-proxy-manager" => vec![8081, 8088, 8444], _ => Vec::new(), }; ports @@ -1778,7 +1894,7 @@ fn with_legacy_extra_ports(container_name: &str, mut ports: Vec) -> Vec http_host_port_ready(port, "/").await, + "nginx-proxy-manager" => http_host_port_ready(port, "/api/").await, _ => tokio::net::TcpStream::connect(("127.0.0.1", port)) .await .is_ok(), @@ -2151,6 +2268,38 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec { #[cfg(test)] mod tests { + #[test] + fn npm_environment_backup_is_private_exact_and_removed_on_drop() { + use std::os::unix::fs::PermissionsExt; + let values = vec![ + "DB_PASSWORD=fixture=a b".to_string(), + "PATH=/container/only".to_string(), + ]; + let parsed = super::npm_repair_environment(&values).unwrap(); + let host_path = std::env::var_os("PATH"); + let path = { + let file = super::NpmRepairEnvironmentFile::create(&parsed).unwrap(); + assert_eq!( + std::fs::metadata(&file.0).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + std::fs::read_to_string(&file.0).unwrap(), + "DB_PASSWORD=fixture=a b\nPATH=/container/only\n" + ); + assert_eq!(std::env::var_os("PATH"), host_path); + file.0.clone() + }; + assert!(!path.exists()); + for value in [ + "INVALID", + "=empty key", + "KEY=value\nINJECTED=true", + "--env=value", + ] { + assert!(super::npm_repair_environment(&[value.to_string()]).is_err()); + } + } use super::*; #[tokio::test] diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 4ea1381e..b706c3c1 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -142,11 +142,40 @@ const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\ /// catalog refresh/reconciliation. Replacing the app tree in the background /// could let a reload observe its temporary empty state and forget disk-only apps. pub async fn ensure_runtime_assets_ready() { + // Install the guard before any startup path can reload an older dashboard + // vhost. The canonical OTA/ISO config contains the same guard inline. + if Path::new(NGINX_CONF_PATH).exists() || Path::new(NGINX_ENABLED_CONF_PATH).exists() { + match host_sudo(&[ + "python3", + "-c", + include_str!("../../../scripts/dashboard-public-guard.py"), + ]) + .await + { + Ok(status) if status.success() => debug!("Dashboard public source guard verified"), + Ok(status) => warn!("Dashboard public source guard needs attention: {status}"), + Err(error) => warn!("Dashboard public source guard could not run: {error}"), + } + } match run_runtime_assets().await { Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"), Ok(_) => debug!("No OTA runtime payload to synchronize"), Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e), } + // A binary-only qualification or OTA rollback can precede the matching + // script payload. Install the exact embedded helper before Quadlet + // reconciliation can introduce its required ExecStartPre command. + if let Err(error) = write_root_if_needed( + "/opt/archipelago/scripts/filebrowser-credentials.py", + include_str!("../../../scripts/filebrowser-credentials.py"), + ) + .await + { + warn!("File Browser credential helper installation failed: {error:#}"); + } + if let Err(error) = run_npm_bridge_bootstrap().await { + warn!("NPM public routing bootstrap needs attention: {error:#}"); + } // Repair the narrowly recognized legacy NPM tunnel override before app // reconciliation. The embedded script ships in both OTA and ISO binaries. // It preserves native wallet services and refuses unknown custom routing. @@ -176,6 +205,52 @@ pub async fn ensure_runtime_assets_ready() { } } +async fn run_npm_bridge_bootstrap() -> Result<()> { + if !Path::new("/opt/archipelago/scripts").is_dir() { + return Ok(()); + } + let mut units_changed = false; + for (path, content) in [ + ( + "/opt/archipelago/scripts/npm-public-bridge.py", + include_str!("../../../scripts/npm-public-bridge.py"), + ), + ( + "/opt/archipelago/scripts/dashboard-public-guard.py", + include_str!("../../../scripts/dashboard-public-guard.py"), + ), + ( + "/etc/systemd/system/archipelago-npm-bridge.service", + include_str!("../../../image-recipe/configs/archipelago-npm-bridge.service"), + ), + ( + "/etc/systemd/system/archipelago-npm-bridge.timer", + include_str!("../../../image-recipe/configs/archipelago-npm-bridge.timer"), + ), + ] { + let changed = write_root_if_needed(path, content).await?; + units_changed |= changed && (path.ends_with(".service") || path.ends_with(".timer")); + } + if units_changed { + anyhow::ensure!( + host_sudo(&["systemctl", "daemon-reload"]).await?.success(), + "NPM bridge daemon reload failed" + ); + } + anyhow::ensure!( + host_sudo(&[ + "systemctl", + "enable", + "--now", + "archipelago-npm-bridge.timer" + ]) + .await? + .success(), + "NPM bridge timer could not start" + ); + Ok(()) +} + /// Entry point called from main startup. Never returns an error to the caller — /// failing to bootstrap host artifacts must not prevent the backend from serving. pub async fn ensure_doctor_installed() { @@ -432,6 +507,17 @@ async fn run_runtime_assets() -> Result { if !status.success() { anyhow::bail!("install nginx-archipelago.conf exited with {}", status); } + let acme_status = host_sudo(&[ + "python3", + "-c", + include_str!("../../../scripts/npm-public-bridge.py"), + "--acme-only", + ]) + .await?; + anyhow::ensure!( + acme_status.success(), + "active NPM ACME root migration failed" + ); changed = true; } @@ -448,6 +534,8 @@ async fn run_runtime_assets() -> Result { "archipelago-doctor.service", "archipelago-doctor.timer", "archipelago-host-secrets-audit.service", + "archipelago-npm-bridge.service", + "archipelago-npm-bridge.timer", ] { let src = configs.join(unit); if src.exists() { @@ -475,7 +563,7 @@ async fn run_runtime_assets() -> Result { // or directory"). Skipped when byte-identical; a running daemon is // unaffected (install replaces the inode) and picks the new binary up // on its next spawn. - for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] { + for tool in ["archy-reticulum-daemon", "archy-rnodeconf", "archy-esptool"] { let src = runtime_dir.join("radio-tools").join(tool); if !src.exists() { continue; diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index f632bec6..8d064750 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -118,10 +118,12 @@ fn selected_manifest(entry: AppCatalogEntry) -> Option { // Never let an unknown future requirement become an unsafe partial match. for variant in entry.manifest_variants.into_iter().rev() { if !variant.requires.is_empty() - && variant - .requires - .iter() - .all(|capability| capability == "runtime-migration-backup-v1") + && variant.requires.iter().all(|capability| { + matches!( + capability.as_str(), + "runtime-migration-backup-v1" | "npm-legacy-host-gateway-v1" + ) + }) { return Some(variant.manifest); } @@ -468,6 +470,12 @@ pub struct CatalogRefresh { /// changed. Best-effort: a fetch failure leaves the existing cache untouched /// (origin-always-wins; updates simply aren't refreshed this cycle). pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result { + // Explicit operator-only qualification of a signed candidate on selected + // nodes. Never change fleet mirrors or fall back to an older public catalog + // while a candidate is selected. Normal signature enforcement still applies. + if let Some(path) = std::env::var_os("ARCHY_APP_CATALOG_CANDIDATE") { + return refresh_candidate_catalog(data_dir, Path::new(&path)).await; + } let mirrors = crate::update::load_mirrors(data_dir) .await .unwrap_or_default(); @@ -514,6 +522,49 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable"))) } +async fn refresh_candidate_catalog(data_dir: &Path, path: &Path) -> anyhow::Result { + anyhow::ensure!( + path.is_absolute(), + "candidate catalog path must be absolute" + ); + let metadata = tokio::fs::metadata(path) + .await + .context("inspect candidate catalog")?; + anyhow::ensure!( + metadata.is_file() && metadata.len() <= 4 * 1024 * 1024, + "candidate catalog must be a file no larger than 4 MiB" + ); + let body = tokio::fs::read_to_string(path) + .await + .context("read candidate catalog")?; + anyhow::ensure!( + body.len() <= 4 * 1024 * 1024, + "candidate catalog exceeds 4 MiB" + ); + let raw: serde_json::Value = serde_json::from_str(&body)?; + anyhow::ensure!( + matches!( + crate::trust::verify_detached(&raw)?, + crate::trust::SignatureStatus::Verified { anchored: true, .. } + ), + "candidate catalog requires a signature anchored to the release root" + ); + let catalog: AppCatalog = serde_json::from_value(raw)?; + let changed = write_cache(data_dir, &body)?; + if changed { + *CACHE.lock().unwrap() = None; + } + info!( + apps = catalog.apps.len(), + changed, + "app-catalog: using explicitly selected signed candidate; public catalog refresh paused" + ); + Ok(CatalogRefresh { + apps: catalog.apps.len(), + changed, + }) +} + async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> { let resp = client.get(url).send().await?; if !resp.status().is_success() { @@ -582,6 +633,77 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result { mod tests { use super::*; + fn signed_candidate(key_byte: u8) -> serde_json::Value { + // Same test anchor as trust::signed_doc tests; never a production key. + let anchor = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + std::env::set_var( + "ARCHY_RELEASE_ROOT_PUBKEY", + hex::encode(anchor.verifying_key().to_bytes()), + ); + let key = ed25519_dalek::SigningKey::from_bytes(&[key_byte; 32]); + let mut value = serde_json::json!({"schema":1,"apps":{"demo":{"version":"2"}},"future_field":{"retain":true}}); + let (sig, did) = crate::trust::signed_doc::sign_detached(&key, &value).unwrap(); + value["signature"] = sig.into(); + value["signed_by"] = did.into(); + value + } + + #[tokio::test] + async fn candidate_catalog_preserves_signed_bytes_and_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("candidate.json"); + let body = serde_json::to_string_pretty(&signed_candidate(7)).unwrap(); + std::fs::write(&path, &body).unwrap(); + let first = refresh_candidate_catalog(dir.path(), &path).await.unwrap(); + assert!(first.changed); + assert_eq!(first.apps, 1); + assert_eq!( + std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(), + body + ); + assert!( + !refresh_candidate_catalog(dir.path(), &path) + .await + .unwrap() + .changed + ); + } + + #[tokio::test] + async fn rejected_candidate_never_replaces_previous_catalog() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("candidate.json"); + let previous = "previous cached bytes"; + write_cache(dir.path(), previous).unwrap(); + let mut tampered = signed_candidate(7); + tampered["apps"]["demo"]["version"] = "tampered".into(); + for body in [ + "malformed".into(), + r#"{"schema":1,"apps":{}}"#.into(), + signed_candidate(11).to_string(), + tampered.to_string(), + " ".repeat(4 * 1024 * 1024 + 1), + ] { + std::fs::write(&path, body).unwrap(); + assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err()); + assert_eq!( + std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(), + previous + ); + } + std::fs::remove_file(&path).unwrap(); + assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err()); + assert!( + refresh_candidate_catalog(dir.path(), Path::new("relative.json")) + .await + .is_err() + ); + assert_eq!( + std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(), + previous + ); + } + #[test] fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() { let raw = serde_json::json!({ @@ -608,6 +730,25 @@ mod tests { assert!(chosen["app"]["container"].get("network").is_none()); } + #[test] + fn npm_gateway_variant_requires_explicit_runtime_support() { + let mut raw = serde_json::json!({ + "version": "2.12.1", "manifest": {"network":"pasta"}, + "manifest_variants": [{"requires":["runtime-migration-backup-v1", "npm-legacy-host-gateway-v1"], + "manifest":{"network":"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"}}] + }); + assert_eq!( + selected_manifest(serde_json::from_value(raw.clone()).unwrap()).unwrap()["network"], + "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + ); + // A runtime missing any required capability must retain the base. + raw["manifest_variants"][0]["requires"][1] = serde_json::json!("unknown-gateway-v2"); + assert_eq!( + selected_manifest(serde_json::from_value(raw).unwrap()).unwrap()["network"], + "pasta" + ); + } + #[test] fn parses_and_ignores_unknown_fields() { let json = r#"{ diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 673d0c72..8d9a9820 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -24,6 +24,7 @@ fn canonical_package_id(name: &str) -> &str { "immich-postgres" => "immich_postgres", "immich-redis" => "immich_redis", "mempool-web" | "mempool-frontend" => "mempool", + "btcpay" | "btcpayserver" => "btcpay-server", name => name, } } @@ -445,6 +446,15 @@ mod lifecycle_regression_tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + #[test] + fn btcpay_aliases_share_one_package_without_promoting_dependencies() { + for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] { + assert_eq!(canonical_package_id(name), "btcpay-server"); + } + assert_eq!(canonical_package_id("archy-btcpay-db"), "btcpay-db"); + assert_eq!(canonical_package_id("archy-nbxplorer"), "nbxplorer"); + } + #[test] fn immich_dependency_aliases_share_the_hidden_component_ids() { for id in [ diff --git a/core/archipelago/src/container/filebrowser.rs b/core/archipelago/src/container/filebrowser.rs index 1e85fa11..95f1d7f2 100644 --- a/core/archipelago/src/container/filebrowser.rs +++ b/core/archipelago/src/container/filebrowser.rs @@ -17,6 +17,84 @@ pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.fi const DEFAULT_CONFIG_JSON: &str = "{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n"; +/// One atomically published record shared by setup, Cloud and credentials UI. +/// Deliberately has no Debug implementation: the password must never be logged. +#[derive(serde::Deserialize)] +pub struct CloudCredentials { + pub schema: u32, + pub username: String, + pub password: String, +} + +pub async fn cloud_credentials(directory: &Path) -> Result { + let path = directory.join("credentials.json"); + match fs::read(&path).await { + Ok(bytes) => { + let value: CloudCredentials = serde_json::from_slice(&bytes) + .context("Invalid private File Browser credential record")?; + let suffix = value.username.strip_prefix("archy-").unwrap_or(""); + anyhow::ensure!( + value.schema == 1 + && suffix.len() == 32 + && suffix.bytes().all(|c| c.is_ascii_hexdigit()) + && value.password.len() == 64 + && value.password.bytes().all(|c| c.is_ascii_hexdigit()), + "Invalid managed File Browser credentials" + ); + Ok(value) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // Compatibility during staged upgrades only. Never invent admin/admin + // when a secret is missing; the pre-start provisioner repairs legacy DBs. + let password = fs::read_to_string(directory.join("password")) + .await + .context("File Browser secure Cloud login has not been provisioned")?; + let password = password.trim().to_owned(); + anyhow::ensure!( + !password.is_empty() && password != "admin", + "File Browser default credentials must be migrated before Cloud login" + ); + Ok(CloudCredentials { + schema: 0, + username: "admin".into(), + password, + }) + } + Err(error) => Err(error).context("Cannot read private File Browser credentials"), + } +} + +/// Prepare a stopped server using the same helper used by Quadlet and the ISO. +pub async fn prepare_credentials( + paths: &EnsurePaths, + secret_dir: &Path, + image: &str, + runtime: &str, +) -> Result<()> { + let output = tokio::process::Command::new("python3") + .args([ + "-c", + include_str!("../../../../scripts/filebrowser-credentials.py"), + "--image", + image, + "--runtime", + runtime, + "--data-dir", + &paths.data_dir.to_string_lossy(), + "--srv-root", + &paths.srv_root.to_string_lossy(), + "--secrets-dir", + &secret_dir.to_string_lossy(), + ]) + .kill_on_drop(true) + .output() + .await + .context("Running File Browser credential setup")?; + anyhow::ensure!(output.status.success(), + "File Browser secure login setup failed; existing state and private rollback backup retained"); + Ok(()) +} + #[derive(Debug, Clone)] pub struct EnsurePaths { pub srv_root: PathBuf, @@ -82,7 +160,18 @@ async fn create_dir_all_or_sudo(path: &std::path::Path) -> Result<()> { async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> { let tmp = paths.config_path.with_extension("tmp"); - match fs::write(&tmp, DEFAULT_CONFIG_JSON).await { + let legacy = paths.data_dir.join("database.db").exists(); + let canonical = paths.data_dir.join("filebrowser.db").exists(); + anyhow::ensure!( + !(legacy && canonical), + "Multiple File Browser databases need explicit config selection" + ); + let config = if legacy { + DEFAULT_CONFIG_JSON.replace("/data/filebrowser.db", "/data/database.db") + } else { + DEFAULT_CONFIG_JSON.to_string() + }; + match fs::write(&tmp, &config).await { Ok(()) => { fs::rename(&tmp, &paths.config_path) .await @@ -99,7 +188,7 @@ async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> { let script = format!( "set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n", shell_quote(&paths.config_path.to_string_lossy()), - DEFAULT_CONFIG_JSON + config ); let status = host_sudo(&["sh", "-lc", &script]) .await @@ -312,6 +401,62 @@ async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec) -> Result< mod tests { use super::*; + #[tokio::test] + async fn cloud_credentials_use_unique_record_and_never_default_password() { + let dir = tempfile::tempdir().unwrap(); + assert!(cloud_credentials(dir.path()).await.is_err()); + fs::write(dir.path().join("password"), "admin") + .await + .unwrap(); + assert!(cloud_credentials(dir.path()).await.is_err()); + fs::write(dir.path().join("password"), "legacy-unique-password") + .await + .unwrap(); + assert_eq!(cloud_credentials(dir.path()).await.unwrap().schema, 0); + let value = serde_json::json!({"schema":1,"username":format!("archy-{}", "a".repeat(32)),"password":"b".repeat(64)}); + fs::write(dir.path().join("credentials.json"), value.to_string()) + .await + .unwrap(); + let loaded = cloud_credentials(dir.path()).await.unwrap(); + assert_eq!(loaded.username, value["username"].as_str().unwrap()); + assert_eq!(loaded.password, value["password"].as_str().unwrap()); + fs::write(dir.path().join("credentials.json"), "{}") + .await + .unwrap(); + assert!( + cloud_credentials(dir.path()).await.is_err(), + "damaged managed record must not fall back to old credentials" + ); + } + + #[tokio::test] + async fn missing_config_preserves_legacy_database_and_refuses_ambiguity() { + let tmp = tempfile::tempdir().unwrap(); + let paths = EnsurePaths { + srv_root: tmp.path().join("srv"), + data_dir: tmp.path().join("data"), + config_path: tmp.path().join("data/.filebrowser.json"), + }; + fs::create_dir_all(&paths.data_dir).await.unwrap(); + fs::write(paths.data_dir.join("database.db"), b"legacy fixture") + .await + .unwrap(); + ensure_config(&paths).await.unwrap(); + let config: serde_json::Value = + serde_json::from_slice(&fs::read(&paths.config_path).await.unwrap()).unwrap(); + assert_eq!(config["database"], "/data/database.db"); + fs::remove_file(&paths.config_path).await.unwrap(); + fs::write(paths.data_dir.join("filebrowser.db"), b"other fixture") + .await + .unwrap(); + assert!(ensure_config(&paths).await.is_err()); + assert!(!paths.config_path.exists()); + assert_eq!( + fs::read(paths.data_dir.join("database.db")).await.unwrap(), + b"legacy fixture" + ); + } + #[tokio::test] async fn ensure_config_creates_dirs_and_file() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index 3455a0e2..da3968fe 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -13,6 +13,7 @@ pub mod image_policy; pub mod image_versions; pub mod lnd; pub mod migration_backup; +pub mod npm; pub mod prod_orchestrator; pub mod quadlet; pub mod registry; diff --git a/core/archipelago/src/container/npm.rs b/core/archipelago/src/container/npm.rs new file mode 100644 index 00000000..00348313 --- /dev/null +++ b/core/archipelago/src/container/npm.rs @@ -0,0 +1,115 @@ +//! Preserve NPM's active persistent mounts across installer and runtime paths. +use anyhow::{bail, Context, Result}; +use archipelago_container::AppManifest; +use serde::Deserialize; +use std::path::Path; +use std::time::Duration; + +#[derive(Debug, Deserialize)] +pub struct Storage { + pub data: String, + pub certificates: String, +} + +impl Storage { + pub fn bind_mounts(&self) -> Vec { + vec![ + format!("{}:/data", self.data), + format!("{}:/etc/letsencrypt", self.certificates), + ] + } + + pub fn apply(&self, manifest: &mut AppManifest) -> Result<()> { + for (target, source) in [ + ("/data", &self.data), + ("/etc/letsencrypt", &self.certificates), + ] { + let matching: Vec<_> = manifest + .app + .volumes + .iter_mut() + .filter(|volume| volume.target == target) + .collect(); + if matching.len() != 1 { + bail!("NPM requires one persistent mount per storage target"); + } + let volume = matching.into_iter().next().unwrap(); + if volume.volume_type != "bind" { + bail!("NPM persistent state requires bind mounts"); + } + volume.source.clone_from(source); + } + Ok(()) + } +} + +fn parse_storage(bytes: &[u8]) -> Result { + let storage: Storage = + serde_json::from_slice(bytes).context("invalid NPM storage resolution")?; + for value in [&storage.data, &storage.certificates] { + if !Path::new(value).is_absolute() || value.chars().any(|c| c.is_control() || c == ':') { + bail!("invalid NPM persistent storage path"); + } + } + Ok(storage) +} + +pub async fn resolve_storage() -> Result { + // Verify live mounts/database before any caller can stop or recreate NPM. + // Remember only validated mount paths so later recreation, after the inspect + // record is removed, still uses the operator's original storage. + let mut command = tokio::process::Command::new("python3"); + command + .args([ + "-c", + include_str!("../../../../scripts/npm-public-bridge.py"), + "--resolve", + "--remember", + "--prepare-realip", + ]) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(75), command.output()) + .await + .context("NPM storage resolution timed out; existing state preserved")??; + if !output.status.success() { + bail!("NPM storage resolution failed; inspect mount/database ambiguity or permissions before migration"); + } + parse_storage(&output.stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolved_mounts_preserve_custom_and_legacy_paths() { + for data in [ + "/var/lib/archipelago/nginx-proxy-manager/data", + "/srv/operator npm", + ] { + let bytes = serde_json::to_vec( + &serde_json::json!({"data": data, "certificates": "/srv/certificates"}), + ) + .unwrap(); + let storage = parse_storage(&bytes).unwrap(); + assert_eq!( + storage.bind_mounts(), + [ + format!("{data}:/data"), + "/srv/certificates:/etc/letsencrypt".into(), + ] + ); + } + } + + #[test] + fn invalid_resolution_cannot_become_a_container_mount() { + for data in ["relative", "/srv/data:ro", "/srv/data\nother"] { + let bytes = + serde_json::to_vec(&serde_json::json!({"data": data, "certificates": "/certs"})) + .unwrap(); + assert!(parse_storage(&bytes).is_err()); + } + assert!(parse_storage(b"{}").is_err()); + } +} diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index d15c3657..f131f495 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -92,16 +92,71 @@ fn is_restart_sensitive_app(app_id: &str) -> bool { fn is_builtin_network_mode(network: &str) -> bool { matches!( network, - "host" | "bridge" | "none" | "slirp4netns" | "pasta" + "host" + | "bridge" + | "none" + | "slirp4netns" + | "slirp4netns:allow_host_loopback=true" + | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + | "pasta" ) } // Only an explicitly selected rootless mode establishes drift. An omitted // network delegates to Podman and must not recreate unrelated installed apps. fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool { - matches!(expected, Some("slirp4netns" | "pasta")) - && !actual.trim().is_empty() - && actual.trim().split(':').next() != expected + let actual = actual.trim(); + if actual.is_empty() { + return false; + } + match expected { + Some( + expected @ ("slirp4netns:allow_host_loopback=true" + | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"), + ) => { + let (mode, options) = actual.split_once(':').unwrap_or((actual, "")); + let required = expected.split_once(':').unwrap().1; + mode != "slirp4netns" + || required.split(',').any(|wanted| { + let key = wanted.split_once('=').unwrap().0; + !options.split(',').any(|option| option == wanted) + || options.split(',').any(|option| { + option.split_once('=').is_some_and(|(name, _)| name == key) + && option != wanted + }) + }) + } + Some(mode @ ("slirp4netns" | "pasta")) => actual.split(':').next() != Some(mode), + _ => false, + } +} + +// API-created containers may omit rootless options from HostConfig.NetworkMode. +// generate spec retains them; inspect alone would cause an endless repair loop. +fn rootless_network_from_spec(bytes: &[u8]) -> Option { + let spec: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let mode = spec.get("netns")?.get("nsmode")?.as_str()?; + if !matches!(mode, "slirp4netns" | "pasta") { + return None; + } + let options = spec + .get("network_options") + .and_then(|options| options.get(mode)); + match options { + None => Some(mode.to_string()), + Some(options) => { + let options = options + .as_array()? + .iter() + .map(|value| value.as_str()) + .collect::>>()?; + if options.is_empty() { + Some(mode.to_string()) + } else { + Some(format!("{mode}:{}", options.join(","))) + } + } + } } fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool { @@ -175,6 +230,11 @@ fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec { } fn host_port_wait_timeout_secs(manifest: &AppManifest) -> u64 { + // First NPM initialization generates keys and migrates its database before + // exposing nginx. Its readiness budget must not depend on the network driver. + if manifest.app.id == "nginx-proxy-manager" { + return 180; + } if manifest.app.id == "uptime-kuma" { return 420; } @@ -2425,6 +2485,49 @@ impl ProdContainerOrchestrator { } match status.state { ContainerState::Running => { + // Legacy runtime path: migrate credentials once without + // recreating accounts or changing operator passwords. + // Quadlet installations receive the same helper through + // the required ExecStartPre drift/restart above. + if app_id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) { + let secrets = self.secrets_dir.join("filebrowser"); + let managed = filebrowser::cloud_credentials(&secrets) + .await + .map(|value| value.schema == 1) + .unwrap_or(false); + if !managed { + if !self.should_attempt_repair(&name).await { + return Ok(ReconcileAction::Left( + "filebrowser-credential-repair-budget-exhausted".into(), + )); + } + let unit_managed = self.runtime.cli_name() == "podman" + && quadlet::unit_exists(&name).await; + let service = format!("{name}.service"); + if unit_managed { + quadlet::stop_service(&service).await?; + } else { + self.runtime.stop_container(&name).await?; + } + let prepared = filebrowser::prepare_credentials( + &self.filebrowser_paths, + &secrets, + &status.image, + self.runtime.cli_name(), + ) + .await; + // Restore service availability even when setup + // rolled back. Preserve the setup failure itself. + let started = if unit_managed { + quadlet::restart_service(&service).await + } else { + self.runtime.start_container(&name).await + }; + prepared?; + started?; + return Ok(ReconcileAction::Started); + } + } // Zombie guard: podman can report a container "running" // after its process has died (conmon SIGKILLed in a // cgroup cascade on archipelago restart, etc.). Such a @@ -2971,6 +3074,18 @@ impl ProdContainerOrchestrator { self.ensure_manifest_files(manifest).await?; self.apply_data_uid(manifest).await?; self.run_post_data_uid_hooks(&manifest.app.id).await?; + if manifest.app.id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) { + let image = manifest.app.container.image.as_deref().ok_or_else(|| { + anyhow::anyhow!("File Browser needs a pinned image for credential setup") + })?; + filebrowser::prepare_credentials( + &self.filebrowser_paths, + &self.secrets_dir.join("filebrowser"), + image, + self.runtime.cli_name(), + ) + .await?; + } Ok(()) } @@ -3068,6 +3183,11 @@ impl ProdContainerOrchestrator { container = %name, "Phase 3.3 migration: replacing pre-Quadlet container with systemd-managed unit" ); + // Resolve active persistent mounts before the old inspect record is + // removed. NPM may use a legacy or operator-selected data directory. + let mut resolved = lm.manifest.clone(); + self.resolve_dynamic_env(&mut resolved).await?; + self.backup_runtime_change(name, &resolved).await?; // Stop+remove the old container record. Volumes survive (host // bind mounts are not touched by podman rm). self.runtime @@ -3077,8 +3197,6 @@ impl ProdContainerOrchestrator { // Re-render the manifest with dynamic env baked in, then go // through the same install path a fresh install would. - let mut resolved = lm.manifest.clone(); - self.resolve_dynamic_env(&mut resolved).await?; self.install_via_quadlet(&resolved, name) .await .with_context(|| format!("Phase 3.3: re-install {name} via Quadlet"))?; @@ -3797,6 +3915,11 @@ impl ProdContainerOrchestrator { } async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> { + if manifest.app.id == "nginx-proxy-manager" { + crate::container::npm::resolve_storage() + .await? + .apply(manifest)?; + } // Idempotency guard: partitioning already ran on this instance. // Re-running would re-taint against an environment that no longer // contains the composite entries and silently drop them. Callers @@ -4068,7 +4191,12 @@ impl ProdContainerOrchestrator { if unmanaged && matches!( manifest.app.container.network.as_deref(), - Some("slirp4netns" | "pasta") + Some( + "slirp4netns" + | "slirp4netns:allow_host_loopback=true" + | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + | "pasta" + ) ) { if let Ok(output) = tokio::process::Command::new("podman") @@ -4076,13 +4204,36 @@ impl ProdContainerOrchestrator { .output() .await { - if output.status.success() - && rootless_network_mode_drifted( + if output.status.success() { + let mut actual = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if matches!( manifest.app.container.network.as_deref(), - &String::from_utf8_lossy(&output.stdout), - ) - { - return true; + Some( + "slirp4netns:allow_host_loopback=true" + | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + ) + ) && actual == "slirp4netns" + { + let spec = tokio::process::Command::new("podman") + .args(["generate", "spec", name]) + .output() + .await; + actual = match spec { + Ok(spec) if spec.status.success() => { + rootless_network_from_spec(&spec.stdout).unwrap_or_default() + } + _ => String::new(), + }; + if actual.is_empty() { + tracing::warn!(app = %name, "Could not verify rootless network options; retaining the existing container"); + } + } + if rootless_network_mode_drifted( + manifest.app.container.network.as_deref(), + &actual, + ) { + return true; + } } } } @@ -5242,8 +5393,68 @@ mod tests { )); } + #[test] + fn npm_legacy_gateway_converges_and_rejects_conflicting_network_options() { + let expected = Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"); + assert!(is_builtin_network_mode(expected.unwrap())); + for actual in [ + "pasta", + "slirp4netns:allow_host_loopback=true", + "slirp4netns:allow_host_loopback=true,cidr=10.0.2.0/24", + "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24,cidr=10.0.2.0/24", + ] { + assert!(rootless_network_mode_drifted(expected, actual), "{actual}"); + } + let actual = "slirp4netns:cidr=169.254.1.0/24,allow_host_loopback=true,mtu=65520"; + assert!(!rootless_network_mode_drifted(expected, actual)); + let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["cidr=169.254.1.0/24","allow_host_loopback=true"]}}"#; + assert!(!rootless_network_mode_drifted( + expected, + &rootless_network_from_spec(spec).unwrap() + )); + } + + #[test] + fn npm_initialization_budget_survives_network_migration() { + let mut manifest = AppManifest::parse(include_str!( + "../../../../apps/nginx-proxy-manager/manifest.yml" + )) + .unwrap(); + for network in ["pasta", "slirp4netns:allow_host_loopback=true"] { + manifest.app.container.network = Some(network.to_string()); + assert_eq!(host_port_wait_timeout_secs(&manifest), 180); + } + } + + #[test] + fn api_created_rootless_options_do_not_cause_repeated_recreation() { + let expected = Some("slirp4netns:allow_host_loopback=true"); + let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["allow_host_loopback=true"]}}"#; + let actual = rootless_network_from_spec(spec).unwrap(); + assert!(!rootless_network_mode_drifted(expected, &actual)); + let plain = rootless_network_from_spec(br#"{"netns":{"nsmode":"slirp4netns"}}"#).unwrap(); + assert!(rootless_network_mode_drifted(expected, &plain)); + assert!(rootless_network_from_spec(b"invalid").is_none()); + } + #[test] fn explicit_rootless_network_change_converges_without_guessing_defaults() { + let npm = Some("slirp4netns:allow_host_loopback=true"); + assert!(is_builtin_network_mode(npm.unwrap())); + for actual in [ + "pasta", + "bridge", + "slirp4netns", + "slirp4netns:allow_host_loopback=false", + ] { + assert!(rootless_network_mode_drifted(npm, actual), "{actual}"); + } + for actual in [ + "slirp4netns:allow_host_loopback=true", + "slirp4netns:mtu=65520,allow_host_loopback=true", + ] { + assert!(!rootless_network_mode_drifted(npm, actual), "{actual}"); + } assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta")); assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge")); assert!(!rootless_network_mode_drifted( diff --git a/core/archipelago/src/container/quadlet.rs b/core/archipelago/src/container/quadlet.rs index 8f2a53bf..476883ad 100644 --- a/core/archipelago/src/container/quadlet.rs +++ b/core/archipelago/src/container/quadlet.rs @@ -68,6 +68,10 @@ pub enum NetworkMode { /// Rootless slirp4netns networking. Podman rejects network aliases with /// this mode, so render only Network=slirp4netns. Slirp4netns, + /// Permit explicit host aliases as well as LAN upstreams for NPM. + Slirp4netnsHostLoopback, + /// Preserve existing NPM upstreams using pasta's former host gateway. + Slirp4netnsLegacyGateway, /// Rootless pasta networking. This is more reliable than slirp4netns for /// host port forwarding on long-running web apps. Pasta, @@ -229,6 +233,15 @@ impl QuadletUnit { NetworkMode::Host => { let _ = writeln!(s, "Network=host"); } + NetworkMode::Slirp4netnsHostLoopback => { + let _ = writeln!(s, "Network=slirp4netns:allow_host_loopback=true"); + } + NetworkMode::Slirp4netnsLegacyGateway => { + let _ = writeln!( + s, + "Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + ); + } NetworkMode::Slirp4netns => { let _ = writeln!(s, "Network=slirp4netns"); } @@ -348,6 +361,26 @@ impl QuadletUnit { } let _ = writeln!(s); let _ = writeln!(s, "[Service]"); + if self.name == "filebrowser" { + // Runs while the managed server is stopped, before it can expose + // a default account. The helper verifies real login and root access. + let mut argv = vec![ + "/usr/bin/python3".to_string(), + "/opt/archipelago/scripts/filebrowser-credentials.py".to_string(), + "--image".to_string(), + self.image.clone(), + ]; + for (target, flag) in [("/data", "--data-dir"), ("/srv", "--srv-root")] { + if let Some(mount) = self + .bind_mounts + .iter() + .find(|m| m.container == Path::new(target)) + { + argv.extend([flag.to_string(), mount.host.display().to_string()]); + } + } + let _ = writeln!(s, "ExecStartPre={}", shell_join(&argv)); + } // Dependency-gated apps may legitimately keep their container entrypoint // in a wait loop before the actual daemon binds ports. Fedimint waits // for Bitcoin IBD to finish before execing fedimintd; systemd's default @@ -447,6 +480,12 @@ impl QuadletUnit { other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()), _ => match app.container.network.as_deref() { Some("slirp4netns") => NetworkMode::Slirp4netns, + Some("slirp4netns:allow_host_loopback=true") => { + NetworkMode::Slirp4netnsHostLoopback + } + Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24") => { + NetworkMode::Slirp4netnsLegacyGateway + } Some("pasta") => NetworkMode::Pasta, Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()), _ => NetworkMode::Default, @@ -1071,7 +1110,8 @@ pub fn exec_changed(old_body: &str, new_body: &str) -> bool { // 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, "ExecStartPre=") != directive_values(new_body, "ExecStartPre=") + || directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=") || directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=") } @@ -1142,6 +1182,28 @@ pub async fn is_active(service: &str) -> bool { #[cfg(test)] mod tests { + #[test] + fn filebrowser_prestart_credentials_are_a_required_runtime_change() { + let unit = super::QuadletUnit { + name: "filebrowser".into(), + image: "registry.example/filebrowser:v2.63.23".into(), + ..Default::default() + }; + let rendered = unit.render(); + assert!(rendered.contains("ExecStartPre=/usr/bin/python3 /opt/archipelago/scripts/filebrowser-credentials.py --image registry.example/filebrowser:v2.63.23")); + let old = rendered + .lines() + .filter(|line| !line.starts_with("ExecStartPre=")) + .collect::>() + .join("\n"); + assert!(super::exec_changed(&old, &rendered)); + assert!(!super::exec_changed(&rendered, &rendered)); + let other = super::QuadletUnit { + name: "other-app".into(), + ..unit + }; + assert!(!other.render().contains("filebrowser-credentials.py")); + } use super::*; use tempfile::tempdir; @@ -1709,6 +1771,24 @@ app: assert!(!s.contains("--network-alias")); } + #[test] + fn npm_network_preserves_same_node_upstreams_without_aliases() { + let m = AppManifest::parse(include_str!( + "../../../../apps/nginx-proxy-manager/manifest.yml" + )) + .unwrap(); + let rendered = QuadletUnit::from_manifest(&m, "nginx-proxy-manager").render(); + assert_eq!( + rendered + .lines() + .filter(|line| line.starts_with("Network=")) + .collect::>(), + vec!["Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"] + ); + assert!(!rendered.contains("NetworkAlias=")); + assert!(!rendered.contains("--network-alias")); + } + #[test] fn from_manifest_pasta_omits_network_alias() { let yaml = r#" diff --git a/core/archipelago/src/content_owned.rs b/core/archipelago/src/content_owned.rs index 6ec47d58..40ce9c14 100644 --- a/core/archipelago/src/content_owned.rs +++ b/core/archipelago/src/content_owned.rs @@ -162,6 +162,11 @@ pub async fn record_purchase( save_index(data_dir, &index).await } +/// Payment decisions must not interpret an unreadable index as no purchases. +pub async fn list_owned_checked(data_dir: &Path) -> Result> { + Ok(load_index_checked(data_dir).await?.items) +} + /// Every item this node owns. pub async fn list_owned(data_dir: &Path) -> Vec { load_index(data_dir).await.items diff --git a/core/archipelago/src/mesh/flash.rs b/core/archipelago/src/mesh/flash.rs index 8b8ade67..59c370d6 100644 --- a/core/archipelago/src/mesh/flash.rs +++ b/core/archipelago/src/mesh/flash.rs @@ -7,7 +7,8 @@ //! to a full chip erase before write. //! //! MeshCore and Meshtastic are flashed the same way: download a released -//! image, `esptool erase_flash`, then `esptool write_flash 0x0 `. +//! image, verify it, then run `write_flash --erase-all 0x0 ` with +//! the packaged esptool executable. //! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the //! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live //! via `archy-rnodeconf --help` — there is no raw esptool path exposed for @@ -49,32 +50,18 @@ impl FlashBoard { } } -/// Map a detected USB vid:pid to a known flashable board, using the same -/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60) -/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to -/// auto-match since that vid:pid is bridge-chip-specific. -/// -/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it -/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's -/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001 -/// with product string "USB JTAG/serial debug unit" — that descriptor is -/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board -/// with native USB enabled, not just Heltec V4. Adding `303a:1001 => -/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3 -/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong -/// board's image. Callers (the RPC layer / frontend) must let the user pick -/// the board manually whenever this returns `None`. -pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option { - match (info.vid.as_deref(), info.pid.as_deref()) { - (Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3), - _ => None, - } +/// Generic CP2102 and native ESP32-S3 USB IDs identify adapters/chips, not +/// board wiring. Require an explicit board until a board-specific identity is +/// available; guessing a Heltec model can write incompatible firmware. +pub fn resolve_flash_board(_info: &DetectedDeviceInfo) -> Option { + None } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum FlashStage { Downloading, + Preparing, Erasing, Writing, Autoinstalling, @@ -101,11 +88,10 @@ const LOG_TAIL_MAX: usize = 200; /// start opening the port (which itself toggles DTR/RTS) again. const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5); -/// Absolute ceiling on a whole flash job (download + erase + write, or -/// autoinstall), regardless of what it's doing internally. Last-resort -/// safety net so a hang anywhere can't wedge the single-flash-job guard -/// forever — generous enough to never trigger on a legitimately slow -/// multi-hundred-MB transfer. +/// Deadline for preparation and warning threshold for the active flasher. +/// A download can be cancelled safely. An active write retains ownership until +/// its subprocess exits, even after this threshold: reporting an aborted job +/// while a detached writer continues would let a retry corrupt the device. const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60); /// How long to wait for MeshService::stop() to release the serial port @@ -247,6 +233,16 @@ fn firmware_cache_dir(data_dir: &Path) -> PathBuf { data_dir.join("mesh").join("firmware-cache") } +async fn invalidate_radio_settings_marker(data_dir: &Path) -> Result<()> { + // A full-chip flash erased the device's preferences. A previous host-side + // marker is no longer evidence that this radio has the requested settings. + match tokio::fs::remove_file(data_dir.join("meshcore-radio-params.json")).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("Firmware written, but old radio-settings marker could not be cleared; reconnect is paused"), + } +} + /// No blanket `.timeout()` here on purpose: reqwest's request timeout covers /// the *entire* request including streaming the response body, which would /// kill a legitimate large download partway through (Meshtastic's esp32s3 @@ -314,6 +310,10 @@ pub async fn list_firmware(family: DeviceType) -> Result> { struct GithubAsset { name: String, browser_download_url: String, + #[serde(default)] + size: Option, + #[serde(default)] + digest: Option, } #[derive(serde::Deserialize)] @@ -322,8 +322,24 @@ struct GithubRelease { assets: Vec, } +async fn register_flash_job(handle: &FlashJobHandle, job: &Arc) -> Result<()> { + // Keep checking and registering under one lock: concurrent RPCs must + // never start two writers on the same device. + let mut existing = handle.try_write().map_err(|_| anyhow::anyhow!( + "The radio is being inspected or reconfigured; wait for that operation to finish before flashing" + ))?; + if let Some(current) = existing.as_ref() { + if !current.snapshot().await.done { + anyhow::bail!("A firmware flash is already in progress on this node"); + } + } + *existing = Some(Arc::clone(job)); + Ok(()) +} + /// Start a flash job in the background. Returns as soon as the job has been -/// registered and the listener released — callers poll `FlashJobHandle` via +/// registered — preparation and listener shutdown run in the background. +/// Callers poll `FlashJobHandle` via /// `mesh.flash-status` for progress. Only one job may be in flight at a time. pub async fn start_flash_job( handle: &FlashJobHandle, @@ -333,21 +349,44 @@ pub async fn start_flash_job( board: FlashBoard, family: DeviceType, ) -> Result<()> { - { - let existing = handle.read().await; - if let Some(job) = existing.as_ref() { - if !job.snapshot().await.done { - anyhow::bail!("A firmware flash is already in progress on this node"); - } - } + // Missing/corrupt tooling must fail before stopping a working radio or + // registering a job that can never reach the serial device. + if matches!(family, DeviceType::Meshtastic | DeviceType::Meshcore) { + preflight_esptool().await?; } - let job = FlashJob::new(board, family, path.clone()); - *handle.write().await = Some(Arc::clone(&job)); + register_flash_job(handle, &job).await?; let bg_job = Arc::clone(&job); let bg_service = Arc::clone(mesh_service); let task = tokio::spawn(async move { + // Downloads and checksum checks do not need exclusive serial access. + // Keep a working listener alive if upstream/download verification fails. + let prepared_image = if matches!(family, DeviceType::Meshcore | DeviceType::Meshtastic) { + match tokio::time::timeout( + MAX_JOB_DURATION, + fetch_esptool_image(board, family, &data_dir, &bg_job), + ) + .await + { + Ok(Ok(image)) => Some(image), + result => { + let error = match result { + Ok(Err(error)) => error, + _ => anyhow::anyhow!( + "Firmware download exceeded the time limit; radio was not changed" + ), + }; + bg_job.fail(&error).await; + return; + } + } + } else { + None + }; + // Cancellation is safe during download. Once listener shutdown starts, + // allow that bounded operation to finish instead of orphaning its task. + bg_job.set_stage(FlashStage::Preparing).await; // esptool/archy-rnodeconf need exclusive serial access — release // the listener's hold on the port before touching it. This USED // TO run synchronously in start_flash_job before the job was even @@ -404,17 +443,31 @@ pub async fn start_flash_job( // subsequent mesh.flash-device call failed with "already in // progress" until the service was restarted). Generous enough that // a legitimately slow multi-hundred-MB transfer still completes. - let result = match tokio::time::timeout( - MAX_JOB_DURATION, - run_flash(board, family, &data_dir, &path, &bg_job), - ) - .await - { + let flash = run_flash( + board, + family, + &data_dir, + &path, + prepared_image.as_deref(), + &bg_job, + ); + tokio::pin!(flash); + let result = match tokio::time::timeout(MAX_JOB_DURATION, &mut flash).await { Ok(inner) => inner, - Err(_) => Err(anyhow::anyhow!( - "Flash job exceeded the {}-minute ceiling — aborted", - MAX_JOB_DURATION.as_secs() / 60 - )), + Err(_) if bg_job.snapshot().await.stage == FlashStage::Downloading => Err( + anyhow::anyhow!("Firmware download exceeded the time limit; radio was not written"), + ), + Err(_) => { + // Dropping this future does NOT stop its flash subprocess. + // Keep the job busy until it exits, rather than allowing a + // second writer while the first one still owns the device. + bg_job.push_log("Flashing is taking longer than expected; waiting for the active tool to exit before allowing another operation").await; + flash.await + } + }; + let result = match result { + Ok(()) => invalidate_radio_settings_marker(&data_dir).await, + error => error, }; let succeeded = result.is_ok(); @@ -423,7 +476,6 @@ pub async fn start_flash_job( bg_job .push_log("Flash completed successfully".to_string()) .await; - bg_job.finish().await; info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded"); } Err(e) => { @@ -434,7 +486,6 @@ pub async fn start_flash_job( // erase_flash failed", no actual esptool stderr). warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed"); bg_job.push_log(format!("ERROR: {e:#}")).await; - bg_job.fail(e).await; } } @@ -450,6 +501,9 @@ pub async fn start_flash_job( } if !succeeded { + if let Err(error) = &result { + bg_job.fail(error).await; + } // Deliberately do NOT auto-restart the listener here. A failed // flash means we can't vouch for the board's state — reopening // the port immediately (esptool/rnodeconf's own reset sequence @@ -499,6 +553,7 @@ pub async fn start_flash_job( Err(e) => warn!(error = %e, "Failed to load mesh config after flash"), } } + bg_job.finish().await; }); *job.abort_handle.write().await = Some(task.abort_handle()); @@ -510,12 +565,13 @@ async fn run_flash( family: DeviceType, data_dir: &Path, path: &str, + prepared_image: Option<&Path>, job: &Arc, ) -> Result<()> { match family { DeviceType::Meshtastic | DeviceType::Meshcore => { - let image = fetch_esptool_image(board, family, data_dir, job).await?; - esptool_erase_and_write(path, &image, job).await + let image = prepared_image.context("Firmware was not prepared before serial access")?; + esptool_erase_and_write(path, image, job).await } DeviceType::Reticulum => { let lora_region = super::load_config(data_dir) @@ -664,15 +720,65 @@ async fn fetch_meshcore_image( anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name) })?; + anyhow::ensure!( + Path::new(&asset.name) + .file_name() + .and_then(|name| name.to_str()) + == Some(asset.name.as_str()), + "Invalid firmware asset filename" + ); let out_path = cache.join(&asset.name); if tokio::fs::metadata(&out_path).await.is_ok() { - job.push_log(format!("Using cached {}", asset.name)).await; - return Ok(out_path); + if verify_meshcore_asset(&out_path, asset).await.is_ok() { + job.push_log(format!("Using verified cached {}", asset.name)) + .await; + return Ok(out_path); + } + tokio::fs::remove_file(&out_path) + .await + .context("Removing invalid cached firmware")?; + job.push_log("Cached firmware failed verification; downloading a fresh copy") + .await; } download_to_file(client, &asset.browser_download_url, &out_path, job).await?; + if let Err(error) = verify_meshcore_asset(&out_path, asset).await { + // A partial/unverified download must not become next attempt's cache. + let _ = tokio::fs::remove_file(&out_path).await; + return Err(error); + } Ok(out_path) } +async fn verify_meshcore_asset(path: &Path, asset: &GithubAsset) -> Result<()> { + use sha2::{Digest, Sha256}; + let size = asset + .size + .context("MeshCore release did not provide firmware size")?; + anyhow::ensure!( + (1..=16 * 1024 * 1024).contains(&size), + "Invalid MeshCore firmware size" + ); + anyhow::ensure!( + tokio::fs::metadata(path).await?.len() == size, + "Firmware size mismatch; radio was not written" + ); + let expected = asset + .digest + .as_deref() + .and_then(|value| value.strip_prefix("sha256:")) + .context("MeshCore release did not provide a SHA-256 checksum")?; + anyhow::ensure!( + expected.len() == 64 && expected.bytes().all(|byte| byte.is_ascii_hexdigit()), + "Invalid MeshCore release checksum" + ); + let actual = hex::encode(Sha256::digest(tokio::fs::read(path).await?)); + anyhow::ensure!( + actual.eq_ignore_ascii_case(expected), + "Firmware checksum mismatch; radio was not written" + ); + Ok(()) +} + async fn download_to_file( client: &reqwest::Client, url: &str, @@ -722,7 +828,8 @@ async fn download_to_file( } } } - file.flush().await.ok(); + file.flush().await.context("Flushing firmware download")?; + file.sync_all().await.context("Saving firmware download")?; tokio::fs::rename(&tmp, dest) .await .context("Finalizing firmware download")?; @@ -773,19 +880,10 @@ async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc) /// Building global args separately from subcommand args keeps this correct /// by construction instead of relying on call-site ordering. /// -/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher -/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/ -/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1) -/// ships without it (stripped for DFSG compliance: the prebuilt blob has no -/// buildable-from-source path Debian could verify), so scripts/self-update.sh -/// fetches the exact same file from the matching upstream esptool release -/// tag and installs it alongside the apt package (see the esptool install -/// step there). --no-stub (talk directly to the ROM bootloader, skip the -/// stub) was tried first and works for connecting, but the ROM bootloader -/// doesn't implement a full-chip-erase opcode at all — only the stub does — -/// so --no-stub broke our "always erase before write" default outright -/// rather than just being slower. Restoring the real stub file is the -/// correct fix, not routing around its absence. +/// Normal stub-loader mode is required for full-chip erase. The packaged +/// archy-esptool includes and self-tests Espressif's ESP32-S3 stub. Debian's +/// stripped esptool package alone did not provide that resource, so changing +/// flags to --no-stub cannot repair this operation. fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> { let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path]; if let Some(b) = baud { @@ -795,24 +893,96 @@ fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> args } +fn esptool_executable() -> Result { + let mut candidates = vec![PathBuf::from("/usr/local/bin/archy-esptool")]; + if let Some(paths) = std::env::var_os("PATH") { + for directory in std::env::split_paths(&paths) { + for name in ["esptool", "esptool.py"] { + candidates.push(directory.join(name)); + } + } + } + executable_from_candidates(candidates) +} + +fn executable_from_candidates(candidates: impl IntoIterator) -> Result { + use std::os::unix::fs::PermissionsExt; + candidates.into_iter().find(|path| { + path.is_file() && path.metadata().is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0) + }).ok_or_else(|| anyhow::anyhow!( + "Radio flashing tools are missing. Install the complete Archipelago update and retry; the radio has not been changed." + )) +} + +async fn preflight_esptool() -> Result { + let executable = esptool_executable()?; + check_esptool(&executable).await?; + Ok(executable) +} + +async fn check_esptool(executable: &Path) -> Result<()> { + let mut command = Command::new(executable); + command + .arg( + if executable + .file_name() + .is_some_and(|name| name == "archy-esptool") + { + "--archy-self-test" + } else { + "version" + }, + ) + .kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(20), command.output()) + .await + .context("Radio flashing tool did not respond; the radio has not been changed")? + .context("Radio flashing tool could not start; check its installation and permissions")?; + anyhow::ensure!( + output.status.success(), + "Radio flashing tool self-check failed; reinstall the complete update before retrying" + ); + Ok(()) +} + +fn retryable_flash_error(error: &anyhow::Error) -> bool { + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + return false; + } + let detail = format!("{error:#}").to_lowercase(); + [ + "failed to connect", + "timed out waiting", + "invalid head of packet", + "serial data stream stopped", + ] + .iter() + .any(|message| detail.contains(message)) +} + async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc) -> Result<()> { - let mut cmd = Command::new("esptool"); + let executable = preflight_esptool().await?; + let mut cmd = Command::new(&executable); cmd.args(esptool_global_args(path, None)); cmd.args(subcommand); match run_streamed(cmd, None, job).await { Ok(()) => Ok(()), - Err(first_err) => { + Err(first_err) if retryable_flash_error(&first_err) => { job.push_log(format!( "First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud" )) .await; - let mut retry = Command::new("esptool"); + let mut retry = Command::new(&executable); retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD))); retry.args(subcommand); run_streamed(retry, None, job) .await .context(format!("retry also failed (first attempt: {first_err:#})")) } + Err(error) => Err(error), } } @@ -990,3 +1160,159 @@ async fn run_streamed(mut cmd: Command, stdin: Option>, job: &Arc String { /// Parse RESP_DEVICE_INFO (0x0D) response. /// Returns firmware version string and device capabilities. pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> { + // Official companion v3+ binary layout: protocol, half-capacity, + // channels, PIN (4), build date (12), model (40), version (20). + // Verified against companion-v1.17.1 MyMesh.cpp and Heltec V3 readback. + if data + .first() + .is_some_and(|version| (3..=31).contains(version)) + { + anyhow::ensure!(data.len() >= 79, "Truncated MeshCore device info"); + return Ok(( + decode_mesh_name(&data[59..79], "unknown"), + u16::from(data[1]) * 2, + )); + } // Device info format varies by firmware version. // Minimum: firmware version string (null-terminated) + max_contacts (u16 LE) if data.is_empty() { @@ -404,6 +417,15 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> { anyhow::bail!("Self info response too short: {} bytes", data.len()); } + // Current companions send type/power/max-power, public key (32), + // position (8), four preference bytes, RF parameters (10), then name. + if data.len() >= 57 { + let node_id = u32::from_le_bytes(data[3..7].try_into().unwrap()); + return Ok(( + node_id, + decode_mesh_name(&data[57..], &format!("node-{node_id:08x}")), + )); + } let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); // Name follows after fixed fields. A firmware whose fixed-field layout @@ -966,4 +988,24 @@ mod tests { fn test_parse_self_info_too_short() { assert!(parse_self_info(&[0x01, 0x02]).is_err()); } + + #[test] + fn current_companion_binary_metadata_is_not_a_name_or_version() { + let mut info = vec![0u8; 81]; + info[0] = 13; + info[1] = 150; + info[19..28].copy_from_slice(b"Heltec V3"); + info[59..74].copy_from_slice(b"v1.17.1-d929643"); + assert_eq!( + parse_device_info(&info).unwrap(), + ("v1.17.1-d929643".into(), 300) + ); + assert!(parse_device_info(&info[..78]).is_err()); + let mut own = vec![0u8; 57]; + own[0..3].copy_from_slice(&[1, 22, 22]); + own[3..7].copy_from_slice(&42u32.to_le_bytes()); + own[47..51].copy_from_slice(&869618u32.to_le_bytes()); + own.extend_from_slice(b"My radio"); + assert_eq!(parse_self_info(&own).unwrap(), (42, "My radio".into())); + } } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index c8abc035..d5726604 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -277,6 +277,19 @@ fn terminate_group(child: &Child) { } } +/// Own the daemon during its asynchronous handshake too. Cancellation drops +/// the future without executing an error branch; a bare Child would survive +/// and keep the serial port open even though the listener had stopped. +struct StartingDaemon(Option); + +impl Drop for StartingDaemon { + fn drop(&mut self) { + if let Some(child) = self.0.as_ref() { + terminate_group(child); + } + } +} + /// One peer learned via an RNS announce (LXMF delivery destination). #[derive(Clone)] struct ReticulumPeer { @@ -517,10 +530,10 @@ impl ReticulumLink { let child = cmd .spawn() .context("Failed to spawn reticulum-daemon — is it installed/packaged?")?; + let mut starting = StartingDaemon(Some(child)); // Wait for the socket to appear, then for the daemon's "ready" event. - // Runs as a block so every failure path tears the just-spawned daemon - // group down via `terminate_group` (the child has no `kill_on_drop`). + // StartingDaemon tears the group down on errors AND cancellation. let init = async { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); let stream = loop { @@ -560,20 +573,17 @@ impl ReticulumLink { dest_hash = %dest_hash_hex, "Reticulum daemon ready" ); - Ok((write_half, reader, dest_hash, display_name)) - }; - let (write_half, reader, dest_hash, display_name) = match init.await { - Ok(parts) => parts, - Err(e) => { - terminate_group(&child); - return Err(e); - } + Ok::<_, anyhow::Error>((write_half, reader, dest_hash, display_name)) }; + let (write_half, reader, dest_hash, display_name) = init.await?; let mut link = Self { device_path: label, socket_path, - child, + child: starting + .0 + .take() + .expect("starting daemon is owned until ready"), writer: write_half, reader, dest_hash, @@ -1557,6 +1567,33 @@ impl Drop for ReticulumLink { mod tests { use super::*; + #[tokio::test] + async fn cancelled_daemon_handshake_terminates_child() { + let (started, ready) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let child = Command::new("sleep") + .arg("60") + .process_group(0) + .spawn() + .unwrap(); + let pid = child.id().unwrap(); + let _starting = StartingDaemon(Some(child)); + started.send(pid).unwrap(); + std::future::pending::<()>().await; + }); + let pid = ready.await.unwrap(); + assert_eq!(unsafe { libc::kill(pid as i32, 0) }, 0); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(3), async { + while unsafe { libc::kill(pid as i32, 0) } == 0 { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("cancelled handshake left its child alive"); + } + #[test] fn announced_name_precedence() { // Daemon-decoded LXMF name always wins. diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 6029f842..d0d9517c 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -146,12 +146,16 @@ impl MeshcoreDevice { } } - let info = DeviceInfo { - firmware_version: name.clone(), + let mut info = DeviceInfo { + firmware_version: "unknown".to_string(), node_id, max_contacts: 100, device_type: super::types::DeviceType::Meshcore, }; + if let Some((version, capacity)) = self.query_device_info().await { + info.firmware_version = version; + info.max_contacts = capacity; + } self.device_info = Some(info.clone()); info!("Meshcore initialization complete on {}", self.device_path); diff --git a/core/archipelago/src/nostr_security_tests.rs b/core/archipelago/src/nostr_security_tests.rs index a960125e..6f0c8de2 100644 --- a/core/archipelago/src/nostr_security_tests.rs +++ b/core/archipelago/src/nostr_security_tests.rs @@ -64,7 +64,16 @@ async fn relay_cannot_substitute_forged_fields_for_a_known_event_id() { let relay = tokio::spawn(async move { let (socket, _) = listener.accept().await.unwrap(); let mut socket = accept_async(socket).await.unwrap(); - while let Some(Ok(Message::Text(text))) = socket.next().await { + while let Some(message) = socket.next().await { + let text = match message.unwrap() { + Message::Text(text) => text, + Message::Ping(payload) => { + socket.send(Message::Pong(payload)).await.unwrap(); + continue; + } + Message::Close(_) => break, + _ => continue, + }; let message: serde_json::Value = serde_json::from_str(&text).unwrap(); if message[0] != "REQ" { continue; diff --git a/core/archipelago/src/rate_limit.rs b/core/archipelago/src/rate_limit.rs index 205f8c21..74496abe 100644 --- a/core/archipelago/src/rate_limit.rs +++ b/core/archipelago/src/rate_limit.rs @@ -83,6 +83,8 @@ impl EndpointRateLimiter { limits.insert("wallet.send".to_string(), (5usize, 300u64)); limits.insert("wallet.ecash-send".to_string(), (10, 300)); limits.insert("lnd.sendcoins".to_string(), (5, 300)); + limits.insert("lnd.bump-submit".to_string(), (5, 300)); + limits.insert("lnd.bump-quote".to_string(), (30, 60)); limits.insert("lnd.payinvoice".to_string(), (10, 300)); limits.insert("lnd.openchannel".to_string(), (3, 300)); limits.insert("lnd.closechannel".to_string(), (3, 300)); diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 66d27b59..15632e48 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -1475,6 +1475,48 @@ pub fn is_peer_allowed_path(path: &str) -> bool { || path.starts_with("/dwn/") } +/// The ordinary API listener is a management surface even when contacted +/// directly, without host nginx. Peer traffic has its own path-restricted +/// listener and retains its existing cryptographic authentication. +fn management_peer_is_private(address: std::net::IpAddr) -> bool { + match address { + std::net::IpAddr::V4(ip) => { + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || (ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])) + } + std::net::IpAddr::V6(ip) => { + if let Some(mapped) = ip.to_ipv4_mapped() { + management_peer_is_private(std::net::IpAddr::V4(mapped)) + } else { + ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() + } + } + } +} + +fn request_surface_allowed( + peer_only: bool, + peer: std::net::IpAddr, + request: &hyper::Request, +) -> bool { + if peer_only { + return is_peer_allowed_path(request.uri().path()); + } + // Keep purpose-built content/peer HTTP endpoints reachable with their + // existing handler-level checks. The general RPC dispatcher is a management + // surface here; only the dedicated peer listener retains public peer RPC. + if request.uri().path() != "/rpc/v1" && is_peer_allowed_path(request.uri().path()) { + return true; + } + management_peer_is_private(peer) + && !request + .headers() + .get("x-archipelago-public-ingress") + .is_some_and(|value| !value.as_bytes().is_empty()) +} + async fn accept_loop( handler: Arc, listener: TcpListener, @@ -1552,7 +1594,7 @@ async fn accept_loop( // forwarded headers on loopback (nginx) connections. req.extensions_mut() .insert(crate::api::rpc::PeerAddr(peer_addr)); - if peer_only && !is_peer_allowed_path(req.uri().path()) { + if !request_surface_allowed(peer_only, peer_addr.ip(), &req) { let resp = hyper::Response::builder() .status(hyper::StatusCode::NOT_FOUND) .body(hyper::Body::empty()) @@ -2520,3 +2562,97 @@ mod merge_tests { ); } } + +#[cfg(test)] +mod management_surface_tests { + use super::*; + + #[test] + fn public_api_listener_rejects_management_despite_forged_headers() { + for peer in [ + "198.18.0.2", + "2001:db8::2", + "::ffff:198.18.0.2", + "100.63.255.255", + "100.128.0.1", + ] { + for path in ["/", "/login", "/assets/index.js", "/rpc/v1", "/ws"] { + for method in ["GET", "POST"] { + let request = hyper::Request::builder() + .uri(path) + .method(method) + .header("host", "127.0.0.1") + .header("x-forwarded-for", "127.0.0.1") + .header("x-real-ip", "192.168.1.10") + .body(hyper::Body::empty()) + .unwrap(); + assert!( + !request_surface_allowed(false, peer.parse().unwrap(), &request), + "{peer} {method} {path}" + ); + } + } + } + } + + #[test] + fn private_management_and_restricted_peer_transport_remain_available() { + let mut request = hyper::Request::builder() + .uri("/rpc/v1") + .body(hyper::Body::empty()) + .unwrap(); + for peer in [ + "127.0.0.1", + "10.0.0.2", + "172.16.0.2", + "192.168.1.2", + "169.254.1.2", + "100.64.0.1", + "100.127.255.254", + "::1", + "fd00::1", + "fe80::1", + "::ffff:192.168.1.2", + ] { + assert!(request_surface_allowed( + false, + peer.parse().unwrap(), + &request + )); + } + request + .headers_mut() + .insert("x-archipelago-public-ingress", "1".parse().unwrap()); + assert!(!request_surface_allowed( + false, + "127.0.0.1".parse().unwrap(), + &request + )); + // The dedicated peer listener retains its existing signed RPC contract. + assert!(request_surface_allowed( + true, + "198.18.0.2".parse().unwrap(), + &request + )); + for path in [ + "/content", + "/content/fixture/invoice", + "/blob/fixture", + "/dwn/health", + "/archipelago/node-message", + ] { + *request.uri_mut() = path.parse().unwrap(); + assert!(request_surface_allowed( + false, + "198.18.0.2".parse().unwrap(), + &request + )); + } + *request.uri_mut() = "/login".parse().unwrap(); + assert!(!request_surface_allowed( + true, + "198.18.0.2".parse().unwrap(), + &request + )); + } +} diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 8c27b1f9..a7f9937f 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -2654,6 +2654,8 @@ mod tests { #[test] fn test_is_newer() { + assert!(is_newer("1.9.0-alpha", "1.8.22-alpha")); + assert!(!is_newer("1.9.0-alpha", "1.9.0-alpha")); assert!(is_newer("1.7.19-alpha", "1.7.18-alpha")); assert!(is_newer("1.8.0-alpha", "1.7.99-alpha")); assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index db8c9a55..694b96c2 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -450,6 +450,17 @@ impl PodmanClient { "nsmode": net_mode }, }); + if matches!( + manifest.app.container.network.as_deref(), + Some( + "slirp4netns:allow_host_loopback=true" + | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24" + ) + ) { + body["network_options"] = serde_json::json!({ + "slirp4netns": manifest.app.container.network.as_deref().unwrap().split_once(':').unwrap().1.split(',').collect::>() + }); + } if let Some(network) = custom_network { // The container always answers to its own name; manifest // network_aliases add extra short hostnames peers may bake in @@ -727,7 +738,11 @@ fn podman_network_settings( Some("host") => ("host", None), Some("bridge") => ("bridge", None), Some("none") => ("none", None), - Some("slirp4netns") => ("slirp4netns", None), + Some( + "slirp4netns" + | "slirp4netns:allow_host_loopback=true" + | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24", + ) => ("slirp4netns", None), Some("pasta") => ("pasta", None), Some("private") => ("private", None), Some(custom) => ("bridge", Some(custom.to_string())), @@ -1077,6 +1092,14 @@ mod tests { )); } + #[test] + fn npm_rootless_options_are_not_a_named_bridge() { + assert_eq!( + podman_network_settings(Some("slirp4netns:allow_host_loopback=true"), "isolated"), + ("slirp4netns", None) + ); + } + #[test] fn portainer_manifest_keeps_private_network_and_loopback_api_publication() { let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap(); diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index 5b318a18..964f0c19 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -40,6 +40,11 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 { #[async_trait] pub trait ContainerRuntime: Send + Sync { + /// CLI used for offline app provisioning in this runtime's storage scope. + fn cli_name(&self) -> &'static str { + "podman" + } + async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>; async fn create_container( &self, @@ -628,7 +633,13 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result Result &'static str { + "docker" + } + async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { // Same signature gate as the podman path — the docker fallback is // dev-only, but a declared signature must never be skippable by @@ -991,6 +1006,10 @@ impl AutoRuntime { #[async_trait] impl ContainerRuntime for AutoRuntime { + fn cli_name(&self) -> &'static str { + self.runtime.cli_name() + } + async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { self.runtime.pull_image(image, signature).await } diff --git a/docs/candidate-catalog-qualification.md b/docs/candidate-catalog-qualification.md new file mode 100644 index 00000000..78ab51a7 --- /dev/null +++ b/docs/candidate-catalog-qualification.md @@ -0,0 +1,42 @@ +# Private signed-catalog qualification + +Use this only on explicitly selected development/acceptance nodes. It permits +testing a release-root-signed catalog before fleet publication. It does not +publish an app image, change the update mirrors, replace the trust anchor, or +authorize an unsigned catalog. + +Set `ARCHY_APP_CATALOG_CANDIDATE` in a management-service systemd drop-in to an +absolute local catalog path. Keep that file readable by the service and outside +temporary storage if testing reboot persistence. The file must be at most 4 MiB +and carry a signature verified against the configured release-root anchor. +Malformed, missing, unsigned, tampered and wrong-key candidates fail before +replacing the previous cached bytes. An invalid explicitly selected candidate +does not fall back to the public catalog. The previous cache remains available; +inspect the refresh error rather than assuming the candidate was accepted. + +Before activation, record the exact candidate hash, service binary hash, native +Bitcoin/LND identities and start times, app configuration, and existing catalog +cache/drop-ins. Back up persistent state before any app runtime migration. +Verify the candidate signature with `archipelago ceremony verify PATH` and +retain the original signed bytes. A private signing ceremony is not publication +approval. + +After management restart, verify: + +- Cached bytes exactly equal the signed candidate and still verify. +- Desired app manifests select the expected capability-compatible variant. +- Changed apps migrate through their supported lifecycle, with state backups. +- Native wallets, intentionally stopped/uninstalled apps and unrelated services + retain their previous state. +- App requests succeed from the actual caller/container namespace; container + health alone is insufficient. +- Repeated reconciliation, app restart, and separately arranged node reboot + preserve routing, state, certificates and management isolation. + +The setting intentionally pins catalog selection. Track its removal as part of +release completion: after the tested catalog is published and verified, remove +only the qualification drop-in, reload systemd, restart management, and confirm +the normal public refresh returns the expected signed catalog. Do not leave the +override behind to silently prevent future app updates. For an aborted test, +restore the reviewed previous catalog/runtime/configuration together; removing +the override alone can reintroduce older manifest settings. diff --git a/docs/npm-certificate-handoff-20261001.md b/docs/npm-certificate-handoff-20261001.md index 015f5c13..6d809134 100644 --- a/docs/npm-certificate-handoff-20261001.md +++ b/docs/npm-certificate-handoff-20261001.md @@ -167,3 +167,46 @@ and observed its explicit acknowledgement. The owner then recorded receipt in `/tmp/npm-release-handoff-ack.txt` and in the acknowledgement section above. Publication remains held for the NPM release gate. Unavailable external acceptance must be stated explicitly and cannot be silently treated as passed. + +## Urgent public dashboard exposure gate — 2026-10-01 + +Investigator reports the Angor relay hostname reached the default Archipelago +login because its certificate existed without a corresponding host-nginx route. +The investigator owns the immediate Shorty nginx repair; the release session +will not modify that configuration concurrently. Exact final evidence is pending. + +- [ ] Unknown public HTTP Host / TLS SNI and direct public-IP requests cannot + expose the dashboard, login assets or RPC, including IPv6 and any trusted + reverse-proxy/tunnel path. Test spoofed forwarding headers explicitly. +- [ ] LAN/private/tailnet dashboard access remains available as intended. +- [ ] Public HTTP ACME challenge access survives those restrictions. +- [ ] NPM host creation/edits automatically propagate HTTP/TLS routing. +- [ ] Relay hostname serves the intended relay and WebSocket upgrade using its + correct certificate; certificate existence is not route acceptance. +- [ ] These protections survive manager/nginx restart, renewal and OTA/ISO. + +## Live security containment and project discovery follow-up + +2026-10-01: relay certificate existed but named public route was absent; default +HTTP/HTTPS vhosts exposed the dashboard to public clients, including direct WAN +IP access. Investigator added live `angor-relay-npm.conf` forwarding TLS cert11 +to loopback8091 with WebSocket upgrade; added `00-dashboard-source-guard.conf` +private-source geo/map guard to both management default vhosts, preserving public +ACME challenge paths. Backup: `/etc/nginx/sites-available/archipelago.before-public-guard-1790879144`. +Nginx syntax validation/reload passed. External tests: public IP root and RPC +404 over HTTP and HTTPS (IP HTTPS certificate validation bypassed only for this +negative routing probe); spoofed private Host, X-Forwarded-For and X-Real-IP and +unknown Host POST RPC all404. Tailnet dashboard200; indexer health200 height969475; +relay trusted TLS NIP11 metadata200, WebSocket101, read-only Nostr REQ returned EOSE. +These are live containment results, not fleet/IPv6/reboot/security-audit completion. +Release owner acknowledged security scope in `/tmp/npm-release-handoff-ack.txt`. + +User then reported no Angor projects after changing BOTH indexer and relays. +Read-only kind3030 subscription limit5: new relay returned zero events + EOSE; +`wss://relay.angor.io/` returned five events + EOSE. Advised retaining original +Angor relays alongside own relay; a newly hosted relay does not automatically +contain global project metadata. Full app project discovery acceptance remains +required. Also observed own `/api/v1/query/Angor/projects?limit=10` returns404; +reference MempoolIndexerAngorApi.GetProjectsAsync uses this older specialized +route, whereas current deployment docs recommend stock Mempool. Verify actual +client version/discovery path rather than claiming fees/health prove compatibility. diff --git a/docs/post-1.8.22-regressions-20261001.md b/docs/post-1.8.22-regressions-20261001.md index fe52b2e3..65203015 100644 --- a/docs/post-1.8.22-regressions-20261001.md +++ b/docs/post-1.8.22-regressions-20261001.md @@ -1,31 +1,102 @@ # Post-1.8.22 regressions and retained release checklist +Release target: **1.9.0**, as requested by the operator. Supersedes the provisional 1.8.23-alpha target. + Status: OPEN. New regressions reported after publication on 2026-10-01. Do not mark complete from source changes alone. Preserve wallets, app state and operator uninstall decisions. Never send a second payment to recover delivery. The earlier Framework startup incident remains separately closed with operator acceptance; this is a new paid-file incident. +## Latest deployment checkpoint + +- Framework physical radio investigation is **operator-deferred**, not passed. +- Unpublished candidate backend/UI deployed on dev and yaya with backups; + management health passed and native Bitcoin/LND stayed running. +- Actual yaya startup exposed missing HTTPS ACME in the shipped template. + Template and narrowly recognized legacy migration are fixed; 28 Python checks, + 120 isolated public-security cases, and the ISO overlay check passed. + Live yaya exact-token and missing-token checks passed over HTTP/HTTPS and + public HTTP; existing public-site TLS/authentication remain intact. +- Latest embedded helper/template rebuilt and deployed on dev/yaya; full isolated + backend suite passed 1,651 tests, zero failed, four explicit ignores. Helper + bytes match source after management restart and live ACME/security probes + pass. Full-machine reboot and signed catalog migration remain unverified. +- Operator signed the candidate catalog; exact reviewed contents and release-root + signature verified on dev/yaya. Only NPM and Angor indexer changed. An explicit + signed local-candidate selector is implemented because mirror ordering always + prioritizes the public origin. Full isolated suite now passes 1,653 tests; its + optimized build completed and the signed candidate is active on dev/yaya. + Live NPM migration found a further compatibility failure: existing saved + upstreams use the former host gateway, which default slirp cannot reach. + A disposable namespace verified preservation using an explicit slirp subnet; + live qualification repair now passes saved-upstream, database/certificate + preservation, bridge, ACME and public HTTPS checks. Durable source/catalog + correction and removal of the temporary qualification network override remain + mandatory before release. See the acceptance document for details. +- Shorty's containment is untouched. Its manual shop HTTPS route versus NPM + certificate ownership remains a blocker. Paid-file regression acceptance, + physical companion uploads, Angor's 34 missing announcements/full-chain + acceptance, and exact OTA/raw-ISO acceptance remain open. +- No new catalog, OTA or ISO has been published. + ## Current tasks -- [ ] Recover the Framework's Lightning paid-file purchase without another payment; - inspect buyer/seller evidence and verify delivered bytes. -- [ ] Correct seller settlement verification when local-node payment skips polling. -- [ ] Durable seller entitlements and safe buyer retry after navigation/restart; +- [x] Yaya App Store component/alias regression (reported 2026-10-02): one + Cuprate entry (hide Cuprate UI companion), one BTCPay Server in Commerce + with its icon (merge legacy btcpay pins), one NetBird entry (hide server + and dashboard components). Apply to fresh signed/community catalogs, + persisted caches and installed Apps/Services; preserve actual components, + data, dependencies and legacy-only installations. Source fix and 34 + focused tests pass; production build and yaya live browser checks at + mobile/desktop widths, including hard reload, pass. Actual new installs + of these three products were not performed during this UI acceptance. + +- [ ] **2026-10-02 operator requirement for the next update: Fast by default for + on-chain transactions**, including sends and cooperative channel closes; + users can explicitly select slower or custom fees. This supersedes the + earlier instruction to leave defaults unchanged. Implement dynamic + next-block targeting, not a fixed sat/vB default. Cover initial form state, + reset/reopen, preview, submission and backend omitted-fee defaults; preserve + explicit user selections. Audit channel opens and other on-chain entry + points for the same policy. Force-close commitment fees and subsequent + sweeps require separate supported LND handling, not cooperative-close + parameters or a promise of immediate spendability. Implementation and + actual-node acceptance remain pending; no default was changed by the + manual acceleration below. +- [ ] **Speed up interface:** implement the plan in the fee-acceleration section + below, with explicit additional/total fee preview, budget, live eligibility, + RBF/CPFP distinction and durable operation tracking. Include in next-update + scope alongside Fast defaults; do not infer completion from this plan. + +- [x] Resolve the tester's missing-file recovery request: operator confirmed on + 2026-10-02 that the tester received the file from Amish Paradise and accepts + closure of this individual recovery. No seller access or further payment + is required. This is operator-confirmed receipt, not independent byte + verification or proof that the candidate fix delivered it. The durable + payment/delivery fixes and regression acceptance below remain required. +- [x] Correct seller settlement verification when local-node payment skips polling. +- [x] Durable seller entitlements and safe buyer retry after navigation/restart; do not issue another payment on an uncertain or successful attempt. - [ ] Cache Lightning purchases, preserve ownership, optional Files copy, free repeat. + Exact bytes, ownership and free repeat passed; optional Files-copy acceptance + must be located or repeated before closing this combined checkbox. - [ ] Diagnose mobile companion uploads on the affected route/device. -- [ ] Real progress in the existing compact upload bar; no increased height. -- [ ] Preserve uploads/progress across screens and original batch destination. -- [ ] Cancel active transfer and queued files; truthful partial/error/server-save status. -- [ ] Transparent transaction-filter container; single horizontal scrolling mobile row. +- [x] Real progress in the existing compact upload bar; no increased height. + Actual browser uploads verified a 44px bar; physical companion remains separate. +- [x] Preserve uploads/progress across screens and original batch destination. + Actual browser batch destination and cross-screen persistence verified. +- [x] Cancel active transfer and queued files; truthful partial/error/server-save status. +- [x] Transparent transaction-filter container; single horizontal scrolling mobile row. + Actual served desktop/mobile styles and geometry verified; retain in final artifact checks. - [ ] Immich displayed as one app, internal components hidden; diagnose restarting services. -- [ ] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal. -- [ ] Identify the other removed unexpected service from affected-node records. +- [x] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal. +- [x] Identify the other removed unexpected service: Core Lightning, confirmed + in the original node investigation and its retained uninstall markers. - [ ] Upgrade regression matrix: installed/stopped/restarting/removed/legacy apps, aliases, dependencies, inventory, desired-state reconciliation and data preservation. -- [ ] Portainer duplicate-network migration: retire the redundant managed repair +- [x] Portainer duplicate-network migration: retire the redundant managed repair override, preserve operator settings/state, verify generated command, actual request namespace, dashboard readiness and repeated reconciliation. @@ -36,6 +107,239 @@ acceptance; this is a new paid-file incident. - [x] Apps search clear control: My Apps, Services and App Store, desktop/mobile, existing design tokens, right-aligned icon, no size change, keyboard focus. +## Fee acceleration and Fast-default handoff — 2026-10-02 + +Operator explicitly authorized accelerating the latest outgoing Bitcoin payment +and subsequently requested Fast defaults for all on-chain transactions in the +next update, with slower options. The later instruction supersedes the earlier +no-default-change restriction. This is independent of paid-file recovery and +does not authorize another purchase payment. + +Live Framework evidence, checked through the existing SSH connection with +hostname verification (not the development node): + +- Original transaction: + `ad3c2ffd14f35e0508045f538b0046876cfeddc732ed8c1f49771c219f2f2b42`. + Recipient 161,650 sats; original fee 144 sats; vsize 142; no unconfirmed + ancestors or descendants before submission. Wallet-owned output 0 was + unspent and worth 21,126 sats. It did not signal BIP125 replacement. +- LND next-block estimate: 2 sat/vB. Bitcoin conservative estimator returned + 2.255 sat/vB (effective target 2 blocks). Mempool/relay floor: 1 sat/vB. +- Submitted exactly one `wallet bumpfee` for original output 0, using + `--sat_per_vbyte 3 --budget 650 --deadline_delta 1 --immediate`. + This registers a CPFP child, not a replacement of the recipient payment. + The budget was explicit; no implicit 50%-of-change budget was used. +- Actual child broadcast and accepted in the node's mempool: + `e04aec1dfbc16043611411de83201a32f7ffdcb9e8bb362c281cf967ee4bdd69`. + Its only input is the specified change output; output 20,476 sats; + fee 650 sats; vsize 111. Parent plus child: 794 sats / 253 vB = + approximately 3.138 sat/vB. Recipient output remains unchanged. +- At the post-submit check, node tip was 969564, both transactions remained + unconfirmed, and child `unbroadcast=false`. LND recorded one broadcast + attempt, budget 650 and deadline height 969565. RPC success is not + confirmation; next-block inclusion is not guaranteed. Do not repeat the + bump blindly if resuming: inspect original, child, sweeper and chain first. +- Bitcoin CLI works with `-conf=/tmp/rpc.conf` inside `bitcoin-knots`. + Do not print/copy that configuration or its credentials. No default settings, + wallets, channels or services were changed/restarted by this acceleration. + +Final confirmation check: **both original and CPFP child confirmed in block +969565**, the next block after submission. LND reports one confirmation for +each, with fees still 144 and 650 sats respectively. The authorized acceleration +is complete. This outcome does not guarantee next-block results for future sends. + +Implementation plan for the next agent: + +1. **Fast default:** update initial/reset states in `SendBitcoinModal.vue` and + `LightningChannelsPanel.vue`, plus their fallback targets and backend + omitted-fee behavior in `lnd/wallet.rs` and `lnd/channels.rs`. Existing Fast + presets already target 1 block; current initial/reset states select Standard + and cooperative-close backend defaults to 6. Preserve explicit slower/custom + settings. Quote a fresh fee and show total cost before confirmation; never + silently substitute a stale/cheap estimate after estimator failure. Explain + that next block is a target, not a guarantee. Audit channel opening and other + on-chain call sites, distinguishing force-close and sweep semantics. +2. **Flow:** transaction details → Speed up → Next block / Custom → review + additional fee, resulting total fee, maximum additional-fee budget and + recipient amount → Confirm. Custom specifies a target package rate in + sat/vB for CPFP, or replacement rate for RBF. Clearly identify the method: + RBF replaces a transaction; CPFP spends wallet-owned change to accelerate + the original. Only expose methods supported for that specific transaction + by the installed wallet; do not infer direct RBF capability from a flag. +3. **Read-only quote RPC:** return eligibility/reason, chosen method, original + txid/outpoint, live chain/mempool status, rate, transaction/package sizes, + existing fee, estimated additional fee, resulting total fee, explicit hard + budget, expiry and a server-bound quote ID. Verify ownership, spendability, + leases, dust, ancestors/descendants, sweep membership and relay/replacement + rules. CPFP fee calculation must cover the ancestor package, not just the + child's vsize. Distinguish estimated spend from maximum approved spend; + LND can consume its entire budget at the deadline (as happened here). +4. **Submit RPC:** require wallet authorization and quote ID/idempotency key; + serialize by affected transaction/outpoint and persist operation state before + calling LND. Revalidate confirmation, conflict, output availability, topology, + fee estimate and policy at Confirm. Invalidate materially changed/expired + quotes for another review; never raise the approved budget silently. A + timeout is an unknown outcome to reconcile, not permission to pay again. + Repeated/restarted submissions return the existing operation. +5. **Track results:** distinguish requested, registered, broadcast, mempool + accepted, confirmed, rejected and unknown. Link original/replacement/child + txids and subsequent child replacements durably. Preserve recipient amount + and original identity; present one payment with fee history, not a second + outgoing payment. Current `lnd.gettransactions` drops output ownership and + input relationships and uses absolute wallet delta as amount; extend the + normalized model deliberately to avoid counting the CPFP fee as a new send. + Home and its transaction-detail component need live refresh and reorg handling. +6. **Acceptance:** default/reset/explicit-slower UI and omitted-fee backend + tests; package math, budget and dust boundaries; stale/confirmed/conflicted + quotes; concurrent submits, timeout and restart reconciliation; unsupported + RBF, CPFP child replacement and history grouping; estimator outage; mobile + review. Run backend tests only through `scripts/test-backend-isolated.sh`. + Use regtest for broadcast/confirmation scenarios; do not close real channels + or send real payments merely to test defaults. Record source results separately + from deployed UI and live-node acceptance before OTA/ISO publication. + +Upstream semantics: [LND BumpFee API](https://lightning.engineering/api-docs/api/lnd/wallet-kit/bump-fee/) +and [LND unconfirmed transactions guide](https://docs.lightning.engineering/lightning-network-tools/lnd/unconfirmed-bitcoin-transactions). +No fee-bump interface or Fast-default source change is implemented by this +handoff. Both remain required next-update work. + +### Bump interface implementation and operator UI review — 2026-10-02 + +This checkpoint supersedes the preceding statement that the interface is only +planned. The operator requested a small **Bump** button on pending on-chain +transactions, asked for a Framework/yaya preview before release handover, and +approved the layout. They then requested the existing glowing green transaction +success animation and approved that addition as well. + +- Implemented `BumpFeeModal.vue` and a small Bump action in `TransactionsModal.vue`; + Home refreshes wallet history after a verified update. Next block is selected + initially; Custom invalidates the previous quote. The review shows recipient + amount, current fee, additional fee cap, resulting total cap and package rate. +- Implemented authenticated/CSRF-protected `lnd.bump-quote`, `lnd.bump-submit` + and `lnd.bump-status` in `lnd/fee_bump.rs`, with submit/quote rate limits. + Quotes expire after 60 seconds and are revalidated before submission. A + synced write-ahead receipt under `wallet/fee-bumps/.json`, a submission + lock and create-new semantics prevent blindly retrying a possibly accepted + mutation after timeout/restart. Unknown outcomes remain blocked for recovery. +- Method is selected from live wallet evidence, not chosen by the user: + CPFP uses spendable/unleased native wallet change on a simple pending outgoing + payment. RBF is limited to LND's existing single-input wallet CPFP sweeps, + with a verified pending input, wallet-owned output and simple parent package. + Arbitrary payment replacement, anchor/HTLC/batched sweeps and complex ancestor + chains are explicitly unsupported. LND 0.21+ is required for the exact + budget/deadline API used. No default wallet fee setting is changed. +- Every mutation supplies an explicit budget below the selected input value + and a one-block deadline; the review explains that the full budget may be + consumed. Node mempool acceptance and LND confirmation evidence drive status. + The shared `PaymentSuccessPane` displays its existing green glow/check only + after acceptance: **BUMP BROADCAST / Awaiting confirmation**, then **CONFIRMED**. + RPC registration alone never triggers the success animation. +- Standalone preview deployed to Framework at `/fee-bump-preview/index.html`. + Its CPFP/RBF buttons are sample scenarios for review, not product method + choices. It compiles the actual components with an isolated mock RPC module; + browser checks verified zero wallet RPC calls. No test payment was sent. + Source: `neode-ui/previews/fee-bump/` and `vite.bump-preview.config.ts`. +- Initial UI verification: eight focused Bump tests, 12 existing Home wallet + freshness tests, TypeScript check and production build passed. Playwright + exercised 390px and 1440px preview, Custom, Confirm, success animation and + Done with no browser errors or wallet RPC requests. An initial stacking + defect was found visually and fixed with explicit dialog z-order; mobile + transaction amounts now stay on one line, with wrapping badges. +- Latest backend validation and actual wallet deployment are still in progress + at this checkpoint. Do not claim regtest or real-wallet RBF/CPFP acceptance + from unit tests or the static preview. The earlier manual CPFP and confirmation + above remain separate evidence. + +**Retain for the next release:** Fast defaults are still an unfinished requirement +from the operator; this interface work does not implement those defaults. Preserve +all other checklist tasks. Broader RBF support and grouping fee-only child rows +with their original payment remain limitations to assess explicitly. No fleet +OTA/catalog/ISO publication is authorized by this preview deployment alone. + +### Release-agent handover: approved Bump UI, production deployment blocked + +Operator approved the design and the added shared glowing green success animation. +The last request was to finish and provide the next-release agent a handover. + +**Verified complete:** + +- The interactive sample preview is deployed on the actual Framework: + `http://100.65.115.109/fee-bump-preview/index.html`. Select a sample scenario, + then Bump → Next block / Custom → preview → Confirm. It cannot send funds. + CPFP/RBF scenario buttons exist only in this preview; the actual wallet chooses + the supported method. The production dialog is not a method-selection menu. +- Eight focused frontend tests passed, including the real shared success badge + appearing only after mempool acceptance/confirmation; 12 existing Home wallet + freshness tests passed. TypeScript check and production UI build passed. +- Seven focused backend tests passed; the full isolated backend run subsequently + passed **1,660 tests, zero failed, four explicit ignores**. No additional live + payment, bump, channel closure or wallet setting change was made for testing. +- Desktop 1440px and mobile 390px browser tests exercised the deployed sample + review, custom fees, Confirm, green animation and Done with zero browser errors + and zero wallet RPC requests. The user approved both design and animation. + +**Deployment boundary and blocker (2026-10-02 10:23 UTC):** + +- Only the standalone sample preview is deployed. The actual dashboard/backend + fee-bump feature is NOT deployed yet. No management/native service was restarted + by this feature work. Framework's existing backend SHA256 was + `8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`. +- The session changed to a restricted sandbox while the release backend was + compiling. The original build process/session is gone. The existing + `core/target/release/archipelago` still had the pre-feature 04:42 local timestamp + at the checkpoint; **do not deploy that stale artifact as this feature**. +- SSH now fails with `Control socket connect(...): Operation not permitted` and + `socket: Operation not permitted`. This is an execution permission blocker, + not another Framework password failure. Approval mode is never, so this session + cannot request an elevated network operation. Resume deployment from a session + with authorized network access; do not alter node credentials to solve it. +- A local offline release-build retry was started after the interruption; its + completion must be checked before using any backend artifact. + +**Exact source scope (uncommitted, mixed workspace; preserve unrelated edits):** + +- New backend: `core/archipelago/src/api/rpc/lnd/fee_bump.rs`. +- Wiring: `api/rpc/lnd/mod.rs`, `api/rpc/dispatcher.rs`, + `api/rpc/bitcoin.rs` (shared read-only Bitcoin RPC helper visibility), and + `core/archipelago/src/rate_limit.rs`. +- UI: `neode-ui/src/components/BumpFeeModal.vue`, `TransactionsModal.vue`, + `neode-ui/src/views/Home.vue`, and + `neode-ui/src/components/__tests__/BumpFeeModal.test.ts`. +- Preview-only files: `neode-ui/previews/fee-bump/` and + `neode-ui/vite.bump-preview.config.ts`; keep its mock RPC alias out of the + production build. The regular production build uses the real RPC client. + +**Staged artifacts and next steps:** + +1. Finish the release backend build and record its SHA256. Production UI archive + `/tmp/archy-bump-ui.tar.gz` SHA256 is + `d0e253aba09ad257136e3feb5b63176c388f3bb968f560702eefb768d29e494d`; + its `index.html` SHA256 is + `9fef18c8c1c9bc21f43c3635b747dfcfbea965b096867b454bbf608121715438`. + This UI contains the approved green animation. +2. With access restored, verify hostname `framework-pt`. The UI archive and + `/tmp/archy-deploy-bump-framework.py` were staged on Framework; the same script + exists locally. Review it, stage the correct backend as `/tmp/archy-bump-backend`, + then supply the exact backend/archive hashes as its two arguments. It prepares + a rollback under `/var/lib/archipelago/support/framework-fee-bump-20261002`, + checks manager health, and compares native container identity/start times, + wallet identity, channels and balances. It has NOT been executed yet; the + rollback directory is therefore planned, not a verified backup. +3. Verify served production assets and authenticated quote/status behavior after + deployment. The previously saved dashboard session returned 401 during + preflight; use a normal fresh login. Do not fabricate authenticated acceptance + from the sample preview. No funded UI transaction test has been authorized. +4. Keep the documented support limits: simple outgoing native-change CPFP and + RBF of LND's single-input CPFP sweeps; no general payment replacement, incoming + payment bumps, batched/channel sweeps or complex unconfirmed ancestry. Quote + expiry, persisted ambiguous outcomes and fee budgets must remain intact. + Full regtest mutation/confirmation/reorg acceptance is still outstanding. +5. Implement the separately authorized **Fast default** for sends/cooperative + closes and audit other on-chain entry points; preserve slower/custom choices + and distinguish force-close semantics. That change remains required for the + next release and is not implemented by this Bump work. Preserve all other + regression/release blockers and the separately closed startup incident. + ## Retained release work (previous acceptance is not new-regression acceptance) - Mempool patched image/catalog version agreement, update-button clearing, one card. @@ -271,3 +575,853 @@ Shorty's Mempool report was inspected read-only: frontend/API had been running for over two weeks, systemd reported zero restarts, and the API was processing current blocks. The operator said it looked normal after applying the latest update. No Mempool repair or native-service restart was performed in this check. + +## Mandatory release control — operator reiterated 2026-10-01 + +Every task in Current tasks and Retained release work above remains in scope. +Use this document as the master coverage map, with detailed evidence in +`release-1.8.23-acceptance.md` and `npm-certificate-handoff-20261001.md`. +Distinguish source implementation, automated tests, live acceptance, packaged +artifact tests and publication verification. An implementation or passing unit +suite alone must not check off end-to-end acceptance. Keep earlier accepted +limitations explicit; never relabel an unavailable check as passed. + +### Newly required NPM/security gates — publication blocked + +- [ ] One authoritative active NPM data/certificate path; fresh, legacy nested + and current flat layouts, ambiguous/corrupt DB and permission errors. +- [ ] Preserve hosts, accounts, certificates/private keys, renewal files, + custom settings, permissions and uninstall decisions; backups/rollback + and repeated migration tested. +- [ ] Default and named HTTP challenge routes follow the active mount, including + first issuance and renewal under forced HTTPS. +- [ ] Automatic host/certificate create/edit/delete/disable/replacement routing + and renewal reload; no manual repair required for each host. +- [ ] NPM access lists, custom locations, multiple domains and WebSocket routes + remain enforced; host bridge must never bypass NPM security settings. +- [ ] Injection/invalid DB data, concurrent sync, failed syntax/reload, delayed + startup and certificate failures retain safe service and clear diagnostics. +- [ ] Public unknown HTTP Host, TLS SNI, raw public IP and forged Host/XFF cannot + serve management login/UI/assets/RPC/WebSockets, for IPv4 and IPv6. +- [ ] Private LAN/tailnet access works; public ACME issuance/renewal works without + opening management; trusted proxy/tunnel paths and spoofed headers tested. +- [ ] Named indexer and relay route to their actual services with verified TLS; + relay WebSocket upgrade and Nostr REQ/EOSE verified separately from certs. +- [ ] Manager/NPM/nginx restart, reconciliation, disposable VM reboot, legacy + upgrade, flat upgrade, fresh ISO and rollback preserve these protections. +- [ ] Exact OTA and raw ISO payloads contain the same correction; required + candidate tests pass before signatures, feed promotion or publication. + +The release owner acknowledged both handoffs in +`/tmp/npm-release-handoff-ack.txt`. Investigator-reported Shorty containment is +recorded separately from release tests. Do not modify Shorty nginx concurrently +or overwrite its containment until an equivalent fix is tested. Known failures +and unverified required security gates block publication. + +### Latest completed release harness + +The pre-NPM candidate's complete release harness passed: 1,633 backend tests +(zero failed, four optional exclusions), 1,157 frontend tests, Rust checks, +formatting, type checking, catalog/trust, runtime build contexts, doctor safety, +pruning, readiness, tunnel migration and ISO overlay regressions. This does not +cover the new NPM bridge/security implementation, which requires its own tests +and relevant repeat gates after changes. No new release has been published. + +### Final handoff additions — all retained + +- [ ] Investigate the existing public website TLS hostname mismatch independently; + preserve unrelated sites and establish a baseline before attributing cause. +- [ ] Verify actual Angor client/version discovery with our indexer and relay + settings end-to-end. New relay kind3030 zero-events versus five on the + original Angor relay is a data/discovery difference, not API health proof. +- [ ] Resolve/document the client's actual project-query API contract, including + the observed legacy `/api/v1/query/Angor/projects` 404. +- [ ] Retain original discovery relays alongside an empty self-hosted relay; + do not imply that running a new relay automatically replicates projects. + +Final investigator security evidence is now available in the NPM handoff. It +confirms the reported live containment but does not replace IPv6, reboot, fleet +or packaged-release tests. All those required gates remain open. + +### Angor ownership and evidence correction + +The operator retained the original relays alongside the new relay. Do not +attribute missing projects to the empty new relay, and do not treat the legacy +specialized-query 404 as a proven cause: current browser logs do not call it, +and sampled transaction IDs/status match the reference indexer. The Angor +investigator owns `apps/angor-*` and scoped tests/docs and will provide verified +project-flow results. This release session owns NPM, the management guard and +packaging; Angor acceptance stays open until that handoff passes. + +### Transactions rail correction — operator report + +The earlier computed-background check missed `backdrop-blur-md`: the rail still +painted a blurred surface even with a transparent background. Remove that effect; +only filter buttons should have visual styling. Verify background color/image, +backdrop filter, border and shadow at mobile and desktop widths, retain the +single scrolling row, then promote the corrected dashboard on the dev box. +This correction is required in the next OTA and ISO. + +### Final Angor handoff retained + +Read `angor-client-acceptance-20261001.md` and the 35-project recovery inventory; +receipt saved to `/tmp/angor-final-handoff-ack.txt`. Investigator verified one +complete Explore/detail/statistics flow and all 35 chain commitments. Recovery +of 34 original signed announcements remains open; queried sources did not yield +them, which does not prove global loss. Retain the upstream discovery defect +and full-recovery gate; repeat the scoped browser test after NPM migration and +OTA, preserve relay data, and include the app README corrections. + +Transactions correction is now deployed on the dev dashboard (static assets +only; no service restart). Production build/type check passed. Both source and +served production browser checks at 390px and 1440px report transparent color, +no background image, no backdrop filter, no shadow and zero borders. Mobile +rail: 324px visible, 419px scroll width, one row. Testing used a disposable +browser profile with layout-only cached transactions; no wallet state changed. +A root-only dashboard backup was taken before promotion. Served index SHA-256: +`670c89a0ceb9d1e64e7460c554c642353a7e1f5bdaff1ec7d2a524135bd82f7f`. + +### Reconfirmed NPM handoff and Framework deferral + +The operator explicitly requested and received another receipt acknowledgement +in `/tmp/npm-release-handoff-ack.txt`. NPM certificate issuance remains a required +release gate: resolve the active flat/nested/custom data mount, preserve the +existing database/hosts/certificates, and verify fresh and legacy installation, +initial issuance, staging renewal, restart/reboot and OTA/raw ISO persistence. +The legacy shell bridge's nested-path assumptions are included in this repair. +Framework work is explicitly deferred for this task. Do not concurrently alter +Shorty's NPM/nginx or overwrite its live containment. Retain later evidence and +security gates; this repeated earlier handoff does not reset their status. + +### Added requirement: Mempool UI on the Angor indexer domain + +- [ ] Serve the existing Mempool explorer UI at the Angor indexer's public + hostname root, with working assets, deep links and live WebSocket updates. +- [ ] Preserve Angor API aliases, CORS, transaction broadcast, readiness and + verified project flow at the same origin. +- [ ] Reuse the existing Mempool stack; do not create a duplicate explorer, + database or node, or expose dashboard/Bitcoin RPC credentials. +- [ ] Update app documentation, interface metadata, dependencies and appropriate + versioned image/catalog entries when the implementation changes. +- [ ] Verify public HTTPS desktop/mobile UI, API, WebSockets, upgrade/restart, + NPM routing and exact OTA/ISO contents before marking this complete. + +Confirmed against the pinned official deployment guide linked in the Angor app +README: its public indexer endpoint includes Mempool frontend plus API; standard +Mempool images are supported without a custom Angor fork or ANGOR_ENABLED flag. +Our current 1.0.1 adapter instead returns service JSON at `/` and 404 for frontend +paths. Therefore UI exposure is a real missing feature, not currently implemented. +This supersedes the earlier API-only/headless requirement for the public endpoint. + +### Deployment order reconfirmed + +Operator requires completing fixes and available acceptance, then deployment and +verification on the dev box and yaya before release. Framework is only a fallback +when a required check cannot be established on those nodes. Yaya address and an +unused public staging-ACME hostname have been requested; dependent checks remain +pending, while source and disposable integration work continues. Release includes +OTA, catalog/app updates and raw ISO only after required gates pass. + +Angor candidate 1.0.2 now proxies the existing Mempool UI and WebSocket feed. +Disposable image checks passed root/assets/deep links, actual WebSocket upgrade +and frame, API aliases/query/body, CORS/credential stripping, method/size limits, +frontend outage with API preserved, and DNS recovery after frontend/backend +recreation. This is candidate implementation, not deployed fleet acceptance. + +### Further NPM qualification findings + +Real same-node routing failed inside the existing pasta namespace even when the +host could reach the LAN upstream. Disposable probes using the proposed explicit +slirp network succeeded through both LAN and host-alias addresses. The manifest, +Quadlet, Podman API and first-boot paths now express that mode, with legacy drift +checks. First initialization retains a 180-second readiness budget independent of +the network driver. The full disposable NPM proxy test passed with an actual LAN +upstream, including TLS/WSS, ACLs, certificate replacement and restart. Production +NPM and its emergency routes remain unchanged pending migration acceptance. + +The dev node uses a copied enabled nginx site. The initial guard edit reached +only sites-available; a live public-ingress-marker test exposed the omission. +Both helper scripts now resolve the active copy or symlink target. After repair, +dev private HTTP returns 200 and public-proxy-marked management requests 404. +Backups stay outside active nginx include directories. Do not claim the earlier +inactive-file edit as successful protection. Focused Python coverage now includes +this layout and pre-render crash recovery, with 26 tests passing. + +### Added release requirement: LoRa flasher and UK MeshCore acceptance + +Operator reports `esptool write_flash failed`, with both attempts failing to start +the subprocess (`No such file or directory`, OS error 2). This is an additional +release requirement; it does not replace the existing tasks or publication gates. + +- [ ] Diagnose executable discovery, installation/runtime packaging, service PATH, + missing interpreter and permissions; fail before changing the device when + required tooling is unavailable. Do not retry a missing executable as though + it were a transient serial fault. +- [ ] Verify board/chip identity and select the correct official MeshCore image; + validate downloads and offsets and preserve readable device configuration. +- [ ] Test missing tool/module, incompatible version, permission denied, busy or + disconnected serial device, timeout, flash failure and recovery reporting. +- [ ] User explicitly authorizes flashing radios attached to the dev box and + Framework with MeshCore UK settings. Identify each radio before writing; + retain backups where supported and verify flash, reboot, serial handshake, + firmware identity and actual UK radio parameters on both devices. +- [ ] Verify application reconnect and communication, and ship required tools and + fixes consistently in fresh ISO and OTA upgrades. Record physical tests + separately from mocked coverage; no universal-success claim. + +Framework deferral is lifted specifically for this requested radio diagnosis and +flash acceptance; wallet/native service work remains outside this new action. + +Operator additionally reports that both Framework and dev have trouble connecting +to their radios. Add connection/reconnection diagnosis and acceptance on both +nodes: USB enumeration, udev permissions/stable paths, exclusive serial ownership, +protocol detection, listener recovery after failures/unplug/replug, and correct +visible connection state. Successful firmware writing alone does not close this +task; verify subsequent serial handshake and communication on each physical radio. + +LoRa investigation update: dev's existing firmware responds as Reticulum/RNode. +After confirming no flash was active, an explicit mesh-listener disable/enable +restored connection without a firmware write or native-service restart. Candidate +code fixes unchanged-settings reconnect after a stopped/finished listener, checks +tooling before disconnection, serializes probes/configuration with flash jobs, +and retains writer ownership through timeout and post-flash cleanup. Downloads +now complete before listener shutdown. MeshCore release size/SHA-256 checks apply +to fresh and cached images; the existing V3 cache matches the official release. + +The packaged flasher passes its self-check and version command on both dev and +Framework without a system esptool module. Its OTA and ISO inclusion is mandatory. +Two UI board-selection/preflight tests and type checking passed; final backend +and release-suite results are still pending. Neither radio has been flashed. + +### Latest acceptance checkpoint: radio connection and release status + +The complete frontend suite passed: 143 files, 1,159 tests. Type checking and +both new radio setup tests also passed. The final isolated backend build/test +run is still pending; the previous run exposed an NPM/Router port collision, +which was corrected by assigning NPM's local HTTP listener port 8088. Do not +report the previous run as fully passing or the final run as completed. + +Yaya's identity has been confirmed. Its existing managed web-tunnel drop-in +clears manifest port publications and supplies private tunnel HTTP/HTTPS ports +plus the local admin port. This would suppress the candidate bridge's new +loopback HTTP/TLS listeners. Migration must preserve the working tunnel/site, +add the required local listeners, validate the managed firewall/lifecycle, +retain custom overrides, and cover repeat upgrade and rollback. The current +bridge rejects non-loopback listeners, so the supported tunnel topology also +needs explicit qualification. No tunnel or NPM runtime change was applied to +yaya during this diagnosis. Its management source guard was applied and tested: +private dashboard HTTP 200, public-ingress-marked request 404. + +Dev radio connection has been restored on its existing Reticulum firmware. +Framework still does not enumerate a USB serial radio. Both nodes now have the +self-tested packaged flasher, but physical MeshCore UK flashing, post-flash +handshake and reconnect acceptance remain open pending board identification and +Framework USB detection. No device firmware has been written. + +OTA, app catalog and raw ISO publication remain held. Outstanding acceptance +includes NPM migration/renewal/reboot and exact artifacts, end-to-end delivery +of the reported paid file, physical companion uploads, full Angor discovery +(the 34 missing original announcements), radio hardware tests, and the final +dev/yaya candidate deployment checks. Retained tasks above remain in scope. + +### Radio models confirmed and additional serial ownership fault + +Operator confirmed dev Heltec V3 and Framework Heltec V4, authorizing the already +requested MeshCore UK flashing on those models. Framework is physically connected +according to the operator but Linux still enumerates no USB serial radio; manual +USER/BOOT plus RST bootloader entry has been requested. Do not assume a missing +serial node is an application-only failure. + +Dev chip query confirms ESP32-S3 with 8 MB flash. Initial read-only backup attempts +failed while a surviving Reticulum sidecar held the serial port despite disabled +mesh status. The exact owning sidecar process group was identified and terminated +gracefully; the subsequent exclusive backup progresses normally. Source review +found that cancelling the asynchronous sidecar startup before its ready event +bypassed ordinary error cleanup. A new owning startup guard terminates the group +on cancellation as well as error, with an isolated real-child regression. Final +validation of this additional fix is running; it was not in the prior passing run. + +The preceding full backend run passed 1,647 tests with zero failures and four +ignored. Complete frontend suite passed 1,159 tests. Added an explicitly named +EU/UK Narrow preset (869.618 MHz, BW62.5, SF8, CR4/8), retaining existing plans; +these parameters match the MeshCore app maintainer's presets in upstream +MeshCore discussion 1650. Neither physical flashing nor reconnect acceptance +is complete at this checkpoint. + +### Dev Heltec V3 physical flashing result + +Full 8 MiB pre-flash backup saved privately with mode 0600 and checksum. After +removing the confirmed stale radio sidecar, the exclusive read completed. +The normal mesh.flash-device RPC then flashed the official Heltec V3 Companion +USB v1.17.1-d929643 merged image successfully (100%, no error). The image's +size and SHA-256 were checked against the official GitHub release metadata. + +Independent serial protocol queries confirmed model Heltec V3, firmware +v1.17.1-d929643 and actual RF readback: 869618 kHz, 62500 Hz bandwidth, SF8, +CR8 (EU/UK Narrow). This is device readback, not merely saved host settings. +The listener was then re-enabled and reported connected as meshcore. No wallet +or native Bitcoin/LND service restart was used. MeshCore remote reboot is not +supported by the current API; that attempted check returned an explicit error. +Physical unplug/replug and communication to Framework remain pending. + +Live qualification additionally found incorrect binary DEVICE_INFO/SELF_INFO +parsing and a stale host-side RF-applied marker after full-chip flashing. +Candidate changes decode the current official binary layout, query the actual +firmware version during initialization, and invalidate the RF marker after a +successful flash. The dev marker was backed up and cleared before provisioning; +the independent readback above confirms settings applied. New parser, startup +cancellation and marker lifecycle regressions are queued/running; the prior +1,647 passing tests do not cover these later changes. + +Framework's V4 official USB image has been downloaded and verified. Despite the +operator confirming it is plugged in, repeated sysfs/device checks show no +ESP32 USB or serial port. USER/BOOT plus RST bootloader entry was requested; +Framework has NOT been flashed and the two-device acceptance remains open. + +### Operator deferral and latest qualification + +Operator explicitly deferred Framework radio/hardware work and instructed us to +continue all other release tasks. Framework V4 flashing, USB reconnect and +radio-to-radio acceptance remain UNVERIFIED / OPERATOR-DEFERRED; this is not a +pass. Do not request further Framework radio operations unless needed and the +operator resumes that work. Dev V3 acceptance and durable fleet fixes remain. + +The final radio backend suite passed 1,650 tests, zero failed, four ignored, +including sidecar-startup cancellation, current MeshCore metadata parsing, and +post-flash RF-marker invalidation. Frontend baseline remains 1,159 passed. + +Correction to the earlier yaya tunnel concern: direct inspection of the active +Quadlet and all drop-ins confirms web-tunnel.conf ADDS private tunnel ports; it +does not contain an empty PublishPort reset. The earlier statement that it +cleared manifest listeners was incorrect. The new loopback publications therefore +coexist declaratively without editing that working tunnel drop-in. The bridge +validator now permits only the known HTTP/TLS tunnel ports bound to a currently +assigned RFC1918 address on an actual WireGuard interface named wg-web; it still +requires a separate loopback upstream, and rejects wildcard/public/unassigned +bindings and any admin-port exception. Python bridge/guard tests: 27 passed. +Actual yaya candidate upgrade and public route acceptance remain pending. + +### NPM public certificate and restart qualification checkpoint + +- Main backend: 1,651 passed, zero failed, four explicitly ignored hardware / + external integration tests. Container runtime library: 80 passed, zero failed. +- Bridge/management guard Python suite: 27 passed. Shell syntax and actual ISO + overlay-content test passed. +- Candidate validator inspected yaya's real runtime/WireGuard interface and + accepted its existing web tunnel publications while selecting proposed + loopback HTTP/TLS upstreams. This was read-only, not a runtime upgrade. +- Existing yaya public HTTP ACME route returned the exact random token body + written inside NPM. Lets Encrypt STAGING initial issuance and renewal dry run + succeeded using separate temporary account/config/work/log storage. Current + production certificate and host records were not replaced. Public site HTTP + and trusted HTTPS retain their authentication requirement (401). +- First renewal harness timed out while Certbot used a 292.7-second randomized + delay; the remote log confirmed successful simulated renewal. A deterministic + rerun with --no-random-sleep-on-renew returned exit 0 and success confirmation. + Only the temporary staging directory was removed afterward. +- Real disposable NPM nested-layout test completed: namespace LAN upstream, + API host creation, custom locations, client-IP spoof rejection, exact ACME + token, trusted TLS/WSS, certificate replacement, password/network ACLs, + enable/disable/delete, and restart with retained DB. Latest restart took 7.6s. + Earlier rerun exceeded the fixture's 90-second restart window; the test now + uses the manifest's 180-second budget and records both route/admin statuses + and container state on failure. Do not erase that earlier observed failure. +- Cleanup was hardened to continue cleaning other fixtures after a timeout. + Two early test runs passed functional assertions but failed cleanup; their + leftover disposable containers/networks were explicitly removed. The latest + full run exited successfully. + +Legacy non-Quadlet repair now retains the old container for rollback, restores +it after replacement failure, preserves its exact image and environment values, +and waits for HTTP API readiness. Environment values use an exclusive mode0600 +file cleaned on drop, not argv or the host process environment. Invalid/multiline +entries fail before stopping the original. An occupied rollback slot is preserved +for review rather than deleting an unknown container. Live interrupted-migration, +additional operator-override and rollback acceptance remain OPEN; unit success +is not proof of those deployment paths. + +The deployment backend and frontend build are in progress. Full candidate dev/ +yaya upgrade acceptance, reboot, exact signed OTA/catalog and booted raw ISO +remain open. Framework radio is operator-deferred, not passed. The original paid +file bytes, physical companion upload and 34 missing Angor announcements remain +separately tracked. + +### Further completed acceptance + +The complete disposable NPM test now includes a deliberately failed replacement +with an occupied host port. Restoring the retained container preserved its exact +container ID, database, configured hosts and authenticated API access; the test +exited successfully and cleaned its fixtures. This verifies the Podman rollback +mechanism, not yet the full installed backend's legacy-repair entry point. + +Dev frontend build completed and was deployed with a separate rollback backup. +Served production Transactions layout passed at widths 390 and 1440: transparent +background, no image/blur/shadow/border, nowrap and exactly one row. Mobile rail +is 324px wide with 419px scroll content. Backend candidate compilation is still +in progress, so the latest backend changes are not yet deployed. + +Dev Bitcoin remains unpruned and in IBD (observed block543676/header969495, +verification progress0.2284). This is not full-chain Angor acceptance. The operator +has been asked which seller and filename identify the failed Lightning purchase; +the earlier recovered Framework-seller invoice is not confirmed as that purchase. + +### Read-only Shorty migration preflight: remaining ownership conflict + +Preflight found both flat and nested NPM databases. The live container's explicit +/data mount identifies the active one, so the candidate resolver now uses that +verified mount (or its saved validated receipt after a managed stop), preserves +both databases, and still refuses multiple databases without an authoritative +selection. Python coverage verifies both explicit choices and unchanged bytes. +This helper update occurred after the deployment binary build started; a final +release rebuild must include it. Do not claim the in-progress binary contains it. + +After resolving storage, the two Angor emergency routes match the exact known +handoff templates. Another existing file, shop-btcpay.conf, conflicts with an +enabled NPM record: the manual route supplies HTTPS using certificate10, while +the NPM host currently has certificate_id0 and SSL forcing disabled. The manual +route and NPM record cover the same two public names and backend web port. Blindly +retiring the route would break its HTTPS. No database, host, certificate, route +or runtime was changed on Shorty. The bridge correctly refuses this ownership +conflict and now names its configuration file in the diagnostic. Align TLS/route +ownership and verify the public shop before retiring that manual configuration; +Shorty's full migration acceptance remains OPEN. Preserve live containment. + +### Candidate deployment and additional real-upgrade ACME regression + +The operator explicitly deferred Framework's physical radio investigation and +requested continuation of all other work. Framework radio remains unverified. +Dev and yaya now run the backed-up unpublished backend candidate SHA256 +4c47269b3480ca0362df18dae160c073a19ea33507e04cacdad5b96837990ca6 and production +frontend index 3099c4ba44528a4a4c524f9a26159414558efa16abdd12cc7bfd64376cbb6089. +Both management health checks passed; native Bitcoin/LND container identities +and start times were unchanged. Yaya public site retains trusted TLS and its +401 authentication requirement; NPM admin API200, private dashboard200 and +public-ingress-marked dashboard404. The first yaya staging attempt stopped +before binary replacement because rsync was absent; deployment now uses Python +copying without that dependency and completed successfully. + +Actual startup exposed an additional regression: the canonical nginx template +had only HTTP ACME, while the bridge demanded two locations. Startup rewrote the +previously repaired config and the bridge rejected it before fixing the nested +root. Source now adds HTTPS ACME to the shipped template and migrates the exact +recognized legacy default-server layout; custom/ambiguous layouts still fail +closed. The missing-token route must return404 rather than the dashboard SPA. +28 Python checks passed. The real isolated nginx suite now starts from the +legacy missing-HTTPS layout, applies the migration, and passes all120 public +negative cases plus HTTP/TLS exact-token, private-access and reload checks. + +Applied the latest helper and its atomic ACME-only repair to yaya: actual token +written inside NPM returned exact200 over host HTTP, host HTTPS and public HTTP; +missing tokens returned404 for allthree. Public site trustedTLS/auth preserved. +Local self-signed host HTTPS was tested with certificate verification disabled; +public site HTTPS used normal certificate verification. Shorty was not modified. +The running backend still embeds the older helper/template; final rebuild is +REQUIRED before restart/reboot/persistent upgrade acceptance can pass. + +The prepared unsigned catalog validates with zero metadata drift and trusted +registry hosts. Its only changed entries are NPM and Angor indexer1.0.2. It has +not been signed, installed or published. Yaya's current signed catalog retains +NPM's old pasta network/tunnel-only HTTP+TLS listeners; full NPM runtime/bridge +migration acceptance awaits the reviewed signed catalog. Do not mistake the +backend/UI deployment or ACME-only fix for completed catalog migration. + +### 2026-10-02: rebuilt candidate deployed; private catalog qualification prepared + +Release-profile candidate build completed successfully. SHA256: +af0648ad4ef8b6183d3c1ff485721fa40b12bb730c6e0322bc5f209ed06fce39. +Focused bootstrap tests:10 passed. Full isolated backend rerun:1651 passed, +zero failed, four explicit hardware/external ignores. Python guard/bridge28 +passed again. No new source changes occurred between these checks and deployment. + +Deployed this rebuilt backend on dev and yaya, with private previous-binary, +nginx and native-container baselines. Both manager health checks passed. A later +post-startup comparison confirmed Bitcoin/LND identities/start times unchanged. +The installed bridge helper now matches latest source bytes after restart. +Private UI200, marked-public HTTP/HTTPS404 and missing HTTPS challenge404 passed. +Dev HTTPS intentionally binds its LAN/WireGuard addresses, not127.0.0.1; an +initial loopback probe got connection refused, corrected to the actual listener. +This was a test-address error, not a product outage. Local self-signed HTTPS +checks skip certificate verification; public-site TLS checks use normal trust. +Full-machine reboot qualification is still pending. + +Prepared a fresh candidate catalog with only NPM and Angor indexer entries changed. +Metadata drift0; registry trust check passed. Unsigned SHA256: +5801309bf21d3f6fd03ce5702d68b383a518f734092bf265f5e0ad243095a25e. +NPM's new manifest is capability-gated by runtime-migration-backup-v1; older +nodes retain the original manifest. This candidate is for private qualification, +not fleet publication. An operator-only hidden-input signer validates the exact +catalog and binary hashes, checks the pinned release root and restores the +unsigned original on failure. Its noninteractive refusal was tested. Signature +is required before testing through the nodes' normal trusted-catalog path. +No catalog, app image, OTA or ISO was published. Framework remains deferred; +all other open requirements retain their previous status. + +### Signed catalog and private qualification selector + +The operator signed the qualification catalog. Cryptographic release-root +verification passed locally and on yaya; after removing signature envelope fields, +its contents exactly match the reviewed unsigned candidate. No publication. +The catalog is staged under /var/lib/archipelago/qualification on both test nodes, +with previous catalog/app metadata and container identities privately backed up. + +Normal mirror loading deliberately forces the public origin first, so simply +prepending a private mirror cannot reliably test an unpublished candidate. +Implemented ARCHY_APP_CATALOG_CANDIDATE as an explicit absolute-file selection: +requires an anchored release-root signature, validates before cache replacement, +retains exact signed bytes, does not alter mirrors/trust, and fails without +public fallback when the selected file is invalid. Added unsigned, tampered, +wrong-key, malformed, missing, oversized, relative-path, valid and idempotent +coverage. Full isolated backend suite:1653 passed,0 failed,4 explicit ignores. +The optimized selector build is still in progress; selection is not enabled yet. +See docs/candidate-catalog-qualification.md for activation and mandatory removal +once the tested public catalog is available. Do not leave test nodes pinned. + +Yaya NPM preflight:8088/8444 are free, active public host has no conflicting +host-nginx ownership, database tables and certificate-file hashes saved privately. +No Shorty mutation. Dev public Angor reference acceptance passed TLS/WSS, exact +funding commitment, official Explore and detail/statistics again; this still +checks only the known original project, not all35. Dev Bitcoin continues syncing +(reported sync_progress approximately0.307); full-chain acceptance remains open. +Current tested hardware product codes:dev20CLS7S900 and yaya20CLS6BH00, both Podman +5.4.2; kernels6.12.74+deb13+1-amd64 and6.12.107+deb13-amd64 respectively. Framework +remains deferred. No Docker or new ISO-boot acceptance is implied. + + +## Live Lightning purchase: Framework to Shorty — 2026-10-02 + +Operator explicitly authorized a very small new test purchase, then performed +it from Framework. This is separate from recovering the Amish Paradise sale. +Fixture: `archy-lightning-delivery-test-20261002.txt`, price 1 sat, Lightning only. +Read-only checks confirmed one matching seller invoice, SETTLED for exactly +1 sat, and Framework payment SUCCEEDED for 1 sat with 1 sat routing fee +(1,000 msat). Total spent was 2 sats. The assistant sent no payment. + +Framework has exactly one durable purchased-content ownership entry for the +fixture, with backend `lightning`, paid_sats=1 and size_bytes=121. Its cached +file SHA256 equals the original seller fixture: +`d55f7a6acd77bdc3c35c65ecac6d1492096e99252d07d433e6286544540cde7e`. +**PASS: actual node-wallet payment, seller settlement, delivered bytes and +persisted buyer ownership/cache.** Framework runs the candidate backend +`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`; +Shorty runs `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`. +This also exercises the candidate buyer against the existing seller version. + +Live reopen without payment and restart acceptance are not established by +this check. Shorty had no matching durable entitlement JSON in the inspected +location; do not attribute the candidate seller persistence implementation to +this older seller binary. No node or wallet was restarted. The tiny fixture +remains available for a free cached reopen check; remove only its catalog +entry/source file afterwards, preserving buyer ownership and payment records. + + +### Operator-confirmed free reopen — 2026-10-02 + +After the verified one-sat purchase, the operator reopened the file on Framework +and confirmed it worked without another payment. **PASS: live paid delivery, +durable buyer ownership/cache, and free repeat access**, with independent +settlement/byte checks above and operator confirmation of the reopen UI. +This closes that specific live acceptance check; it does not establish an +untested restart, outage, or seller-upgrade scenario. + +The temporary seller catalog entry and source fixture were removed after +acceptance. Buyer purchased bytes/ownership and all payment records were preserved. + + +## Release-agent continuation: Bump production deployment — 2026-10-02 + +Authorized network access was restored without changing credentials. The +previously running optimized build completed; its Bump implementation was +verified present and artifact frozen separately from subsequent Fast-default +and NPM source edits. Backend SHA256: +`af0cf7bd8bdc60c7b29976c11cbc002c46979be5120909f169856f8bd6e71058`. +The approved production archive retained SHA256 +`d0e253aba09ad257136e3feb5b63176c388f3bb968f560702eefb768d29e494d`. + +The reviewed staged rollback deployment ran successfully on Framework. Manager +health200, native Bitcoin/LND container IDs and start times unchanged, LND +wallet identity/channels/balance unchanged. Actual rollback files now exist +under `support/framework-fee-bump-20261002`. Served UI index SHA256: +`9fef18c8c1c9bc21f43c3635b747dfcfbea965b096867b454bbf608121715438`. +The approved Bump layout and shared green animation were preserved. No bump, +channel operation or payment was submitted. Normal authenticated quote/status +acceptance needs a fresh dashboard TOTP login; requested from the operator. + +Later source work, NOT in that deployed artifact: Fast defaults for send, +channel open/cooperative close, reset/reopen and hardware PSBT estimates. +97 focused frontend/client tests and three isolated fee-policy tests passed. +Full integration/release validation remains pending. NPM legacy gateway support +was added across runtime paths with a separate catalog capability, but the +real integration test exposed lost client-IP preservation on that subnet; +this remains an explicit blocker until corrected and retested. Neither the +new catalog nor these later backend changes has been deployed or published. + + +### Combined continuation validation checkpoint + +Fast-default, Bump and App Store UI changes pass the complete frontend suite: +1,176 tests in146 files, zero failures. Production TypeScript/Vite build passed. +The App Store change filters Cuprate/NetBird implementation parts from signed, +community and persisted catalogs, collapses BTCPay aliases onto the canonical +Commerce app, and keeps installed legacy-only BTCPay visible. Thirty-four +focused grouping/launch checks passed; actual yaya browser acceptance is running. + +NPM legacy host gateway correction now passes the complete disposable integration: +LAN/host alias/old gateway requests from its namespace; forwarded client IP; +spoofed headers; custom paths; real HTTP/TLS/WSS; ACME exact file; password/network +ACLs; certificate replacement; restart; forced-failure rollback; disable/delete. +The trusted rootless forwarding source is169.254.1.100, added as one managed block +through NPM's supported custom http_top.conf include. Existing custom directives +are preserved with a private pre-change copy; symlink/modified managed blocks +fail for review. A read-only bind under conf.d was rejected during qualification +because NPM's startup mutates that directory; it is absent from the final source. +Twenty-three Python bridge checks pass. Older gateways and manifests still need +the new signed capability variant and actual upgrade acceptance; no fleet release. + +### Yaya App Store grouping deployed — 2026-10-02 + +The dashboard fix is now deployed on the affected yaya-server. Actual served +index SHA256 is `076290e992a18fe418ea5ad411007cffe3280608c63576da2587d1cc371a50e4`. +The previous dashboard is backed up under +`/var/lib/archipelago/support/app-grouping-ui-20261002`. Every container ID and +start time matched before/after; this UI deployment did not recreate apps. + +Authenticated live Chromium checks at 390px and 1440px show exactly one Cuprate, +one NetBird and one BTCPay Server, with loaded icons. Repeated checks after a +hard reload pass at both widths. Acceptance used the actual served dashboard, +backend and signed catalog. The earlier local asset-overlay attempt caused a +Chromium private-network classification error and is not counted as acceptance. +Installed-component hiding and legacy-only BTCPay preservation have automated +coverage; these browser checks did not install these products on yaya. + +The latest strict custom-fee validation adds eleven invalid-input cases; +95 focused SendBitcoinModal/RPC tests pass after that change. The subsequent +TypeScript/Vite production build passes. Prior full frontend suite: 1,176 passed. +Combined optimized backend and final-source isolated backend rerun remain in +progress. No public release, catalog update or ISO has been published. + +Follow-up audit: Marketplace.vue still uses plain desktop/mobile search inputs; +extend the existing shared clear-search control to this category view before +claiming the earlier all-App-Store search requirement complete. + +Additional live category checks pass: BTCPay Server appears in Commerce and is +absent from Money. The final browser run passed all listing, icon, hard-reload +and category assertions; its trailing optional screenshot timed out after font +loading. This capture failure is recorded separately, not reported as a fully +successful harness exit. Earlier actual-node desktop/mobile screenshots exist. + +### 1.9.0 continuation checkpoint + +See [the current 1.9.0 acceptance summary](release-1.9.0-acceptance.md). +The category-view clear-search gap is fixed and verified on yaya at 390/1440px: +click and Escape clear, focus is retained, button remains inside the field, +heights 52/40px. Dev mobile passed; a companion introduction and then resource +alerts obscured the desktop test, and a later navigation timed out under build +load. Dev desktop must be repeated after the build; these attempts are not passes. + +Read-only Framework recheck: CryptPad/Core Lightning containers remain absent, +uninstall markers retained, all three real Immich containers running continuously +since 2026-09-21. The duplicate Immich entries were synthetic inventory, not actual +restarting databases. Rendered Framework inventory still requires normal dashboard +authentication. No Framework native service was changed by this check. + +### Verified ledger reconciliation — 2026-10-02 + +Completed checkboxes above now reflect the retained source, automated, live-node +and operator evidence rather than leaving those accepted tasks apparently open. +Seller settlement/persistence and buyer exact-byte one-sat purchase/free reopen +are recorded separately; no additional payment was sent. CryptPad removal and +uninstall markers survive the recorded manager restart. Actual Portainer namespace +smart HTTP and Compose access passed after the current combined deployment. +The installed/stopped/removed upgrade matrix, physical companion route, Framework +rendered Immich inventory, final Fast-default acceptance, NPM signed migration and +final artifacts remain open. No artifact-wide reboot acceptance is inferred. + +Dev category search now passes 390px and1440px, including click/Escape, retained +focus and40/52px field heights. The earlier build-load timeouts remain recorded. + +The final audit found the fee-only child grouping requirement still outstanding. +A source correction now groups only a recorded simple CPFP child verified against +wallet ownership, input relationship, fee-only delta and current chain/mempool +state. It preserves recipient amount, excludes replaced fees from the total, +exposes linked fee history and targets the current child for another Bump. +Focused UI tests pass; new backend and real-regtest history checks are pending. + +### Signed qualification — 2026-10-05 + +See the current1.9.0 acceptance record for exact hashes/evidence. Signed catalog +and final payment/history corrections are deployed on dev/yaya; actual regtest +with grouped history passes. Yaya NPM's managed gateway works without its +temporary override, with preserved DB/certificates, actual upstream access and +manager-restart/reconciliation stability. ACME/public application and Portainer +checks pass. Full-machine reboot, final artifacts and remaining operator/Angor +gates are still open; nothing published. + +## Operator checks accepted; upload UX amendment — 2026-10-05 + +Operator reports the requested human checks worked perfectly: Shorty shop SSL, +physical upload flow and Framework dashboard/purchased-file checks. This is +operator acceptance, not a claim of newly independent device testing. Read-only +Shorty verification confirms shop certificate_id12 and Force SSL enabled. +Remaining migration/artifact/security and Angor requirements still apply. + +Operator supersedes the globally persistent upload-bar requirement: keep the +bar only on the screen where the batch originated, continue transfers across +navigation, show explicit Complete on successful server save, and use a +completion notification elsewhere. Source now retains the originating route, +removes the global floating bar, keeps the original44px inline bar, and reports +success/error/cancellation distinctly.25 focused store/component/notification +tests pass. The subsequent full frontend suite passed1,193 tests; production +build and actual served desktop/mobile real-upload checks passed. Deployed to +dev/yaya with index SHA256 +`86bb728017b118d8e98f032419e7fbfd6ecd78b7e464c982a2075cc38c582814`; +no apps or backend services restarted. Retain this as the preceding UI evidence, +not evidence for the later resumable-upload implementation. No new payment was requested or performed. + +### Resumable upload addition — 2026-10-05 + +Operator requests recovery after a background pause or connection loss. The +installed File Browser identifies as2.63.23/e8a388f8 and supports TUS. Cloud now +has a candidate chunked upload implementation: random same-folder staging path, +server-offset reconciliation, transient retry/online/visibility recovery, +cancellation, final SHA256 verification and rename. Lost final chunk/rename +responses are reconciled without restarting or accepting a same-size old file. +The original-screen-only44px bar, Complete label and off-screen notification +remain. Fifteen focused protocol tests pass; full build/deployed fault injection +are in progress. This is not yet live acceptance. + +Recovery requires the selected File to remain available in the running page. +An OS-killed app or expired server upload session may require reselecting the +file. Do not promise uninterrupted background execution or restart persistence. +This gate is additional to the already accepted physical upload flow. + +## ngit PR integration — 2026-10-05 + +Both requested proposals are merged and pushed to Gitea and ngit main at +`2c1bcacf`; ngit independently reports both as `applied`. + +- `494d2483`: opt-in NODE_IDENTITY_PUBKEYS for app owner allow-lists. Review + corrected ECMAScript/Rust whitespace differences and added strict public-key + validation. Appliance identity excluded; no private keys or signing capability + given to apps. Existing manifests and the native signing flow are unchanged. + Documentation explicitly describes linking all offered user identities. +- `c18ebd7f`: nostr0.44.7 and nostr-relay-pool0.44.3. The standalone relay pool's + maintenance advisory remains; SDK0.45 migration is a separate follow-up. +- Combined isolated backend suite:1,678 passed, zero failed,4 explicit ignores. + Real loopback hostile-relay test rejects altered content, author and signature + reusing a known DB event ID while accepting a valid event. NIP04/NIP44 normal + encryption and hostile/oversized payload tests pass. The initial relay harness + returned before connection establishment; corrected to wait for an actual + connection before fetch, and the complete rerun passes. +- Evidence: /tmp/archy-190-ngit-complete-tests.log, origin/ngit push logs and + /tmp/archy-190-ngit-postmerge.json. This is source publication, not OTA/ISO or + catalog publication. Later File Browser credential changes need a new suite. + +## File Browser secure automatic login — NEW REQUIRED GATE + +Operator requests unique per-node credentials, working Cloud from first launch, +no admin/admin and fleet-wide testing. Framework's reported authentication issue +recovered, which is not proof that this gate is fixed. Yaya rejects the saved +password with403 despite healthy File Browser2.63.23. Never count that as a +passed upload test. + +Confirmed source issues: first-boot paths still try noauth/admin defaults; the +post-install hook assumes admin/admin and uses an incompatible password-change +request shape; the generated ISO path updates a running DB and uses a different +DB filename; Cloud hardcodes admin and invents admin/admin on missing secrets. + +Candidate scripts/filebrowser-credentials.py now provisions a random username +and256-bit password offline with the pinned app image, backs up the selected +DB/config, preserves custom accounts, tests automatic login plus folder access +in a network-isolated container, rejects unauthenticated access, rotates only a +proven admin/admin login, and atomically publishes a0600 credential record. +Fresh real-image acceptance passes. Legacy/default/custom/restart/rollback, +first-boot/Quadlet/runtime wiring, live yaya/dev/Framework qualification and final +artifacts remain OPEN. No live File Browser account or DB has been modified. + +Upload resume: source/build/full frontend1,208 tests passed; subsequent48 focused +protocol/client tests passed after filename escaping correction. UI deployed on +dev/yaya index SHA256 +`31ac7bcc704c18f88a8b9800fb46bc7941651983e1a99b97d036a8d9e95a58b5`. +Actual dev1440/390px real-server fault injection passed partial offset123456, +offline reconnect, lost final PATCH and rename replies, exactSHA256, encoded +filenames, original-screen-only44px bar, notification, cancel and empty files. +Yaya is blocked at the credential gate above. Physical suspended/killed-app +acceptance is not inferred from these viewport tests. + +## 2026-10-05 resumed release qualification + +- Latest full frontend: 1,210 tests passed across 148 files. Production Cloud UI + and AIUI builds passed. Dev and yaya serve index SHA256 + `3e10a25db75e4712310eb34c98bf7595ad5db3a444f9126a73715b1d40493a33`; + UI archive SHA256 `586d1864c5c4027086194f6b5951a9b770c4e7ce9ba9ec3128e2ac0c1bde55e7`. + Private UI backups: `/var/lib/archipelago/support/cloud-auth-20261005`. +- Real dev browser upload tests pass at 1440/390px, including interrupted JWT + refresh, partial write, offline recovery, lost final PATCH/rename responses, + exact SHA256, encoded filenames, cancellation, empty file, origin-only 44px + bar and completion notification. Initial run overlapped UI deployment and + failed navigation/bar timing; kept as failed evidence. Clean rerun explicitly + verifies successful navigation and passes both viewports. Physical OS suspension + and yaya authentication/upload acceptance remain separate gates. +- Real File Browser image matrix passed fresh, legacy-default, legacy-custom, + legacy-noauth and forced-failure exact DB rollback. Existing file bytes and + user IDs/permissions preserved; custom credentials preserved; admin/admin and + anonymous access rejected. Actual disposable Quadlet pre-start and restart + also pass, with stable managed credentials. Four Python unit tests pass. +- File Browser startup integration now covers the direct runtime, Quadlet, + first boot and ISO script. Binary bootstrap installs its matching helper before + reconciliation. Fixed bundled first-boot missing NET_BIND_SERVICE and duplicate + creation attempt for a stopped File Browser. Live credential migration is still + pending the optimized backend build; no production DB/account modified yet. +- Final combined isolated backend suite: 1,681 passed, zero failed, four ignored. + An earlier run failed the Nostr relay fixture after a normal ping closed its + text-only receive loop. Fixed the fixture to answer pings; the complete rerun + passes. No failed run is counted as acceptance. +- NPM: 23 Python tests pass, including exact emergency BTCPay route recognition, + operator edit preservation, missing certificate/alias refusal and transactional + rollback. Existing emergency Angor routes now also require complete TLS + replacements before retirement. Actual disposable flat-layout NPM integration + passed namespace reachability, legacy gateway, ACME exact bytes, forced HTTPS, + WSS, certificate replacement, password/network ACLs and forged-header rejection, + restart, disable/delete, and forced bind-failure restoration. This is not a + staging-CA issuance/renewal or ISO/reboot pass. +- Shorty read-only inspection confirms shop certificate12 and Force SSL with both + hostname aliases; old manual shop route still uses certificate10. No live + Shorty routing change in this qualification. Migration remains pending. +- Evidence logs: `/tmp/archy-190-final-combined-backend.log`, + `/tmp/archy-190-cloud-auth-ui-dev-live-2.log`, + `/tmp/archy-190-filebrowser-final-integration.log`, + `/tmp/archy-190-filebrowser-quadlet.log`, + `/tmp/archy-190-npm-final-integration.log`. +- OTA/catalog/raw ISO publication remains held. Framework radio deferred; + Angor 34 unrecovered original announcements and dev full-chain acceptance + remain open. README alpha/funds notice is separately published to both remotes. + +## Live File Browser ownership regression — publication hold + +2026-10-05 dev candidate backend SHA256 +`3e01da72fcea0a61852f3d9038e67630e328c65d6433da749671d60b91c37ffa` +built successfully, then failed live credential migration before DB mutation. +The helper could not create its private backup under the legacy data-directory +owner (host UID100000). The original real-image fixtures aligned data ownership +to the image UID and therefore missed the shipped manifest's different mapping. +The managed File Browser has DAC_OVERRIDE for that layout; the helper did not. + +Restored prior backend SHA256 +`cfddec834a53609f8bef924f3905da76df45f22426cac2628c4c2cb06bea5d09` +and original File Browser Quadlet with the staged rollback script. Both services +are active. Yaya backend was not changed. No candidate credential record was +published; failed setup stopped at backup-directory creation before DB changes. + +Source helper now includes the managed server's DAC_OVERRIDE storage capability; +new real-image legacy-owner and actual-Quadlet fixtures reproduce that mapping. +Rollback fixture now forces an account-policy failure after noauth migration so +it still verifies restoration after a real DB mutation. These revised tests and +combined backend validation are in progress. A corrected embedded-helper build +and new live qualification remain required. Do not reuse the failed binary as +final release or mark the credential gate passed from earlier fixture results. + +Release-note drift corrected to1.9.0/current upload behavior and File Browser/ +Nostr additions. The checker now rejects stale descriptions/dates for an existing +version; its regression passes. Latest notes UI built and deployed dev/yaya index +SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`. +Phone background/reconnect acceptance question is pending, not passed. diff --git a/docs/release-1.8.23-acceptance.md b/docs/release-1.8.23-acceptance.md index 1fc8eec9..70cab54e 100644 --- a/docs/release-1.8.23-acceptance.md +++ b/docs/release-1.8.23-acceptance.md @@ -1,4 +1,6 @@ -# Archipelago 1.8.23-alpha acceptance +# Archipelago 1.9.0 acceptance + +The operator changed the release target from 1.8.23-alpha to **1.9.0**. This file retains its historical path so handoff links remain valid. Status: PREPARING. Do not publish until artifact checks and offline signatures pass. @@ -58,3 +60,529 @@ The release owner acknowledged `docs/npm-certificate-handoff-20261001.md` in were reported by the operator; durable data-path resolution, safe host routing, automatic certificate renewal/reload, and the handoff acceptance matrix remain required before publication. Earlier authorization does not waive this new gate. + +## Urgent public dashboard exposure gate — 2026-10-01 + +Investigator reports the Angor relay hostname reached the default Archipelago +login because its certificate existed without a corresponding host-nginx route. +The investigator owns the immediate Shorty nginx repair; the release session +will not modify that configuration concurrently. Exact final evidence is pending. + +- [ ] Unknown public HTTP Host / TLS SNI and direct public-IP requests cannot + expose the dashboard, login assets or RPC, including IPv6 and any trusted + reverse-proxy/tunnel path. Test spoofed forwarding headers explicitly. +- [ ] LAN/private/tailnet dashboard access remains available as intended. +- [ ] Public HTTP ACME challenge access survives those restrictions. +- [ ] NPM host creation/edits automatically propagate HTTP/TLS routing. +- [ ] Relay hostname serves the intended relay and WebSocket upgrade using its + correct certificate; certificate existence is not route acceptance. +- [ ] These protections survive manager/nginx restart, renewal and OTA/ISO. + +## Additional isolated security checks — not deployed + +The management source-guard prototype passed five unit checks including legacy +address-specific HTTPS, idempotence, backup permissions and syntax/reload rollback. +An actual nginx instance in a private network namespace passed 120 negative +HTTP/TLS cases across IPv4/IPv6, raw/unknown/spoofed Host/SNI and forwarded headers, +including POST RPC and WebSocket upgrade requests. Exact ACME token reads, +private LAN/tailnet/ULA access, named public HTTP app routing and reload passed. +These are scoped checks, not complete fleet, trusted-tunnel, reboot or artifact +acceptance. Shorty's containment was not modified. + +The NPM storage/routing prototype passed eight focused tests: fresh/flat/nested/ +custom mount selection without mutation, ambiguity/wrong-mount refusal, corrupt +or uninitialized database preservation, duplicate/missing mounts, deleted/disabled +host exclusion, domain injection rejection, and rejection of mixed public and +loopback listener bindings. Automatic application/migration and end-to-end NPM +security/routing remain unfinished and block release. + +Final Angor handoff was read and acknowledged in +`/tmp/angor-final-handoff-ack.txt`; see `angor-client-acceptance-20261001.md`. +One complete project flow is investigator-verified; 34 original announcements +remain unrecovered from queried sources. Full recovery acceptance remains open. + +## Additional Angor public explorer gate + +- [ ] Public indexer hostname serves Mempool UI and its assets/deep links/live + WebSocket updates while preserving Angor API/CORS/broadcast/readiness. +- [ ] Existing stack reused; no duplicate Mempool app/database and no management + or Bitcoin RPC exposure. +- [ ] Documentation/catalog/runtime metadata and exact OTA/ISO reflect the tested + implementation; repeat official-client browser acceptance afterward. + +Confirmed official deployment guide describes a shared frontend/API origin. +Current adapter 1.0.1 is API-only; this requirement is not yet implemented. + +## NPM real-image integration progress + +Disposable real NPM API tests passed for both flat and legacy nested `/data` +layouts: initial account/host creation, multiple domains, custom location, +forwarded-client spoof rejection, exact challenge file access under forced HTTPS, +trusted TLS and WSS upgrade/frame, certificate replacement/reload, password and +network access lists, disable/enable/delete propagation, and restart with the +original database and account authentication preserved. Local fixture certificates +are not public Let's Encrypt staging issuance/renewal evidence; that gate is open. + +Certificate replacement initially failed because the updated bridge could not +complete the upstream TLS request after replacing the fixture certificate. +Reloading and validating NPM's own TLS listener on certificate fingerprint changes, +as well as host nginx, resolved the test. Rollback/retry unit coverage was added. + +The initial dev guard deployment changed only the inactive sites-available copy; +private HTTP 200 and unchanged entry bytes were insufficient acceptance evidence. +A later public-ingress-marker probe caught this: it incorrectly returned 200. +The resolver now chooses the active sites-enabled copy or resolves its symlink +without replacing the link. Guard backups live outside nginx include directories. +After the correction, live private requests return200 and marked requests 404. +No app was restarted. Direct-backend protection still awaits its candidate build. + +Angor candidate UI was exercised against the actual dev Mempool stack in a +throwaway gateway: desktop/mobile rendered, zero failed JS/CSS assets, one +WebSocket connection each. The gateway was removed afterward; this is candidate +integration evidence, not a published or permanently installed app update. + +### Same-node NPM networking correction + +Read-only inspection of the production NPM namespace reproduced HTTP 502 for its +own configured indexer route. Host requests to the LAN upstream returned 200; +requests from the existing pasta namespace to that same LAN address were refused. +A disposable container on the proposed `slirp4netns:allow_host_loopback=true` +network returned 200 for both the LAN upstream and `host.containers.internal`. +No production NPM/nginx configuration or container was changed in this check. + +The candidate now declares this network in the manifest and first-boot path, +with explicit Quadlet/API support and legacy drift detection. The Podman API +`network_options` shape was checked against `podman generate spec` locally. +The real NPM integration fixture now uses the proposed network and a LAN-bound +same-node upstream, rather than placing both fixtures on one custom bridge. +Flat-layout integration passed namespace reachability, host routing, verified +TLS/WSS, ACME file access, certificate replacement/reload, custom routes, password +and IP ACLs, spoof rejection, host lifecycle and NPM restart with preserved DB. +The earlier loopback-only fixture failed because this machine resolves the host +alias to its LAN address; its bind was corrected before repeating the test. + +The latest isolated nginx guard test passed 120 public IPv4/IPv6 negative cases +plus private access, ACME, proxy-marker rejection and reload. Python guard/bridge +regressions passed 23 tests, including exact emergency-route retirement, failed +migration rollback and preserving operator-modified routes. Backend compilation +and new direct-listener tests are still pending. Required public staging renewal, +actual upgrade/reboot, yaya and exact OTA/ISO acceptance remain open. + + +### Active nginx site layout regression + +The dev node has a regular `sites-enabled/archipelago` file, not a symlink to +`sites-available`. Both the guard and ACME resolver now select the active file. +Unit coverage verifies copied sites and symlink targets, preserving inactive +operator copies and the links themselves. Nginx configuration backups must not +be created inside `sites-enabled`, whose wildcard include would load them. +The active guard was applied on dev, with private HTTP 200 and public-ingress +marker 404 verified after reload. Shorty remains untouched. Do not count the +initial inactive-file edit as a security deployment pass. + +### LoRa flasher added to release gates + +Both dev and Framework lack the esptool executable and Python module. The +backend invokes a bare `esptool` and retries even process-spawn failures. The +shell updater installs it opportunistically, but the OTA runtime tool list +omits it. Candidate packaging adds a pinned self-contained `archy-esptool` +with its ESP32-S3 stub to mandatory OTA/ISO payloads. Candidate preflight runs +before stopping the radio; retries are limited to recognized serial transport +failures. Concurrent flash registration now uses one exclusive lock. + +CP2102 USB identity does not uniquely identify a Heltec V3. The candidate removes +that unsafe inference from backend and UI and requires explicit board selection. +Actual board models were requested before firmware writes. Dev exposes one +CP2102 serial radio. Framework SSH works but currently exposes no mesh-radio, +ttyUSB or ttyACM port. No radio has been erased or flashed in this investigation. +Physical MeshCore UK acceptance on both nodes remains required and pending. + +Dev connection diagnosis: the failed flash stopped its listener before spawn +failed and left the enabled setting true. A read-only protocol probe identifies +the existing radio as Reticulum/RNode. An explicit listener disable/enable restored +`device_connected: true` on `/dev/mesh-radio`, without flashing or native-service +restarts. Candidate configure logic now compares desired enabled state with the +actual listener task, including stopped/finished handles; same-settings reconnect +is covered by a new isolated regression. Probe/configure and flash registration +are coordinated to prevent concurrent serial owners. + +The packaged `archy-esptool` build passes in a clean environment, including loading +the actual ESP32-S3 stub through esptool's own loader. It is installed and self-tested +on dev and Framework; a compatibility command supports their current backends. +This verifies tooling availability, not physical flashing. Framework's USB sysfs +inventory shows its hub/storage/network/keyboard/display devices but no serial +radio. Board confirmation and Framework radio detection remain pending. + +Additional Podman API acceptance: a disposable API service created a container +with the candidate `netns`/`network_options` payload. Its effective generated +specification preserves `allow_host_loopback=true`. The normal inspect network +mode omits options for API-created containers, unlike the CLI-created case; +candidate drift detection now consults the effective specification before +recreating such a container. Added a regression against repeated recreation. +The probe container and temporary API service were removed. The real isolated +nftables tunnel regression also passed (NPM peer port and LND remain separate). + +### Latest acceptance checkpoint: radio connection and release status + +The complete frontend suite passed: 143 files, 1,159 tests. Type checking and +both new radio setup tests also passed. The final isolated backend build/test +run is still pending; the previous run exposed an NPM/Router port collision, +which was corrected by assigning NPM's local HTTP listener port 8088. Do not +report the previous run as fully passing or the final run as completed. + +Yaya's identity has been confirmed. Its existing managed web-tunnel drop-in +clears manifest port publications and supplies private tunnel HTTP/HTTPS ports +plus the local admin port. This would suppress the candidate bridge's new +loopback HTTP/TLS listeners. Migration must preserve the working tunnel/site, +add the required local listeners, validate the managed firewall/lifecycle, +retain custom overrides, and cover repeat upgrade and rollback. The current +bridge rejects non-loopback listeners, so the supported tunnel topology also +needs explicit qualification. No tunnel or NPM runtime change was applied to +yaya during this diagnosis. Its management source guard was applied and tested: +private dashboard HTTP 200, public-ingress-marked request 404. + +Dev radio connection has been restored on its existing Reticulum firmware. +Framework still does not enumerate a USB serial radio. Both nodes now have the +self-tested packaged flasher, but physical MeshCore UK flashing, post-flash +handshake and reconnect acceptance remain open pending board identification and +Framework USB detection. No device firmware has been written. + +OTA, app catalog and raw ISO publication remain held. Outstanding acceptance +includes NPM migration/renewal/reboot and exact artifacts, end-to-end delivery +of the reported paid file, physical companion uploads, full Angor discovery +(the 34 missing original announcements), radio hardware tests, and the final +dev/yaya candidate deployment checks. Retained tasks above remain in scope. + +### Dev Heltec V3 physical flashing result + +Full 8 MiB pre-flash backup saved privately with mode 0600 and checksum. After +removing the confirmed stale radio sidecar, the exclusive read completed. +The normal mesh.flash-device RPC then flashed the official Heltec V3 Companion +USB v1.17.1-d929643 merged image successfully (100%, no error). The image's +size and SHA-256 were checked against the official GitHub release metadata. + +Independent serial protocol queries confirmed model Heltec V3, firmware +v1.17.1-d929643 and actual RF readback: 869618 kHz, 62500 Hz bandwidth, SF8, +CR8 (EU/UK Narrow). This is device readback, not merely saved host settings. +The listener was then re-enabled and reported connected as meshcore. No wallet +or native Bitcoin/LND service restart was used. MeshCore remote reboot is not +supported by the current API; that attempted check returned an explicit error. +Physical unplug/replug and communication to Framework remain pending. + +Live qualification additionally found incorrect binary DEVICE_INFO/SELF_INFO +parsing and a stale host-side RF-applied marker after full-chip flashing. +Candidate changes decode the current official binary layout, query the actual +firmware version during initialization, and invalidate the RF marker after a +successful flash. The dev marker was backed up and cleared before provisioning; +the independent readback above confirms settings applied. New parser, startup +cancellation and marker lifecycle regressions are queued/running; the prior +1,647 passing tests do not cover these later changes. + +Framework's V4 official USB image has been downloaded and verified. Despite the +operator confirming it is plugged in, repeated sysfs/device checks show no +ESP32 USB or serial port. USER/BOOT plus RST bootloader entry was requested; +Framework has NOT been flashed and the two-device acceptance remains open. + +### Operator deferral and latest qualification + +Operator explicitly deferred Framework radio/hardware work and instructed us to +continue all other release tasks. Framework V4 flashing, USB reconnect and +radio-to-radio acceptance remain UNVERIFIED / OPERATOR-DEFERRED; this is not a +pass. Do not request further Framework radio operations unless needed and the +operator resumes that work. Dev V3 acceptance and durable fleet fixes remain. + +The final radio backend suite passed 1,650 tests, zero failed, four ignored, +including sidecar-startup cancellation, current MeshCore metadata parsing, and +post-flash RF-marker invalidation. Frontend baseline remains 1,159 passed. + +Correction to the earlier yaya tunnel concern: direct inspection of the active +Quadlet and all drop-ins confirms web-tunnel.conf ADDS private tunnel ports; it +does not contain an empty PublishPort reset. The earlier statement that it +cleared manifest listeners was incorrect. The new loopback publications therefore +coexist declaratively without editing that working tunnel drop-in. The bridge +validator now permits only the known HTTP/TLS tunnel ports bound to a currently +assigned RFC1918 address on an actual WireGuard interface named wg-web; it still +requires a separate loopback upstream, and rejects wildcard/public/unassigned +bindings and any admin-port exception. Python bridge/guard tests: 27 passed. +Actual yaya candidate upgrade and public route acceptance remain pending. + +### NPM public certificate and restart qualification checkpoint + +- Main backend: 1,651 passed, zero failed, four explicitly ignored hardware / + external integration tests. Container runtime library: 80 passed, zero failed. +- Bridge/management guard Python suite: 27 passed. Shell syntax and actual ISO + overlay-content test passed. +- Candidate validator inspected yaya's real runtime/WireGuard interface and + accepted its existing web tunnel publications while selecting proposed + loopback HTTP/TLS upstreams. This was read-only, not a runtime upgrade. +- Existing yaya public HTTP ACME route returned the exact random token body + written inside NPM. Lets Encrypt STAGING initial issuance and renewal dry run + succeeded using separate temporary account/config/work/log storage. Current + production certificate and host records were not replaced. Public site HTTP + and trusted HTTPS retain their authentication requirement (401). +- First renewal harness timed out while Certbot used a 292.7-second randomized + delay; the remote log confirmed successful simulated renewal. A deterministic + rerun with --no-random-sleep-on-renew returned exit 0 and success confirmation. + Only the temporary staging directory was removed afterward. +- Real disposable NPM nested-layout test completed: namespace LAN upstream, + API host creation, custom locations, client-IP spoof rejection, exact ACME + token, trusted TLS/WSS, certificate replacement, password/network ACLs, + enable/disable/delete, and restart with retained DB. Latest restart took 7.6s. + Earlier rerun exceeded the fixture's 90-second restart window; the test now + uses the manifest's 180-second budget and records both route/admin statuses + and container state on failure. Do not erase that earlier observed failure. +- Cleanup was hardened to continue cleaning other fixtures after a timeout. + Two early test runs passed functional assertions but failed cleanup; their + leftover disposable containers/networks were explicitly removed. The latest + full run exited successfully. + +Legacy non-Quadlet repair now retains the old container for rollback, restores +it after replacement failure, preserves its exact image and environment values, +and waits for HTTP API readiness. Environment values use an exclusive mode0600 +file cleaned on drop, not argv or the host process environment. Invalid/multiline +entries fail before stopping the original. An occupied rollback slot is preserved +for review rather than deleting an unknown container. Live interrupted-migration, +additional operator-override and rollback acceptance remain OPEN; unit success +is not proof of those deployment paths. + +The deployment backend and frontend build are in progress. Full candidate dev/ +yaya upgrade acceptance, reboot, exact signed OTA/catalog and booted raw ISO +remain open. Framework radio is operator-deferred, not passed. The original paid +file bytes, physical companion upload and 34 missing Angor announcements remain +separately tracked. + +### Further completed acceptance + +The complete disposable NPM test now includes a deliberately failed replacement +with an occupied host port. Restoring the retained container preserved its exact +container ID, database, configured hosts and authenticated API access; the test +exited successfully and cleaned its fixtures. This verifies the Podman rollback +mechanism, not yet the full installed backend's legacy-repair entry point. + +Dev frontend build completed and was deployed with a separate rollback backup. +Served production Transactions layout passed at widths 390 and 1440: transparent +background, no image/blur/shadow/border, nowrap and exactly one row. Mobile rail +is 324px wide with 419px scroll content. Backend candidate compilation is still +in progress, so the latest backend changes are not yet deployed. + +Dev Bitcoin remains unpruned and in IBD (observed block543676/header969495, +verification progress0.2284). This is not full-chain Angor acceptance. The operator +identified the seller of the failed Lightning purchase as Amish Paradise. +On 2026-10-02 the operator confirmed the other tester received the file and +accepted closure of this individual recovery. Seller access is no longer needed +for that recovery. This does not establish that the candidate fix delivered it; +durable settlement/delivery regression acceptance remains required before +release. No additional payment was made. Earlier references in this document +to missing original purchase bytes are superseded by this operator acceptance. + +### Read-only Shorty migration preflight: remaining ownership conflict + +Preflight found both flat and nested NPM databases. The live container's explicit +/data mount identifies the active one, so the candidate resolver now uses that +verified mount (or its saved validated receipt after a managed stop), preserves +both databases, and still refuses multiple databases without an authoritative +selection. Python coverage verifies both explicit choices and unchanged bytes. +This helper update occurred after the deployment binary build started; a final +release rebuild must include it. Do not claim the in-progress binary contains it. + +After resolving storage, the two Angor emergency routes match the exact known +handoff templates. Another existing file, shop-btcpay.conf, conflicts with an +enabled NPM record: the manual route supplies HTTPS using certificate10, while +the NPM host currently has certificate_id0 and SSL forcing disabled. The manual +route and NPM record cover the same two public names and backend web port. Blindly +retiring the route would break its HTTPS. No database, host, certificate, route +or runtime was changed on Shorty. The bridge correctly refuses this ownership +conflict and now names its configuration file in the diagnostic. Align TLS/route +ownership and verify the public shop before retiring that manual configuration; +Shorty's full migration acceptance remains OPEN. Preserve live containment. + +### Candidate deployment and additional real-upgrade ACME regression + +The operator explicitly deferred Framework's physical radio investigation and +requested continuation of all other work. Framework radio remains unverified. +Dev and yaya now run the backed-up unpublished backend candidate SHA256 +4c47269b3480ca0362df18dae160c073a19ea33507e04cacdad5b96837990ca6 and production +frontend index 3099c4ba44528a4a4c524f9a26159414558efa16abdd12cc7bfd64376cbb6089. +Both management health checks passed; native Bitcoin/LND container identities +and start times were unchanged. Yaya public site retains trusted TLS and its +401 authentication requirement; NPM admin API200, private dashboard200 and +public-ingress-marked dashboard404. The first yaya staging attempt stopped +before binary replacement because rsync was absent; deployment now uses Python +copying without that dependency and completed successfully. + +Actual startup exposed an additional regression: the canonical nginx template +had only HTTP ACME, while the bridge demanded two locations. Startup rewrote the +previously repaired config and the bridge rejected it before fixing the nested +root. Source now adds HTTPS ACME to the shipped template and migrates the exact +recognized legacy default-server layout; custom/ambiguous layouts still fail +closed. The missing-token route must return404 rather than the dashboard SPA. +28 Python checks passed. The real isolated nginx suite now starts from the +legacy missing-HTTPS layout, applies the migration, and passes all120 public +negative cases plus HTTP/TLS exact-token, private-access and reload checks. + +Applied the latest helper and its atomic ACME-only repair to yaya: actual token +written inside NPM returned exact200 over host HTTP, host HTTPS and public HTTP; +missing tokens returned404 for allthree. Public site trustedTLS/auth preserved. +Local self-signed host HTTPS was tested with certificate verification disabled; +public site HTTPS used normal certificate verification. Shorty was not modified. +The running backend still embeds the older helper/template; final rebuild is +REQUIRED before restart/reboot/persistent upgrade acceptance can pass. + +The prepared unsigned catalog validates with zero metadata drift and trusted +registry hosts. Its only changed entries are NPM and Angor indexer1.0.2. It has +not been signed, installed or published. Yaya's current signed catalog retains +NPM's old pasta network/tunnel-only HTTP+TLS listeners; full NPM runtime/bridge +migration acceptance awaits the reviewed signed catalog. Do not mistake the +backend/UI deployment or ACME-only fix for completed catalog migration. + +### 2026-10-02: rebuilt candidate deployed; private catalog qualification prepared + +Release-profile candidate build completed successfully. SHA256: +af0648ad4ef8b6183d3c1ff485721fa40b12bb730c6e0322bc5f209ed06fce39. +Focused bootstrap tests:10 passed. Full isolated backend rerun:1651 passed, +zero failed, four explicit hardware/external ignores. Python guard/bridge28 +passed again. No new source changes occurred between these checks and deployment. + +Deployed this rebuilt backend on dev and yaya, with private previous-binary, +nginx and native-container baselines. Both manager health checks passed. A later +post-startup comparison confirmed Bitcoin/LND identities/start times unchanged. +The installed bridge helper now matches latest source bytes after restart. +Private UI200, marked-public HTTP/HTTPS404 and missing HTTPS challenge404 passed. +Dev HTTPS intentionally binds its LAN/WireGuard addresses, not127.0.0.1; an +initial loopback probe got connection refused, corrected to the actual listener. +This was a test-address error, not a product outage. Local self-signed HTTPS +checks skip certificate verification; public-site TLS checks use normal trust. +Full-machine reboot qualification is still pending. + +Prepared a fresh candidate catalog with only NPM and Angor indexer entries changed. +Metadata drift0; registry trust check passed. Unsigned SHA256: +5801309bf21d3f6fd03ce5702d68b383a518f734092bf265f5e0ad243095a25e. +NPM's new manifest is capability-gated by runtime-migration-backup-v1; older +nodes retain the original manifest. This candidate is for private qualification, +not fleet publication. An operator-only hidden-input signer validates the exact +catalog and binary hashes, checks the pinned release root and restores the +unsigned original on failure. Its noninteractive refusal was tested. Signature +is required before testing through the nodes' normal trusted-catalog path. +No catalog, app image, OTA or ISO was published. Framework remains deferred; +all other open requirements retain their previous status. + +### Signed catalog and private qualification selector + +The operator signed the qualification catalog. Cryptographic release-root +verification passed locally and on yaya; after removing signature envelope fields, +its contents exactly match the reviewed unsigned candidate. No publication. +The catalog is staged under /var/lib/archipelago/qualification on both test nodes, +with previous catalog/app metadata and container identities privately backed up. + +Normal mirror loading deliberately forces the public origin first, so simply +prepending a private mirror cannot reliably test an unpublished candidate. +Implemented ARCHY_APP_CATALOG_CANDIDATE as an explicit absolute-file selection: +requires an anchored release-root signature, validates before cache replacement, +retains exact signed bytes, does not alter mirrors/trust, and fails without +public fallback when the selected file is invalid. Added unsigned, tampered, +wrong-key, malformed, missing, oversized, relative-path, valid and idempotent +coverage. Full isolated backend suite:1653 passed,0 failed,4 explicit ignores. +The optimized selector build is still in progress; selection is not enabled yet. +See docs/candidate-catalog-qualification.md for activation and mandatory removal +once the tested public catalog is available. Do not leave test nodes pinned. + +Yaya NPM preflight:8088/8444 are free, active public host has no conflicting +host-nginx ownership, database tables and certificate-file hashes saved privately. +No Shorty mutation. Dev public Angor reference acceptance passed TLS/WSS, exact +funding commitment, official Explore and detail/statistics again; this still +checks only the known original project, not all35. Dev Bitcoin continues syncing +(reported sync_progress approximately0.307); full-chain acceptance remains open. +Current tested hardware product codes:dev20CLS7S900 and yaya20CLS6BH00, both Podman +5.4.2; kernels6.12.74+deb13+1-amd64 and6.12.107+deb13-amd64 respectively. Framework +remains deferred. No Docker or new ISO-boot acceptance is implied. + + +### Signed qualification deployment and legacy upstream compatibility + +The optimized candidate selector build completed, SHA256 +`9cc9271ee1b4f8f13d798193cef97c1e4304a89a240f11f851eb71568bd105e1`. +Both development acceptance nodes now run it with the exact root-verified private +catalog. The isolated backend suite passed 1,653 tests, zero failures, four +explicit ignores. This is unpublished qualification, not a release. + +Actual NPM migration exposed an additional regression that the LAN-upstream +fixture missed: a saved site uses pasta's former host gateway `169.254.1.2`. +Default slirp's gateway differs, so the request timed out from inside NPM even +though admin readiness passed. A disposable container verified the same saved +upstream with `slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24`. +A temporary qualification Quadlet drop-in now selects that network, using an +empty `Network=` reset before the replacement to avoid multiple network modes. +The existing site's trusted public HTTPS authentication response is restored. + +Post-repair checks passed: request from NPM's actual namespace; exact saved user, +host, certificate, ACL and settings rows; unchanged certificate bytes; private +migration backup and prior unit; unchanged unrelated container IDs/start times; +retained WireGuard tunnel ports; host bridge completion; private dashboard200, +marked public HTTP/HTTPS404; missing challenge404; exact challenge body from +inside NPM over local HTTP/HTTPS and public HTTP. Native Bitcoin/LND identities +and start times remain unchanged. + +**Release blocker:** integrate and test legacy gateway compatibility in all +supported runtime paths and signed manifest, including fresh/upgrade/restart +cases and LAN/host.containers.internal upstreams. The current signed candidate +alone is insufficient. Temporary user-unit drop-in +`nginx-proxy-manager.container.d/90-qualification-host-gateway.conf` must be +removed after the corrected managed configuration is verified. Do not remove +it before then or claim the migration passed without it. Candidate catalog +service selectors also require the cleanup described in +`docs/candidate-catalog-qualification.md` after final publication. + + +### Development Angor candidate update + +The supported `package.update` RPC selected the private signed catalog and +upgraded only `angor-indexer` to the locally built 1.0.2 image. The actual dev +endpoint rendered the Mempool explorer at widths 390 and 1440 with zero failed +JavaScript/CSS requests and one WebSocket connection each. All unrelated dev +containers retained their exact IDs and start times. This verifies the local +explorer presentation, not complete blockchain indexing or recovery of the 34 +missing original Angor project announcements. Bitcoin remains in IBD. + +The repaired NPM namespace also reached the saved gateway, +`host.containers.internal`, and the node LAN address with the expected site +authentication response. The durable compatibility blocker remains open. + + +## Live Lightning purchase: Framework to Shorty — 2026-10-02 + +Operator explicitly authorized a very small new test purchase, then performed +it from Framework. This is separate from recovering the Amish Paradise sale. +Fixture: `archy-lightning-delivery-test-20261002.txt`, price 1 sat, Lightning only. +Read-only checks confirmed one matching seller invoice, SETTLED for exactly +1 sat, and Framework payment SUCCEEDED for 1 sat with 1 sat routing fee +(1,000 msat). Total spent was 2 sats. The assistant sent no payment. + +Framework has exactly one durable purchased-content ownership entry for the +fixture, with backend `lightning`, paid_sats=1 and size_bytes=121. Its cached +file SHA256 equals the original seller fixture: +`d55f7a6acd77bdc3c35c65ecac6d1492096e99252d07d433e6286544540cde7e`. +**PASS: actual node-wallet payment, seller settlement, delivered bytes and +persisted buyer ownership/cache.** Framework runs the candidate backend +`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`; +Shorty runs `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`. +This also exercises the candidate buyer against the existing seller version. + +Live reopen without payment and restart acceptance are not established by +this check. Shorty had no matching durable entitlement JSON in the inspected +location; do not attribute the candidate seller persistence implementation to +this older seller binary. No node or wallet was restarted. The tiny fixture +remains available for a free cached reopen check; remove only its catalog +entry/source file afterwards, preserving buyer ownership and payment records. + + +### Operator-confirmed free reopen — 2026-10-02 + +After the verified one-sat purchase, the operator reopened the file on Framework +and confirmed it worked without another payment. **PASS: live paid delivery, +durable buyer ownership/cache, and free repeat access**, with independent +settlement/byte checks above and operator confirmation of the reopen UI. +This closes that specific live acceptance check; it does not establish an +untested restart, outage, or seller-upgrade scenario. + +The temporary seller catalog entry and source fixture were removed after +acceptance. Buyer purchased bytes/ownership and all payment records were preserved. diff --git a/docs/release-1.9.0-acceptance.md b/docs/release-1.9.0-acceptance.md new file mode 100644 index 00000000..8f883cfc --- /dev/null +++ b/docs/release-1.9.0-acceptance.md @@ -0,0 +1,519 @@ +# Archipelago 1.9.0-alpha release acceptance + +Status: **OPEN — unpublished.** Operator requires the `-alpha` suffix: final version +`1.9.0-alpha`, tag `v1.9.0-alpha`, and matching OTA/ISO artifact names. Earlier +unsuffixed candidate evidence below is historical, not a final artifact pass. +Operator selected the 1.9.0 series instead of the provisional +1.8.23-alpha. This is the current summary; retain the detailed history and all +requirements in [the regression ledger](post-1.8.22-regressions-20261001.md) and +[the earlier acceptance record](release-1.8.23-acceptance.md). No unexecuted test +is a pass. Provide the operator a node-specific action/expected-result checklist +whenever human acceptance is needed. + +## Current evidence + +- Latest alpha backend source: isolated suite 1,681 passed, zero failed, + four explicit ignores; separate container runtime suite 82 passed. Optimized + build including the Nostr security and File Browser changes is in progress. +- Frontend: full suite 1,211 passed across 148 files; production UI and AIUI + builds passed. Real dev desktop/mobile upload fault injection passes, including + interrupted JWT refresh and exact saved-file hashes. +- Currently deployed backend on dev/yaya SHA256 + `e218e40f5c16c3d0cc4dc06c0a378c14b56b9087ded5c515b8a48351ceea3bcf`. + It includes Nostr/File Browser fixes but predates the alpha version suffix. + Private rollback backups exist. +- Latest deployed UI index SHA256 on dev/yaya + `67de835a25db59a483314eff583b809c3468c8529080bfa74c9962e44a6f54f9`. + Both served byte checks pass; unrelated production containers stayed unchanged. +- NPM corrected gateway/client-IP integration: 23 Python checks and complete + disposable real-image integration passed. Catalog generator now requires both + migration-backup and legacy-gateway capabilities; its generator/drift selection + regression passes. Candidate metadata drift zero and registry trust passed. +- Cuprate/NetBird/BTCPay grouping previously passed actual yaya desktop/mobile, + hard reload and BTCPay category/icon checks. No product installs were performed. +- Real one-sat Lightning purchase, exact bytes, buyer ownership/cache and operator + free reopen passed. Original tester recovery accepted separately. +- Transparent transaction rail, compact origin-screen upload bar/cancellation and + cooperative-close controls have recorded desktop/mobile browser acceptance. +- Framework original LND startup incident is closed with operator acceptance. + +## Open release gates + +- [ ] **NPM:** corrected private signature, dev/yaya selection and yaya override + retirement now PASS (2026-10-05). Remaining: full boot/OTA/ISO and + staging-CA issuance/renewal and full legacy-backend migration/rollback + acceptance; retain the completed + fresh/nested disposable and actual yaya state-preservation checks. +- [ ] **Shorty NPM:** shop certificate12/Force SSL are operator accepted and + independently verified; qualify and apply the manual-route migration. Preserve live + management containment and current public app routing. +- [ ] **Security:** verify final deployed/booted artifacts against public raw IP, + unknown Host/SNI, forged forwarding headers, IPv4/IPv6, assets/RPC/WS; + preserve private access, ACME issuance/renewal and public app TLS/WSS. +- [ ] **Fees/Bump:** deployed dev/yaya Fast UI and real isolated funded regtest + (CPFP/RBF, fee history, restart, confirmation/reorg) pass. Authenticated + Framework read-only quote/status acceptance remains. Preserve approved green UI. + No production spending or channel closure is authorized by this checklist. +- [ ] **Paid files:** finish buyer restart/outage and updated-seller persistence + acceptance; preserve atomic ownership and safe retries without repayment. +- [ ] **Uploads:** prior physical companion flow is operator accepted. New + resumable-transfer requirement needs interrupted-network/background + recovery acceptance; viewport checks alone are not physical-phone proof. +- [ ] **File Browser credentials:** unique managed login, default-password + removal, account/file preservation and rollback pass real Podman fixtures + including actual Quadlet restart. Deploy/verify dev and yaya, rerun yaya + upload tests, qualify Framework's reported auth issue, and verify packaged + OTA/ISO startup. Docker behavior is not inferred from Podman fixtures. +- [ ] **Apps:** complete upgrade inventory matrix for installed/stopped/removed/ + restarting/legacy aliases; Immich/retired-app removal and unexpected-service + identification; Portainer/Gitea migration from actual request namespace. +- [x] **UI:** category-view clear-search control passes on served dev/yaya UI + at390/1440px: click and Escape clear the field, retain focus and stay inside + the existing field. `/tmp/archy-190-final-search-live.log`. Earlier grouping + and transaction-rail results are retained. +- [ ] **Angor:** dev full-chain acceptance after unpruned Bitcoin sync; retain + all-project discovery requirement and 34 unrecovered original announcements. + Publish tested explorer/API app update and optional relay in signed catalog. +- [ ] **Post-release demo deployment:** operator requests updating the existing + public software demo at https://demo.archipelago-foundation.org/ through its + established Portainer/Gitea workflow after release. Inspect the exact + stack/source, preserve rollback, verify served 1.9.0-alpha and demo flows. + This is not the Yaya v4v website or a Portainer version upgrade. +- [ ] **Final artifacts:** finish versioned build; exact candidate deployment; + OTA update/rollback and raw ISO boot/install; signature/checksum validation; + publish Git/ngit, app images/catalog and artifacts; verify public downloads + and fleet discovery; remove temporary catalog selectors after publication; + supply LAN SCP command for the raw ISO. + +## Retained regression scope + +Mempool version/update clearing/deduplication; Minibits and Cashu same-mint payment +handling; LND startup/Receive/unknown balances; Bitcoin warmup and IBD dashboards; +pruning and X250 kiosk picker; AIUI background; launch readiness/card geometry; +GitWorkshop; Gitea/Portainer; safe network diagnostics; operator uninstall/stop +choices; companion images and generated service configuration; radio payload and +UK MeshCore dev V3 acceptance; previously reviewed/merged PRs. Detailed original +requirements and evidence remain in the linked ledger, not silently dropped. + +## Explicit boundaries + +Framework V4 radio is now reported working by the operator (2026-10-05). +No reflash is requested; retain this as operator evidence, separate from automated +hardware coverage. Previously +accepted Primal comment and lost-response Cashu receipt follow-ups remain separate. +Only one Angor project has full public browser acceptance; 34 missing announcements +are not proven globally lost. Do not claim complete recovery from one fixture. + +### Further live evidence + +Framework's existing one-sat purchased-file ownership entry and exact 121-byte +cache remain present after the Bump management restart. Purchase timestamp +09:15:03 UTC precedes manager start 10:42:52 UTC on 2026-10-02. SHA256 remains +`d55f7a6acd77bdc3c35c65ecac6d1492096e99252d07d433e6286544540cde7e`. +This establishes buyer persisted bytes/ownership across that actual manager +restart. It does not establish a full-machine reboot or seller outage scenario. +No payment or restart was performed for this read-only check. + +Yaya post-deployment checks pass: native Bitcoin/LND unchanged, private UI200, +marked public HTTP/HTTPS404, missing HTTPS challenge404, exact challenge bytes +written inside NPM over local HTTP/HTTPS and public HTTP, existing public site +trusted HTTPS/authentication preserved. This is not yet the new signed-catalog +migration without the temporary network override. + +### Versioned build and final suites + +The optimized 1.9.0 backend build passed; SHA256 +`e1b94e6de9b5cc3dfcec16994bc3d0f710f3b7bcc6e5af045c6e53bb94b6abf0`. +The final frontend suite passed **1,187 tests in 146 files**, zero failed. +All 48 script unit tests passed, as did app build-context, manifest-shell, +ISO overlay, network-doctor, pruning and LND UI readiness checks. The release +harness now includes NPM bridge, guard, catalog capability and isolated actual +nginx security tests. All 120 public-network rejection cases passed again. + +Yaya category search clearing passes desktop/mobile: click, Escape, focus and +contained icon, unchanged 40/52px field heights. Portainer's actual namespace +still reads Git smart HTTP refs and Compose from the expected branch; native +services and the production site were not changed by those probes. + +Fresh Angor relay queries still recover only one of the 35 original signed +announcements. Eight relays returned results/EOSE; two archive endpoints were +unavailable. A release-scope decision was requested rather than silently waiving +this external-data requirement. The reference HTTP endpoint was readable through +Python, while the Node HTTP client received HTML; relay queries used the recorded +35 exact event IDs, and accepted only matching validly signed kind3030 events. + +A new isolated runtime harness executes the production backend against actual +Bitcoin/LND regtest processes, with private process/network/filesystem namespaces, +normal account setup/login and disposable wallets. Its CPFP, child RBF, recipient, +fee-budget, duplicate-submit and backend-restart checks passed. Confirmation and reorg recovery also passed after the fixture announced a +competing empty block. The earlier disconnect-only fixture failed to notify the +expected new chain state and is retained as a failed attempt. Full run evidence: +`/tmp/archy-fee-regtest-run3.log`. No production wallets or funds were used. + +### Current deployment and final history correction + +The versioned backend `e1b94e6de9b5cc3dfcec16994bc3d0f710f3b7bcc6e5af045c6e53bb94b6abf0` +and the production UI are deployed on dev and yaya. Manager health200, served +index byte match and unchanged unrelated container IDs/start times passed on +both. Private rollback directories are `support/190-versioned-20261002`. +Actual category search clearing passes desktop/mobile on both nodes. + +A final source audit found fee-only child history grouping was still absent. +The correction is now implemented with conservative receipt/ownership/input/ +fee-only/current-chain verification, replacement-aware totals, linked fee +history and current-child Bump targeting. Nine focused UI tests and the new +production dashboard build passed. This correction is NOT in the deployed +backend above. Its isolated backend suite and extended actual regtest acceptance +remain in progress. The concurrent optimized compile was deliberately stopped +to reduce build contention and must be restarted after isolated compilation. + +Corrected NPM candidate remains unsigned. The operator was given the exact +private signing command and asked for the affected physical companion route. +No publication or release-scope waiver is inferred from silence. + +### Payment audit follow-up + +Found a separate older Cashu repeat-download fallback that allowed a new spend +when an ownership record existed but its cached bytes were missing; an unreadable +index was also treated as empty. The payment guard now reads ownership strictly +and returns a recovery error before mint/spend in either case. Successful cache +hits retain the zero-payment response and same-seller filename alias handling. +The new regression exercises first purchase, exact/alias cache hits, different +seller, missing bytes and damaged index preservation. Final suite/rebuild are +running; no live wallet was modified. Previously documented lost-response ecash +receipt limitations remain separate from this correction. + +Framework read-only optional Files-copy verification could not proceed because +the SSH control connection expired and BatchMode login was rejected. Existing +buyer cache/restart evidence remains valid; no password or account was changed. + +Actual served Fast-send controls pass on dev/yaya at390/1440px: initial Fast, +explicit Standard selection and reopen reset to Fast. No spending RPC submitted. +Two earlier harness attempts had ambiguous Close/Send locators during modal +transitions; the corrected final run passes all four cases. This is send-form +acceptance, not a real cooperative-close transaction or omitted-fee wallet spend. + +Final isolated suite after fee-history and missing-cache payment corrections: +**1,668 passed, zero failed, four explicit hardware/external ignores**. The +optimized build and extended real-regtest run are chained in +`/tmp/archy-190-complete-validation.py`; log +`/tmp/archy-190-complete-validation.log`. They have not yet completed. +The packaged radio flasher self-test also passes (`archy-esptool4.8.1`, +ESP32-S3 stub ready); this does not change Framework radio deferral. + +## Signed qualification completed — 2026-10-05 + +Operator confirmed signing; exact private catalog verifies against the pinned +release root. Final optimized backend SHA256 +`cfddec834a53609f8bef924f3905da76df45f22426cac2628c4c2cb06bea5d09` +and final dashboard index SHA256 +`4b8f6ceb4ebe1e3b8ce1a0786f3e8a9d38e6d42ba174bf64bd54f4b23d7c13ff` +are now deployed on dev and yaya. Both health checks, served byte matches and +unrelated container identity/start-time checks passed. Root-only rollback +directories: `support/190-final-20261005-20261002` (literal generated name). +Both cached catalogs exactly match the new signed candidate. + +The optimized actual Bitcoin/LND regtest passed with the new history assertions: +CPFP, child replacement, unchanged recipient, bounded fee, duplicate submission, +one payment with replacement-aware fee history, backend restart, confirmation +and reorg. No production funds were spent. Log: `/tmp/archy-fee-regtest-run4.log`. + +Yaya selected the managed legacy-compatible gateway from the signed variant. +The temporary `90-qualification-host-gateway.conf` was backed up and removed +only after inspecting the generated managed network. NPM restarted successfully. +Complete selected DB tables and certificate bytes match the private baseline; +loopback and existing tunnel publications remain intact, admin API is healthy, +and an actual NPM-namespace upstream request returns the expected authenticated +site response. A private managed migration archive exists. + +A subsequent manager restart and120 seconds of repeated reconciliation retained +all container identities/start times and did not recreate the removed override. +Migration checks passed again. Logs: `/tmp/archy-190-npm-override-retirement.log` +and `/tmp/archy-190-npm-persistence.log`. This is not full-machine reboot evidence. + +Post-migration public integration passes: exact challenge bytes from NPM on +local HTTP/HTTPS and public HTTP, missing HTTPS challenge404, private UI200, +public-marked management HTTP/HTTPS404, public application trusted TLS and +authentication retained. Portainer's actual namespace reads Git refs and Compose +at verified tip `3ae171d6b0c728665a860520fe393c0abb772798`. Native Bitcoin/LND +unchanged. Deployed Fast-default/reopen/slower-select browser checks pass on +both nodes at390/1440px, without submitting transactions. + +Additional external IPv4 probes from Shorty passed24 raw-IP/unknown/forged-host +cases with forged forwarding headers and root/RPC/assets/WebSocket paths. +Important boundary: the public front gateway returns its static Default Site +for unknown HTTP roots, rejects assets/RPC/WS with400/404, and rejects unknown +TLS names during handshake. Those are not dashboard responses. The first +harness required404 everywhere and failed on that public Default Site; retained +logs record the corrected interpretation. These probes validate the deployed +public gateway path, not direct WAN access to the node nginx. External IPv6 +remains unverified; prior isolated IPv4/IPv6 guard tests remain separate. +No Shorty nginx/NPM configuration was changed. + +Remaining operator inputs: normal Shorty NPM shop SSL ownership correction +(existing admin login unavailable), affected physical companion upload route, +and the retained Angor34-announcement recovery/release-scope requirement. +OTA/catalog publication, final ISO build/boot and fleet discovery remain held. + +Read-only dev chain check2026-10-05: unpruned Bitcoin at830743/970017, verification progress0.63846, IBD true, warnings empty. Full-chain Angor acceptance remains pending sync; no service or wallet change made. + +## Operator checks accepted; upload UX amendment — 2026-10-05 + +Operator reports the requested human checks worked perfectly: Shorty shop SSL, +physical upload flow and Framework dashboard/purchased-file checks. This is +operator acceptance, not a claim of newly independent device testing. Read-only +Shorty verification confirms shop certificate_id12 and Force SSL enabled. +Remaining migration/artifact/security and Angor requirements still apply. + +Operator supersedes the globally persistent upload-bar requirement: keep the +bar only on the screen where the batch originated, continue transfers across +navigation, show explicit Complete on successful server save, and use a +completion notification elsewhere. Source now retains the originating route, +removes the global floating bar, keeps the original44px inline bar, and reports +success/error/cancellation distinctly.25 focused store/component/notification +tests pass. The subsequent full frontend suite passed1,193 tests; production +build and actual served desktop/mobile real-upload checks passed. Deployed to +dev/yaya with index SHA256 +`86bb728017b118d8e98f032419e7fbfd6ecd78b7e464c982a2075cc38c582814`; +no apps or backend services restarted. Retain this as the preceding UI evidence, +not evidence for the later resumable-upload implementation. No new payment was requested or performed. + +### Resumable upload addition — 2026-10-05 + +Operator requests recovery after a background pause or connection loss. The +installed File Browser identifies as2.63.23/e8a388f8 and supports TUS. Cloud now +has a candidate chunked upload implementation: random same-folder staging path, +server-offset reconciliation, transient retry/online/visibility recovery, +cancellation, final SHA256 verification and rename. Lost final chunk/rename +responses are reconciled without restarting or accepting a same-size old file. +The original-screen-only44px bar, Complete label and off-screen notification +remain. Fifteen focused protocol tests pass; full build/deployed fault injection +are in progress. This is not yet live acceptance. + +Recovery requires the selected File to remain available in the running page. +An OS-killed app or expired server upload session may require reselecting the +file. Do not promise uninterrupted background execution or restart persistence. +This gate is additional to the already accepted physical upload flow. + +## ngit PR integration — 2026-10-05 + +Both requested proposals are merged and pushed to Gitea and ngit main at +`2c1bcacf`; ngit independently reports both as `applied`. + +- `494d2483`: opt-in NODE_IDENTITY_PUBKEYS for app owner allow-lists. Review + corrected ECMAScript/Rust whitespace differences and added strict public-key + validation. Appliance identity excluded; no private keys or signing capability + given to apps. Existing manifests and the native signing flow are unchanged. + Documentation explicitly describes linking all offered user identities. +- `c18ebd7f`: nostr0.44.7 and nostr-relay-pool0.44.3. The standalone relay pool's + maintenance advisory remains; SDK0.45 migration is a separate follow-up. +- Combined isolated backend suite:1,678 passed, zero failed,4 explicit ignores. + Real loopback hostile-relay test rejects altered content, author and signature + reusing a known DB event ID while accepting a valid event. NIP04/NIP44 normal + encryption and hostile/oversized payload tests pass. The initial relay harness + returned before connection establishment; corrected to wait for an actual + connection before fetch, and the complete rerun passes. +- Evidence: /tmp/archy-190-ngit-complete-tests.log, origin/ngit push logs and + /tmp/archy-190-ngit-postmerge.json. This is source publication, not OTA/ISO or + catalog publication. Later File Browser credential changes need a new suite. + +## File Browser secure automatic login — NEW REQUIRED GATE + +Operator requests unique per-node credentials, working Cloud from first launch, +no admin/admin and fleet-wide testing. Framework's reported authentication issue +recovered, which is not proof that this gate is fixed. Yaya rejects the saved +password with403 despite healthy File Browser2.63.23. Never count that as a +passed upload test. + +Confirmed source issues: first-boot paths still try noauth/admin defaults; the +post-install hook assumes admin/admin and uses an incompatible password-change +request shape; the generated ISO path updates a running DB and uses a different +DB filename; Cloud hardcodes admin and invents admin/admin on missing secrets. + +Candidate scripts/filebrowser-credentials.py now provisions a random username +and256-bit password offline with the pinned app image, backs up the selected +DB/config, preserves custom accounts, tests automatic login plus folder access +in a network-isolated container, rejects unauthenticated access, rotates only a +proven admin/admin login, and atomically publishes a0600 credential record. +Fresh real-image acceptance passes. Legacy/default/custom/restart/rollback, +first-boot/Quadlet/runtime wiring, live yaya/dev/Framework qualification and final +artifacts remain OPEN. No live File Browser account or DB has been modified. + +Upload resume: source/build/full frontend1,208 tests passed; subsequent48 focused +protocol/client tests passed after filename escaping correction. UI deployed on +dev/yaya index SHA256 +`31ac7bcc704c18f88a8b9800fb46bc7941651983e1a99b97d036a8d9e95a58b5`. +Actual dev1440/390px real-server fault injection passed partial offset123456, +offline reconnect, lost final PATCH and rename replies, exactSHA256, encoded +filenames, original-screen-only44px bar, notification, cancel and empty files. +Yaya is blocked at the credential gate above. Physical suspended/killed-app +acceptance is not inferred from these viewport tests. + +## 2026-10-05 resumed release qualification + +- Latest full frontend: 1,210 tests passed across 148 files. Production Cloud UI + and AIUI builds passed. Dev and yaya serve index SHA256 + `3e10a25db75e4712310eb34c98bf7595ad5db3a444f9126a73715b1d40493a33`; + UI archive SHA256 `586d1864c5c4027086194f6b5951a9b770c4e7ce9ba9ec3128e2ac0c1bde55e7`. + Private UI backups: `/var/lib/archipelago/support/cloud-auth-20261005`. +- Real dev browser upload tests pass at 1440/390px, including interrupted JWT + refresh, partial write, offline recovery, lost final PATCH/rename responses, + exact SHA256, encoded filenames, cancellation, empty file, origin-only 44px + bar and completion notification. Initial run overlapped UI deployment and + failed navigation/bar timing; kept as failed evidence. Clean rerun explicitly + verifies successful navigation and passes both viewports. Physical OS suspension + and yaya authentication/upload acceptance remain separate gates. +- Real File Browser image matrix passed fresh, legacy-default, legacy-custom, + legacy-noauth and forced-failure exact DB rollback. Existing file bytes and + user IDs/permissions preserved; custom credentials preserved; admin/admin and + anonymous access rejected. Actual disposable Quadlet pre-start and restart + also pass, with stable managed credentials. Four Python unit tests pass. +- File Browser startup integration now covers the direct runtime, Quadlet, + first boot and ISO script. Binary bootstrap installs its matching helper before + reconciliation. Fixed bundled first-boot missing NET_BIND_SERVICE and duplicate + creation attempt for a stopped File Browser. Live credential migration is still + pending the optimized backend build; no production DB/account modified yet. +- Final combined isolated backend suite: 1,681 passed, zero failed, four ignored. + An earlier run failed the Nostr relay fixture after a normal ping closed its + text-only receive loop. Fixed the fixture to answer pings; the complete rerun + passes. No failed run is counted as acceptance. +- NPM: 23 Python tests pass, including exact emergency BTCPay route recognition, + operator edit preservation, missing certificate/alias refusal and transactional + rollback. Existing emergency Angor routes now also require complete TLS + replacements before retirement. Actual disposable flat-layout NPM integration + passed namespace reachability, legacy gateway, ACME exact bytes, forced HTTPS, + WSS, certificate replacement, password/network ACLs and forged-header rejection, + restart, disable/delete, and forced bind-failure restoration. This is not a + staging-CA issuance/renewal or ISO/reboot pass. +- Shorty read-only inspection confirms shop certificate12 and Force SSL with both + hostname aliases; old manual shop route still uses certificate10. No live + Shorty routing change in this qualification. Migration remains pending. +- Evidence logs: `/tmp/archy-190-final-combined-backend.log`, + `/tmp/archy-190-cloud-auth-ui-dev-live-2.log`, + `/tmp/archy-190-filebrowser-final-integration.log`, + `/tmp/archy-190-filebrowser-quadlet.log`, + `/tmp/archy-190-npm-final-integration.log`. +- OTA/catalog/raw ISO publication remains held. Framework radio deferred; + Angor 34 unrecovered original announcements and dev full-chain acceptance + remain open. README alpha/funds notice is separately published to both remotes. + +Additional qualification: real nested-layout NPM integration passed the same +namespace/ACME/TLS/WSS/access-control/restart/rollback matrix as flat layout +(`/tmp/archy-190-npm-final-nested-integration.log`). Container crate isolated +suite: 82 passed, zero failed. Corrected Nostr hostile-relay test passed a separate +isolated repeat (`/tmp/archy-190-nostr-relay-repeat.log`). Dev Bitcoin read-only +status: height832232 of970036, verification0.641006, IBDtrue, prunedfalse. Full-chain +Angor acceptance therefore remains blocked on synchronization, not passed. + +## Live File Browser ownership regression — publication hold + +2026-10-05 dev candidate backend SHA256 +`3e01da72fcea0a61852f3d9038e67630e328c65d6433da749671d60b91c37ffa` +built successfully, then failed live credential migration before DB mutation. +The helper could not create its private backup under the legacy data-directory +owner (host UID100000). The original real-image fixtures aligned data ownership +to the image UID and therefore missed the shipped manifest's different mapping. +The managed File Browser has DAC_OVERRIDE for that layout; the helper did not. + +Restored prior backend SHA256 +`cfddec834a53609f8bef924f3905da76df45f22426cac2628c4c2cb06bea5d09` +and original File Browser Quadlet with the staged rollback script. Both services +are active. Yaya backend was not changed. No candidate credential record was +published; failed setup stopped at backup-directory creation before DB changes. + +Source helper now includes the managed server's DAC_OVERRIDE storage capability; +new real-image legacy-owner and actual-Quadlet fixtures reproduce that mapping. +Rollback fixture now forces an account-policy failure after noauth migration so +it still verifies restoration after a real DB mutation. These revised tests and +combined backend validation are in progress. A corrected embedded-helper build +and new live qualification remain required. Do not reuse the failed binary as +final release or mark the credential gate passed from earlier fixture results. + +Release-note drift corrected to1.9.0/current upload behavior and File Browser/ +Nostr additions. The checker now rejects stale descriptions/dates for an existing +version; its regression passes. Latest notes UI built and deployed dev/yaya index +SHA256 `97aab07e67eccc1bb3d215534b537b83e1372bf5c36b505b6127a24a2b629e23`. +Phone background/reconnect acceptance question is pending, not passed. + +## Corrected credential qualification and mirror policy — 2026-10-05 + +The DAC_OVERRIDE correction passed all six real-image cases, including legacy +manifest ownership and restoration after an actual DB mutation. Actual disposable +Quadlet first start/restart passed with legacy ownership. Corrected helper SHA256 +`e9e2fd94534130f10f19f81ebe0d4e382dca4338118393c7d1e30535a8478eb5` +also migrated the dev node's actual File Browser storage successfully: managed +login/folder200, private credential record, unchanged unrelated containers, and +manager/File Browser restored active. The old backend remains deployed pending +the corrected optimized build. Yaya credential/backend acceptance remains open. + +Evidence: `/tmp/archy-190-filebrowser-ownership-integration.log`, +`/tmp/archy-190-filebrowser-ownership-quadlet.log`, +`/tmp/archy-190-filebrowser-ownership-live-dev.log`, +`/tmp/archy-190-filebrowser-ownership-dev-cloud.log`. +Updated isolated backend suite: 1,681 passed, zero failed, four ignored +(`/tmp/archy-190-filebrowser-ownership-backend.log`). +Browser protocol recovery also passes explicit CDP frozen-page/offline/reconnect +at both widths (`/tmp/archy-190-cloud-frozen-dev.log`); physical phone acceptance +is still pending and is not inferred from browser automation. + +Operator selected ngit as the canonical contribution/review platform; Gitea +mirrors accepted main and release tag objects without requiring duplicate PRs. +Rule, contributor docs and read-only parity gate are committed as `138a541d`, +pushed to both mirrors and main/local parity verified. Disposable bare-repository +regression covers missing refs, partial pushes, divergence, annotation drift, +unpublished local commits, intentionally separate branches and inaccessible +remotes. Final release gate must additionally check the actual release tag. + +## Alpha candidate: live Cloud and ACME qualification — 2026-10-05 + +Operator requires final version **1.9.0-alpha** and tag **v1.9.0-alpha**. Cargo, +frontend package/lock, changelog and What's New now agree; the unused unsuffixed +What's New block was removed. The optimized alpha build is in progress. Current +backend qualification SHA256 `e218e40f5c16c3d0cc4dc06c0a378c14b56b9087ded5c515b8a48351ceea3bcf` +is deployed on dev and yaya but predates this suffix change; it is not the final +artifact. Both returned backend health200 and managed Cloud login/folder200 with +unrelated container IDs/start times unchanged. + +A real upload rerun initially failed after concurrent token refresh. A new unit +regression reproduced the race: mutable shared failure state let another login +turn a network interruption into a credential rejection. Authentication now +shares an in-flight request and returns its own retryability result. Regression +failed before and passes after. Full frontend: **1,211 passed / 148 files**. +Full alpha backend isolated suite: **1,681 passed, zero failed, four ignored**. +Deployed alpha UI index SHA256 on dev/yaya: +`67de835a25db59a483314eff583b809c3468c8529080bfa74c9962e44a6f54f9`. +Both real browser upload suites pass 390/1440px including partial writes, frozen +page/offline return, interrupted refresh, lost final replies, exact saved hash, +encoded filenames, origin-only bar, completion notification and cancellation. + +Framework access was restored with the supplied updated SSH credential. Its +LND reports chain/graph sync; balance and channel queries work. Confirmed the +legacy File Browser still accepted admin/admin, then applied the exact qualified +helper with a private backup and bounded File Browser/manager stop-start. Both +managed and compatibility logins/folder reads200; admin/admin403; credential mode +0600; all other container IDs/start times unchanged. Prior backend retained until +final alpha deployment. Dashboard RPC session needs second-factor login; no +wallet funds were spent. Operator now reports Framework radio working; no reflash. + +Local Pebble ACME **fresh and legacy nested layouts passed** actual pre-host +issuance, HTTP challenges, forced-HTTPS renewal, new certificate served, unknown +management404, trusted WSS, access controls, restart and forced-bind rollback. +Fixture fixes: modern NPM meta schema; explicit slirp loopback CA route; disable +random test-CA nonce rejection for deterministic route/renewal coverage. This is +an isolated test CA, not a public Let's Encrypt staging/ISO/reboot pass. All test +containers were cleaned up. Source NPM regression remains23/23. + +Evidence: `/tmp/archy-190-alpha-backend-tests.log`, +`/tmp/archy-190-alpha-frontend-tests.log`, +`/tmp/archy-190-cloud-concurrent-login-before.log`, +`/tmp/archy-190-cloud-concurrent-login-after.log`, +`/tmp/archy-190-alpha-cloud-dev.log`, `/tmp/archy-190-alpha-cloud-yaya.log`, +`/tmp/archy-190-framework-secure-cloud.log`, +`/tmp/archy-190-framework-cloud-compatibility.log`, +`/tmp/archy-190-npm-acme-flat-6.log`, `/tmp/archy-190-npm-acme-nested.log`. + +Public demo target clarified: https://demo.archipelago-foundation.org/, currently +reported1.8.8. Existing Docker Compose demo deployment located read-only; do not +confuse it with Yaya's v4v stack. Update after release, preserving rollback and +qualifying mock backend compatibility with new Cloud uploads. No demo deployed yet. +No OTA/catalog/ISO has been published. diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index 5e273033..5d557392 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -1343,6 +1343,15 @@ else echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded" fi +# Mandatory offline flasher: cached rootfs images may lack system esptool. +ESPTOOL_BUNDLE="${ARCHY_ESPTOOL:-$SCRIPT_DIR/../../reticulum-daemon/dist/archy-esptool}" +if [ ! -x "$ESPTOOL_BUNDLE" ]; then + echo "ERROR: packaged archy-esptool missing; build reticulum-daemon/build-esptool.sh" >&2 + exit 1 +fi +"$ESPTOOL_BUNDLE" --archy-self-test || exit 1 +install -m 755 "$ESPTOOL_BUNDLE" "$ARCH_DIR/bin/archy-esptool" + if [ "$BACKEND_CAPTURED" = "0" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then echo " ⚠️ Could not capture from live server, building from source..." @@ -2449,42 +2458,24 @@ runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && syst # Ensure podman socket is active for archipelago user runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && systemctl --user enable --now podman.socket' 2>>"$LOG" || true -# Create FileBrowser container as archipelago user (rootless podman) -# Generate random FileBrowser password and store for auto-login -FB_PASS_DIR="/var/lib/archipelago/secrets/filebrowser" -mkdir -p "$FB_PASS_DIR" -if [ ! -f "$FB_PASS_DIR/password" ]; then - head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24 > "$FB_PASS_DIR/password" - chmod 600 "$FB_PASS_DIR/password" - chown 1000:1000 "$FB_PASS_DIR/password" -fi - -if ! runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman ps -a --format "{{.Names}}"' 2>/dev/null | grep -q filebrowser; then - echo "[$(date)] Creating FileBrowser container ($FILEBROWSER_IMAGE)..." >> "$LOG" - runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman run -d --name filebrowser --restart unless-stopped \ - --cap-drop=ALL \ - --cap-add=DAC_OVERRIDE \ - --cap-add=NET_BIND_SERVICE \ +# Provision the same unique verified Cloud login used by app installation/OTA. +if ! runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman container exists filebrowser; then + install -d -o 100000 -g 100000 /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data + install -d -o archipelago -g archipelago -m 700 /var/lib/archipelago/secrets/filebrowser + runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 \ + python3 /opt/archipelago/scripts/filebrowser-credentials.py --image "$FILEBROWSER_IMAGE" >>"$LOG" 2>&1 || exit 1 + runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman run -d \ + --name filebrowser --restart unless-stopped \ + --cap-drop=ALL --cap-add=DAC_OVERRIDE --cap-add=NET_BIND_SERVICE \ --security-opt=no-new-privileges:true \ - --read-only \ --tmpfs=/tmp:rw,noexec,nosuid,size=64m \ - --health-cmd='curl -sf http://localhost:80/ || exit 1' \ + --health-cmd='wget -q --spider http://localhost:80/health || exit 1' \ --health-interval=30s --health-timeout=5s --health-retries=3 \ - --memory=256m \ - -p 8083:80 \ + --memory=256m -p 127.0.0.1:8083:80 \ -v /var/lib/archipelago/filebrowser:/srv \ -v /var/lib/archipelago/filebrowser-data:/data \ -v /var/lib/archipelago/data/cloud:/srv/cloud \ - $FILEBROWSER_IMAGE \ - --database=/data/database.db --root=/srv --address=0.0.0.0 --port=80" 2>>"$LOG" && \ - echo "[$(date)] FileBrowser created successfully" >> "$LOG" || \ - echo "[$(date)] WARNING: FileBrowser creation failed" >> "$LOG" - # Set FileBrowser password to match the stored random password - sleep 5 - FB_PASS=$(cat "$FB_PASS_DIR/password" 2>/dev/null || echo "admin") - runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman exec filebrowser filebrowser users update admin --password '$FB_PASS' --database /data/database.db" 2>>"$LOG" && \ - echo "[$(date)] FileBrowser admin password set" >> "$LOG" || \ - echo "[$(date)] WARNING: Could not set FileBrowser password" >> "$LOG" + "$FILEBROWSER_IMAGE" --config /data/.filebrowser.json >>"$LOG" 2>&1 || exit 1 fi echo "[$(date)] Minimal first-boot complete" >> "$LOG" FBUNBUNDLED @@ -2611,6 +2602,12 @@ fi cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/" cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/" cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/" +for npm_file in dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh filebrowser-credentials.py; do + cp "$SCRIPT_DIR/../../scripts/$npm_file" "$ARCH_DIR/scripts/" +done +for npm_unit in archipelago-npm-bridge.service archipelago-npm-bridge.timer; do + cp "$SCRIPT_DIR/../configs/$npm_unit" "$ARCH_DIR/scripts/" +done # Build-source apps need their complete contexts even on unbundled ISOs. # Keep this identical to the OTA runtime payload; a per-app allowlist silently @@ -3142,6 +3139,10 @@ if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then chmod +x /mnt/target/usr/local/bin/* 2>/dev/null || true fi +# Required even when the cached rootfs never had esptool installed. +install -m 755 "$BOOT_MEDIA/archipelago/bin/archy-esptool" /mnt/target/usr/local/bin/archy-esptool || exit 1 +chroot /mnt/target /usr/local/bin/archy-esptool --archy-self-test || exit 1 + if [ -d "$BOOT_MEDIA/archipelago/web-ui" ]; then cp -r "$BOOT_MEDIA/archipelago/web-ui" /mnt/target/opt/archipelago/ fi @@ -3245,6 +3246,13 @@ done for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1 done +for npm_file in dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh filebrowser-credentials.py; do + install -m 755 "$BOOT_MEDIA/archipelago/scripts/$npm_file" "/mnt/target/opt/archipelago/scripts/$npm_file" || exit 1 +done +for npm_unit in archipelago-npm-bridge.service archipelago-npm-bridge.timer; do + install -m 644 "$BOOT_MEDIA/archipelago/scripts/$npm_unit" "/mnt/target/etc/systemd/system/$npm_unit" || exit 1 +done +systemctl --root=/mnt/target enable archipelago-npm-bridge.timer || exit 1 # END DOCTOR OVERLAY # Copy self-update script diff --git a/image-recipe/_archived/test-iso-qemu.sh b/image-recipe/_archived/test-iso-qemu.sh index ec2d6f8a..7d9fea69 100755 --- a/image-recipe/_archived/test-iso-qemu.sh +++ b/image-recipe/_archived/test-iso-qemu.sh @@ -15,6 +15,8 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" QEMU_TMPDIR="${TMPDIR:-/tmp}" +SSH_FORWARD_PORT="${QEMU_SSH_PORT:-2222}" +HTTP_FORWARD_PORT="${QEMU_HTTP_PORT:-8100}" SERIAL_LOG="$QEMU_TMPDIR/archipelago-qemu-serial.log" FORCE_BIOS=false NOGRAPHIC=false @@ -67,6 +69,10 @@ echo " CPU: 2 cores" echo " Serial: $SERIAL_LOG" echo "" +# Never accept boot markers left by an earlier VM. +mkdir -p "$QEMU_TMPDIR" +: > "$SERIAL_LOG" + # Create test disk if it doesn't exist DISK="$QEMU_TMPDIR/archipelago-test-disk.qcow2" if [ ! -f "$DISK" ]; then @@ -81,8 +87,9 @@ QEMU_ARGS=( -boot d -cdrom "$ISO" -drive if=virtio,format=qcow2,file="$DISK" - -net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:80 + -net nic,model=virtio -net "user,hostfwd=tcp:127.0.0.1:${SSH_FORWARD_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_FORWARD_PORT}-:80" -serial file:"$SERIAL_LOG" + -qmp "unix:$QEMU_TMPDIR/archipelago-qmp.sock,server=on,wait=off" ) # Display mode @@ -99,28 +106,37 @@ echo "" # Detect UEFI firmware OVMF="" +OVMF_VARS="" if [ "$FORCE_BIOS" = false ]; then if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd" elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then OVMF="/usr/share/OVMF/OVMF_CODE.fd" + OVMF_VARS="/usr/share/OVMF/OVMF_VARS.fd" + elif [ -f "/usr/share/OVMF/OVMF_CODE_4M.fd" ]; then + OVMF="/usr/share/OVMF/OVMF_CODE_4M.fd" + OVMF_VARS="/usr/share/OVMF/OVMF_VARS_4M.fd" fi fi -run_qemu() { - if [ -n "$OVMF" ]; then - echo " Boot: UEFI ($OVMF)" - qemu-system-x86_64 \ - -machine q35 \ - -drive if=pflash,format=raw,readonly=on,file="$OVMF" \ - "${QEMU_ARGS[@]}" - else - echo " Boot: Legacy BIOS" - qemu-system-x86_64 \ - -machine pc \ - "${QEMU_ARGS[@]}" +QEMU_COMMAND=(qemu-system-x86_64) +if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then + QEMU_COMMAND+=(-enable-kvm) +fi +if [ -n "$OVMF" ]; then + echo " Boot: UEFI ($OVMF)" + QEMU_COMMAND+=(-machine q35 -drive "if=pflash,format=raw,readonly=on,file=$OVMF") + if [ -f "$OVMF_VARS" ]; then + if [ ! -f "$QEMU_TMPDIR/archipelago-uefi-vars.fd" ]; then + cp "$OVMF_VARS" "$QEMU_TMPDIR/archipelago-uefi-vars.fd" || exit 1 + fi + QEMU_COMMAND+=(-drive "if=pflash,format=raw,file=$QEMU_TMPDIR/archipelago-uefi-vars.fd") fi -} +else + echo " Boot: Legacy BIOS" + QEMU_COMMAND+=(-machine pc) +fi +QEMU_COMMAND+=("${QEMU_ARGS[@]}") # Wrap the QEMU invocation in `timeout` when a CI caller passed one so # the script always returns instead of hanging on a VM that never exits @@ -129,14 +145,16 @@ run_qemu() { # the serial log shows a kernel reaching userspace — we inspect that # after the QEMU process ends. if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then - timeout --foreground --preserve-status "${TIMEOUT}s" bash -c "$(declare -f run_qemu); run_qemu" + # A new bash -c loses the unexportable argument array and firmware path. + # Invoke the complete command directly and keep timeout's distinct status. + timeout --foreground --kill-after=10 "${TIMEOUT}s" "${QEMU_COMMAND[@]}" rc=$? - if [ $rc -eq 124 ] || [ $rc -eq 137 ]; then + if [ $rc -eq 124 ]; then echo "(QEMU terminated after ${TIMEOUT}s boot-test window)" rc=0 fi else - run_qemu + "${QEMU_COMMAND[@]}" rc=$? fi @@ -150,12 +168,15 @@ tail -20 "$SERIAL_LOG" 2>/dev/null # by live-boot/systemd early in the sequence. If the marker never # appeared, surface the real failure; otherwise treat "timeout reached # with a live kernel" as a pass. +if [ "${rc:-0}" -ne 0 ]; then + exit "$rc" +fi if [ "$TIMEOUT" -gt 0 ] 2>/dev/null && [ -f "$SERIAL_LOG" ]; then - if grep -qE "Welcome to Debian|Reached target|systemd\[1\]:" "$SERIAL_LOG"; then - echo " Boot sanity: OK (systemd reached in serial log)" + if grep -qE 'Welcome to Debian|Reached target|systemd\[1\]:|Debian GNU/Linux [0-9]+ archipelago-installer ttyS0' "$SERIAL_LOG"; then + echo " Boot sanity: OK (userspace reached in serial log; installation not yet tested)" exit 0 fi - echo " Boot sanity: FAIL — no systemd markers in serial log within ${TIMEOUT}s" + echo " Boot sanity: FAIL — no userspace markers in serial log within ${TIMEOUT}s" exit 1 fi exit "${rc:-0}" diff --git a/image-recipe/configs/archipelago-npm-bridge.service b/image-recipe/configs/archipelago-npm-bridge.service new file mode 100644 index 00000000..0d576852 --- /dev/null +++ b/image-recipe/configs/archipelago-npm-bridge.service @@ -0,0 +1,15 @@ +[Unit] +Description=Synchronize NPM public domains and certificate renewal +After=nginx.service archipelago.service +ConditionPathExists=/etc/nginx/sites-available/archipelago + +[Service] +Type=oneshot +User=root +ExecStartPre=/usr/bin/python3 /opt/archipelago/scripts/dashboard-public-guard.py +ExecStart=/usr/bin/python3 /opt/archipelago/scripts/npm-public-bridge.py +TimeoutStartSec=120 +UMask=0077 +Nice=10 +StandardOutput=journal +StandardError=journal diff --git a/image-recipe/configs/archipelago-npm-bridge.timer b/image-recipe/configs/archipelago-npm-bridge.timer new file mode 100644 index 00000000..d0652c24 --- /dev/null +++ b/image-recipe/configs/archipelago-npm-bridge.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Watch NPM domain configuration and renewed certificates + +[Timer] +OnBootSec=30s +OnUnitInactiveSec=15s +AccuracySec=1s +Unit=archipelago-npm-bridge.service + +[Install] +WantedBy=timers.target diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 7fb83ff4..8aa671ab 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -1,3 +1,42 @@ +# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD +# Use the original socket peer, before any real_ip / forwarded-header rewrite. +geo $realip_remote_addr $archy_management_private_source { + default 0; + 127.0.0.0/8 1; + 169.254.0.0/16 1; + 10.0.0.0/8 1; + 172.16.0.0/12 1; + 192.168.0.0/16 1; + 100.64.0.0/10 1; + ::1/128 1; + fc00::/7 1; + fe80::/10 1; +} +# A configured trusted proxy may have rewritten remote_addr. Require both +# the original peer and the validated effective client to be private. +geo $remote_addr $archy_management_private_client { + default 0; + 127.0.0.0/8 1; + 169.254.0.0/16 1; + 10.0.0.0/8 1; + 172.16.0.0/12 1; + 192.168.0.0/16 1; + 100.64.0.0/10 1; + ::1/128 1; + fc00::/7 1; + fe80::/10 1; +} +map $http_x_archipelago_public_ingress $archy_management_public_ingress { + default 1; + '' 0; +} +map "$archy_management_private_source:$archy_management_private_client:$archy_management_public_ingress:$uri" $archy_management_denied { + default 1; + ~^1:1:0: 0; + "~^[01]:[01]:[01]:/\.well-known/acme-challenge/[A-Za-z0-9_-]+$" 0; +} +# END ARCHIPELAGO MANAGEMENT SOURCE GUARD + # Rate limit zones limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s; limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s; @@ -8,6 +47,8 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off; resolver_timeout 5s; server { + if ($archy_management_denied) { return 404; } + listen 80 default_server; # IPv6 listener is REQUIRED: companion phones reach this node over the # FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that @@ -48,7 +89,7 @@ server { # Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback. location ^~ /.well-known/acme-challenge/ { default_type text/plain; - root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge; + root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge; try_files $uri =404; } @@ -1021,6 +1062,8 @@ server { # HTTPS - required for PWA install (Add to Home Screen) from dev servers server { + if ($archy_management_denied) { return 404; } + listen 443 ssl default_server; listen [::]:443 ssl default_server; server_name _; @@ -1035,6 +1078,12 @@ server { include snippets/archipelago-pwa.conf; # Same CA download over HTTPS — see the note in the HTTP block above. + location ^~ /.well-known/acme-challenge/ { + default_type text/plain; + root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge; + try_files $uri =404; + } + location = /ca.crt { alias /etc/archipelago/ssl/ca-download.crt; default_type application/x-x509-ca-cert; diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 79d8ffac..27ada981 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.8.22-alpha", + "version": "1.9.0-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.8.22-alpha", + "version": "1.9.0-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index a484ccac..4d5d86ba 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.8.22-alpha", + "version": "1.9.0-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", @@ -10,7 +10,7 @@ "test:watch": "vitest", "test:mock-parity": "node scripts/mock-rpc-parity.mjs", "dev": "vite", - "dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"", + "dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI \u2014 chat will show placeholder'\"", "dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"", "dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite", "backend:mock": "node mock-backend.js", diff --git a/neode-ui/previews/fee-bump/Preview.vue b/neode-ui/previews/fee-bump/Preview.vue new file mode 100644 index 00000000..55d80dee --- /dev/null +++ b/neode-ui/previews/fee-bump/Preview.vue @@ -0,0 +1,23 @@ + + diff --git a/neode-ui/previews/fee-bump/explorer.ts b/neode-ui/previews/fee-bump/explorer.ts new file mode 100644 index 00000000..9fd4a679 --- /dev/null +++ b/neode-ui/previews/fee-bump/explorer.ts @@ -0,0 +1 @@ +export function useTxExplorer() { return { openTx() {} } } diff --git a/neode-ui/previews/fee-bump/index.html b/neode-ui/previews/fee-bump/index.html new file mode 100644 index 00000000..cf953bff --- /dev/null +++ b/neode-ui/previews/fee-bump/index.html @@ -0,0 +1 @@ +Archy · Bump preview
diff --git a/neode-ui/previews/fee-bump/main.ts b/neode-ui/previews/fee-bump/main.ts new file mode 100644 index 00000000..69890d79 --- /dev/null +++ b/neode-ui/previews/fee-bump/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import { createI18n } from 'vue-i18n' +import { createRouter, createWebHashHistory } from 'vue-router' +import '../../src/style.css' +import Preview from './Preview.vue' +const router = createRouter({ history: createWebHashHistory(), routes: [{path: '/:pathMatch(.*)*', component: {template: '
'}}] }) +createApp(Preview).use(router).use(createI18n({ legacy: false, locale: 'en', messages: {en: {common: {done: 'Done', copy: 'Copy'}, transactions: {title: 'Transactions', unconfirmed: 'Pending', minutesAgo: '{count}m ago', confirmations: '{count} confirmations'}}} })).mount('#app') diff --git a/neode-ui/previews/fee-bump/rpc.ts b/neode-ui/previews/fee-bump/rpc.ts new file mode 100644 index 00000000..fddfbd31 --- /dev/null +++ b/neode-ui/previews/fee-bump/rpc.ts @@ -0,0 +1,19 @@ +// Standalone preview only. No network calls and no wallet access. +export let method = 'cpfp' +let submitted = false +let sweepFee = 618 +export function choose(value: string) { method = value; submitted = false } +export const rpcClient = { async call(request: { method: string; params?: Record }) { + if (request.method === 'lnd.bump-status') return submitted + ? { status: 'mempool', message: 'Preview: fee bump accepted. No real transaction was sent.', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: sweepFee, quote: { method } } + : { status: 'none' } + if (request.method === 'lnd.bump-submit') { submitted = true; return { status: 'registered', message: 'Preview: bump registered. No real transaction was sent.' } } + if (request.method !== 'lnd.bump-quote') throw new Error('Wallet access is disabled in this preview') + const rate = Number(request.params?.sat_per_vbyte || 3) + const budget = Math.max(rate * 254 - 144, method === 'rbf' ? 763 : 112) + sweepFee = budget + return { quote_id: 'preview', txid: request.params?.txid, expires_at: Math.floor(Date.now()/1000)+60, + method, recipient_sats: 161650, current_fee_sats: method === 'rbf' ? 794 : 144, + additional_fee_sats: budget - (method === 'rbf' ? 650 : 0), total_fee_sats: 144 + budget, + budget_sats: budget, rate_sat_vb: rate } +} } diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index e7d14094..b37c4146 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -436,13 +436,13 @@ { "id": "nginx-proxy-manager", "title": "Nginx Proxy Manager", - "version": "2.12.1", - "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", + "version": "2.14.0", + "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.", "icon": "/assets/img/app-icons/nginx.svg", "author": "Nginx Proxy Manager", "category": "networking", "tier": "optional", - "dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest", + "dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08", "repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager" }, { @@ -648,9 +648,9 @@ { "id": "angor-indexer", "title": "Angor Indexer", - "version": "1.0.1", - "description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.", - "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1", + "version": "1.0.2", + "description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.", + "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2", "author": "Angor / Archipelago", "requires": [ "Mempool API", diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index 2055bf7b..68c52046 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -19,8 +19,6 @@ - - @@ -98,7 +96,7 @@ diff --git a/neode-ui/src/components/LightningChannelsPanel.vue b/neode-ui/src/components/LightningChannelsPanel.vue index 95c41b2c..20a615a4 100644 --- a/neode-ui/src/components/LightningChannelsPanel.vue +++ b/neode-ui/src/components/LightningChannelsPanel.vue @@ -553,7 +553,7 @@ const defaultOpenForm = () => ({ peerUri: '', amount: 100000, private: false, - feePreset: 'standard' as FeePreset, + feePreset: 'fast' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null, }) @@ -624,7 +624,7 @@ function feeParams( setError: (message: string) => void = message => { openError.value = message }, ): { target_conf?: number; sat_per_vbyte?: number } | null { if (form.feePreset !== 'custom') { - return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 } + return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 1 } } const rate = form.customSatPerVbyte const conf = form.customConfTarget @@ -673,7 +673,7 @@ async function openChannel() { } } -const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null }) +const defaultCloseForm = () => ({ feePreset: 'fast' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null }) const closeForm = ref(defaultCloseForm()) function confirmClose(ch: Channel) { diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 9d6f61fb..152fe1be 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -337,7 +337,7 @@ watch(() => props.show, (shown) => { successInfo.value = null sendAll.value = false onchainBalance.value = null - feePreset.value = 'standard' + feePreset.value = 'fast' customConfTarget.value = null customSatPerVbyte.value = null resolvedFeeParams.value = {} @@ -359,7 +359,7 @@ const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; { key: 'custom', label: 'Custom' }, ] -const feePreset = ref('standard') +const feePreset = ref('fast') const customConfTarget = ref(null) const customSatPerVbyte = ref(null) // Resolved at review time so confirm + send use the same params. @@ -367,16 +367,16 @@ const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>( function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null { if (feePreset.value !== 'custom') { - return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 } + return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 1 } } const rate = customSatPerVbyte.value const conf = customConfTarget.value if (rate != null && rate !== 0) { - if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null } + if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be a whole number between 1 and 5000'; return null } return { sat_per_vbyte: Math.floor(rate) } } if (conf != null && conf !== 0) { - if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null } + if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { error.value = 'Target blocks must be a whole number between 1 and 1008'; return null } return { target_conf: Math.floor(conf) } } error.value = 'Custom fee requires target blocks or sats per vByte' @@ -409,7 +409,7 @@ async function loadFeeEstimate() { try { const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({ method: 'lnd.estimatefee', - params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 }, + params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 1 }, timeout: 10000, }) if (res.fee_sat > 0) feeEstimate.value = res @@ -569,6 +569,11 @@ function sendAnother() { dest.value = '' amount.value = 0 sendAll.value = false + feePreset.value = 'fast' + customConfTarget.value = null + customSatPerVbyte.value = null + resolvedFeeParams.value = {} + feeEstimate.value = null error.value = '' } diff --git a/neode-ui/src/components/TransactionsModal.vue b/neode-ui/src/components/TransactionsModal.vue index 02fc1fad..15c6974d 100644 --- a/neode-ui/src/components/TransactionsModal.vue +++ b/neode-ui/src/components/TransactionsModal.vue @@ -61,9 +61,9 @@
-
+
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats @@ -92,10 +92,22 @@ fee {{ feeFor(tx).toLocaleString() }} sats {{ tx.label }}
+
+ Fee history +

Original fee {{ tx.total_fees.toLocaleString() }} sats

+ +
- {{ formatTxTime(tx.time_stamp) }} + + @@ -103,12 +115,14 @@
+ diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue index d5650761..a1ad2bf5 100644 --- a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue +++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue @@ -149,9 +149,8 @@ -

- Couldn't confirm the board automatically — double check before flashing. - Flashing the wrong board's image can brick it. +

+ Select the model printed on your board. USB adapters are shared across models.

@@ -300,7 +299,7 @@