fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
+15 -2
View File
@@ -1,10 +1,23 @@
# Changelog # 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. - 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. - 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. - 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. - 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. - Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.
+1 -1
View File
@@ -95,7 +95,7 @@ python3 scripts/check-git-mirrors.py --local
Before publishing release artifacts, also check the actual release tag: Before publishing release artifacts, also check the actual release tag:
```bash ```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 Use the release's actual tag name. Missing refs, inaccessible mirrors or differing
+6 -6
View File
@@ -436,13 +436,13 @@
{ {
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager", "title": "Nginx Proxy Manager",
"version": "2.12.1", "version": "2.14.0",
"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).", "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", "icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager", "author": "Nginx Proxy Manager",
"category": "networking", "category": "networking",
"tier": "optional", "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" "repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
}, },
{ {
@@ -648,9 +648,9 @@
{ {
"id": "angor-indexer", "id": "angor-indexer",
"title": "Angor Indexer", "title": "Angor Indexer",
"version": "1.0.1", "version": "1.0.2",
"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.", "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.1", "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
"author": "Angor / Archipelago", "author": "Angor / Archipelago",
"requires": [ "requires": [
"Mempool API", "Mempool API",
+20 -6
View File
@@ -1,20 +1,23 @@
app: app:
id: nginx-proxy-manager id: nginx-proxy-manager
name: Nginx Proxy Manager name: Nginx Proxy Manager
version: 2.12.1 version: 2.14.0
upstream: upstream:
kind: github kind: github
repo: NginxProxyManager/nginx-proxy-manager repo: NginxProxyManager/nginx-proxy-manager
description: >- description: >-
Reverse proxy with SSL. Beautiful web interface for managing proxies. Reverse proxy with SSL. Beautiful web interface for managing proxies.
On a node, this manages its admin UI and upstream configuration — the The node's public web server forwards configured domains through this
proxy's own :80/:443 listeners are not published (the node's web server service, preserving its access lists, certificates and custom routes.
owns those ports). backup_before_runtime_change: true
container: 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 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: dependencies:
- storage: 1Gi - storage: 1Gi
@@ -49,6 +52,17 @@ app:
Nginx Proxy Manager enforces its own admin account on every page; Nginx Proxy Manager enforces its own admin account on every page;
the initial setup wizard also has to answer before any account exists. 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: volumes:
- type: bind - type: bind
source: /var/lib/archipelago/nginx-proxy-manager source: /var/lib/archipelago/nginx-proxy-manager
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]] [[package]]
name = "archipelago" name = "archipelago"
version = "1.8.22-alpha" version = "1.9.0-alpha"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"archipelago-container", "archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "archipelago" name = "archipelago"
version = "1.8.22-alpha" version = "1.9.0-alpha"
edition = "2021" edition = "2021"
license.workspace = true license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend" description = "Archipelago Bitcoin Node OS - Native backend"
+1 -1
View File
@@ -136,7 +136,7 @@ impl RpcHandler {
/// ~30% of UI calls error out even though the node is perfectly healthy. /// ~30% of UI calls error out even though the node is perfectly healthy.
/// With retry + backoff, the UI sees a uniform slow-but-successful /// With retry + backoff, the UI sees a uniform slow-but-successful
/// response instead of intermittent failures. /// response instead of intermittent failures.
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>( pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
&self, &self,
client: &reqwest::Client, client: &reqwest::Client,
method: &str, method: &str,
+36 -29
View File
@@ -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<Option<serde_json::Value>> {
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 // 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. // endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
// Keep older clients compatible until both sides have upgraded. // 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 // by exact (onion, content_id) and by (onion, filename) — the latter
// catches duplicate ids pointing at the same file on the same // catches duplicate ids pointing at the same file on the same
// seller. The owned copy is served from the local cache instead. // 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()); return Ok(cached);
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.
}
} }
// `method` pins the backend the user confirmed in the UI ("cashu" | // `method` pins the backend the user confirmed in the UI ("cashu" |
@@ -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"
);
}
@@ -132,6 +132,9 @@ impl RpcHandler {
"lnd.newaddress" => self.handle_lnd_newaddress().await, "lnd.newaddress" => self.handle_lnd_newaddress().await,
"lnd.sendcoins" => self.handle_lnd_sendcoins(params).await, "lnd.sendcoins" => self.handle_lnd_sendcoins(params).await,
"lnd.estimatefee" => self.handle_lnd_estimatefee(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.createinvoice" => self.handle_lnd_createinvoice(params).await,
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await, "lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await, "lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
+10 -25
View File
@@ -272,28 +272,8 @@ impl RpcHandler {
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(false);
// Fee control: either a confirmation target or an explicit fee rate // Omitted fees target the next block; explicit slower/custom choices win.
let target_conf = params.get("target_conf").and_then(|v| v.as_i64()); let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(&params)?;
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"
));
}
}
info!( info!(
peer = pubkey, peer = pubkey,
@@ -574,7 +554,7 @@ impl RpcHandler {
/// LND's CloseChannel REST endpoint takes fee selection as query parameters. /// LND's CloseChannel REST endpoint takes fee selection as query parameters.
/// With neither parameter LND uses a lax target; keep legacy clients on our /// 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<Vec<(&'static str, String)>> { fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
let force = match params.get("force") { let force = match params.get("force") {
None | Some(serde_json::Value::Null) => false, None | Some(serde_json::Value::Null) => false,
@@ -609,7 +589,12 @@ fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static s
if let Some(rate) = rate { if let Some(rate) = rate {
query.push(("sat_per_vbyte", rate.to_string())); query.push(("sat_per_vbyte", rate.to_string()));
} else { } else {
query.push(("target_conf", target.unwrap_or(6).to_string())); query.push((
"target_conf",
target
.unwrap_or(super::fee_policy::DEFAULT_TARGET as u64)
.to_string(),
));
} }
} }
Ok(query) Ok(query)
@@ -645,7 +630,7 @@ mod close_fee_tests {
} }
assert_eq!( assert_eq!(
close_channel_fee_query(&serde_json::json!({})).unwrap(), close_channel_fee_query(&serde_json::json!({})).unwrap(),
vec![("force", "false".into()), ("target_conf", "6".into())] vec![("force", "false".into()), ("target_conf", "1".into())]
); );
assert_eq!( assert_eq!(
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(), close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
@@ -0,0 +1,835 @@
//! WalletKit BumpFee is CPFP for new wallet outputs, RBF only for sweeper inputs.
//! Never feed an ordinary payment input to it and call that a replacement.
use super::LND_REST_BASE_URL;
use crate::api::rpc::RpcHandler;
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{collections::HashMap, path::Path, sync::LazyLock};
use tokio::{io::AsyncWriteExt, sync::Mutex};
static QUOTES: LazyLock<Mutex<HashMap<String, Quote>>> = 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<u64>,
#[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<u64> {
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<String> {
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<u64> {
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<Value>> {
v[key]
.as_array()
.with_context(|| format!("Missing wallet field: {key}"))
}
fn sweep_size(output: &Value) -> Result<u64> {
// 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<u64> {
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<Value>,
) -> Result<Value> {
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<Value>,
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::<Operation>(&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::<Value>(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(&current["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::<Vec<_>>());
}
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<u64>) -> Result<Plan> {
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::<i64>().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<Value>,
) -> Result<Value> {
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<Value>,
) -> Result<Value> {
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(&quote, &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<Value>,
) -> Result<Value> {
let txid = txid_param(&params.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::<Value>(&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<u64> {
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();
}
}
@@ -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<u64> {
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<i64>, Option<i64>)> {
let integer = |key: &str, max: i64| -> Result<Option<i64>> {
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());
}
}
+2
View File
@@ -1,4 +1,6 @@
mod channels; mod channels;
mod fee_bump;
mod fee_policy;
mod info; mod info;
mod macaroons; mod macaroons;
mod payments; mod payments;
@@ -402,6 +402,9 @@ impl RpcHandler {
})); }));
} }
self.group_fee_bump_history(raw_txs, &mut transactions, &client)
.await;
// Sort by timestamp descending (most recent first) // Sort by timestamp descending (most recent first)
transactions.sort_by(|a, b| { transactions.sort_by(|a, b| {
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0); let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
+26 -37
View File
@@ -124,28 +124,8 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Invalid Bitcoin address format")); return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
} }
// Fee control: either a confirmation target or an explicit fee rate // Omitted fees target the next block; explicit slower/custom choices win.
let target_conf = params.get("target_conf").and_then(|v| v.as_i64()); let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(&params)?;
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"
));
}
}
info!( info!(
addr = addr, addr = addr,
@@ -238,15 +218,12 @@ impl RpcHandler {
if !(546..=21_000_000 * 100_000_000).contains(&amount) { if !(546..=21_000_000 * 100_000_000).contains(&amount) {
return Err(anyhow::anyhow!("Invalid amount")); return Err(anyhow::anyhow!("Invalid amount"));
} }
let target_conf = params let (target_conf, custom_rate) = super::fee_policy::fee_options(&params)?;
.get("target_conf") anyhow::ensure!(
.and_then(|v| v.as_i64()) custom_rate.is_none(),
.unwrap_or(6); "Fee estimation requires a confirmation target"
if !(1..=1008).contains(&target_conf) { );
return Err(anyhow::anyhow!( let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
"Invalid target_conf: must be between 1 and 1008 blocks"
));
}
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
@@ -782,10 +759,24 @@ impl RpcHandler {
total_amount += amount; total_amount += amount;
} }
let sat_per_vbyte = params let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
.get("fee_rate_sat_per_vbyte") "sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
.and_then(|v| v.as_u64()) }))?;
.unwrap_or(10); 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!( info!(
total_amount = total_amount, total_amount = total_amount,
@@ -793,8 +784,6 @@ impl RpcHandler {
"Creating PSBT for hardware wallet signing" "Creating PSBT for hardware wallet signing"
); );
let (client, macaroon_hex) = self.lnd_client().await?;
let fund_body = serde_json::json!({ let fund_body = serde_json::json!({
"raw": { "raw": {
"outputs": lnd_outputs, "outputs": lnd_outputs,
@@ -221,6 +221,10 @@ impl RpcHandler {
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; 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?; 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 service_arc = Arc::clone(&self.mesh_service);
let config_for_apply = config.clone(); let config_for_apply = config.clone();
let flash_jobs = Arc::clone(&self.flash_job);
tokio::spawn(async move { 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; let mut service = service_arc.write().await;
if let Some(svc) = service.as_mut() { if let Some(svc) = service.as_mut() {
if let Err(e) = svc.configure(config_for_apply).await { if let Err(e) = svc.configure(config_for_apply).await {
+3 -1
View File
@@ -110,7 +110,8 @@ impl RpcHandler {
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe) // `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 // from opening the identical port at the same time and corrupting
// both operations' handshakes. // 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!( anyhow::ensure!(
job.snapshot().await.done, job.snapshot().await.done,
"A firmware flash is in progress — refusing to probe the serial port until it finishes" "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?; let probe = mesh::listener::probe_device(&path).await?;
drop(flash_guard);
Ok(serde_json::to_value(probe)?) Ok(serde_json::to_value(probe)?)
} }
@@ -985,28 +985,26 @@ pub(super) async fn get_app_config(
) )
} }
"nginx-proxy-manager" => { "nginx-proxy-manager" => {
let storage = crate::container::npm::resolve_storage().await?;
let admin_port = allocator let admin_port = allocator
.allocate_or_get(app_id, 8081, 81) .allocate_or_get(app_id, 8081, 81)
.await .await
.unwrap_or(8081); .unwrap_or(8081);
let http_port = allocator let http_port = allocator
.allocate_or_get("nginx-proxy-manager-http", 8084, 80) .allocate_or_get("nginx-proxy-manager-http", 8088, 80)
.await .await
.unwrap_or(8084); .unwrap_or(8088);
let https_port = allocator let https_port = allocator
.allocate_or_get("nginx-proxy-manager-https", 8444, 443) .allocate_or_get("nginx-proxy-manager-https", 8444, 443)
.await .await
.unwrap_or(8444); .unwrap_or(8444);
( (
vec![ vec![
format!("{}:81", admin_port), format!("127.0.0.1:{}:81", admin_port),
format!("{}:80", http_port), format!("127.0.0.1:{}:80", http_port),
format!("{}:443", https_port), format!("127.0.0.1:{}: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(),
], ],
storage.bind_mounts(),
vec![], vec![],
None, None,
None, None,
+18 -95
View File
@@ -693,7 +693,11 @@ impl RpcHandler {
// These standalone web UIs have repeatedly lost host listeners // These standalone web UIs have repeatedly lost host listeners
// under Podman's rootless pasta backend while staying healthy internally. // under Podman's rootless pasta backend while staying healthy internally.
// Use slirp4netns/rootlessport for this standalone web UI. // 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) { } else if needs_archy_net(package_id) {
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine) // Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
match tokio::process::Command::new("podman") match tokio::process::Command::new("podman")
@@ -1568,88 +1572,8 @@ autopilot.active=false\n",
super::pine_ha::restart_home_assistant_if_running().await; super::pine_ha::restart_home_assistant_if_running().await;
} }
} }
if package_id == "filebrowser" { // File Browser credentials are provisioned and verified before the
// Generate a random password (32 bytes, hex-encoded) // server starts. Never attempt a default-password change after launch.
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"
);
}
}
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin) // 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<serde_json::Value> { pub(in crate::api::rpc) async fn handle_filebrowser_token(&self) -> Result<serde_json::Value> {
let secret_path = "/var/lib/archipelago/secrets/filebrowser/password"; let credentials = crate::container::filebrowser::cloud_credentials(std::path::Path::new(
let password = tokio::fs::read_to_string(secret_path) "/var/lib/archipelago/secrets/filebrowser",
.await ))
.unwrap_or_else(|_| "admin".to_string()); .await?;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
@@ -1924,7 +1848,7 @@ autopilot.active=false\n",
let resp = client let resp = client
.post("http://127.0.0.1:8083/api/login") .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() .send()
.await .await
.context("Failed to connect to FileBrowser")?; .context("Failed to connect to FileBrowser")?;
@@ -1954,17 +1878,16 @@ autopilot.active=false\n",
super::validation::validate_app_id(app_id)?; super::validation::validate_app_id(app_id)?;
if app_id == "filebrowser" { if app_id == "filebrowser" {
let password = let credentials = crate::container::filebrowser::cloud_credentials(
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password") std::path::Path::new("/var/lib/archipelago/secrets/filebrowser"),
.await )
.map(|p| p.trim().to_string()) .await?;
.unwrap_or_else(|_| "admin".to_string());
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"title": "File Browser credentials", "title": "File Browser credentials",
"description": "Use these credentials when File Browser asks you to sign in.", "description": "Use these credentials when File Browser asks you to sign in.",
"credentials": [ "credentials": [
{ "label": "Username", "value": "admin" }, { "label": "Username", "value": credentials.username },
{ "label": "Password", "value": password, "sensitive": true } { "label": "Password", "value": credentials.password, "sensitive": true }
] ]
})); }));
} }
+210 -61
View File
@@ -1576,41 +1576,110 @@ async fn repair_netbird_network() {
} }
async fn repair_nginx_proxy_manager_container() { 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 { if !nginx_proxy_manager_has_legacy_admin_port().await {
return; return;
} }
if let Err(error) = repair_legacy_nginx_proxy_manager().await {
install_log( tracing::warn!(error = %error, "NPM legacy repair failed; persistent state preserved");
"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");
} }
} }
async fn repair_nginx_proxy_manager_dirs() { const NPM_PREVIOUS_CONTAINER: &str = "archy-npm-upgrade-previous";
let _ = tokio::process::Command::new("sudo")
.args([ async fn restore_failed_npm_repair() -> Result<()> {
"mkdir", let removed = podman_control(&["rm", "-f", "--ignore", "nginx-proxy-manager"]).await?;
"-p", anyhow::ensure!(
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge", removed.status.success(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt", "cannot remove failed NPM replacement; previous container retained"
]) );
.output() let renamed =
.await; podman_control(&["rename", NPM_PREVIOUS_CONTAINER, "nginx-proxy-manager"]).await?;
let _ = tokio::process::Command::new("sudo") anyhow::ensure!(
.args([ renamed.status.success(),
"chown", "cannot restore previous NPM container name"
"-R", );
"1000:1000", let started = podman_control(&["start", "nginx-proxy-manager"]).await?;
"/var/lib/archipelago/nginx-proxy-manager", anyhow::ensure!(
]) started.status.success(),
.output() "previous NPM container restored but failed to start"
.await; );
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<String> =
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 { 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") ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
} }
async fn recreate_nginx_proxy_manager_container() -> Result<()> { fn npm_repair_environment(values: &[String]) -> Result<Vec<(String, String)>> {
tokio::process::Command::new("sudo") values.iter().map(|value| {
.args([ let (key, value) = value.split_once('=').context("invalid NPM environment entry")?;
"mkdir", anyhow::ensure!(!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
"-p", "unsupported NPM environment name; original container preserved");
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge", anyhow::ensure!(!value.contains(['\n', '\r', '\0']),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt", "NPM environment requires explicit migration of a multiline value; original container preserved");
]) Ok((key.to_owned(), value.to_owned()))
.output() }).collect()
.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;
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager") struct NpmRepairEnvironmentFile(std::path::PathBuf);
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
impl NpmRepairEnvironmentFile {
fn create(environment: &[(String, String)]) -> Result<Self> {
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![ let mut args = vec![
"run".to_string(), "run".to_string(),
"-d".to_string(), "-d".to_string(),
"--name".to_string(), "--name".to_string(),
"nginx-proxy-manager".to_string(), "nginx-proxy-manager".to_string(),
"--restart=unless-stopped".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(), "--cap-drop=ALL".to_string(),
"--security-opt=no-new-privileges:true".to_string(), "--security-opt=no-new-privileges:true".to_string(),
"--pids-limit=4096".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(get_app_capabilities("nginx-proxy-manager"));
args.extend([ args.extend([
"-p".to_string(), "-p".to_string(),
"8081:81".to_string(), "127.0.0.1:8081:81".to_string(),
"-p".to_string(), "-p".to_string(),
"8084:80".to_string(), "127.0.0.1:8088:80".to_string(),
"-p".to_string(), "-p".to_string(),
"8444:443".to_string(), "127.0.0.1:8444:443".to_string(),
"-v".to_string(), "-v".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(), format!("{}:/data", storage.data),
"-v".to_string(), "-v".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(), format!("{}:/etc/letsencrypt", storage.certificates),
"--memory".to_string(), "--memory".to_string(),
get_memory_limit("nginx-proxy-manager").to_string(), get_memory_limit("nginx-proxy-manager").to_string(),
"--cpus=2".to_string(), "--cpus=2".to_string(),
]); ]);
args.extend(get_health_check_args("nginx-proxy-manager", "")); 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); args.push(image);
let refs = args.iter().map(String::as_str).collect::<Vec<_>>(); let mut command = tokio::process::Command::new("podman");
let output = podman_control(&refs).await?; command.args(&args);
let output = command_with_timeout(command, Duration::from_secs(120), "NPM replacement").await?;
if !output.status.success() { if !output.status.success() {
anyhow::bail!( anyhow::bail!(
"podman run nginx-proxy-manager failed: {}", "podman run nginx-proxy-manager failed: {}",
@@ -1767,7 +1883,7 @@ fn runtime_host_ports(container_name: &str) -> Vec<u16> {
"vaultwarden" => vec![8082], "vaultwarden" => vec![8082],
"gitea" => vec![3001, 2222, 3000], "gitea" => vec![3001, 2222, 3000],
"nextcloud" => vec![8085], "nextcloud" => vec![8085],
"nginx-proxy-manager" => vec![8081, 8084, 8444], "nginx-proxy-manager" => vec![8081, 8088, 8444],
_ => Vec::new(), _ => Vec::new(),
}; };
ports ports
@@ -1778,7 +1894,7 @@ fn with_legacy_extra_ports(container_name: &str, mut ports: Vec<u16>) -> Vec<u16
ports.push(3000); ports.push(3000);
} }
if container_name == "nginx-proxy-manager" { if container_name == "nginx-proxy-manager" {
for port in [8084, 8444] { for port in [8088, 8444] {
if !ports.contains(&port) { if !ports.contains(&port) {
ports.push(port); ports.push(port);
} }
@@ -1843,6 +1959,7 @@ async fn wait_for_runtime_host_port(container_name: &str, port: u16, timeout_sec
loop { loop {
let ready = match container_name { let ready = match container_name {
"uptime-kuma" => http_host_port_ready(port, "/").await, "uptime-kuma" => 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)) _ => tokio::net::TcpStream::connect(("127.0.0.1", port))
.await .await
.is_ok(), .is_ok(),
@@ -2151,6 +2268,38 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[tokio::test] #[tokio::test]
+89 -1
View File
@@ -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 /// catalog refresh/reconciliation. Replacing the app tree in the background
/// could let a reload observe its temporary empty state and forget disk-only apps. /// could let a reload observe its temporary empty state and forget disk-only apps.
pub async fn ensure_runtime_assets_ready() { 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 { match run_runtime_assets().await {
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"), Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
Ok(_) => debug!("No OTA runtime payload to synchronize"), Ok(_) => debug!("No OTA runtime payload to synchronize"),
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e), 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 // Repair the narrowly recognized legacy NPM tunnel override before app
// reconciliation. The embedded script ships in both OTA and ISO binaries. // reconciliation. The embedded script ships in both OTA and ISO binaries.
// It preserves native wallet services and refuses unknown custom routing. // 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 — /// 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. /// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() { pub async fn ensure_doctor_installed() {
@@ -432,6 +507,17 @@ async fn run_runtime_assets() -> Result<bool> {
if !status.success() { if !status.success() {
anyhow::bail!("install nginx-archipelago.conf exited with {}", status); 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; changed = true;
} }
@@ -448,6 +534,8 @@ async fn run_runtime_assets() -> Result<bool> {
"archipelago-doctor.service", "archipelago-doctor.service",
"archipelago-doctor.timer", "archipelago-doctor.timer",
"archipelago-host-secrets-audit.service", "archipelago-host-secrets-audit.service",
"archipelago-npm-bridge.service",
"archipelago-npm-bridge.timer",
] { ] {
let src = configs.join(unit); let src = configs.join(unit);
if src.exists() { if src.exists() {
@@ -475,7 +563,7 @@ async fn run_runtime_assets() -> Result<bool> {
// or directory"). Skipped when byte-identical; a running daemon is // or directory"). Skipped when byte-identical; a running daemon is
// unaffected (install replaces the inode) and picks the new binary up // unaffected (install replaces the inode) and picks the new binary up
// on its next spawn. // 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); let src = runtime_dir.join("radio-tools").join(tool);
if !src.exists() { if !src.exists() {
continue; continue;
+145 -4
View File
@@ -118,10 +118,12 @@ fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
// Never let an unknown future requirement become an unsafe partial match. // Never let an unknown future requirement become an unsafe partial match.
for variant in entry.manifest_variants.into_iter().rev() { for variant in entry.manifest_variants.into_iter().rev() {
if !variant.requires.is_empty() if !variant.requires.is_empty()
&& variant && variant.requires.iter().all(|capability| {
.requires matches!(
.iter() capability.as_str(),
.all(|capability| capability == "runtime-migration-backup-v1") "runtime-migration-backup-v1" | "npm-legacy-host-gateway-v1"
)
})
{ {
return Some(variant.manifest); return Some(variant.manifest);
} }
@@ -468,6 +470,12 @@ pub struct CatalogRefresh {
/// changed. Best-effort: a fetch failure leaves the existing cache untouched /// changed. Best-effort: a fetch failure leaves the existing cache untouched
/// (origin-always-wins; updates simply aren't refreshed this cycle). /// (origin-always-wins; updates simply aren't refreshed this cycle).
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> { pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh> {
// 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) let mirrors = crate::update::load_mirrors(data_dir)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@@ -514,6 +522,49 @@ pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<CatalogRefresh>
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable"))) Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
} }
async fn refresh_candidate_catalog(data_dir: &Path, path: &Path) -> anyhow::Result<CatalogRefresh> {
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)> { async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<(AppCatalog, String)> {
let resp = client.get(url).send().await?; let resp = client.get(url).send().await?;
if !resp.status().is_success() { if !resp.status().is_success() {
@@ -582,6 +633,77 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
mod tests { mod tests {
use super::*; 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] #[test]
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() { fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
let raw = serde_json::json!({ let raw = serde_json::json!({
@@ -608,6 +730,25 @@ mod tests {
assert!(chosen["app"]["container"].get("network").is_none()); 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] #[test]
fn parses_and_ignores_unknown_fields() { fn parses_and_ignores_unknown_fields() {
let json = r#"{ let json = r#"{
@@ -24,6 +24,7 @@ fn canonical_package_id(name: &str) -> &str {
"immich-postgres" => "immich_postgres", "immich-postgres" => "immich_postgres",
"immich-redis" => "immich_redis", "immich-redis" => "immich_redis",
"mempool-web" | "mempool-frontend" => "mempool", "mempool-web" | "mempool-frontend" => "mempool",
"btcpay" | "btcpayserver" => "btcpay-server",
name => name, name => name,
} }
} }
@@ -445,6 +446,15 @@ mod lifecycle_regression_tests {
use super::*; use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; 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] #[test]
fn immich_dependency_aliases_share_the_hidden_component_ids() { fn immich_dependency_aliases_share_the_hidden_component_ids() {
for id in [ for id in [
+147 -2
View File
@@ -17,6 +17,84 @@ pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.fi
const DEFAULT_CONFIG_JSON: &str = const DEFAULT_CONFIG_JSON: &str =
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n"; "{\"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<CloudCredentials> {
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)] #[derive(Debug, Clone)]
pub struct EnsurePaths { pub struct EnsurePaths {
pub srv_root: PathBuf, 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<()> { async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
let tmp = paths.config_path.with_extension("tmp"); 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(()) => { Ok(()) => {
fs::rename(&tmp, &paths.config_path) fs::rename(&tmp, &paths.config_path)
.await .await
@@ -99,7 +188,7 @@ async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
let script = format!( let script = format!(
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n", "set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
shell_quote(&paths.config_path.to_string_lossy()), shell_quote(&paths.config_path.to_string_lossy()),
DEFAULT_CONFIG_JSON config
); );
let status = host_sudo(&["sh", "-lc", &script]) let status = host_sudo(&["sh", "-lc", &script])
.await .await
@@ -312,6 +401,62 @@ async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn ensure_config_creates_dirs_and_file() { async fn ensure_config_creates_dirs_and_file() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
+1
View File
@@ -13,6 +13,7 @@ pub mod image_policy;
pub mod image_versions; pub mod image_versions;
pub mod lnd; pub mod lnd;
pub mod migration_backup; pub mod migration_backup;
pub mod npm;
pub mod prod_orchestrator; pub mod prod_orchestrator;
pub mod quadlet; pub mod quadlet;
pub mod registry; pub mod registry;
+115
View File
@@ -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<String> {
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<Storage> {
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<Storage> {
// 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());
}
}
@@ -92,16 +92,71 @@ fn is_restart_sensitive_app(app_id: &str) -> bool {
fn is_builtin_network_mode(network: &str) -> bool { fn is_builtin_network_mode(network: &str) -> bool {
matches!( matches!(
network, 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 // Only an explicitly selected rootless mode establishes drift. An omitted
// network delegates to Podman and must not recreate unrelated installed apps. // network delegates to Podman and must not recreate unrelated installed apps.
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool { fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
matches!(expected, Some("slirp4netns" | "pasta")) let actual = actual.trim();
&& !actual.trim().is_empty() if actual.is_empty() {
&& actual.trim().split(':').next() != expected 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<String> {
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::<Option<Vec<_>>>()?;
if options.is_empty() {
Some(mode.to_string())
} else {
Some(format!("{mode}:{}", options.join(",")))
}
}
}
} }
fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool { fn missing_declared_capability(expected: &[String], actual: &[String]) -> bool {
@@ -175,6 +230,11 @@ fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
} }
fn host_port_wait_timeout_secs(manifest: &AppManifest) -> u64 { 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" { if manifest.app.id == "uptime-kuma" {
return 420; return 420;
} }
@@ -2425,6 +2485,49 @@ impl ProdContainerOrchestrator {
} }
match status.state { match status.state {
ContainerState::Running => { 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" // Zombie guard: podman can report a container "running"
// after its process has died (conmon SIGKILLed in a // after its process has died (conmon SIGKILLed in a
// cgroup cascade on archipelago restart, etc.). Such a // cgroup cascade on archipelago restart, etc.). Such a
@@ -2971,6 +3074,18 @@ impl ProdContainerOrchestrator {
self.ensure_manifest_files(manifest).await?; self.ensure_manifest_files(manifest).await?;
self.apply_data_uid(manifest).await?; self.apply_data_uid(manifest).await?;
self.run_post_data_uid_hooks(&manifest.app.id).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(()) Ok(())
} }
@@ -3068,6 +3183,11 @@ impl ProdContainerOrchestrator {
container = %name, container = %name,
"Phase 3.3 migration: replacing pre-Quadlet container with systemd-managed unit" "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 // Stop+remove the old container record. Volumes survive (host
// bind mounts are not touched by podman rm). // bind mounts are not touched by podman rm).
self.runtime self.runtime
@@ -3077,8 +3197,6 @@ impl ProdContainerOrchestrator {
// Re-render the manifest with dynamic env baked in, then go // Re-render the manifest with dynamic env baked in, then go
// through the same install path a fresh install would. // 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) self.install_via_quadlet(&resolved, name)
.await .await
.with_context(|| format!("Phase 3.3: re-install {name} via Quadlet"))?; .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<()> { 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. // Idempotency guard: partitioning already ran on this instance.
// Re-running would re-taint against an environment that no longer // Re-running would re-taint against an environment that no longer
// contains the composite entries and silently drop them. Callers // contains the composite entries and silently drop them. Callers
@@ -4068,7 +4191,12 @@ impl ProdContainerOrchestrator {
if unmanaged if unmanaged
&& matches!( && matches!(
manifest.app.container.network.as_deref(), 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") if let Ok(output) = tokio::process::Command::new("podman")
@@ -4076,13 +4204,36 @@ impl ProdContainerOrchestrator {
.output() .output()
.await .await
{ {
if output.status.success() if output.status.success() {
&& rootless_network_mode_drifted( let mut actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
if matches!(
manifest.app.container.network.as_deref(), manifest.app.container.network.as_deref(),
&String::from_utf8_lossy(&output.stdout), Some(
) "slirp4netns:allow_host_loopback=true"
{ | "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
return true; )
) && 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] #[test]
fn explicit_rootless_network_change_converges_without_guessing_defaults() { 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"), "pasta"));
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge")); assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
assert!(!rootless_network_mode_drifted( assert!(!rootless_network_mode_drifted(
+81 -1
View File
@@ -68,6 +68,10 @@ pub enum NetworkMode {
/// Rootless slirp4netns networking. Podman rejects network aliases with /// Rootless slirp4netns networking. Podman rejects network aliases with
/// this mode, so render only Network=slirp4netns. /// this mode, so render only Network=slirp4netns.
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 /// Rootless pasta networking. This is more reliable than slirp4netns for
/// host port forwarding on long-running web apps. /// host port forwarding on long-running web apps.
Pasta, Pasta,
@@ -229,6 +233,15 @@ impl QuadletUnit {
NetworkMode::Host => { NetworkMode::Host => {
let _ = writeln!(s, "Network=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 => { NetworkMode::Slirp4netns => {
let _ = writeln!(s, "Network=slirp4netns"); let _ = writeln!(s, "Network=slirp4netns");
} }
@@ -348,6 +361,26 @@ impl QuadletUnit {
} }
let _ = writeln!(s); let _ = writeln!(s);
let _ = writeln!(s, "[Service]"); 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 // Dependency-gated apps may legitimately keep their container entrypoint
// in a wait loop before the actual daemon binds ports. Fedimint waits // in a wait loop before the actual daemon binds ports. Fedimint waits
// for Bitcoin IBD to finish before execing fedimintd; systemd's default // 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()), other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
_ => match app.container.network.as_deref() { _ => match app.container.network.as_deref() {
Some("slirp4netns") => NetworkMode::Slirp4netns, 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("pasta") => NetworkMode::Pasta,
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()), Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
_ => NetworkMode::Default, _ => 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 // Entrypoint= and Exec= together define what the container runs, so a drift
// in either must recreate the container (e.g. when this renderer first // in either must recreate the container (e.g. when this renderer first
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`). // 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=") || directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
} }
@@ -1142,6 +1182,28 @@ pub async fn is_active(service: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { 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::<Vec<_>>()
.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 super::*;
use tempfile::tempdir; use tempfile::tempdir;
@@ -1709,6 +1771,24 @@ app:
assert!(!s.contains("--network-alias")); 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<_>>(),
vec!["Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"]
);
assert!(!rendered.contains("NetworkAlias="));
assert!(!rendered.contains("--network-alias"));
}
#[test] #[test]
fn from_manifest_pasta_omits_network_alias() { fn from_manifest_pasta_omits_network_alias() {
let yaml = r#" let yaml = r#"
+5
View File
@@ -162,6 +162,11 @@ pub async fn record_purchase(
save_index(data_dir, &index).await 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<Vec<OwnedItem>> {
Ok(load_index_checked(data_dir).await?.items)
}
/// Every item this node owns. /// Every item this node owns.
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> { pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
load_index(data_dir).await.items load_index(data_dir).await.items
+395 -69
View File
@@ -7,7 +7,8 @@
//! to a full chip erase before write. //! to a full chip erase before write.
//! //!
//! MeshCore and Meshtastic are flashed the same way: download a released //! MeshCore and Meshtastic are flashed the same way: download a released
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`. //! image, verify it, then run `write_flash --erase-all 0x0 <image>` with
//! the packaged esptool executable.
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the //! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live //! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for //! 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 /// Generic CP2102 and native ESP32-S3 USB IDs identify adapters/chips, not
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60) /// board wiring. Require an explicit board until a board-specific identity is
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to /// available; guessing a Heltec model can write incompatible firmware.
/// auto-match since that vid:pid is bridge-chip-specific. pub fn resolve_flash_board(_info: &DetectedDeviceInfo) -> Option<FlashBoard> {
/// None
/// 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<FlashBoard> {
match (info.vid.as_deref(), info.pid.as_deref()) {
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
_ => None,
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum FlashStage { pub enum FlashStage {
Downloading, Downloading,
Preparing,
Erasing, Erasing,
Writing, Writing,
Autoinstalling, Autoinstalling,
@@ -101,11 +88,10 @@ const LOG_TAIL_MAX: usize = 200;
/// start opening the port (which itself toggles DTR/RTS) again. /// start opening the port (which itself toggles DTR/RTS) again.
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5); 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 /// Deadline for preparation and warning threshold for the active flasher.
/// autoinstall), regardless of what it's doing internally. Last-resort /// A download can be cancelled safely. An active write retains ownership until
/// safety net so a hang anywhere can't wedge the single-flash-job guard /// its subprocess exits, even after this threshold: reporting an aborted job
/// forever — generous enough to never trigger on a legitimately slow /// while a detached writer continues would let a retry corrupt the device.
/// multi-hundred-MB transfer.
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60); 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 /// 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") 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 /// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
/// the *entire* request including streaming the response body, which would /// the *entire* request including streaming the response body, which would
/// kill a legitimate large download partway through (Meshtastic's esp32s3 /// kill a legitimate large download partway through (Meshtastic's esp32s3
@@ -314,6 +310,10 @@ pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
struct GithubAsset { struct GithubAsset {
name: String, name: String,
browser_download_url: String, browser_download_url: String,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
digest: Option<String>,
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -322,8 +322,24 @@ struct GithubRelease {
assets: Vec<GithubAsset>, assets: Vec<GithubAsset>,
} }
async fn register_flash_job(handle: &FlashJobHandle, job: &Arc<FlashJob>) -> 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 /// 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. /// `mesh.flash-status` for progress. Only one job may be in flight at a time.
pub async fn start_flash_job( pub async fn start_flash_job(
handle: &FlashJobHandle, handle: &FlashJobHandle,
@@ -333,21 +349,44 @@ pub async fn start_flash_job(
board: FlashBoard, board: FlashBoard,
family: DeviceType, family: DeviceType,
) -> Result<()> { ) -> Result<()> {
{ // Missing/corrupt tooling must fail before stopping a working radio or
let existing = handle.read().await; // registering a job that can never reach the serial device.
if let Some(job) = existing.as_ref() { if matches!(family, DeviceType::Meshtastic | DeviceType::Meshcore) {
if !job.snapshot().await.done { preflight_esptool().await?;
anyhow::bail!("A firmware flash is already in progress on this node");
}
}
} }
let job = FlashJob::new(board, family, path.clone()); 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_job = Arc::clone(&job);
let bg_service = Arc::clone(mesh_service); let bg_service = Arc::clone(mesh_service);
let task = tokio::spawn(async move { 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 // esptool/archy-rnodeconf need exclusive serial access — release
// the listener's hold on the port before touching it. This USED // the listener's hold on the port before touching it. This USED
// TO run synchronously in start_flash_job before the job was even // 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 // subsequent mesh.flash-device call failed with "already in
// progress" until the service was restarted). Generous enough that // progress" until the service was restarted). Generous enough that
// a legitimately slow multi-hundred-MB transfer still completes. // a legitimately slow multi-hundred-MB transfer still completes.
let result = match tokio::time::timeout( let flash = run_flash(
MAX_JOB_DURATION, board,
run_flash(board, family, &data_dir, &path, &bg_job), family,
) &data_dir,
.await &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, Ok(inner) => inner,
Err(_) => Err(anyhow::anyhow!( Err(_) if bg_job.snapshot().await.stage == FlashStage::Downloading => Err(
"Flash job exceeded the {}-minute ceiling — aborted", anyhow::anyhow!("Firmware download exceeded the time limit; radio was not written"),
MAX_JOB_DURATION.as_secs() / 60 ),
)), 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(); let succeeded = result.is_ok();
@@ -423,7 +476,6 @@ pub async fn start_flash_job(
bg_job bg_job
.push_log("Flash completed successfully".to_string()) .push_log("Flash completed successfully".to_string())
.await; .await;
bg_job.finish().await;
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded"); info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
} }
Err(e) => { Err(e) => {
@@ -434,7 +486,6 @@ pub async fn start_flash_job(
// erase_flash failed", no actual esptool stderr). // erase_flash failed", no actual esptool stderr).
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed"); warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
bg_job.push_log(format!("ERROR: {e:#}")).await; 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 !succeeded {
if let Err(error) = &result {
bg_job.fail(error).await;
}
// Deliberately do NOT auto-restart the listener here. A failed // Deliberately do NOT auto-restart the listener here. A failed
// flash means we can't vouch for the board's state — reopening // flash means we can't vouch for the board's state — reopening
// the port immediately (esptool/rnodeconf's own reset sequence // 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"), 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()); *job.abort_handle.write().await = Some(task.abort_handle());
@@ -510,12 +565,13 @@ async fn run_flash(
family: DeviceType, family: DeviceType,
data_dir: &Path, data_dir: &Path,
path: &str, path: &str,
prepared_image: Option<&Path>,
job: &Arc<FlashJob>, job: &Arc<FlashJob>,
) -> Result<()> { ) -> Result<()> {
match family { match family {
DeviceType::Meshtastic | DeviceType::Meshcore => { DeviceType::Meshtastic | DeviceType::Meshcore => {
let image = fetch_esptool_image(board, family, data_dir, job).await?; let image = prepared_image.context("Firmware was not prepared before serial access")?;
esptool_erase_and_write(path, &image, job).await esptool_erase_and_write(path, image, job).await
} }
DeviceType::Reticulum => { DeviceType::Reticulum => {
let lora_region = super::load_config(data_dir) 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::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); let out_path = cache.join(&asset.name);
if tokio::fs::metadata(&out_path).await.is_ok() { if tokio::fs::metadata(&out_path).await.is_ok() {
job.push_log(format!("Using cached {}", asset.name)).await; if verify_meshcore_asset(&out_path, asset).await.is_ok() {
return Ok(out_path); 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?; 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) 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( async fn download_to_file(
client: &reqwest::Client, client: &reqwest::Client,
url: &str, 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) tokio::fs::rename(&tmp, dest)
.await .await
.context("Finalizing firmware download")?; .context("Finalizing firmware download")?;
@@ -773,19 +880,10 @@ async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>)
/// Building global args separately from subcommand args keeps this correct /// Building global args separately from subcommand args keeps this correct
/// by construction instead of relying on call-site ordering. /// by construction instead of relying on call-site ordering.
/// ///
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher /// Normal stub-loader mode is required for full-chip erase. The packaged
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/ /// archy-esptool includes and self-tests Espressif's ESP32-S3 stub. Debian's
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1) /// stripped esptool package alone did not provide that resource, so changing
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no /// flags to --no-stub cannot repair this operation.
/// 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.
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> { fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path]; let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
if let Some(b) = baud { 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 args
} }
fn esptool_executable() -> Result<PathBuf> {
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<Item = PathBuf>) -> Result<PathBuf> {
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<PathBuf> {
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::<std::io::Error>().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<FlashJob>) -> Result<()> { async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> 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(esptool_global_args(path, None));
cmd.args(subcommand); cmd.args(subcommand);
match run_streamed(cmd, None, job).await { match run_streamed(cmd, None, job).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(first_err) => { Err(first_err) if retryable_flash_error(&first_err) => {
job.push_log(format!( job.push_log(format!(
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud" "First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
)) ))
.await; .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(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
retry.args(subcommand); retry.args(subcommand);
run_streamed(retry, None, job) run_streamed(retry, None, job)
.await .await
.context(format!("retry also failed (first attempt: {first_err:#})")) .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<Vec<u8>>, job: &Arc<FlashJ
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod flashing_regression_tests {
use super::*;
#[tokio::test]
async fn full_flash_invalidates_only_radio_settings_marker() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("meshcore-radio-params.json");
tokio::fs::write(&marker, b"old settings").await.unwrap();
let other = dir.path().join("mesh-config.json");
tokio::fs::write(&other, b"preserved").await.unwrap();
invalidate_radio_settings_marker(dir.path()).await.unwrap();
assert!(!marker.exists());
assert_eq!(tokio::fs::read(&other).await.unwrap(), b"preserved");
invalidate_radio_settings_marker(dir.path()).await.unwrap();
tokio::fs::create_dir(&marker).await.unwrap();
assert!(invalidate_radio_settings_marker(dir.path()).await.is_err());
}
use std::os::unix::fs::PermissionsExt;
#[tokio::test]
async fn meshcore_cache_requires_release_size_and_checksum() {
use sha2::{Digest, Sha256};
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("fixture.bin");
tokio::fs::write(&file, b"firmware fixture").await.unwrap();
let mut asset = GithubAsset {
name: "fixture.bin".into(),
browser_download_url: "https://example.invalid/fixture.bin".into(),
size: Some(16),
digest: Some(format!(
"sha256:{}",
hex::encode(Sha256::digest(b"firmware fixture"))
)),
};
assert!(verify_meshcore_asset(&file, &asset).await.is_ok());
tokio::fs::write(&file, b"tampered fixture").await.unwrap();
assert!(verify_meshcore_asset(&file, &asset).await.is_err());
tokio::fs::write(&file, b"short").await.unwrap();
assert!(verify_meshcore_asset(&file, &asset).await.is_err());
tokio::fs::write(&file, b"firmware fixture").await.unwrap();
asset.digest = None;
assert!(verify_meshcore_asset(&file, &asset).await.is_err());
}
#[test]
fn generic_usb_ids_do_not_select_firmware() {
for (vid, pid) in [("10c4", "ea60"), ("303a", "1001")] {
let info = DetectedDeviceInfo {
path: "/dev/fixture".into(),
vid: Some(vid.into()),
pid: Some(pid.into()),
product: None,
manufacturer: None,
plugged_at: None,
};
assert_eq!(resolve_flash_board(&info), None);
}
}
#[test]
fn absent_nonexecutable_and_directory_candidates_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("flasher");
std::fs::write(&file, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(executable_from_candidates([
dir.path().join("absent"),
file.clone(),
dir.path().to_path_buf()
])
.is_err());
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(executable_from_candidates([file.clone()]).unwrap(), file);
}
#[tokio::test]
async fn flasher_preflight_reports_missing_interpreter_and_failed_self_check() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("archy-esptool");
for script in ["#!/missing/python\n", "#!/bin/sh\nexit 7\n"] {
std::fs::write(&file, script).unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(check_esptool(&file).await.is_err());
}
std::fs::write(&file, "#!/bin/sh\n[ \"$1\" = --archy-self-test ]\n").unwrap();
assert!(check_esptool(&file).await.is_ok());
}
#[tokio::test]
async fn flash_registration_excludes_other_writers_and_active_probes() {
let handle = new_job_handle();
let first = FlashJob::new(
FlashBoard::HeltecV3,
DeviceType::Meshcore,
"/dev/fixture".into(),
);
let second = FlashJob::new(
FlashBoard::HeltecV3,
DeviceType::Meshcore,
"/dev/fixture".into(),
);
let probe = handle.read().await;
assert!(register_flash_job(&handle, &first).await.is_err());
drop(probe);
let (a, b) = tokio::join!(
register_flash_job(&handle, &first),
register_flash_job(&handle, &second)
);
assert_eq!(usize::from(a.is_ok()) + usize::from(b.is_ok()), 1);
handle.read().await.as_ref().unwrap().finish().await;
let next = FlashJob::new(
FlashBoard::HeltecV3,
DeviceType::Meshcore,
"/dev/fixture".into(),
);
assert!(register_flash_job(&handle, &next).await.is_ok());
}
#[test]
fn retries_only_known_serial_transport_failures() {
for kind in [
std::io::ErrorKind::NotFound,
std::io::ErrorKind::PermissionDenied,
] {
assert!(!retryable_flash_error(
&anyhow::Error::from(std::io::Error::from(kind))
.context("Failed to start subprocess")
));
}
for message in [
"Wrong chip",
"Invalid image",
"module missing",
"permission denied",
"port is busy",
] {
assert!(!retryable_flash_error(&anyhow::anyhow!(message)));
}
assert!(retryable_flash_error(&anyhow::anyhow!(
"Failed to connect to ESP32-S3: timed out waiting for packet header"
)));
assert_eq!(
esptool_global_args("/dev/fixture", Some("115200")),
[
"--chip",
"esp32s3",
"--port",
"/dev/fixture",
"--baud",
"115200"
]
);
}
}
+54 -5
View File
@@ -1092,6 +1092,14 @@ impl MeshService {
let (new_tx, new_rx) = tokio::sync::mpsc::channel(32); let (new_tx, new_rx) = tokio::sync::mpsc::channel(32);
*self.state.cmd_tx.write().await = new_tx; *self.state.cmd_tx.write().await = new_tx;
self.cmd_rx = Some(new_rx); self.cmd_rx = Some(new_rx);
{
let mut status = self.state.status.write().await;
status.device_connected = false;
status.device_path = None;
status.firmware_version = None;
status.self_node_id = None;
status.peer_count = 0;
}
info!("Mesh service stopped"); info!("Mesh service stopped");
} }
@@ -2321,7 +2329,10 @@ impl MeshService {
} }
} }
let was_enabled = self.config.enabled; let listener_running = self
.listener_handle
.as_ref()
.is_some_and(|handle| !handle.is_finished());
let needs_session_restart = session_config_changed(&self.config, &config); let needs_session_restart = session_config_changed(&self.config, &config);
self.config = config.clone(); self.config = config.clone();
@@ -2335,10 +2346,13 @@ impl MeshService {
.unwrap_or_else(|| "archipelago".to_string()); .unwrap_or_else(|| "archipelago".to_string());
} }
// If enabled state changed, start/stop the listener // Reconcile desired state with the actual task, not the old enabled
if config.enabled && !was_enabled { // flag. A failed flash can stop the task while keeping enabled=true;
// explicitly reconnecting with the same settings must restart it.
if config.enabled && !listener_running {
self.stop().await;
self.start()?; self.start()?;
} else if !config.enabled && was_enabled { } else if !config.enabled {
self.stop().await; self.stop().await;
// Clear connected state // Clear connected state
let mut status = self.state.status.write().await; let mut status = self.state.status.write().await;
@@ -2347,7 +2361,7 @@ impl MeshService {
status.firmware_version = None; status.firmware_version = None;
status.self_node_id = None; status.self_node_id = None;
status.peer_count = 0; status.peer_count = 0;
} else if config.enabled && was_enabled && needs_session_restart { } else if needs_session_restart {
info!("Mesh session config changed — restarting listener to apply"); info!("Mesh session config changed — restarting listener to apply");
self.stop().await; self.stop().await;
self.start()?; self.start()?;
@@ -2537,6 +2551,41 @@ mod tests {
} }
use super::*; use super::*;
#[tokio::test]
async fn reconnect_with_unchanged_enabled_config_restarts_stopped_listener() {
let dir = tempfile::tempdir().unwrap();
let key = SigningKey::from_bytes(&[7; 32]);
let public = hex::encode(key.verifying_key().to_bytes());
let did = crate::identity::did_key_from_pubkey_hex(&public).unwrap();
let config = MeshConfig {
enabled: true,
..Default::default()
};
save_config(dir.path(), &config).await.unwrap();
let mut service = MeshService::new(dir.path(), &key, &did, &public)
.await
.unwrap();
assert!(service.listener_handle.is_none());
service.configure(config.clone()).await.unwrap();
assert!(service.listener_handle.is_some());
service.stop().await;
assert!(service.config.enabled);
service.configure(config.clone()).await.unwrap();
assert!(service.listener_handle.is_some());
service.stop().await;
service.listener_handle = Some(tokio::spawn(async {}));
tokio::task::yield_now().await;
service.configure(config).await.unwrap();
assert!(!service.listener_handle.as_ref().unwrap().is_finished());
service.stop().await;
let disabled = MeshConfig {
enabled: false,
..Default::default()
};
service.configure(disabled).await.unwrap();
assert!(service.listener_handle.is_none());
}
#[test] #[test]
fn session_config_change_detection() { fn session_config_change_detection() {
let base = MeshConfig::default(); let base = MeshConfig::default();
+42
View File
@@ -378,6 +378,19 @@ fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
/// Parse RESP_DEVICE_INFO (0x0D) response. /// Parse RESP_DEVICE_INFO (0x0D) response.
/// Returns firmware version string and device capabilities. /// Returns firmware version string and device capabilities.
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> { 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. // Device info format varies by firmware version.
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE) // Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
if data.is_empty() { 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()); 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]]); 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 // Name follows after fixed fields. A firmware whose fixed-field layout
@@ -966,4 +988,24 @@ mod tests {
fn test_parse_self_info_too_short() { fn test_parse_self_info_too_short() {
assert!(parse_self_info(&[0x01, 0x02]).is_err()); 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()));
}
} }
+48 -11
View File
@@ -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<Child>);
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). /// One peer learned via an RNS announce (LXMF delivery destination).
#[derive(Clone)] #[derive(Clone)]
struct ReticulumPeer { struct ReticulumPeer {
@@ -517,10 +530,10 @@ impl ReticulumLink {
let child = cmd let child = cmd
.spawn() .spawn()
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?; .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. // 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 // StartingDaemon tears the group down on errors AND cancellation.
// group down via `terminate_group` (the child has no `kill_on_drop`).
let init = async { let init = async {
let deadline = tokio::time::Instant::now() + Duration::from_secs(15); let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
let stream = loop { let stream = loop {
@@ -560,20 +573,17 @@ impl ReticulumLink {
dest_hash = %dest_hash_hex, dest_hash = %dest_hash_hex,
"Reticulum daemon ready" "Reticulum daemon ready"
); );
Ok((write_half, reader, dest_hash, display_name)) Ok::<_, anyhow::Error>((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);
}
}; };
let (write_half, reader, dest_hash, display_name) = init.await?;
let mut link = Self { let mut link = Self {
device_path: label, device_path: label,
socket_path, socket_path,
child, child: starting
.0
.take()
.expect("starting daemon is owned until ready"),
writer: write_half, writer: write_half,
reader, reader,
dest_hash, dest_hash,
@@ -1557,6 +1567,33 @@ impl Drop for ReticulumLink {
mod tests { mod tests {
use super::*; 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] #[test]
fn announced_name_precedence() { fn announced_name_precedence() {
// Daemon-decoded LXMF name always wins. // Daemon-decoded LXMF name always wins.
+6 -2
View File
@@ -146,12 +146,16 @@ impl MeshcoreDevice {
} }
} }
let info = DeviceInfo { let mut info = DeviceInfo {
firmware_version: name.clone(), firmware_version: "unknown".to_string(),
node_id, node_id,
max_contacts: 100, max_contacts: 100,
device_type: super::types::DeviceType::Meshcore, 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()); self.device_info = Some(info.clone());
info!("Meshcore initialization complete on {}", self.device_path); info!("Meshcore initialization complete on {}", self.device_path);
+10 -1
View File
@@ -64,7 +64,16 @@ async fn relay_cannot_substitute_forged_fields_for_a_known_event_id() {
let relay = tokio::spawn(async move { let relay = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap(); let (socket, _) = listener.accept().await.unwrap();
let mut socket = accept_async(socket).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(); let message: serde_json::Value = serde_json::from_str(&text).unwrap();
if message[0] != "REQ" { if message[0] != "REQ" {
continue; continue;
+2
View File
@@ -83,6 +83,8 @@ impl EndpointRateLimiter {
limits.insert("wallet.send".to_string(), (5usize, 300u64)); limits.insert("wallet.send".to_string(), (5usize, 300u64));
limits.insert("wallet.ecash-send".to_string(), (10, 300)); limits.insert("wallet.ecash-send".to_string(), (10, 300));
limits.insert("lnd.sendcoins".to_string(), (5, 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.payinvoice".to_string(), (10, 300));
limits.insert("lnd.openchannel".to_string(), (3, 300)); limits.insert("lnd.openchannel".to_string(), (3, 300));
limits.insert("lnd.closechannel".to_string(), (3, 300)); limits.insert("lnd.closechannel".to_string(), (3, 300));
+137 -1
View File
@@ -1475,6 +1475,48 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|| path.starts_with("/dwn/") || 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<hyper::Body>,
) -> 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( async fn accept_loop(
handler: Arc<ApiHandler>, handler: Arc<ApiHandler>,
listener: TcpListener, listener: TcpListener,
@@ -1552,7 +1594,7 @@ async fn accept_loop(
// forwarded headers on loopback (nginx) connections. // forwarded headers on loopback (nginx) connections.
req.extensions_mut() req.extensions_mut()
.insert(crate::api::rpc::PeerAddr(peer_addr)); .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() let resp = hyper::Response::builder()
.status(hyper::StatusCode::NOT_FOUND) .status(hyper::StatusCode::NOT_FOUND)
.body(hyper::Body::empty()) .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
));
}
}
+2
View File
@@ -2654,6 +2654,8 @@ mod tests {
#[test] #[test]
fn test_is_newer() { 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.7.19-alpha", "1.7.18-alpha"));
assert!(is_newer("1.8.0-alpha", "1.7.99-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 assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical
+24 -1
View File
@@ -450,6 +450,17 @@ impl PodmanClient {
"nsmode": net_mode "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::<Vec<_>>()
});
}
if let Some(network) = custom_network { if let Some(network) = custom_network {
// The container always answers to its own name; manifest // The container always answers to its own name; manifest
// network_aliases add extra short hostnames peers may bake in // network_aliases add extra short hostnames peers may bake in
@@ -727,7 +738,11 @@ fn podman_network_settings(
Some("host") => ("host", None), Some("host") => ("host", None),
Some("bridge") => ("bridge", None), Some("bridge") => ("bridge", None),
Some("none") => ("none", 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("pasta") => ("pasta", None),
Some("private") => ("private", None), Some("private") => ("private", None),
Some(custom) => ("bridge", Some(custom.to_string())), 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] #[test]
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() { fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap(); let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
+20 -1
View File
@@ -40,6 +40,11 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
#[async_trait] #[async_trait]
pub trait ContainerRuntime: Send + Sync { 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 pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
async fn create_container( async fn create_container(
&self, &self,
@@ -628,7 +633,13 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
.as_deref() .as_deref()
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
.unwrap_or(&manifest.app.security.network_policy); .unwrap_or(&manifest.app.security.network_policy);
if matches!(network, "slirp4netns" | "pasta") { if matches!(
network,
"slirp4netns"
| "slirp4netns:allow_host_loopback=true"
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
| "pasta"
) {
anyhow::bail!("this app requires rootless Podman networking ({network})"); anyhow::bail!("this app requires rootless Podman networking ({network})");
} }
let mut args = Vec::new(); let mut args = Vec::new();
@@ -660,6 +671,10 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
#[async_trait] #[async_trait]
impl ContainerRuntime for DockerRuntime { impl ContainerRuntime for DockerRuntime {
fn cli_name(&self) -> &'static str {
"docker"
}
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
// Same signature gate as the podman path — the docker fallback is // Same signature gate as the podman path — the docker fallback is
// dev-only, but a declared signature must never be skippable by // dev-only, but a declared signature must never be skippable by
@@ -991,6 +1006,10 @@ impl AutoRuntime {
#[async_trait] #[async_trait]
impl ContainerRuntime for AutoRuntime { 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<()> { async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
self.runtime.pull_image(image, signature).await self.runtime.pull_image(image, signature).await
} }
+42
View File
@@ -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.
+43
View File
@@ -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. `/tmp/npm-release-handoff-ack.txt` and in the acknowledgement section above.
Publication remains held for the NPM release gate. Unavailable external acceptance Publication remains held for the NPM release gate. Unavailable external acceptance
must be stated explicitly and cannot be silently treated as passed. 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.
File diff suppressed because it is too large Load Diff
+529 -1
View File
@@ -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. 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, were reported by the operator; durable data-path resolution, safe host routing,
automatic certificate renewal/reload, and the handoff acceptance matrix remain automatic certificate renewal/reload, and the handoff acceptance matrix remain
required before publication. Earlier authorization does not waive this new gate. 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.
+519
View File
@@ -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.
@@ -1343,6 +1343,15 @@ else
echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded" echo " ⚠️ archy-rnodeconf not found at $RNODECONF — ISO nodes can't flash RNode firmware until it's sideloaded"
fi 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 [ "$BACKEND_CAPTURED" = "0" ]; then
if [ "$BUILD_FROM_SOURCE" != "1" ]; then if [ "$BUILD_FROM_SOURCE" != "1" ]; then
echo " ⚠️ Could not capture from live server, building from source..." 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 # 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 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) # Provision the same unique verified Cloud login used by app installation/OTA.
# Generate random FileBrowser password and store for auto-login if ! runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman container exists filebrowser; then
FB_PASS_DIR="/var/lib/archipelago/secrets/filebrowser" install -d -o 100000 -g 100000 /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
mkdir -p "$FB_PASS_DIR" install -d -o archipelago -g archipelago -m 700 /var/lib/archipelago/secrets/filebrowser
if [ ! -f "$FB_PASS_DIR/password" ]; then runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 \
head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24 > "$FB_PASS_DIR/password" python3 /opt/archipelago/scripts/filebrowser-credentials.py --image "$FILEBROWSER_IMAGE" >>"$LOG" 2>&1 || exit 1
chmod 600 "$FB_PASS_DIR/password" runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman run -d \
chown 1000:1000 "$FB_PASS_DIR/password" --name filebrowser --restart unless-stopped \
fi --cap-drop=ALL --cap-add=DAC_OVERRIDE --cap-add=NET_BIND_SERVICE \
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 \
--security-opt=no-new-privileges:true \ --security-opt=no-new-privileges:true \
--read-only \
--tmpfs=/tmp:rw,noexec,nosuid,size=64m \ --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 \ --health-interval=30s --health-timeout=5s --health-retries=3 \
--memory=256m \ --memory=256m -p 127.0.0.1:8083:80 \
-p 8083:80 \
-v /var/lib/archipelago/filebrowser:/srv \ -v /var/lib/archipelago/filebrowser:/srv \
-v /var/lib/archipelago/filebrowser-data:/data \ -v /var/lib/archipelago/filebrowser-data:/data \
-v /var/lib/archipelago/data/cloud:/srv/cloud \ -v /var/lib/archipelago/data/cloud:/srv/cloud \
$FILEBROWSER_IMAGE \ "$FILEBROWSER_IMAGE" --config /data/.filebrowser.json >>"$LOG" 2>&1 || exit 1
--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"
fi fi
echo "[$(date)] Minimal first-boot complete" >> "$LOG" echo "[$(date)] Minimal first-boot complete" >> "$LOG"
FBUNBUNDLED FBUNBUNDLED
@@ -2611,6 +2602,12 @@ fi
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/" 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.service" "$ARCH_DIR/scripts/"
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$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. # Build-source apps need their complete contexts even on unbundled ISOs.
# Keep this identical to the OTA runtime payload; a per-app allowlist silently # 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 chmod +x /mnt/target/usr/local/bin/* 2>/dev/null || true
fi 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 if [ -d "$BOOT_MEDIA/archipelago/web-ui" ]; then
cp -r "$BOOT_MEDIA/archipelago/web-ui" /mnt/target/opt/archipelago/ cp -r "$BOOT_MEDIA/archipelago/web-ui" /mnt/target/opt/archipelago/
fi fi
@@ -3245,6 +3246,13 @@ done
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do 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 install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
done 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 # END DOCTOR OVERLAY
# Copy self-update script # Copy self-update script
+41 -20
View File
@@ -15,6 +15,8 @@
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
QEMU_TMPDIR="${TMPDIR:-/tmp}" 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" SERIAL_LOG="$QEMU_TMPDIR/archipelago-qemu-serial.log"
FORCE_BIOS=false FORCE_BIOS=false
NOGRAPHIC=false NOGRAPHIC=false
@@ -67,6 +69,10 @@ echo " CPU: 2 cores"
echo " Serial: $SERIAL_LOG" echo " Serial: $SERIAL_LOG"
echo "" echo ""
# Never accept boot markers left by an earlier VM.
mkdir -p "$QEMU_TMPDIR"
: > "$SERIAL_LOG"
# Create test disk if it doesn't exist # Create test disk if it doesn't exist
DISK="$QEMU_TMPDIR/archipelago-test-disk.qcow2" DISK="$QEMU_TMPDIR/archipelago-test-disk.qcow2"
if [ ! -f "$DISK" ]; then if [ ! -f "$DISK" ]; then
@@ -81,8 +87,9 @@ QEMU_ARGS=(
-boot d -boot d
-cdrom "$ISO" -cdrom "$ISO"
-drive if=virtio,format=qcow2,file="$DISK" -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" -serial file:"$SERIAL_LOG"
-qmp "unix:$QEMU_TMPDIR/archipelago-qmp.sock,server=on,wait=off"
) )
# Display mode # Display mode
@@ -99,28 +106,37 @@ echo ""
# Detect UEFI firmware # Detect UEFI firmware
OVMF="" OVMF=""
OVMF_VARS=""
if [ "$FORCE_BIOS" = false ]; then if [ "$FORCE_BIOS" = false ]; then
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd" OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
OVMF="/usr/share/OVMF/OVMF_CODE.fd" 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
fi fi
run_qemu() { QEMU_COMMAND=(qemu-system-x86_64)
if [ -n "$OVMF" ]; then if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
echo " Boot: UEFI ($OVMF)" QEMU_COMMAND+=(-enable-kvm)
qemu-system-x86_64 \ fi
-machine q35 \ if [ -n "$OVMF" ]; then
-drive if=pflash,format=raw,readonly=on,file="$OVMF" \ echo " Boot: UEFI ($OVMF)"
"${QEMU_ARGS[@]}" QEMU_COMMAND+=(-machine q35 -drive "if=pflash,format=raw,readonly=on,file=$OVMF")
else if [ -f "$OVMF_VARS" ]; then
echo " Boot: Legacy BIOS" if [ ! -f "$QEMU_TMPDIR/archipelago-uefi-vars.fd" ]; then
qemu-system-x86_64 \ cp "$OVMF_VARS" "$QEMU_TMPDIR/archipelago-uefi-vars.fd" || exit 1
-machine pc \ fi
"${QEMU_ARGS[@]}" QEMU_COMMAND+=(-drive "if=pflash,format=raw,file=$QEMU_TMPDIR/archipelago-uefi-vars.fd")
fi 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 # 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 # 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 # the serial log shows a kernel reaching userspace — we inspect that
# after the QEMU process ends. # after the QEMU process ends.
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then 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=$? rc=$?
if [ $rc -eq 124 ] || [ $rc -eq 137 ]; then if [ $rc -eq 124 ]; then
echo "(QEMU terminated after ${TIMEOUT}s boot-test window)" echo "(QEMU terminated after ${TIMEOUT}s boot-test window)"
rc=0 rc=0
fi fi
else else
run_qemu "${QEMU_COMMAND[@]}"
rc=$? rc=$?
fi fi
@@ -150,12 +168,15 @@ tail -20 "$SERIAL_LOG" 2>/dev/null
# by live-boot/systemd early in the sequence. If the marker never # by live-boot/systemd early in the sequence. If the marker never
# appeared, surface the real failure; otherwise treat "timeout reached # appeared, surface the real failure; otherwise treat "timeout reached
# with a live kernel" as a pass. # 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 [ "$TIMEOUT" -gt 0 ] 2>/dev/null && [ -f "$SERIAL_LOG" ]; then
if grep -qE "Welcome to Debian|Reached target|systemd\[1\]:" "$SERIAL_LOG"; then 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 (systemd reached in serial log)" echo " Boot sanity: OK (userspace reached in serial log; installation not yet tested)"
exit 0 exit 0
fi 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 exit 1
fi fi
exit "${rc:-0}" exit "${rc:-0}"
@@ -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
@@ -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
+50 -1
View File
@@ -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 # Rate limit zones
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s; limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/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; resolver_timeout 5s;
server { server {
if ($archy_management_denied) { return 404; }
listen 80 default_server; listen 80 default_server;
# IPv6 listener is REQUIRED: companion phones reach this node over the # IPv6 listener is REQUIRED: companion phones reach this node over the
# FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that # 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. # Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
location ^~ /.well-known/acme-challenge/ { location ^~ /.well-known/acme-challenge/ {
default_type text/plain; 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; try_files $uri =404;
} }
@@ -1021,6 +1062,8 @@ server {
# HTTPS - required for PWA install (Add to Home Screen) from dev servers # HTTPS - required for PWA install (Add to Home Screen) from dev servers
server { server {
if ($archy_management_denied) { return 404; }
listen 443 ssl default_server; listen 443 ssl default_server;
listen [::]:443 ssl default_server; listen [::]:443 ssl default_server;
server_name _; server_name _;
@@ -1035,6 +1078,12 @@ server {
include snippets/archipelago-pwa.conf; include snippets/archipelago-pwa.conf;
# Same CA download over HTTPS — see the note in the HTTP block above. # 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 { location = /ca.crt {
alias /etc/archipelago/ssl/ca-download.crt; alias /etc/archipelago/ssl/ca-download.crt;
default_type application/x-x509-ca-cert; default_type application/x-x509-ca-cert;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.22-alpha", "version": "1.9.0-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "neode-ui", "name": "neode-ui",
"version": "1.8.22-alpha", "version": "1.9.0-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0", "@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "neode-ui", "name": "neode-ui",
"private": true, "private": true,
"version": "1.8.22-alpha", "version": "1.9.0-alpha",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "./start-dev.sh", "start": "./start-dev.sh",
@@ -10,7 +10,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:mock-parity": "node scripts/mock-rpc-parity.mjs", "test:mock-parity": "node scripts/mock-rpc-parity.mjs",
"dev": "vite", "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: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", "dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js", "backend:mock": "node mock-backend.js",
+23
View File
@@ -0,0 +1,23 @@
<template>
<main class="min-h-screen text-white p-6">
<div class="max-w-md mx-auto py-8 space-y-4">
<p class="text-xs text-orange-200">UI PREVIEW · SAMPLE DATA · NO WALLET ACCESS</p>
<h1 class="text-2xl font-semibold">Bump a transaction</h1>
<p class="text-sm text-white/55">Open the transaction list and tap the small Bump button. You can try both supported methods and the custom fee review without spending anything.</p>
<div class="flex gap-2">
<button class="rounded-lg px-4 py-2 bg-white/10 text-sm" @click="open('cpfp')">Preview CPFP</button>
<button class="rounded-lg px-4 py-2 bg-white/10 text-sm" @click="open('rbf')">Preview RBF</button>
</div>
</div>
<TransactionsModal :key="revision" :show="show" :transactions="transactions" @close="show = false" />
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TransactionsModal from '@/components/TransactionsModal.vue'
import { choose } from './rpc'
const show = ref(false)
const revision = ref(0)
const transactions = [{ tx_hash: 'a'.repeat(64), amount_sats: 161794, direction: 'outgoing' as const, num_confirmations: 0, time_stamp: Math.floor(Date.now()/1000)-120, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }]
function open(method: string) { choose(method); revision.value++; show.value = true }
</script>
+1
View File
@@ -0,0 +1 @@
export function useTxExplorer() { return { openTx() {} } }
+1
View File
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Archy · Bump preview</title></head><body style="background:#080808"><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
+7
View File
@@ -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: '<div />'}}] })
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')
+19
View File
@@ -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<string, unknown> }) {
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 }
} }
+6 -6
View File
@@ -436,13 +436,13 @@
{ {
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager", "title": "Nginx Proxy Manager",
"version": "2.12.1", "version": "2.14.0",
"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).", "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", "icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager", "author": "Nginx Proxy Manager",
"category": "networking", "category": "networking",
"tier": "optional", "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" "repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
}, },
{ {
@@ -648,9 +648,9 @@
{ {
"id": "angor-indexer", "id": "angor-indexer",
"title": "Angor Indexer", "title": "Angor Indexer",
"version": "1.0.1", "version": "1.0.2",
"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.", "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.1", "dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
"author": "Angor / Archipelago", "author": "Angor / Archipelago",
"requires": [ "requires": [
"Mempool API", "Mempool API",
+2 -3
View File
@@ -19,8 +19,6 @@
<AppLauncherOverlay /> <AppLauncherOverlay />
<AppCredentialInterstitial /> <AppCredentialInterstitial />
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
<!-- Global toast notifications --> <!-- Global toast notifications -->
<ToastStack /> <ToastStack />
@@ -98,7 +96,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue' import { useUploadNotifications } from '@/composables/useUploadNotifications'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue' import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue' import SplashScreen from './components/SplashScreen.vue'
@@ -248,6 +246,7 @@ function onKeyDown(e: KeyboardEvent) {
} }
const route = useRoute() const route = useRoute()
useUploadNotifications()
const isSignerBroker = computed(() => route.meta.signerBroker === true) const isSignerBroker = computed(() => route.meta.signerBroker === true)
// Start with splash hidden — onMounted decides whether to show it // Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false) const showSplash = ref(false)
@@ -46,9 +46,45 @@ describe('FileBrowserClient', () => {
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset() mockFetch.mockReset()
;(fileBrowserClient as any)._authenticated = false ;(fileBrowserClient as any)._authenticated = false
;(fileBrowserClient as any)._lastLoginFailure = 0
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT' document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
}) })
it('allows a failed network login to recover immediately instead of caching an auth rejection', async () => {
;(fileBrowserClient as any)._lastLoginFailure = 0
mockFetch.mockRejectedValueOnce(new TypeError('Network disconnected'))
await expect(fileBrowserClient.listDirectory('/')).rejects.toBeInstanceOf(TypeError)
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'reconnected-token' } }))
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [] }))
await expect(fileBrowserClient.listDirectory('/')).resolves.toEqual([])
})
it('keeps an interrupted refresh retryable when another screen requests login concurrently', async () => {
let disconnect!: (error: Error) => void
mockFetch.mockImplementationOnce(() => new Promise((_resolve, reject) => { disconnect = reject }))
const listing = fileBrowserClient.listDirectory('/')
const assertion = expect(listing).rejects.toBeInstanceOf(TypeError)
disconnect(new TypeError('Connection reset'))
let second!: Promise<boolean>
mockFetch.mockResolvedValue(jsonResponse({ result: { token: 'reconnected-token' } }))
queueMicrotask(() => { second = fileBrowserClient.login() })
await assertion
await second
})
it('encodes literal filename punctuation for listing, reading and deleting', async () => {
setAuthenticated()
mockFetch.mockResolvedValue(jsonResponse({ items: [] }))
const path = '/a + 100% ? # ü.txt'
const encoded = '/a%20%2B%20100%25%20%3F%20%23%20%C3%BC.txt'
await fileBrowserClient.listDirectory(path)
expect(mockFetch.mock.calls[0]![0]).toContain('/app/filebrowser/api/resources' + encoded)
await fileBrowserClient.deleteItem(path)
expect(mockFetch.mock.calls[1]![0]).toContain('/app/filebrowser/api/resources' + encoded)
expect(fileBrowserClient.downloadUrl(path)).toContain('/app/filebrowser/api/raw' + encoded)
expect(await fileBrowserClient.streamUrl(path)).toContain('/app/filebrowser/api/raw' + encoded)
})
describe('login', () => { describe('login', () => {
it('authenticates via backend RPC and stores token', async () => { it('authenticates via backend RPC and stores token', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } })) mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resumableUpload } from '../resumable-upload'
const MiB = 1024 * 1024
const hash = 'a'.repeat(64)
function fixture(size = 5 * MiB, name = 'resume.txt') {
const file = new File([new Uint8Array(size)], name)
const controller = new AbortController()
const progress: number[] = []
const paused: boolean[] = []
let stage = false, active = false, bytes = 0, target = false
const patches: number[] = []
const methods: string[] = []
let hook: ((url: URL, init: RequestInit) => Response | void | Promise<Response | void>) | undefined
const response = (status: number, data?: unknown, headers?: Record<string, string>) => new Response(data === undefined ? null : JSON.stringify(data), { status, headers: { ...(data ? { 'content-type': 'application/json' } : {}), ...headers } })
const transport = vi.fn(async (input: string, init: RequestInit = {}) => {
const url = new URL(input)
methods.push(init.method || 'GET')
const special = await hook?.(url, init)
if (special) return special
if (url.pathname.includes('/api/tus/')) {
if (init.method === 'HEAD') return active ? response(200, undefined, { 'Upload-Offset': String(bytes), 'Upload-Length': String(size) }) : response(404)
if (init.method === 'POST') {
expect(url.searchParams.get('override')).not.toBe('true')
if (stage) return response(409)
stage = active = true; return response(201)
}
if (init.method === 'PATCH') {
const offset = Number((init.headers as Record<string, string>)['Upload-Offset'])
patches.push(offset)
if (!active) return response(404)
if (offset !== bytes) return response(409)
bytes += (init.body as Blob).size
if (bytes === size) active = false
return response(204, undefined, { 'Upload-Offset': String(bytes) })
}
if (init.method === 'DELETE') { if (!active) return response(404); stage = active = false; return response(204) }
}
if (init.method === 'PATCH') { expect(decodeURIComponent(url.searchParams.get('destination')!)).toBe('/folder/' + name); target = true; stage = false; return response(200) }
if (init.method === 'DELETE') { expect(url.pathname).toContain('.archy-upload-'); stage = false; return response(200) }
if (url.pathname.includes('.archy-upload-') ? stage : target) return response(200, { size: bytes, isDir: false, checksums: { sha256: hash } })
return response(404)
})
return {
file, controller, progress, paused, patches, methods, transport, response,
setHook: (fn: typeof hook) => { hook = fn },
createStage: () => { stage = active = true },
setBytes: (n: number) => { bytes = n },
finishStage: () => { bytes = size; active = false },
rename: () => { target = true; stage = false },
hasStage: () => stage, hasTarget: () => target,
run: () => resumableUpload('https://node/app/filebrowser', '/folder', file, transport, { signal: controller.signal, onProgress: n => progress.push(n), onPaused: p => paused.push(p) }),
}
}
afterEach(() => vi.useRealTimers())
describe('resumable Cloud upload', () => {
it('uploads bounded chunks and publishes only complete verified bytes', async () => {
const f = fixture(); await f.run()
expect(f.patches).toEqual([0, 2 * MiB, 4 * MiB])
expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(true)
expect(f.progress[f.progress.length - 1]).toBe(f.file.size)
})
it('resumes after partial network write at the server offset, never truncating', async () => {
vi.useFakeTimers(); const f = fixture(); let lost = false
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) {
lost = true; f.setBytes(123456); throw new TypeError('Network lost')
}
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.patches[0]).toBe(123456)
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
expect(f.paused).toContain(true); expect(f.paused[f.paused.length - 1]).toBe(false)
})
it('recovers a lost final chunk response after TUS completion removes the session', async () => {
vi.useFakeTimers(); const f = fixture(1024); let lost = false
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/tus/') && init.method === 'PATCH' && !lost) {
lost = true; f.finishStage(); throw new TypeError('Reply lost')
}
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1); expect(f.hasTarget()).toBe(true)
})
it('confirms a lost rename response using exact checksum', async () => {
vi.useFakeTimers(); const f = fixture(1024); let lost = false
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/resources/') && init.method === 'PATCH' && !lost) {
lost = true; f.rename(); throw new TypeError('Reply lost')
}
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.hasTarget()).toBe(true)
expect(f.transport.mock.calls.some(([url]) => url.includes('resume.txt?checksum=sha256'))).toBe(true)
})
it('never accepts a same-size destination with different content after ambiguous rename', async () => {
vi.useFakeTimers(); const f = fixture(1024)
f.setHook(async (url, init) => {
if (url.pathname.includes('/api/resources/') && init.method === 'PATCH') { f.rename(); throw new TypeError('Reply lost') }
if (url.pathname.endsWith('/resume.txt')) return f.response(200, { size: 1024, checksums: { sha256: 'b'.repeat(64) } })
})
const result = expect(f.run()).rejects.toThrow('Cannot confirm'); await vi.runAllTimersAsync(); await result
})
it('reconciles a lost creation reply without creating or truncating a second upload', async () => {
vi.useFakeTimers(); const f = fixture(1024); let created = false
f.setHook(async (_, init) => {
if (init.method === 'POST' && !created) { created = true; f.createStage(); throw new TypeError('Creation reply lost') }
})
const run = f.run(); await vi.runAllTimersAsync(); await run
expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([0]); expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
})
it('does not silently restart an expired partially saved session', async () => {
const f = fixture(); let patched = false
f.setHook(async (_, init) => {
if (init.method === 'PATCH') patched = true
if (init.method === 'HEAD' && patched) return f.response(404)
})
await expect(f.run()).rejects.toThrow('session expired')
expect(f.methods.filter(m => m === 'POST')).toHaveLength(1)
expect(f.hasTarget()).toBe(false)
})
it('wakes immediately when the network returns and cleans up wake listeners', async () => {
vi.useFakeTimers(); const f = fixture(1024); let offline = true
f.setHook(async (_, init) => { if (init.method === 'PATCH' && offline) throw new TypeError('Offline') })
const run = f.run(); await vi.advanceTimersByTimeAsync(0)
expect(f.paused).toContain(true)
offline = false; window.dispatchEvent(new Event('online'))
await vi.advanceTimersByTimeAsync(0); await run
expect(vi.getTimerCount()).toBe(0); expect(f.hasTarget()).toBe(true)
})
it('handles empty files and encoded filenames', async () => {
const f = fixture(0, 'a + 100% ? ü.txt'); await f.run(); expect(f.hasTarget()).toBe(true); expect(f.patches).toEqual([])
})
it.each([401, 403, 507])('stops on permanent HTTP %s without endless retry', async status => {
const f = fixture(); f.setHook(async () => f.response(status))
await expect(f.run()).rejects.toThrow(status === 507 ? 'storage' : 'access denied')
expect(f.paused).not.toContain(true)
})
it('rejects HTML login interception', async () => {
const f = fixture(); f.setHook(async () => new Response('<html>', { headers: { 'content-type': 'text/html' } }))
await expect(f.run()).rejects.toThrow('working File Browser')
})
it('rejects corrupt server offsets', async () => {
const f = fixture(); f.setHook(async (_, init) => init.method === 'HEAD' ? f.response(200, undefined, { 'Upload-Offset': '-1', 'Upload-Length': String(f.file.size) }) : undefined)
await expect(f.run()).rejects.toThrow('invalid upload position')
})
it('cancels during retry and removes only its staging file', async () => {
const f = fixture(); f.setHook(async (_, init) => {
if (init.method === 'PATCH') { queueMicrotask(() => f.controller.abort()); throw new TypeError('Offline') }
})
await expect(f.run()).rejects.toMatchObject({ name: 'AbortError' })
expect(f.hasStage()).toBe(false); expect(f.hasTarget()).toBe(false)
})
})
+11 -1
View File
@@ -320,7 +320,17 @@ describe('RPCClient convenience methods', () => {
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 }) mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] }) await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
expect(getLastMethod()).toBe('lnd.create-psbt') expect(getLastMethod()).toBe('lnd.create-psbt')
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10) expect(getLastParams()).not.toHaveProperty('fee_rate_sat_per_vbyte')
})
it('preserves the explicit hardware-wallet fee rate', async () => {
mockSuccess({ psbt_base64: 'psbt', fee_rate_sat_per_vbyte: 17 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }], feeRateSatPerVbyte: 17 })
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(17)
})
it.each([NaN, Infinity, 0, -1, 0.5, 5001])('rejects invalid PSBT rate %s rather than silently selecting a default', async rate => {
await expect(rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }], feeRateSatPerVbyte: rate })).rejects.toThrow('whole number')
}) })
it('finalizePsbt calls lnd.finalize-psbt', async () => { it('finalizePsbt calls lnd.finalize-psbt', async () => {
+45 -16
View File
@@ -1,3 +1,5 @@
import { resumableUpload, type ResumableUploadOptions } from './resumable-upload'
export interface FileBrowserItem { export interface FileBrowserItem {
name: string name: string
path: string path: string
@@ -35,8 +37,11 @@ export function sanitizePath(path: string): string {
return '/' + resolved.join('/') return '/' + resolved.join('/')
} }
const encodeFilePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
class FileBrowserClient { class FileBrowserClient {
private _authenticated = false private _authenticated = false
private _loginPromise: Promise<{ authenticated: boolean; retryable: boolean }> | null = null
private baseUrl: string private baseUrl: string
constructor() { constructor() {
@@ -53,6 +58,20 @@ class FileBrowserClient {
} }
async login(): Promise<boolean> { async login(): Promise<boolean> {
return (await this.authenticate()).authenticated
}
private authenticate(): Promise<{ authenticated: boolean; retryable: boolean }> {
// Folder polling and uploads can refresh together after returning online.
// Share one request and keep its failure classification in its result;
// another refresh must not turn a disconnected request into an auth denial.
if (!this._loginPromise) {
this._loginPromise = this.performLogin().finally(() => { this._loginPromise = null })
}
return this._loginPromise
}
private async performLogin(): Promise<{ authenticated: boolean; retryable: boolean }> {
try { try {
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser) // Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
// Use credentials: 'include' and CSRF token for proper auth // Use credentials: 'include' and CSRF token for proper auth
@@ -67,18 +86,21 @@ class FileBrowserClient {
body: JSON.stringify({ method: 'app.filebrowser-token' }), body: JSON.stringify({ method: 'app.filebrowser-token' }),
credentials: 'include', credentials: 'include',
}) })
if (!rpcRes.ok) return false if (!rpcRes.ok) {
return { authenticated: false, retryable: rpcRes.status >= 500 || rpcRes.status === 408 || rpcRes.status === 429 }
}
const rpcData = await rpcRes.json() const rpcData = await rpcRes.json()
const token = rpcData?.result?.token const token = rpcData?.result?.token
if (!token) return false if (!token) return { authenticated: false, retryable: false }
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString() const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString()
const secure = window.location.protocol === 'https:' ? '; Secure' : '' const secure = window.location.protocol === 'https:' ? '; Secure' : ''
document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}` document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}`
this._authenticated = true this._authenticated = true
return true this._lastLoginFailure = 0
return { authenticated: true, retryable: false }
} catch { } catch {
return false return { authenticated: false, retryable: true }
} }
} }
@@ -101,8 +123,11 @@ class FileBrowserClient {
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) { if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
throw new Error('FileBrowser authentication failed — please open Cloud to log in') throw new Error('FileBrowser authentication failed — please open Cloud to log in')
} }
const ok = await this.login() const outcome = await this.authenticate()
if (!ok) { if (!outcome.authenticated) {
// An interrupted token refresh is a transport failure, not a rejected
// credential. Resumable uploads must be able to retry it on reconnect.
if (outcome.retryable) throw new TypeError('Cloud login connection interrupted')
this._lastLoginFailure = Date.now() this._lastLoginFailure = Date.now()
throw new Error('FileBrowser authentication failed — please open Cloud to log in') throw new Error('FileBrowser authentication failed — please open Cloud to log in')
} }
@@ -125,7 +150,7 @@ class FileBrowserClient {
async listDirectory(path: string): Promise<FileBrowserItem[]> { async listDirectory(path: string): Promise<FileBrowserItem[]> {
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`) const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`)
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`) if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and // When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502. // returns index.html (200, text/html); when it's down it returns 502.
@@ -148,7 +173,7 @@ class FileBrowserClient {
*/ */
downloadUrl(path: string): string { downloadUrl(path: string): string {
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}` return `${this.baseUrl}/api/raw${encodeFilePath(safePath)}`
} }
/** /**
@@ -158,7 +183,7 @@ class FileBrowserClient {
*/ */
async fetchBlobUrl(path: string): Promise<string> { async fetchBlobUrl(path: string): Promise<string> {
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`) const res = await this.authedFetch(`${this.baseUrl}/api/raw${encodeFilePath(safePath)}`)
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`) if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
return URL.createObjectURL(blob) return URL.createObjectURL(blob)
@@ -183,7 +208,7 @@ class FileBrowserClient {
async streamUrl(path: string): Promise<string> { async streamUrl(path: string): Promise<string> {
await this.ensureAuth() await this.ensureAuth()
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
return `${this.baseUrl}/api/raw${safePath}` return `${this.baseUrl}/api/raw${encodeFilePath(safePath)}`
} }
/** /**
@@ -201,7 +226,11 @@ class FileBrowserClient {
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> { async upload(dirPath: string, file: File, options?: ResumableUploadOptions & { resumable?: boolean }): Promise<void> {
if (options?.resumable) {
await this.ensureAuth()
return resumableUpload(this.baseUrl, sanitizePath(dirPath), file, (url, init) => this.authedFetch(url, init), options)
}
if (options) { if (options) {
await this.ensureAuth() await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError') if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
@@ -242,7 +271,7 @@ class FileBrowserClient {
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name) const encodedName = encodeURIComponent(file.name)
const res = await this.authedFetch( const res = await this.authedFetch(
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`, `${this.baseUrl}/api/resources${encodeFilePath(safePath)}${encodedName}?override=true`,
{ method: 'POST', body: file }, { method: 'POST', body: file },
) )
if (!res.ok) { if (!res.ok) {
@@ -255,7 +284,7 @@ class FileBrowserClient {
const sanitized = sanitizePath(parentPath) const sanitized = sanitizePath(parentPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '') const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}${encodeURIComponent(sanitizedName)}/`, {
method: 'POST', method: 'POST',
}) })
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`) if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
@@ -263,7 +292,7 @@ class FileBrowserClient {
async deleteItem(path: string): Promise<void> { async deleteItem(path: string): Promise<void> {
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`, {
method: 'DELETE', method: 'DELETE',
}) })
if (!res.ok) throw new Error(`Delete failed: ${res.status}`) if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
@@ -304,7 +333,7 @@ class FileBrowserClient {
throw new Error(`Cannot read binary file: ${path}`) throw new Error(`Cannot read binary file: ${path}`)
} }
const safePath = sanitizePath(path) const safePath = sanitizePath(path)
const res = await this.authedFetch(`${this.baseUrl}/api/raw${safePath}`) const res = await this.authedFetch(`${this.baseUrl}/api/raw${encodeFilePath(safePath)}`)
if (!res.ok) throw new Error(`Failed to read file: ${res.status}`) if (!res.ok) throw new Error(`Failed to read file: ${res.status}`)
const blob = await res.blob() const blob = await res.blob()
const size = blob.size const size = blob.size
@@ -318,7 +347,7 @@ class FileBrowserClient {
const safePath = sanitizePath(oldPath) const safePath = sanitizePath(oldPath)
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1) const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '') const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
const res = await this.authedFetch(`${this.baseUrl}/api/resources${safePath}`, { const res = await this.authedFetch(`${this.baseUrl}/api/resources${encodeFilePath(safePath)}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }), body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
+170
View File
@@ -0,0 +1,170 @@
/** File Browser 2.63.23 TUS uploads. Keep partial data under a unique name;
* only publish the requested filename after the server has all the bytes.
* The File remains in memory: this recovers a suspended page, not a killed page.
*/
export interface ResumableUploadOptions {
signal: AbortSignal
onProgress: (bytes: number) => void
onPaused?: (paused: boolean) => void
}
type Transport = (url: string, init?: RequestInit) => Promise<Response>
class Retryable extends Error {}
const abortError = () => new DOMException('Upload cancelled', 'AbortError')
const encodePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
function waitForConnection(signal: AbortSignal, delay: number): Promise<void> {
return new Promise((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer)
window.removeEventListener('online', wake)
document.removeEventListener('visibilitychange', visible)
signal.removeEventListener('abort', abort)
}
const wake = () => { cleanup(); resolve() }
const visible = () => { if (document.visibilityState === 'visible') wake() }
const abort = () => { cleanup(); reject(abortError()) }
const timer = setTimeout(wake, delay)
window.addEventListener('online', wake)
document.addEventListener('visibilitychange', visible)
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) abort()
})
}
export async function resumableUpload(
baseUrl: string, folder: string, file: File, transport: Transport,
options: ResumableUploadOptions,
): Promise<void> {
if (!file.name || /[/\\\0]/.test(file.name) || file.name === '.' || file.name === '..') {
throw new Error('Invalid upload filename')
}
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), n => n.toString(16).padStart(2, '0')).join('')
const stage = `${folder.replace(/\/$/, '')}/.archy-upload-${nonce}.part`
const destination = `${folder.replace(/\/$/, '')}/${file.name}`
const tus = `${baseUrl}/api/tus${encodePath(stage)}`
const resource = (path: string) => `${baseUrl}/api/resources${encodePath(path)}`
let created = false
let checksum: string | undefined
let retry = 0
let finished = false
const request = async (url: string, init: RequestInit = {}, signal = options.signal) => {
if (signal.aborted) throw abortError()
const controller = new AbortController()
const abort = () => controller.abort()
signal.addEventListener('abort', abort, { once: true })
const timer = setTimeout(abort, 60_000)
try {
const response = await transport(url, { ...init, signal: controller.signal, cache: 'no-store' })
if (response.status === 408 || response.status === 429 || (response.status >= 500 && response.status !== 507)) {
throw new Retryable('Server temporarily unavailable')
}
if (response.headers.get('content-type')?.includes('text/html')) {
throw new Error('Upload needs a working File Browser connection. Reopen Cloud and try again.')
}
if (response.status === 401 || response.status === 403) throw new Error('Upload access denied. Sign in again or check folder permissions.')
if (response.status === 507) throw new Error('Upload stopped: the server has insufficient storage.')
return response
} catch (error) {
if (signal.aborted) throw abortError()
if (controller.signal.aborted || error instanceof TypeError) throw new Retryable('Connection interrupted')
throw error
} finally {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
}
}
const metadata = async (path: string, hash = false) => {
const response = await request(`${resource(path)}${hash ? '?checksum=sha256' : ''}`)
if (response.status === 404) return null
if (response.status !== 200) throw new Error(`Cannot verify uploaded file (HTTP ${response.status})`)
const data = await response.json()
if (data.isDir || !Number.isSafeInteger(data.size) || data.size < 0) throw new Error('Invalid upload verification response')
return data as { size: number; checksums?: Record<string, string> }
}
try {
while (!finished) {
if (options.signal.aborted) throw abortError()
try {
if (checksum) {
// A rename can succeed while its response is lost. Verify exact server
// content, not just size (an old destination might have the same size).
const source = await metadata(stage)
if (!source) {
const target = await metadata(destination, true)
if (target?.size !== file.size || target.checksums?.sha256 !== checksum) {
throw new Error('Cannot confirm the saved upload. Check the destination before trying again.')
}
finished = true
} else {
if (source.size !== file.size) throw new Error('Upload changed before it could be saved')
// File Browser decodes destination twice: protect literal %, + and ?.
const query = new URLSearchParams({ action: 'rename', destination: encodeURIComponent(destination), override: 'true', rename: 'false' })
const saved = await request(`${resource(stage)}?${query}`, { method: 'PATCH' })
if (saved.status === 404) throw new Retryable('Checking completed upload')
if (!saved.ok) throw new Error(`Could not save uploaded file (HTTP ${saved.status})`)
finished = true
}
} else {
const head = await request(tus, { method: 'HEAD' })
let offset: number
if (head.status === 404) {
const existing = await metadata(stage)
if (existing?.size === file.size) {
offset = file.size // final PATCH acknowledged on server, reply lost
} else if (existing || created) {
throw new Error('The server upload session expired. Please select the file again.')
} else {
const post = await request(tus, { method: 'POST', headers: { 'Upload-Length': String(file.size), 'Tus-Resumable': '1.0.0' } })
if (post.status === 409) throw new Retryable('Checking existing upload')
if (post.status !== 201) throw new Error(`Could not start resumable upload (HTTP ${post.status})`)
created = true
offset = 0
}
} else {
const rawOffset = head.headers.get('Upload-Offset')
const rawLength = head.headers.get('Upload-Length')
offset = Number(rawOffset)
if (head.status !== 200 || rawOffset === null || rawLength === null || Number(rawLength) !== file.size || !Number.isSafeInteger(offset) || offset < 0 || offset > file.size) {
throw new Error('The server returned an invalid upload position')
}
created = true
}
options.onProgress(offset)
options.onPaused?.(false)
if (offset < file.size) {
const end = Math.min(offset + 2 * 1024 * 1024, file.size)
const patch = await request(tus, { method: 'PATCH', headers: { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': String(offset), 'Tus-Resumable': '1.0.0' }, body: file.slice(offset, end) })
if (patch.status === 409) throw new Retryable('Checking saved upload position')
if (patch.status !== 204 || Number(patch.headers.get('Upload-Offset')) !== end) throw new Error(`Server did not confirm the upload chunk (HTTP ${patch.status})`)
options.onProgress(end)
} else {
const saved = await metadata(stage, true)
checksum = saved?.checksums?.sha256
if (saved?.size !== file.size || !checksum || !/^[a-f0-9]{64}$/i.test(checksum)) throw new Error('Could not verify the completed upload')
}
}
retry = 0
} catch (error) {
if (!(error instanceof Retryable)) throw error
options.onPaused?.(true)
await waitForConnection(options.signal, Math.min(20_000, 1000 * 2 ** Math.min(retry++, 5)))
}
}
options.onProgress(file.size)
options.onPaused?.(false)
} finally {
if (!finished) {
// Never delete the destination. TUS deletion also removes its cache entry;
// resource deletion covers an already-completed staging file. Offline
// partial sessions are subsequently removed by File Browser's expiry.
const cleanup = new AbortController()
const timer = setTimeout(() => cleanup.abort(), 5000)
try {
const deleted = await request(tus, { method: 'DELETE' }, cleanup.signal)
if (deleted.status === 404) await request(resource(stage), { method: 'DELETE' }, cleanup.signal)
} catch { /* original error remains the user-visible result */ }
finally { clearTimeout(timer) }
}
}
}
+5 -1
View File
@@ -432,11 +432,15 @@ class RPCClient {
total_amount_sats: number total_amount_sats: number
fee_rate_sat_per_vbyte: number fee_rate_sat_per_vbyte: number
}> { }> {
if (params.feeRateSatPerVbyte !== undefined &&
(!Number.isInteger(params.feeRateSatPerVbyte) || params.feeRateSatPerVbyte < 1 || params.feeRateSatPerVbyte > 5000)) {
throw new Error('Fee rate must be a whole number from 1 to 5000 sat/vB')
}
return this.call({ return this.call({
method: 'lnd.create-psbt', method: 'lnd.create-psbt',
params: { params: {
outputs: params.outputs, outputs: params.outputs,
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10, ...(params.feeRateSatPerVbyte === undefined ? {} : { fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte }),
}, },
}) })
} }
+164
View File
@@ -0,0 +1,164 @@
<template>
<BaseModal :show="show" title="Bump transaction" max-width="max-w-md" z-index="z-[3100]" @close="close">
<PaymentSuccessPane v-if="accepted" :amount="0"
:verb="operation?.status === 'confirmed' ? 'CONFIRMED' : 'BUMP BROADCAST'"
:method-label="operation?.quote?.method === 'rbf' ? 'Replace by fee · RBF' : 'Child pays for parent · CPFP'"
:rows="successRows"
:note="operation?.status === 'confirmed' ? 'Your fee bump is confirmed on-chain.' : 'Accepted in the node’s mempool. Awaiting confirmation; next-block inclusion is not guaranteed.'"
:again-label="operation?.status === 'confirmed' ? '' : 'Check status'"
@again="checkStatus" @done="close" />
<div v-else class="space-y-4">
<p class="text-xs font-mono text-white/45 break-all">{{ txid }}</p>
<template v-if="!operation">
<div class="flex gap-2" role="group" aria-label="Fee target">
<button v-for="option in ['next', 'custom'] as const" :key="option" type="button"
:disabled="submitting" :aria-pressed="mode === option"
class="flex-1 rounded-lg px-3 py-2 text-sm border transition-colors"
:class="mode === option ? 'bg-orange-500/20 border-orange-400/40 text-orange-200' : 'border-white/10 text-white/60'"
@click="mode = option">{{ option === 'next' ? 'Next block' : 'Custom' }}</button>
</div>
<label v-if="mode === 'custom'" class="block text-sm text-white/70">
Fee rate (sat/vB)
<input v-model.number="customRate" :disabled="submitting" type="number" min="1" max="5000" step="1"
class="mt-1 block w-full rounded-lg bg-white/5 border border-white/15 px-3 py-2 text-white" />
</label>
<p class="text-xs text-white/45">Targets faster confirmation. Next-block confirmation is not guaranteed.</p>
<p v-if="loading" role="status" class="text-sm text-white/60">Checking the transaction and current fees…</p>
<template v-if="quote">
<div class="rounded-lg bg-white/5 p-3 space-y-2">
<p class="text-sm font-medium text-white">{{ quote.method === 'rbf' ? 'RBF · Replace sweep' : 'CPFP · Spend change' }}</p>
<p class="text-xs text-white/55">{{ quote.method === 'rbf'
? 'Replaces the wallet’s fee-bump transaction with a higher-fee version. The original payment stays unchanged.'
: 'RBF is unavailable for this payment. A child transaction spends your change to accelerate the original payment.' }}</p>
<dl class="text-sm space-y-2 pt-2">
<div class="flex justify-between gap-3"><dt class="text-white/50">Recipient amount</dt><dd class="text-white">{{ sats(quote.recipient_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Current total fee</dt><dd class="text-white">{{ sats(quote.current_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Additional fee · up to</dt><dd class="text-orange-200">{{ sats(quote.additional_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">New total fee · up to</dt><dd class="text-white font-medium">{{ sats(quote.total_fee_sats) }}</dd></div>
<div class="flex justify-between gap-3"><dt class="text-white/50">Package target</dt><dd class="text-white">{{ quote.rate_sat_vb }} sat/vB</dd></div>
</dl>
</div>
<p class="text-xs text-white/45">The wallet may use the full {{ sats(quote.budget_sats) }} sweep budget by the next block. The fee cap applies only to this bump.</p>
<p v-if="expired" role="status" class="text-xs text-amber-300">Fee quote expired. Refresh before confirming.</p>
</template>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<div class="flex gap-2">
<button type="button" class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/75 text-sm" :disabled="loading || submitting" @click="getQuote">{{ quote ? 'Refresh quote' : 'Preview fee' }}</button>
<button v-if="quote" type="button" class="flex-1 px-3 py-2 rounded-lg bg-orange-500 text-white text-sm font-medium disabled:opacity-40"
:disabled="submitting || loading || expired" @click="submit">{{ submitting ? 'Submitting…' : 'Confirm bump' }}</button>
</div>
</template>
<template v-else>
<p role="status" class="text-sm text-white/80">{{ operation.message }}</p>
<p v-if="operation.bump_txid" class="text-xs text-white/50 break-all">{{ operation.quote?.method === 'rbf' ? 'Replacement' : 'Child' }}: {{ operation.bump_txid }}</p>
<p v-if="operation.actual_sweep_fee_sats !== undefined" class="text-sm text-white/70">Sweep fee: {{ sats(operation.actual_sweep_fee_sats) }}</p>
<p v-if="error" role="alert" class="text-sm text-red-300">{{ error }}</p>
<button v-if="operation.status !== 'confirmed'" type="button" class="w-full px-3 py-2 rounded-lg bg-white/10 text-white text-sm" :disabled="loading" @click="checkStatus">Check status</button>
</template>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BaseModal from './BaseModal.vue'
import PaymentSuccessPane, { type SuccessRow } from './PaymentSuccessPane.vue'
import { rpcClient } from '@/api/rpc-client'
interface Quote {
quote_id: string; txid: string; expires_at: number; method: 'rbf' | 'cpfp'
recipient_sats: number; current_fee_sats: number; additional_fee_sats: number
total_fee_sats: number; budget_sats: number; rate_sat_vb: number
}
interface Operation {
status: 'none' | 'registered' | 'unknown' | 'mempool' | 'confirmed'
message: string; bump_txid?: string; actual_sweep_fee_sats?: number; quote?: Quote
}
const props = defineProps<{ show: boolean; txid: string }>()
const emit = defineEmits<{ close: []; updated: [] }>()
const mode = ref<'next' | 'custom'>('next')
const customRate = ref<number | ''>('')
const quote = ref<Quote | null>(null)
const operation = ref<Operation | null>(null)
const loading = ref(false)
const submitting = ref(false)
const error = ref('')
const time = ref(Date.now())
let generation = 0
let lastStatusCheck = 0
const timer = setInterval(() => {
time.value = Date.now()
if (props.show && operation.value && operation.value.status !== 'confirmed' && time.value - lastStatusCheck > 5000) void checkStatus()
}, 1000)
onUnmounted(() => { clearInterval(timer); generation++ })
const expired = computed(() => !quote.value || quote.value.expires_at * 1000 <= time.value)
const sats = (n: number) => `${n.toLocaleString()} sats`
const accepted = computed(() => operation.value?.status === 'mempool' || operation.value?.status === 'confirmed')
const successRows = computed<SuccessRow[]>(() => {
const op = operation.value
if (!op) return []
const rows: SuccessRow[] = [{ label: 'Original transaction', value: props.txid }]
if (op.bump_txid) rows.push({ label: op.quote?.method === 'rbf' ? 'Replacement transaction' : 'Child transaction', value: op.bump_txid })
if (op.actual_sweep_fee_sats !== undefined) rows.push({ label: 'Sweep fee', value: sats(op.actual_sweep_fee_sats) })
return rows
})
const message = (e: unknown) => e instanceof Error ? e.message : String(e)
function close() { if (!submitting.value) emit('close') }
watch([mode, customRate], () => { generation++; loading.value = false; quote.value = null; error.value = '' })
watch(() => [props.show, props.txid] as const, async ([show]) => {
generation++; quote.value = null; operation.value = null; error.value = ''; loading.value = false
mode.value = 'next'; customRate.value = ''
if (show && props.txid) {
// Let mode/reset watchers settle before the first request.
await Promise.resolve()
await checkStatus()
if (!operation.value && !error.value && props.show) await getQuote()
}
}, { immediate: true })
async function getQuote() {
if (loading.value || submitting.value || operation.value) return
if (mode.value === 'custom' && (!Number.isInteger(customRate.value) || Number(customRate.value) < 1 || Number(customRate.value) > 5000)) {
error.value = 'Enter a whole-number fee rate from 1 to 5000 sat/vB.'; return
}
const request = ++generation
loading.value = true; error.value = ''; quote.value = null
try {
const result = await rpcClient.call<Quote>({ method: 'lnd.bump-quote', params: { txid: props.txid, ...(mode.value === 'custom' ? { sat_per_vbyte: customRate.value } : {}) }, maxRetries: 1, timeout: 60000 })
if (request === generation) quote.value = result
} catch (e) { if (request === generation) error.value = message(e) }
finally { if (request === generation) loading.value = false }
}
async function submit() {
if (submitting.value || loading.value || !quote.value || expired.value) return
submitting.value = true; error.value = ''
const id = props.txid
try {
operation.value = await rpcClient.call<Operation>({ method: 'lnd.bump-submit', params: { txid: id, quote_id: quote.value.quote_id }, maxRetries: 1, timeout: 90000 })
emit('updated')
} catch (e) {
// An interrupted response may conceal success. A status check must happen
// before showing another quote/submit action.
operation.value = { status: 'unknown', message: 'Submission response was not confirmed. Check status before taking another action.' }
error.value = message(e)
} finally { submitting.value = false }
await checkStatus()
}
async function checkStatus() {
if (loading.value || submitting.value) return
lastStatusCheck = Date.now()
const request = ++generation
loading.value = true
try {
const result = await rpcClient.call<Operation>({ method: 'lnd.bump-status', params: { txid: props.txid }, maxRetries: 1, timeout: 60000 })
if (request !== generation) return
error.value = ''
if (result.status === 'none') {
operation.value = null
quote.value = null
} else {
operation.value = result
if (result.status === 'confirmed' || result.status === 'mempool') emit('updated')
}
} catch (e) { if (request === generation) error.value = message(e) }
finally { if (request === generation) loading.value = false }
}
</script>
@@ -553,7 +553,7 @@ const defaultOpenForm = () => ({
peerUri: '', peerUri: '',
amount: 100000, amount: 100000,
private: false, private: false,
feePreset: 'standard' as FeePreset, feePreset: 'fast' as FeePreset,
customConfTarget: null as number | null, customConfTarget: null as number | null,
customSatPerVbyte: null as number | null, customSatPerVbyte: null as number | null,
}) })
@@ -624,7 +624,7 @@ function feeParams(
setError: (message: string) => void = message => { openError.value = message }, setError: (message: string) => void = message => { openError.value = message },
): { target_conf?: number; sat_per_vbyte?: number } | null { ): { target_conf?: number; sat_per_vbyte?: number } | null {
if (form.feePreset !== 'custom') { 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 rate = form.customSatPerVbyte
const conf = form.customConfTarget 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()) const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) { function confirmClose(ch: Channel) {
+11 -6
View File
@@ -337,7 +337,7 @@ watch(() => props.show, (shown) => {
successInfo.value = null successInfo.value = null
sendAll.value = false sendAll.value = false
onchainBalance.value = null onchainBalance.value = null
feePreset.value = 'standard' feePreset.value = 'fast'
customConfTarget.value = null customConfTarget.value = null
customSatPerVbyte.value = null customSatPerVbyte.value = null
resolvedFeeParams.value = {} resolvedFeeParams.value = {}
@@ -359,7 +359,7 @@ const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string;
{ key: 'custom', label: 'Custom' }, { key: 'custom', label: 'Custom' },
] ]
const feePreset = ref<OnchainFeePreset>('standard') const feePreset = ref<OnchainFeePreset>('fast')
const customConfTarget = ref<number | null>(null) const customConfTarget = ref<number | null>(null)
const customSatPerVbyte = ref<number | null>(null) const customSatPerVbyte = ref<number | null>(null)
// Resolved at review time so confirm + send use the same params. // 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 { function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
if (feePreset.value !== 'custom') { 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 rate = customSatPerVbyte.value
const conf = customConfTarget.value const conf = customConfTarget.value
if (rate != null && rate !== 0) { 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) } return { sat_per_vbyte: Math.floor(rate) }
} }
if (conf != null && conf !== 0) { 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) } return { target_conf: Math.floor(conf) }
} }
error.value = 'Custom fee requires target blocks or sats per vByte' error.value = 'Custom fee requires target blocks or sats per vByte'
@@ -409,7 +409,7 @@ async function loadFeeEstimate() {
try { try {
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({ const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
method: 'lnd.estimatefee', 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, timeout: 10000,
}) })
if (res.fee_sat > 0) feeEstimate.value = res if (res.fee_sat > 0) feeEstimate.value = res
@@ -569,6 +569,11 @@ function sendAnother() {
dest.value = '' dest.value = ''
amount.value = 0 amount.value = 0
sendAll.value = false sendAll.value = false
feePreset.value = 'fast'
customConfTarget.value = null
customSatPerVbyte.value = null
resolvedFeeParams.value = {}
feeEstimate.value = null
error.value = '' error.value = ''
} }
+26 -6
View File
@@ -61,9 +61,9 @@
</svg> </svg>
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span <span
class="text-sm font-medium" class="text-sm font-medium whitespace-nowrap"
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'" :class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
> >
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats {{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats
@@ -92,10 +92,22 @@
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span> <span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span> <span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
</div> </div>
<details v-if="tx.fee_bump_history?.length" class="mt-1 text-[11px] text-white/50" @click.stop>
<summary class="cursor-pointer">Fee history</summary>
<p>Original fee {{ tx.total_fees.toLocaleString() }} sats</p>
<button v-for="bump in tx.fee_bump_history" :key="bump.tx_hash" type="button"
class="block max-w-full truncate text-left hover:text-white/80"
@click.stop="txExplorer.openTx(bump.tx_hash)">
{{ bump.status === 'replaced' ? 'Replaced bump' : 'Fee bump' }} · {{ bump.fee_sats.toLocaleString() }} sats · {{ bump.status }} · {{ bump.tx_hash }}
</button>
</details>
</div> </div>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span> <button v-if="isOnchain(tx) && tx.direction === 'outgoing' && (tx.num_confirmations === 0 || (tx.fee_bump_txid && tx.fee_bump_confirmations === 0))" type="button"
class="px-2 py-1 rounded-md border border-orange-400/25 text-orange-200 text-[11px] hover:bg-orange-500/15"
:aria-label="`Bump transaction ${tx.tx_hash}`" @click.stop="bumpTxid = tx.fee_bump_txid || tx.tx_hash">Bump</button>
<span class="hidden sm:inline text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg> </svg>
@@ -103,12 +115,14 @@
</div> </div>
</div> </div>
</BaseModal> </BaseModal>
<BumpFeeModal :show="show && !!bumpTxid" :txid="bumpTxid" @close="bumpTxid = ''" @updated="emit('updated')" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import BumpFeeModal from '@/components/BumpFeeModal.vue'
import { useTxExplorer } from '@/composables/useTxExplorer' import { useTxExplorer } from '@/composables/useTxExplorer'
interface WalletTransaction { interface WalletTransaction {
@@ -118,6 +132,10 @@ interface WalletTransaction {
num_confirmations: number num_confirmations: number
time_stamp: number time_stamp: number
total_fees: number total_fees: number
bump_fee_sats?: number
fee_bump_txid?: string
fee_bump_confirmations?: number
fee_bump_history?: Array<{ tx_hash: string; fee_sats: number; status: string }>
dest_addresses: string[] dest_addresses: string[]
label: string label: string
block_height: number block_height: number
@@ -130,7 +148,8 @@ const props = defineProps<{
transactions: WalletTransaction[] transactions: WalletTransaction[]
}>() }>()
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: []; updated: [] }>()
const bumpTxid = ref('')
const { t } = useI18n() const { t } = useI18n()
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark' type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
@@ -160,6 +179,7 @@ function countFor(f: FilterKey): number {
} }
function close() { function close() {
bumpTxid.value = ''
emit('close') emit('close')
} }
@@ -169,14 +189,14 @@ function isOnchain(tx: WalletTransaction): boolean {
} }
function feeFor(tx: WalletTransaction): number { function feeFor(tx: WalletTransaction): number {
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0 return tx.direction === 'outgoing' ? (tx.total_fees || 0) + (tx.bump_fee_sats || 0) : 0
} }
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee /** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
* itself is broken out on its own tag. Incoming rows are untouched. */ * itself is broken out on its own tag. Incoming rows are untouched. */
function displayAmount(tx: WalletTransaction): number { function displayAmount(tx: WalletTransaction): number {
const gross = Math.abs(tx.amount_sats) const gross = Math.abs(tx.amount_sats)
const fee = feeFor(tx) const fee = tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
return fee > 0 && gross > fee ? gross - fee : gross return fee > 0 && gross > fee ? gross - fee : gross
} }
@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import BumpFeeModal from '../BumpFeeModal.vue'
import TransactionsModal from '../TransactionsModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
const explorer = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: explorer }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
const quote = { quote_id: 'q', txid: 'a'.repeat(64), expires_at: Math.floor(Date.now() / 1000) + 60, method: 'cpfp', recipient_sats: 161650, current_fee_sats: 144, additional_fee_sats: 618, total_fee_sats: 762, budget_sats: 618, rate_sat_vb: 3 }
const stubs = { BaseModal: { template: '<div><slot /></div>' } }
function open() {
const wrapper = mount(BumpFeeModal, { props: { show: true, txid: quote.txid }, global: { stubs } })
return { wrapper, vm: (wrapper.vm as any).$.setupState }
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.bump-status') return { status: 'none' } as never
return { ...quote } as never
})
})
describe('Bump review', () => {
it('defaults to next block and clearly explains CPFP with additional/total preview', async () => {
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('CPFP · Spend change')
expect(wrapper.text()).toContain('RBF is unavailable')
expect(wrapper.text()).toContain('618 sats')
expect(wrapper.text()).toContain('762 sats')
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
wrapper.unmount()
})
it('shows RBF only for a backend-supported sweep', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => (method === 'lnd.bump-status' ? { status: 'none' } : { ...quote, method: 'rbf' }) as never)
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('RBF · Replace sweep')
expect(wrapper.text()).not.toContain('RBF is unavailable')
wrapper.unmount()
})
it('invalidates the old quote when custom settings change', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.mode = 'custom'; await flushPromises()
expect(vm.quote).toBeNull()
vm.customRate = 0.5; await flushPromises(); await vm.getQuote()
expect(vm.error).toContain('whole-number')
vm.customRate = 10; await flushPromises(); await vm.getQuote()
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'lnd.bump-quote', params: { txid: quote.txid, sat_per_vbyte: 10 } }))
wrapper.unmount()
})
it('blocks expired quotes and duplicate clicks; never retries a mutation', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.quote.expires_at = 1; await vm.submit()
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit' }))
vm.quote.expires_at = Math.floor(Date.now()/1000) + 60
let finish!: (value: any) => void
vi.mocked(rpcClient.call).mockImplementation(({ method }) => method === 'lnd.bump-submit' ? new Promise(resolve => { finish = resolve }) as never : Promise.resolve({ status: 'registered', message: 'Waiting for broadcast' }) as never)
const pending = vm.submit(); await vm.submit()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'lnd.bump-submit')).toHaveLength(1)
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.bump-submit', maxRetries: 1 }))
finish({ status: 'registered', message: 'Waiting for broadcast' }); await pending
expect(wrapper.text()).toContain('Waiting for broadcast')
wrapper.unmount()
})
it('keeps unknown submission outcome from becoming a second payment', async () => {
const { wrapper, vm } = open(); await flushPromises()
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Network timeout'))
await vm.submit()
expect(vm.operation.status).toBe('unknown')
expect(wrapper.text()).not.toContain('Confirm bump')
expect(wrapper.text()).toContain('Check status')
wrapper.unmount()
})
it('reconciles a previous submission when reopening', async () => {
vi.mocked(rpcClient.call).mockResolvedValue({ status: 'mempool', message: 'Accepted, awaiting confirmation', bump_txid: 'b'.repeat(64), actual_sweep_fee_sats: 618 } as never)
const { wrapper } = open(); await flushPromises()
expect(wrapper.text()).toContain('BUMP BROADCAST')
expect(wrapper.text()).toContain('Awaiting confirmation')
expect(rpcClient.call).toHaveBeenCalledTimes(1)
expect(wrapper.text()).not.toContain('Confirm bump')
wrapper.unmount()
})
it('uses the existing green success animation only after verified acceptance', async () => {
const { wrapper, vm } = open(); await flushPromises()
vm.operation = { status: 'registered', message: 'Waiting for broadcast' }; await flushPromises()
expect(wrapper.find('.send-success-badge').exists()).toBe(false)
vm.operation = { status: 'mempool', message: 'Accepted', bump_txid: 'b', quote: { method: 'rbf' } }; await flushPromises()
expect(wrapper.find('.send-success-badge').exists()).toBe(true)
expect(wrapper.text()).toContain('BUMP BROADCAST')
expect(wrapper.text()).toContain('Awaiting confirmation')
vm.operation.status = 'confirmed'; await flushPromises()
expect(wrapper.text()).toContain('CONFIRMED')
expect(wrapper.text()).not.toContain('Awaiting confirmation')
wrapper.unmount()
})
it('offers Bump only on pending on-chain rows and stops explorer navigation', async () => {
const tx = { tx_hash: quote.txid, amount_sats: 161794, direction: 'outgoing', num_confirmations: 0, time_stamp: 1, total_fees: 144, dest_addresses: [], label: '', block_height: 0 }
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [tx, { ...tx, tx_hash: 'b', num_confirmations: 1 }, { ...tx, tx_hash: 'c', kind: 'lightning' }] as any }, global: { stubs: { ...stubs, BumpFeeModal: true } } })
const buttons = wrapper.findAll('button').filter(b => b.text() === 'Bump')
expect(buttons).toHaveLength(1)
await buttons[0]!.trigger('click')
expect(explorer).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'BumpFeeModal' }).props('txid')).toBe(quote.txid)
wrapper.unmount()
})
})
describe('Grouped fee-bump history', () => {
it('preserves recipient amount, counts only active extra fees, and opens the replacement bump', async () => {
const parent = { tx_hash: 'a'.repeat(64), amount_sats: 100144, total_fees: 144, direction: 'outgoing' as const,
num_confirmations: 0, time_stamp: 1, dest_addresses: [], label: '', block_height: 0,
bump_fee_sats: 600, fee_bump_txid: 'c'.repeat(64), fee_bump_confirmations: 0,
fee_bump_history: [{ tx_hash: 'b'.repeat(64), fee_sats: 300, status: 'replaced' },
{ tx_hash: 'c'.repeat(64), fee_sats: 600, status: 'mempool' }] }
const wrapper = mount(TransactionsModal, { props: { show: true, transactions: [parent] }, global: { stubs } })
expect(wrapper.text()).toContain('-100,000 sats')
expect(wrapper.text()).toContain('fee 744 sats')
expect(wrapper.text()).toContain('Replaced bump')
await wrapper.get(`button[aria-label="Bump transaction ${parent.tx_hash}"]`).trigger('click')
await flushPromises()
expect(wrapper.findComponent(BumpFeeModal).props('txid')).toBe(parent.fee_bump_txid)
wrapper.unmount()
})
})
@@ -17,6 +17,20 @@ function open() {
} }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) }) beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => { describe('channel closing fee choice', () => {
it('defaults to next block, preserves explicit choices, and resets a new close to Fast', async () => {
const { wrapper, vm } = open()
expect(vm.closeForm.feePreset).toBe('fast')
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 1 } }))
vm.confirmClose(channel)
vm.closeForm.feePreset = 'standard'
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 6 } }))
vm.confirmClose(channel)
expect(vm.closeForm.feePreset).toBe('fast')
expect(vm.openForm.feePreset).toBe('fast')
wrapper.unmount()
})
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => { it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
await vm.closeChannel() await vm.closeChannel()
@@ -0,0 +1,62 @@
import { mount, flushPromises } from '@vue/test-utils'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MeshDeviceSetupModal from '../mesh/MeshDeviceSetupModal.vue'
const state = reactive({
flashFlowPath: null as string | null,
undismissedDetectedDevices: [] as string[],
status: {
device_type: 'meshcore',
detected_device_info: [{ path: '/dev/fixture', vid: '10c4', pid: 'ea60' }],
},
flashDevice: vi.fn(),
flashStatus: vi.fn(),
closeFlashFlow: vi.fn(),
})
vi.mock('@/stores/mesh', () => ({ useMeshStore: () => state }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ serverName: 'Fixture' }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
async function open() {
const wrapper = mount(MeshDeviceSetupModal, {
global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } },
})
state.flashFlowPath = '/dev/fixture'
await flushPromises()
return wrapper
}
describe('LoRa firmware board selection', () => {
beforeEach(() => {
state.flashFlowPath = null
state.flashDevice.mockReset()
})
it('does not infer Heltec V3 from a generic CP2102 adapter', async () => {
const wrapper = await open()
const board = wrapper.findAll('select')[1]!
expect((board.element as HTMLSelectElement).value).toBe('')
await wrapper.find('input[type="checkbox"]').setValue(true)
const flash = wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!
expect((flash.element as HTMLButtonElement).disabled).toBe(true)
expect(state.flashDevice).not.toHaveBeenCalled()
await board.setValue('heltec-v3')
await flash.trigger('click')
expect(state.flashDevice).toHaveBeenCalledExactlyOnceWith('/dev/fixture', 'meshcore', 'heltec-v3')
wrapper.unmount()
})
it('shows a failed tool preflight without entering flashing progress', async () => {
state.flashDevice.mockRejectedValueOnce(new Error('Radio flashing tools are missing'))
const wrapper = await open()
await wrapper.findAll('select')[1]!.setValue('heltec-v4')
await wrapper.find('input[type="checkbox"]').setValue(true)
await wrapper.findAll('button').find(button => button.text().includes('Erase & Flash Now'))!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Radio flashing tools are missing')
expect(wrapper.text()).toContain('Erase & Flash Now')
expect(state.flashStatus).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -0,0 +1,56 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SendBitcoinModal from '../SendBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (s: string) => s }) }))
vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) }))
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.getinfo') return { balance_sats: 100000 } as never
if (method === 'lnd.estimatefee') return { fee_sat: 500, sat_per_vbyte: 3 } as never
return { txid: 'a'.repeat(64) } as never
})
})
function open() {
const wrapper = mount(SendBitcoinModal, { props: { show: true }, global: { stubs: { BaseModal: { template: '<div><slot /></div>' } } } })
const vm = (wrapper.vm as any).$.setupState
vm.sendMethod = 'onchain'; vm.dest = 'bc1qtestaddress'; vm.amount = 1000
return { wrapper, vm }
}
describe('on-chain fee defaults', () => {
it.each([NaN, Infinity, 0.5, -1, 5001])('rejects invalid custom rate %s before preview or submission', async rate => {
const { wrapper, vm } = open()
vm.feePreset = 'custom'; vm.customSatPerVbyte = rate
await vm.review()
expect(vm.confirming).toBe(false)
expect(vm.error).toContain('whole number')
expect(rpcClient.call).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each([['fast', 1], ['standard', 6], ['slow', 144]])('uses %s consistently in preview and submission', async (preset, target) => {
const { wrapper, vm } = open()
expect(vm.feePreset).toBe('fast')
vm.feePreset = preset
await vm.review(); await flushPromises()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee', params: expect.objectContaining({ target_conf: target }) }))
await vm.send()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ target_conf: target }) }))
wrapper.unmount()
})
it('preserves an explicit custom rate and resets reopened/new sends to Fast', async () => {
const { wrapper, vm } = open()
vm.feePreset = 'custom'; vm.customSatPerVbyte = 17
await vm.review(); await flushPromises(); await vm.send()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.sendcoins', params: expect.objectContaining({ sat_per_vbyte: 17 }) }))
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.estimatefee' }))
vm.sendAnother(); expect(vm.feePreset).toBe('fast'); expect(vm.customSatPerVbyte).toBeNull()
vm.feePreset = 'slow'
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true })
expect(vm.feePreset).toBe('fast'); expect(vm.resolvedFeeParams).toEqual({})
wrapper.unmount()
})
})
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'"> <div v-if="task && route.fullPath === task.originRoute" class="mb-3 shrink-0">
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite"> <div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" /> <div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" /> <div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
@@ -13,8 +13,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useCloudStore } from '@/stores/cloud' import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>() const route = useRoute()
const store = useCloudStore() const store = useCloudStore()
const task = computed(() => store.upload) const task = computed(() => store.upload)
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100) const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
@@ -23,7 +24,8 @@ const label = computed(() => {
if (!t) return '' if (!t) return ''
if (t.error) return t.error if (t.error) return t.error
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved` if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded` if (!t.active) return `Complete · ${t.count === 1 ? t.filename : `${t.count} files`}`
if (t.paused) return `Connection interrupted · resuming ${t.filename}`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}` return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
}) })
</script> </script>
@@ -149,9 +149,8 @@
<option value="heltec-v3">Heltec LoRa 32 V3</option> <option value="heltec-v3">Heltec LoRa 32 V3</option>
<option value="heltec-v4">Heltec LoRa 32 V4</option> <option value="heltec-v4">Heltec LoRa 32 V4</option>
</select> </select>
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1"> <p class="text-[11px] text-amber-400/80 mt-1">
Couldn't confirm the board automatically — double check before flashing. Select the model printed on your board. USB adapters are shared across models.
Flashing the wrong board's image can brick it.
</p> </p>
</div> </div>
</div> </div>
@@ -300,7 +299,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import BaseModal from '@/components/BaseModal.vue' import BaseModal from '@/components/BaseModal.vue'
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh' import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
@@ -536,31 +535,11 @@ const starting = ref(false)
const flashJob = ref<FlashJobStatus | null>(null) const flashJob = ref<FlashJobStatus | null>(null)
let flashPollTimer: ReturnType<typeof setInterval> | null = null let flashPollTimer: ReturnType<typeof setInterval> | null = null
const detectedInfo = computed(() =>
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
)
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
// exactly — matching on the display label was wrong: a Heltec V3's CP2102
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
// "couldn't confirm automatically" warning even though the backend CAN
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
// safely auto-matched and always requires manual selection.
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
const info = detectedInfo.value
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
return ''
})
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
const flashStageLabel = computed(() => { const flashStageLabel = computed(() => {
switch (flashJob.value?.stage) { switch (flashJob.value?.stage) {
case 'downloading': return 'Downloading firmware…' case 'downloading': return 'Downloading firmware…'
case 'preparing': return 'Preparing radio…'
case 'erasing': return 'Erasing chip…' case 'erasing': return 'Erasing chip…'
case 'writing': return 'Writing firmware…' case 'writing': return 'Writing firmware…'
case 'autoinstalling': return 'Installing (rnodeconf)…' case 'autoinstalling': return 'Installing (rnodeconf)…'
@@ -572,7 +551,7 @@ const flashStageLabel = computed(() => {
function openFlashStep() { function openFlashStep() {
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? '' flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
flashBoard.value = resolvedFlashBoard.value flashBoard.value = ''
flashConfirmed.value = false flashConfirmed.value = false
flashJob.value = null flashJob.value = null
error.value = '' error.value = ''
@@ -635,6 +614,11 @@ async function cancelFlash() {
} }
} }
onBeforeUnmount(() => {
stopFlashPoll()
stopProbeProgress()
})
function closeFlashStep() { function closeFlashStep() {
stopFlashPoll() stopFlashPoll()
if (mesh.flashFlowPath) { if (mesh.flashFlowPath) {
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, nextTick } from 'vue'
import { createRouter, createMemoryHistory } from 'vue-router'
import { useCloudStore } from '@/stores/cloud'
import { useUploadNotifications } from '../useUploadNotifications'
import UploadProgress from '@/components/cloud/UploadProgress.vue'
const action = vi.hoisted(() => vi.fn())
vi.mock('../useToast', () => ({ useToast: () => ({ action }) }))
const origin = '/dashboard/cloud/documents?path=/uploads'
async function setup() {
const pinia = createPinia(); setActivePinia(pinia)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }] })
await router.push(origin); await router.isReady()
const wrapper = mount(defineComponent({ components: { UploadProgress }, setup() { useUploadNotifications() }, template: '<UploadProgress />' }), { global: { plugins: [pinia, router] } })
const store = useCloudStore()
store.upload = { active: true, paused: false, filename: 'photo.png', destination: '/uploads', originRoute: origin, sent: 100, total: 100, completed: 0, count: 1, error: null, cancelled: false }
await nextTick()
return { store, router, wrapper }
}
beforeEach(() => action.mockClear())
describe('upload screen and completion notification', () => {
it('shows Saving until the server finishes, then Complete with dismiss on the origin only', async () => {
const { store, wrapper } = await setup()
expect(wrapper.text()).toContain('Saving photo.png'); expect(action).not.toHaveBeenCalled()
store.upload!.completed = 1; store.upload!.active = false; await nextTick()
expect(wrapper.text()).toContain('Complete · photo.png'); expect(action).not.toHaveBeenCalled()
await wrapper.get('button[aria-label="Dismiss upload"]').trigger('click')
expect(store.upload).toBeNull(); wrapper.unmount()
})
it('hides progress on another screen, notifies once on completion and returns to the origin', async () => {
const { store, router, wrapper } = await setup()
await router.push('/dashboard/apps'); expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(store.upload!.active).toBe(true)
store.upload!.completed = 1; store.upload!.active = false; await nextTick()
expect(action).toHaveBeenCalledTimes(1)
expect(action.mock.calls[0]![0]).toBe('Upload complete · photo.png')
action.mock.calls[0]![1].onClick(); await router.isReady(); await new Promise(resolve => setTimeout(resolve, 0))
expect(router.currentRoute.value.fullPath).toBe(origin)
expect(wrapper.text()).toContain('Complete · photo.png')
await router.push('/dashboard'); expect(action).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('also hides on other Cloud folders and shows progress again on return', async () => {
const { router, wrapper } = await setup()
await router.push('/dashboard/cloud/documents?path=/different')
expect(wrapper.find('[role="status"]').exists()).toBe(false)
await router.push(origin); expect(wrapper.text()).toContain('Saving'); wrapper.unmount()
})
it.each(['error', 'cancelled'] as const)('never calls a %s outcome complete', async field => {
const { store, router, wrapper } = await setup(); await router.push('/dashboard')
if (field === 'error') store.upload!.error = 'No space'; else store.upload!.cancelled = true
store.upload!.active = false; await nextTick()
expect(action).toHaveBeenCalledTimes(1); expect(action.mock.calls[0]![0]).not.toContain('complete')
expect(action.mock.calls[0]![0]).toContain('0/1 saved'); wrapper.unmount()
})
})
@@ -0,0 +1,25 @@
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useCloudStore } from '@/stores/cloud'
import { useToast } from './useToast'
/** Keep progress on its originating screen; announce completion elsewhere once. */
export function useUploadNotifications() {
const store = useCloudStore()
const route = useRoute()
const router = useRouter()
const toast = useToast()
watch(() => store.upload?.active, (active, wasActive) => {
const task = store.upload
if (wasActive !== true || active !== false || !task || route.fullPath === task.originRoute) return
const message = task.error
? `Upload failed · ${task.completed}/${task.count} saved: ${task.error}`
: task.cancelled
? `Upload stopped · ${task.completed}/${task.count} saved`
: `Upload complete · ${task.count === 1 ? task.filename : `${task.count} files`}`
toast.action(message, {
label: 'View upload',
onClick: () => { void router.push(task.originRoute || '/dashboard/cloud') },
}, { variant: task.error ? 'error' : task.cancelled ? 'info' : 'success', duration: 8000 })
}, { flush: 'sync' })
}
+4 -4
View File
@@ -8,23 +8,23 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null) const upload = ref<{ active: boolean; paused: boolean; filename: string; destination: string; originRoute: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
let uploadController: AbortController | null = null let uploadController: AbortController | null = null
function cancelUpload() { uploadController?.abort() } function cancelUpload() { uploadController?.abort() }
function dismissUpload() { if (!upload.value?.active) upload.value = null } function dismissUpload() { if (!upload.value?.active) upload.value = null }
async function uploadFiles(files: File[]) { async function uploadFiles(files: File[], originRoute = '') {
if (!files.length || upload.value?.active) return if (!files.length || upload.value?.active) return
const destination = currentPath.value const destination = currentPath.value
const controller = new AbortController() const controller = new AbortController()
uploadController = controller uploadController = controller
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false } const task = { active: true, paused: false, filename: files[0]!.name, destination, originRoute, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
upload.value = task upload.value = task
let completedBytes = 0 let completedBytes = 0
try { try {
for (const file of files) { for (const file of files) {
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError') if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
upload.value.filename = file.name upload.value.filename = file.name
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => { await fileBrowserClient.upload(destination, file, { resumable: true, signal: controller.signal, onPaused: (paused) => { if (upload.value?.active) upload.value.paused = paused }, onProgress: (sent) => {
if (upload.value?.active) upload.value.sent = completedBytes + sent if (upload.value?.active) upload.value.sent = completedBytes + sent
} }) } })
completedBytes += file.size completedBytes += file.size
+1 -1
View File
@@ -59,7 +59,7 @@ export interface MeshDeviceProbe {
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum' export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
export type FlashBoard = 'heltec-v3' | 'heltec-v4' export type FlashBoard = 'heltec-v3' | 'heltec-v4'
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed' export type FlashStage = 'downloading' | 'preparing' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
/** Live progress for the one flash job that can run at a time. */ /** Live progress for the one flash job that can run at a time. */
export interface FlashJobStatus { export interface FlashJobStatus {
+3 -1
View File
@@ -120,7 +120,9 @@ export interface MeshcoreRfPreset {
* and operator-attested deployment plans. * and operator-attested deployment plans.
*/ */
export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [ export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [
{ id: 'eu_868', label: 'Europe / UK — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 }, // MeshCore app maintainer's presets: MeshCore discussion #1650 (2026-02-15).
{ id: 'eu_uk_narrow', label: 'Europe / UK (Narrow) — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
{ id: 'eu_868', label: 'Europe / UK (Long Range) — 869.525 MHz, 250 kHz, SF11, CR4/5', freqMhz: 869.525, bwKhz: 250, sf: 11, cr: 5 },
{ id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 }, { id: 'pt', label: 'Portugal — 869.618 MHz, 62.5 kHz, SF8, CR4/8', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 8 },
{ id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 }, { id: 'fw_default', label: 'MeshCore firmware default — 869.618 MHz, 62.5 kHz, SF8, CR4/5', freqMhz: 869.618, bwKhz: 62.5, sf: 8, cr: 5 },
{ id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 }, { id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 },
+1 -1
View File
@@ -366,7 +366,7 @@ function onDrop(e: DragEvent) {
} }
async function handleUpload(files: File[]) { async function handleUpload(files: File[]) {
await cloudStore.uploadFiles(files) await cloudStore.uploadFiles(files, route.fullPath)
} }
async function handleDelete(path: string) { async function handleDelete(path: string) {
+4 -3
View File
@@ -331,6 +331,7 @@ let discoverAnimationDone = false
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { normalizeStoreApps } from './apps/serviceNames'
import AppSearchField from '@/components/AppSearchField.vue' import AppSearchField from '@/components/AppSearchField.vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue' import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
@@ -407,7 +408,7 @@ const catalogStorefront = useCachedResource<CatalogStorefront | null>({
ttlMs: 300_000, ttlMs: 300_000,
persist: true, persist: true,
}).data }).data
const communityApps = computed(() => catalogResource.data.value ?? []) const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading') const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never // Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here — content stays on screen either way. // raises a toast, it only surfaces here — content stays on screen either way.
@@ -535,9 +536,9 @@ const allApps = computed(() => {
const nostrMerged = nostrApps.value const nostrMerged = nostrApps.value
.filter(app => !existingIds.has(app.id)) .filter(app => !existingIds.has(app.id))
.map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' })) .map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' }))
return [...base, ...nostrMerged] return normalizeStoreApps([...base, ...nostrMerged])
} }
return base return normalizeStoreApps(base)
}) })
const filteredApps = computed(() => { const filteredApps = computed(() => {
+1 -1
View File
@@ -288,7 +288,7 @@
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" /> <WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" /> <SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" /> <ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" /> <TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @updated="loadWeb5Status" @close="showTransactionsModal = false" />
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" /> <WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
</div> </div>
</template> </template>
+9 -11
View File
@@ -50,12 +50,10 @@
{{ section.name }} {{ section.name }}
</button> </button>
</div> </div>
<input <AppSearchField
v-model="searchQuery" v-model="searchQuery"
type="text"
:placeholder="t('marketplace.searchPlaceholder')" :placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')" :label="t('marketplace.searchApps')"
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/> />
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" /> <RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div> </div>
@@ -82,12 +80,10 @@
type="button" type="button"
>{{ section.name }}</button> >{{ section.name }}</button>
</div> </div>
<input <AppSearchField
v-model="searchQuery" v-model="searchQuery"
type="text"
:placeholder="t('marketplace.searchPlaceholder')" :placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')" :label="t('marketplace.searchApps')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
/> />
</div> </div>
</div> </div>
@@ -168,6 +164,7 @@ let marketplaceAnimationDone = false
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { normalizeStoreApps } from './apps/serviceNames'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@@ -181,6 +178,7 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout' import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource' import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue' import RefreshIndicator from '@/components/RefreshIndicator.vue'
import AppSearchField from '@/components/AppSearchField.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue' import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories' import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue' import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
@@ -275,7 +273,7 @@ const catalogResource = useCachedResource<MarketplaceApp[]>({
ttlMs: 300_000, ttlMs: 300_000,
persist: true, persist: true,
}) })
const communityApps = computed(() => catalogResource.data.value ?? []) const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading') const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never // Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here — content stays on screen either way. // raises a toast, it only surfaces here — content stays on screen either way.
@@ -383,10 +381,10 @@ const allApps = computed(() => {
const category = app.category || categorizeCommunityApp(app) const category = app.category || categorizeCommunityApp(app)
return { ...app, category, source: 'nostr' } return { ...app, category, source: 'nostr' }
}) })
return [...base, ...nostrMerged] return normalizeStoreApps([...base, ...nostrMerged])
} }
return base return normalizeStoreApps(base)
}) })
const filteredApps = computed(() => { const filteredApps = computed(() => {
@@ -89,11 +89,25 @@ describe('appsConfig service filtering', () => {
const entries: Array<[string, PackageDataEntry]> = [ const entries: Array<[string, PackageDataEntry]> = [
['cuprate', makePkg('cuprate', 'Cuprate', 'money')], ['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')], ['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
] ]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate']) expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([]) expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
}) })
it('groups NetBird parts and BTCPay aliases without hiding a legacy-only installation', () => {
const entries: Array<[string, PackageDataEntry]> = [
['netbird', makePkg('netbird', 'NetBird', 'networking')],
['netbird-dashboard', makePkg('netbird-dashboard', 'NetBird Dashboard', 'networking')],
['netbird-server', makePkg('netbird-server', 'NetBird Server', 'networking')],
['btcpay', makePkg('btcpay', 'BTCPay', 'money')],
['btcpay-server', makePkg('btcpay-server', 'BTCPay Server', 'commerce')],
]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['netbird', 'btcpay-server'])
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
expect(filterEntriesForTab([entries[3]!], 'apps', 'all').map(([id]) => id)).toEqual(['btcpay'])
})
it('falls back to packaged app icon when static icon token is not a path', () => { it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev') const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch' pkg['static-files']!.icon = 'git-branch'
+4 -2
View File
@@ -35,7 +35,7 @@ const INTERNAL_TOOLING_NAMES = new Set([
// Cuprate's dashboard is bundled as a companion of the primary cuprate // Cuprate's dashboard is bundled as a companion of the primary cuprate
// package; showing the generated container as a second Services entry // package; showing the generated container as a second Services entry
// defeats the one-app presentation. // defeats the one-app presentation.
'archy-cuprate-ui', 'archy-cuprate-ui', 'cuprate-ui', 'netbird-dashboard', 'netbird-server',
]) ])
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean { export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
@@ -54,7 +54,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation) // Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = { export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money', 'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'btcpay': 'commerce', 'btcpayserver': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money', 'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media', 'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data', 'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
@@ -115,11 +115,13 @@ export function filterEntriesForTab(
selectedCategory: string, selectedCategory: string,
): Array<[string, PackageDataEntry]> { ): Array<[string, PackageDataEntry]> {
const hasMempool = entries.some(([id]) => id === 'mempool') const hasMempool = entries.some(([id]) => id === 'mempool')
const hasBtcpay = entries.some(([id]) => id === 'btcpay-server')
return entries.filter(([id, pkg]) => { return entries.filter(([id, pkg]) => {
// Older daemons can retain the frontend manifest alias during a restart. // Older daemons can retain the frontend manifest alias during a restart.
// Keep one tile while the updated scanner converges; a legacy-only node // Keep one tile while the updated scanner converges; a legacy-only node
// must still be able to see and operate its sole Mempool entry. // must still be able to see and operate its sole Mempool entry.
if (hasMempool && ['mempool-web', 'mempool-frontend', 'archy-mempool-web'].includes(id)) return false if (hasMempool && ['mempool-web', 'mempool-frontend', 'archy-mempool-web'].includes(id)) return false
if (hasBtcpay && ['btcpay', 'btcpayserver'].includes(id)) return false
if (isInternalToolingPackage(id, pkg)) return false if (isInternalToolingPackage(id, pkg)) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services' const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage(id, pkg) const isWebsite = isWebsitePackage(id, pkg)
+13
View File
@@ -29,6 +29,7 @@ export const SERVICE_NAMES = new Set([
'mysql-mempool', 'mempool-api', 'archy-mempool-web', 'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui', 'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui', 'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'cuprate-ui', 'netbird-dashboard', 'netbird-server',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio', 'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg', 'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1', 'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
@@ -64,3 +65,15 @@ export function isServiceContainer(id: string): boolean {
export function isStoreListedApp(id: string): boolean { export function isStoreListedApp(id: string): boolean {
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id) return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
} }
/** Normalize every catalog source, including persisted caches from older UIs. */
export function normalizeStoreApps<T extends { id: string }>(apps: readonly T[]): T[] {
const result = new Map<string, T>()
for (const app of apps) {
if (!isStoreListedApp(app.id)) continue
const id = ['btcpay', 'btcpayserver'].includes(app.id) ? 'btcpay-server' : app.id
// Prefer the real app's metadata to a legacy image-pin-only alias.
if (!result.has(id) || app.id === id) result.set(id, { ...app, id })
}
return [...result.values()]
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { signedCatalogToApps } from '../curatedApps'
import { normalizeStoreApps } from '../../apps/serviceNames'
describe('one App Store card per product', () => {
it('keeps Cuprate and NetBird components out and prefers canonical BTCPay metadata', () => {
const entries = {
btcpay: { version: '2.4.3' },
'btcpay-server': { version: '2.4.3', manifest: { app: { name: 'BTCPay Server', category: 'commerce', metadata: { icon: '/btcpay.svg' } } } },
cuprate: { version: '0.1' }, 'cuprate-ui': { version: '1.7' },
netbird: { version: '2.38' }, 'netbird-dashboard': { version: '2.38' }, 'netbird-server': { version: '0.71' },
}
for (const apps of [entries, Object.fromEntries(Object.entries(entries).reverse())]) {
const result = signedCatalogToApps({ apps })
expect(result.map(x => x.id).sort()).toEqual(['btcpay-server', 'cuprate', 'netbird'])
expect(result.find(x => x.id === 'btcpay-server')).toMatchObject({ title: 'BTCPay Server', category: 'commerce', icon: '/btcpay.svg', version: '2.4.3' })
}
})
it('normalizes persisted/community entries repeatedly without hiding independent services', () => {
const stale = ['cuprate-ui', 'netbird-server', 'netbird-dashboard', 'btcpay', 'btcpay-server', 'angor-indexer', 'angor-relay', 'electrumx'].map(id => ({ id }))
const result = normalizeStoreApps(stale)
expect(result.map(x => x.id)).toEqual(['btcpay-server', 'angor-indexer', 'angor-relay', 'electrumx'])
expect(normalizeStoreApps(result)).toEqual(result)
expect(normalizeStoreApps([{ id: 'btcpay' }])).toEqual([{ id: 'btcpay-server' }])
})
})
+8 -6
View File
@@ -1,5 +1,5 @@
import type { MarketplaceApp } from './types' import type { MarketplaceApp } from './types'
import { isStoreListedApp } from '../apps/serviceNames' import { isStoreListedApp, normalizeStoreApps } from '../apps/serviceNames'
const R = 'source.archipelago-foundation.org/lfg2025' const R = 'source.archipelago-foundation.org/lfg2025'
@@ -98,7 +98,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
source: 'signed-catalog', source: 'signed-catalog',
}) })
} }
return out return normalizeStoreApps(out)
} }
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups /** The daemon-verified signed catalog, kept for synchronous port-auth lookups
@@ -192,7 +192,7 @@ const CATALOG_URLS = [
* Caches for 1 hour. Returns null only if ALL sources fail. */ * Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> { export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh // Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }
// The daemon-verified signed catalog first (release-root signature checked // The daemon-verified signed catalog first (release-root signature checked
// server-side): it is what makes a newly published app appear without a // server-side): it is what makes a newly published app appear without a
@@ -239,16 +239,16 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// apps); signed entries fill version/image gaps and append brand-new apps. // apps); signed entries fill version/image gaps and append brand-new apps.
const byId = new Map<string, MarketplaceApp>() const byId = new Map<string, MarketplaceApp>()
for (const app of signedApps) byId.set(app.id, app) for (const app of signedApps) byId.set(app.id, app)
for (const app of community?.apps ?? []) { for (const app of normalizeStoreApps(community?.apps ?? [])) {
const existing = byId.get(app.id) const existing = byId.get(app.id)
byId.set(app.id, existing ? { ...app, version: app.version || existing.version, dockerImage: app.dockerImage || existing.dockerImage } : app) byId.set(app.id, existing ? { ...app, version: existing.version || app.version, dockerImage: existing.dockerImage || app.dockerImage } : app)
} }
const merged: AppCatalog = { const merged: AppCatalog = {
version: community?.version ?? 1, version: community?.version ?? 1,
registry: community?.registry ?? R, registry: community?.registry ?? R,
featured: signedFeatured ?? community?.featured, featured: signedFeatured ?? community?.featured,
storefront: signedStorefront ?? community?.storefront, storefront: signedStorefront ?? community?.storefront,
apps: [...byId.values()], apps: normalizeStoreApps([...byId.values()]),
} }
cachedCatalog = merged cachedCatalog = merged
catalogFetchedAt = Date.now() catalogFetchedAt = Date.now()
@@ -261,6 +261,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const stored = localStorage.getItem('archy_catalog') const stored = localStorage.getItem('archy_catalog')
if (stored) { if (stored) {
cachedCatalog = JSON.parse(stored) as AppCatalog cachedCatalog = JSON.parse(stored) as AppCatalog
cachedCatalog.apps = normalizeStoreApps(cachedCatalog.apps)
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
return cachedCatalog return cachedCatalog
} }
@@ -314,6 +315,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'], mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'], bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'], btcpay: ['btcpay-server'],
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
immich: ['immich-server', 'immich-app', 'immich_server'], immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'], nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'], fedimint: ['fedimint-gateway'],
@@ -72,6 +72,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'], mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'], bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'], btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'], immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'], nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'], fedimint: ['fedimint-gateway'],
@@ -362,16 +362,27 @@ init()
</button> </button>
</div> </div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1"> <div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.23-alpha --> <!-- v1.9.0-alpha -->
<div> <div>
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.23-alpha</span> <span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.9.0-alpha</span>
<span class="text-xs text-white/40">October 1, 2026</span> <span class="text-xs text-white/40">October 5, 2026</span>
</div> </div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10"> <div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.</p>
<p>Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.</p>
<p>Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.</p>
<p>Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.</p>
<p>Restrict public management access while retaining configured public apps and ACME certificate validation.</p>
<p>Serve the Mempool explorer on the Angor indexer origin alongside its API.</p>
<p>Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.</p>
<p>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.</p> <p>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.</p>
<p>Return explicit payment-status errors with safe retry guidance when verification is unavailable.</p> <p>Return explicit payment-status errors with safe retry guidance when verification is unavailable.</p>
<p>Show compact upload progress across screens, retain the original destination, and cancel active and queued uploads.</p> <p>Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.</p>
<p>Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.</p>
<p>Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.</p>
<p>Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.</p>
<p>Allow apps to opt in to a validated public-key list of user identities without granting signing access.</p>
<p>Make transaction filters transparent and horizontally scrollable on mobile.</p> <p>Make transaction filters transparent and horizontally scrollable on mobile.</p>
<p>Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.</p> <p>Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.</p>
<p>Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.</p> <p>Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.</p>
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
export default defineConfig({
root: path.resolve(__dirname, 'previews/fee-bump'),
base: '/fee-bump-preview/', publicDir: false, plugins: [vue()],
resolve: { alias: [
{find: '@/api/rpc-client', replacement: path.resolve(__dirname, 'previews/fee-bump/rpc.ts')},
{find: '@/composables/useTxExplorer', replacement: path.resolve(__dirname, 'previews/fee-bump/explorer.ts')},
{find: '@', replacement: path.resolve(__dirname, 'src')},
] },
build: { outDir: '/tmp/archy-fee-bump-preview', emptyOutDir: true },
})
+18
View File
@@ -64,3 +64,21 @@ packaged binary (same dest hash). The signed-identity announce (`ARCHY:2:{ed}:{x
the Rust side (`reticulum.rs`) already passes the node's real keys through. Packaging is the Rust side (`reticulum.rs`) already passes the node's real keys through. Packaging is
done and verified standalone. What's left is entirely hardware-dependent: the live LoRa done and verified standalone. What's left is entirely hardware-dependent: the live LoRa
message path (Phase-0 gates #2/#3) needs a real RNode-flashed board. message path (Phase-0 gates #2/#3) needs a real RNode-flashed board.
### ESP32 flashing tool
`build-esptool.sh` builds `dist/archy-esptool` from Espressif esptool 4.8.1.
`build.sh` includes this step. The executable bundles its Python dependencies
and ESP32-S3 stub, so radio flashing does not depend on a system esptool package,
a first-use package download, or a build-machine virtual environment.
The OTA runtime and ISO require this artifact. Build and installed-tool checks
run `archy-esptool --archy-self-test`, which loads the actual ESP32-S3 stub using
the upstream loader without opening a serial port. Source updater and bootstrap
install it to `/usr/local/bin/archy-esptool`; firmware writes still require the
operator's explicit board model and confirmation.
Official upstream: https://github.com/espressif/esptool/tree/v4.8.1
The binary includes upstream GPL-2.0-or-later software; retain upstream license
metadata and source availability with distribution.
+20
View File
@@ -0,0 +1,20 @@
"""Packaged Espressif CLI: no system Python or first-use package install required."""
import sys
import esptool
from esptool.loader import StubFlasher
def self_test():
# Debian's stripped package omitted this blob; validate the actual bundled
# resources before accepting a flash job, without opening a serial port.
stub = StubFlasher('ESP32-S3')
if not stub.text or not stub.text_start or not stub.entry:
raise RuntimeError('ESP32-S3 flashing stub is incomplete')
print(f'archy-esptool {esptool.__version__}: ESP32-S3 stub ready')
if __name__ == '__main__':
if sys.argv[1:] == ['--archy-self-test']:
self_test()
else:
esptool._main()
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Self-contained flasher shipped identically in OTA and ISO payloads.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
[ -d .venv ] || python3 -m venv .venv
.venv/bin/python -m pip install -q 'esptool==4.8.1' -r requirements-build.txt
.venv/bin/python archy_esptool.py --archy-self-test
.venv/bin/pyinstaller --onefile --name archy-esptool --clean --noconfirm \
--collect-all esptool --copy-metadata esptool archy_esptool.py
env -i PATH=/usr/bin:/bin dist/archy-esptool --archy-self-test
env -i PATH=/usr/bin:/bin dist/archy-esptool version
+3
View File
@@ -63,3 +63,6 @@ if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
else else
echo "WARNING: rnodeconf.py not found at $RNODECONF_SRC (RNS version mismatch?) — skipping archy-rnodeconf build" >&2 echo "WARNING: rnodeconf.py not found at $RNODECONF_SRC (RNS version mismatch?) — skipping archy-rnodeconf build" >&2
fi fi
# Bundle the ESP32 flasher and its stub; OTA nodes must not depend on apt/pip.
bash build-esptool.sh
+22 -8
View File
@@ -162,22 +162,36 @@ ISO="$(find_iso)"
# ── Stage 4: mount-level smoke test ────────────────────────────────── # ── Stage 4: mount-level smoke test ──────────────────────────────────
stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION" stage "iso-smoke" bash scripts/iso-smoke-test.sh "$ISO" "$VERSION"
# ── Stage 5: QEMU boot test (best-effort) ──────────────────────────── # ── Stage 5: QEMU boot test ─────────────────────────────────────────
# The ISO's kernel cmdline has no serial console, so the serial-log # The ISO's kernel cmdline has no serial console, so the serial-log
# sanity grep can miss a perfectly healthy boot. Run it, report it, # sanity grep can miss a healthy boot. That requires separate evidence;
# but don't fail an otherwise-green build on it. # inconclusive results must never be counted as passing release gates.
if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then if [ "$NO_QEMU" = "0" ] && command -v qemu-system-x86_64 >/dev/null 2>&1; then
echo echo
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180" echo "═══ [qemu-boot] test-iso-qemu.sh $ISO 180"
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then qemu_work=$(mktemp -d -t archipelago-iso-boot.XXXXXX)
echo " Disposable boot disk/logs: $qemu_work"
read -r qemu_ssh qemu_http < <(python3 - <<'PY'
import socket
with socket.socket() as ssh, socket.socket() as http:
ssh.bind(('127.0.0.1', 0)); http.bind(('127.0.0.1', 0))
print(ssh.getsockname()[1], http.getsockname()[1])
PY
)
if TMPDIR="$qemu_work" QEMU_SSH_PORT="$qemu_ssh" QEMU_HTTP_PORT="$qemu_http" bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
echo "═══ [qemu-boot] PASS" echo "═══ [qemu-boot] PASS"
PASS+=("qemu-boot") PASS+=("qemu-boot")
else else
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)" echo "═══ [qemu-boot] NOT VERIFIED — inspect boot evidence before publication"
PASS+=("qemu-boot(inconclusive)") FAIL+=("qemu-boot")
summary 1
fi fi
else elif [ "$NO_QEMU" = "1" ]; then
echo; echo "═══ [qemu-boot] SKIPPED" echo; echo "═══ [qemu-boot] SKIPPED"
else
echo "═══ [qemu-boot] NOT VERIFIED — qemu-system-x86_64 is missing"
FAIL+=("qemu-boot")
summary 1
fi fi
# ── Done ───────────────────────────────────────────────────────────── # ── Done ─────────────────────────────────────────────────────────────
+1 -1
View File
@@ -83,7 +83,7 @@ def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
manifest = entry.get("manifest") manifest = entry.get("manifest")
for variant in reversed(entry.get("manifest_variants", [])): for variant in reversed(entry.get("manifest_variants", [])):
requires = variant.get("requires", []) requires = variant.get("requires", [])
if requires and all(cap == "runtime-migration-backup-v1" for cap in requires): if requires and all(cap in {"runtime-migration-backup-v1", "npm-legacy-host-gateway-v1"} for cap in requires):
manifest = variant.get("manifest") manifest = variant.get("manifest")
break break
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict): if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
-1
View File
@@ -558,7 +558,6 @@ fix_npm_public_hosts() {
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh" local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh" [ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
[ -x "$script" ] || return 1 [ -x "$script" ] || return 1
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
if "$script" >/dev/null 2>&1; then if "$script" >/dev/null 2>&1; then
log "Synced Nginx Proxy Manager public hosts into host nginx" log "Synced Nginx Proxy Manager public hosts into host nginx"

Some files were not shown because too many files have changed in this diff Show More