fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
+15
-2
@@ -1,10 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.23-alpha (2026-10-01)
|
||||
## v1.9.0-alpha (2026-10-05)
|
||||
|
||||
Unpublished release candidate; qualification is still in progress.
|
||||
|
||||
- Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.
|
||||
- Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.
|
||||
- Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.
|
||||
- Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.
|
||||
- Restrict public management access while retaining configured public apps and ACME certificate validation.
|
||||
- Serve the Mempool explorer on the Angor indexer origin alongside its API.
|
||||
- Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.
|
||||
- Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.
|
||||
- Return explicit payment-status errors with safe retry guidance when verification is unavailable.
|
||||
- Show compact upload progress across screens, retain the original destination, and cancel active and queued uploads.
|
||||
- Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.
|
||||
- Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.
|
||||
- Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.
|
||||
- Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.
|
||||
- Allow apps to opt in to a validated public-key list of user identities without granting signing access.
|
||||
- Make transaction filters transparent and horizontally scrollable on mobile.
|
||||
- Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.
|
||||
- Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ python3 scripts/check-git-mirrors.py --local
|
||||
Before publishing release artifacts, also check the actual release tag:
|
||||
|
||||
```bash
|
||||
python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0
|
||||
python3 scripts/check-git-mirrors.py --local --ref refs/tags/v1.9.0-alpha
|
||||
```
|
||||
|
||||
Use the release's actual tag name. Missing refs, inaccessible mirrors or differing
|
||||
|
||||
@@ -436,13 +436,13 @@
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"version": "2.14.0",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
|
||||
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
{
|
||||
@@ -648,9 +648,9 @@
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.1",
|
||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [
|
||||
"Mempool API",
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
app:
|
||||
id: nginx-proxy-manager
|
||||
name: Nginx Proxy Manager
|
||||
version: 2.12.1
|
||||
version: 2.14.0
|
||||
upstream:
|
||||
kind: github
|
||||
repo: NginxProxyManager/nginx-proxy-manager
|
||||
description: >-
|
||||
Reverse proxy with SSL. Beautiful web interface for managing proxies.
|
||||
On a node, this manages its admin UI and upstream configuration — the
|
||||
proxy's own :80/:443 listeners are not published (the node's web server
|
||||
owns those ports).
|
||||
The node's public web server forwards configured domains through this
|
||||
service, preserving its access lists, certificates and custom routes.
|
||||
backup_before_runtime_change: true
|
||||
|
||||
container:
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
|
||||
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08
|
||||
pull_policy: if-not-present
|
||||
network: pasta
|
||||
# Rootless pasta copies the LAN IP, preventing requests back to this node.
|
||||
# Retain the old pasta host gateway used by saved NPM upstreams, plus
|
||||
# host.containers.internal. This subnet stays inside the private rootless namespace.
|
||||
network: slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24
|
||||
|
||||
dependencies:
|
||||
- storage: 1Gi
|
||||
@@ -49,6 +52,17 @@ app:
|
||||
Nginx Proxy Manager enforces its own admin account on every page;
|
||||
the initial setup wizard also has to answer before any account exists.
|
||||
|
||||
- host: 8088
|
||||
container: 80
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
- host: 8444
|
||||
container: 443
|
||||
protocol: tcp
|
||||
bind: 127.0.0.1
|
||||
auth: local
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/nginx-proxy-manager
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.22-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.8.22-alpha"
|
||||
version = "1.9.0-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -136,7 +136,7 @@ impl RpcHandler {
|
||||
/// ~30% of UI calls error out even though the node is perfectly healthy.
|
||||
/// With retry + backoff, the UI sees a uniform slow-but-successful
|
||||
/// response instead of intermittent failures.
|
||||
async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
pub(in crate::api::rpc) async fn bitcoin_rpc_call<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
method: &str,
|
||||
|
||||
@@ -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
|
||||
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
|
||||
// Keep older clients compatible until both sides have upgraded.
|
||||
@@ -517,36 +545,15 @@ impl RpcHandler {
|
||||
// by exact (onion, content_id) and by (onion, filename) — the latter
|
||||
// catches duplicate ids pointing at the same file on the same
|
||||
// seller. The owned copy is served from the local cache instead.
|
||||
if let Some(cached) = existing_paid_content(
|
||||
&self.config.data_dir,
|
||||
onion,
|
||||
content_id,
|
||||
params.get("filename").and_then(|v| v.as_str()),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
let filename = params.get("filename").and_then(|v| v.as_str());
|
||||
let owned = crate::content_owned::list_owned(&self.config.data_dir).await;
|
||||
let already = owned.iter().find(|o| {
|
||||
o.onion == onion
|
||||
&& (o.content_id == content_id
|
||||
|| filename.is_some_and(|f| {
|
||||
!f.is_empty()
|
||||
&& o.filename.trim_start_matches('/') == f.trim_start_matches('/')
|
||||
}))
|
||||
});
|
||||
if let Some(o) = already {
|
||||
tracing::info!(
|
||||
onion,
|
||||
content_id,
|
||||
owned_as = %o.content_id,
|
||||
"paid download: already owned — serving cached copy, NOT paying again"
|
||||
);
|
||||
if let Some((mime, bytes)) =
|
||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||
.await
|
||||
{
|
||||
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||
result["already_owned"] = serde_json::json!(true);
|
||||
result["filename"] = serde_json::json!(o.filename);
|
||||
return Ok(result);
|
||||
}
|
||||
// Cache record exists but bytes are gone — fall through and
|
||||
// repurchase rather than stranding the user.
|
||||
}
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||
|
||||
@@ -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.sendcoins" => self.handle_lnd_sendcoins(params).await,
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.bump-quote" => self.handle_lnd_bump_quote(params).await,
|
||||
"lnd.bump-submit" => self.handle_lnd_bump_submit(params).await,
|
||||
"lnd.bump-status" => self.handle_lnd_bump_status(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.invoicestatus" => self.handle_lnd_invoicestatus(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
|
||||
@@ -272,28 +272,8 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
peer = pubkey,
|
||||
@@ -574,7 +554,7 @@ impl RpcHandler {
|
||||
|
||||
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
|
||||
/// With neither parameter LND uses a lax target; keep legacy clients on our
|
||||
/// explicit Standard target rather than silently accepting that default.
|
||||
/// explicit next-block target rather than silently accepting that default.
|
||||
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
|
||||
let force = match params.get("force") {
|
||||
None | Some(serde_json::Value::Null) => false,
|
||||
@@ -609,7 +589,12 @@ fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static s
|
||||
if let Some(rate) = rate {
|
||||
query.push(("sat_per_vbyte", rate.to_string()));
|
||||
} 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)
|
||||
@@ -645,7 +630,7 @@ mod close_fee_tests {
|
||||
}
|
||||
assert_eq!(
|
||||
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!(
|
||||
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(¤t["total_fees"]).unwrap());
|
||||
parent["fee_bump_txid"] = current["tx_hash"].clone();
|
||||
parent["fee_bump_confirmations"] = current["num_confirmations"].clone();
|
||||
parent["fee_bump_history"] = json!(candidates.iter().map(|child| {
|
||||
let child_id = child["tx_hash"].as_str().unwrap();
|
||||
hidden.insert(child_id.to_owned());
|
||||
json!({"tx_hash":child_id,"fee_sats":number(&child["total_fees"]).unwrap(),
|
||||
"status":if child["tx_hash"] != current["tx_hash"] { "replaced" }
|
||||
else if child["num_confirmations"].as_i64().unwrap_or(0) > 0 { "confirmed" } else { "mempool" }})
|
||||
}).collect::<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("e, &fresh, now())?;
|
||||
let op = Operation {
|
||||
quote: quote.clone(),
|
||||
status: "unknown".into(),
|
||||
message: "Submission recorded; checking the wallet. Do not submit another bump.".into(),
|
||||
};
|
||||
reserve(&path, &op).await?;
|
||||
QUOTES.lock().await.remove(id);
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
// At a one-block deadline LND may spend ALL this explicitly previewed
|
||||
// budget. It is always below input value, so no extra funding is requested.
|
||||
let result = lnd(
|
||||
&client,
|
||||
&macaroon,
|
||||
"/v2/wallet/bumpfee",
|
||||
Some(bump_body(&fresh)),
|
||||
)
|
||||
.await;
|
||||
// Keep the write-ahead record even for an RPC error: a lost response can
|
||||
// conceal an accepted bump. Status reconciles from wallet/mempool evidence.
|
||||
match result {
|
||||
Ok(_) => Ok(
|
||||
json!({"status":"registered", "message":"Bump registered with the wallet. Waiting for broadcast.", "quote":quote}),
|
||||
),
|
||||
Err(_) => Ok(
|
||||
json!({"status":"unknown", "message":"The wallet response was not confirmed. Check status; do not submit again.", "quote":quote}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_lnd_bump_status(
|
||||
&self,
|
||||
params: Option<Value>,
|
||||
) -> Result<Value> {
|
||||
let txid = txid_param(¶ms.unwrap_or_default())?;
|
||||
let path = self
|
||||
.config
|
||||
.data_dir
|
||||
.join("wallet/fee-bumps")
|
||||
.join(format!("{txid}.json"));
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(json!({"status":"none"}))
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let op: Operation = serde_json::from_slice(&bytes)
|
||||
.context("Bump receipt needs recovery; do not resubmit")?;
|
||||
let (client, macaroon) = self.lnd_client().await?;
|
||||
let history = lnd(&client, &macaroon, "/v1/transactions", None).await?;
|
||||
let plan = &op.quote.plan;
|
||||
let input = format!("{}:{}", plan.input_txid, plan.input_index);
|
||||
let mut candidates: Vec<&Value> = array(&history, "transactions")?
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t["tx_hash"] != txid
|
||||
&& t["output_details"].as_array().is_some_and(|outputs| {
|
||||
!outputs.is_empty() && outputs.iter().all(|o| o["is_our_address"] == true)
|
||||
})
|
||||
&& t["previous_outpoints"]
|
||||
.as_array()
|
||||
.is_some_and(|inputs| inputs.iter().any(|i| i["outpoint"] == input))
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by_key(|t| std::cmp::Reverse(number(&t["time_stamp"]).unwrap_or(0)));
|
||||
for t in candidates {
|
||||
let id = t["tx_hash"]
|
||||
.as_str()
|
||||
.context("Missing bump transaction ID")?;
|
||||
let confirmed = t["num_confirmations"].as_i64().unwrap_or(0) > 0;
|
||||
let accepted = if confirmed {
|
||||
false
|
||||
} else {
|
||||
self.bitcoin_rpc_call::<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());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod channels;
|
||||
mod fee_bump;
|
||||
mod fee_policy;
|
||||
mod info;
|
||||
mod macaroons;
|
||||
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)
|
||||
transactions.sort_by(|a, b| {
|
||||
let ta = a.get("time_stamp").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
|
||||
@@ -124,28 +124,8 @@ impl RpcHandler {
|
||||
return Err(anyhow::anyhow!("Invalid Bitcoin address format"));
|
||||
}
|
||||
|
||||
// Fee control: either a confirmation target or an explicit fee rate
|
||||
let target_conf = params.get("target_conf").and_then(|v| v.as_i64());
|
||||
let sat_per_vbyte = params.get("sat_per_vbyte").and_then(|v| v.as_i64());
|
||||
if target_conf.is_some() && sat_per_vbyte.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid fee parameters: specify either target_conf or sat_per_vbyte, not both"
|
||||
));
|
||||
}
|
||||
if let Some(tc) = target_conf {
|
||||
if !(1..=1008).contains(&tc) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(rate) = sat_per_vbyte {
|
||||
if !(1..=5000).contains(&rate) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid sat_per_vbyte: must be between 1 and 5000"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Omitted fees target the next block; explicit slower/custom choices win.
|
||||
let (target_conf, sat_per_vbyte) = super::fee_policy::fee_options(¶ms)?;
|
||||
|
||||
info!(
|
||||
addr = addr,
|
||||
@@ -238,15 +218,12 @@ impl RpcHandler {
|
||||
if !(546..=21_000_000 * 100_000_000).contains(&amount) {
|
||||
return Err(anyhow::anyhow!("Invalid amount"));
|
||||
}
|
||||
let target_conf = params
|
||||
.get("target_conf")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(6);
|
||||
if !(1..=1008).contains(&target_conf) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid target_conf: must be between 1 and 1008 blocks"
|
||||
));
|
||||
}
|
||||
let (target_conf, custom_rate) = super::fee_policy::fee_options(¶ms)?;
|
||||
anyhow::ensure!(
|
||||
custom_rate.is_none(),
|
||||
"Fee estimation requires a confirmation target"
|
||||
);
|
||||
let target_conf = target_conf.unwrap_or(super::fee_policy::DEFAULT_TARGET);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
@@ -782,10 +759,24 @@ impl RpcHandler {
|
||||
total_amount += amount;
|
||||
}
|
||||
|
||||
let sat_per_vbyte = params
|
||||
.get("fee_rate_sat_per_vbyte")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10);
|
||||
let (_, explicit_rate) = super::fee_policy::fee_options(&serde_json::json!({
|
||||
"sat_per_vbyte": params.get("fee_rate_sat_per_vbyte")
|
||||
}))?;
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let sat_per_vbyte = if let Some(rate) = explicit_rate {
|
||||
rate as u64
|
||||
} else {
|
||||
let response = client
|
||||
.get(format!("{LND_REST_BASE_URL}/v2/wallet/estimatefee/1"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("Cannot estimate the next-block fee")?
|
||||
.error_for_status()
|
||||
.context("Next-block fee estimate rejected")?;
|
||||
let estimate: serde_json::Value = response.json().await?;
|
||||
super::fee_policy::estimated_sat_per_vbyte(&estimate)?
|
||||
};
|
||||
|
||||
info!(
|
||||
total_amount = total_amount,
|
||||
@@ -793,8 +784,6 @@ impl RpcHandler {
|
||||
"Creating PSBT for hardware wallet signing"
|
||||
);
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
let fund_body = serde_json::json!({
|
||||
"raw": {
|
||||
"outputs": lnd_outputs,
|
||||
|
||||
@@ -221,6 +221,10 @@ impl RpcHandler {
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
anyhow::ensure!(job.snapshot().await.done,
|
||||
"A firmware flash is in progress; wait before reconnecting or changing radio settings");
|
||||
}
|
||||
|
||||
let mut config = mesh::load_config(&self.config.data_dir).await?;
|
||||
|
||||
@@ -325,7 +329,16 @@ impl RpcHandler {
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
let flash_jobs = Arc::clone(&self.flash_job);
|
||||
tokio::spawn(async move {
|
||||
// Serialize against flash registration. If a flash started
|
||||
// after this RPC saved settings, its completion applies them.
|
||||
let flash_guard = flash_jobs.read().await;
|
||||
if let Some(job) = flash_guard.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
|
||||
@@ -110,7 +110,8 @@ impl RpcHandler {
|
||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||
// from opening the identical port at the same time and corrupting
|
||||
// both operations' handshakes.
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
let flash_guard = self.flash_job.read().await;
|
||||
if let Some(job) = flash_guard.as_ref() {
|
||||
anyhow::ensure!(
|
||||
job.snapshot().await.done,
|
||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||
@@ -131,6 +132,7 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
let probe = mesh::listener::probe_device(&path).await?;
|
||||
drop(flash_guard);
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
|
||||
@@ -985,28 +985,26 @@ pub(super) async fn get_app_config(
|
||||
)
|
||||
}
|
||||
"nginx-proxy-manager" => {
|
||||
let storage = crate::container::npm::resolve_storage().await?;
|
||||
let admin_port = allocator
|
||||
.allocate_or_get(app_id, 8081, 81)
|
||||
.await
|
||||
.unwrap_or(8081);
|
||||
let http_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
|
||||
.allocate_or_get("nginx-proxy-manager-http", 8088, 80)
|
||||
.await
|
||||
.unwrap_or(8084);
|
||||
.unwrap_or(8088);
|
||||
let https_port = allocator
|
||||
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
|
||||
.await
|
||||
.unwrap_or(8444);
|
||||
(
|
||||
vec![
|
||||
format!("{}:81", admin_port),
|
||||
format!("{}:80", http_port),
|
||||
format!("{}:443", https_port),
|
||||
],
|
||||
vec![
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
format!("127.0.0.1:{}:81", admin_port),
|
||||
format!("127.0.0.1:{}:80", http_port),
|
||||
format!("127.0.0.1:{}:443", https_port),
|
||||
],
|
||||
storage.bind_mounts(),
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -693,7 +693,11 @@ impl RpcHandler {
|
||||
// These standalone web UIs have repeatedly lost host listeners
|
||||
// under Podman's rootless pasta backend while staying healthy internally.
|
||||
// Use slirp4netns/rootlessport for this standalone web UI.
|
||||
run_args.push("--network=slirp4netns:allow_host_loopback=true");
|
||||
run_args.push(if package_id == "nginx-proxy-manager" {
|
||||
"--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
} else {
|
||||
"--network=slirp4netns:allow_host_loopback=true"
|
||||
});
|
||||
} else if needs_archy_net(package_id) {
|
||||
// Create archy-net if it doesn't exist (idempotent — "already exists" is fine)
|
||||
match tokio::process::Command::new("podman")
|
||||
@@ -1568,88 +1572,8 @@ autopilot.active=false\n",
|
||||
super::pine_ha::restart_home_assistant_if_running().await;
|
||||
}
|
||||
}
|
||||
if package_id == "filebrowser" {
|
||||
// Generate a random password (32 bytes, hex-encoded)
|
||||
let mut buf = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut buf);
|
||||
let password = hex::encode(buf);
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to create HTTP client for FileBrowser hook: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Retry loop: FileBrowser may take time to initialize its SQLite database
|
||||
let mut password_changed = false;
|
||||
for attempt in 0..6u32 {
|
||||
let delay = if attempt == 0 { 5 } else { 10 };
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
|
||||
// Try to log in with default credentials
|
||||
let login_res = client
|
||||
.post("http://127.0.0.1:8083/api/login")
|
||||
.json(&serde_json::json!({"username": "admin", "password": "admin"}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let token = match login_res {
|
||||
Ok(resp) if resp.status().is_success() => match resp.text().await {
|
||||
Ok(t) => t.trim_matches('"').to_string(),
|
||||
Err(_) => continue,
|
||||
},
|
||||
_ => {
|
||||
debug!("FileBrowser not ready (attempt {}/6)", attempt + 1);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Change admin password
|
||||
let change_res = client
|
||||
.put("http://127.0.0.1:8083/api/users/1")
|
||||
.header("X-Auth", &token)
|
||||
.json(&serde_json::json!({"password": password}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match change_res {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let secret_dir = "/var/lib/archipelago/secrets/filebrowser";
|
||||
if let Err(e) = tokio::fs::create_dir_all(secret_dir).await {
|
||||
tracing::warn!("Failed to create filebrowser secrets dir: {}", e);
|
||||
}
|
||||
let pw_path = format!("{}/password", secret_dir);
|
||||
if let Err(e) = tokio::fs::write(&pw_path, &password).await {
|
||||
tracing::warn!("Failed to write filebrowser password: {}", e);
|
||||
}
|
||||
// Set restrictive permissions on the password file
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(
|
||||
&pw_path,
|
||||
std::fs::Permissions::from_mode(0o600),
|
||||
);
|
||||
}
|
||||
info!("FileBrowser admin password secured (default credentials replaced)");
|
||||
password_changed = true;
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
if !password_changed {
|
||||
tracing::warn!(
|
||||
"FileBrowser password could not be changed after 6 attempts — \
|
||||
default credentials (admin/admin) remain active"
|
||||
);
|
||||
}
|
||||
}
|
||||
// File Browser credentials are provisioned and verified before the
|
||||
// server starts. Never attempt a default-password change after launch.
|
||||
|
||||
// Auto-configure Tor hidden service for protocol services (LND, ElectrumX, Bitcoin)
|
||||
{
|
||||
@@ -1912,10 +1836,10 @@ autopilot.active=false\n",
|
||||
}
|
||||
|
||||
pub(in crate::api::rpc) async fn handle_filebrowser_token(&self) -> Result<serde_json::Value> {
|
||||
let secret_path = "/var/lib/archipelago/secrets/filebrowser/password";
|
||||
let password = tokio::fs::read_to_string(secret_path)
|
||||
.await
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let credentials = crate::container::filebrowser::cloud_credentials(std::path::Path::new(
|
||||
"/var/lib/archipelago/secrets/filebrowser",
|
||||
))
|
||||
.await?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
@@ -1924,7 +1848,7 @@ autopilot.active=false\n",
|
||||
|
||||
let resp = client
|
||||
.post("http://127.0.0.1:8083/api/login")
|
||||
.json(&serde_json::json!({"username": "admin", "password": password}))
|
||||
.json(&serde_json::json!({"username": credentials.username, "password": credentials.password}))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to connect to FileBrowser")?;
|
||||
@@ -1954,17 +1878,16 @@ autopilot.active=false\n",
|
||||
super::validation::validate_app_id(app_id)?;
|
||||
|
||||
if app_id == "filebrowser" {
|
||||
let password =
|
||||
tokio::fs::read_to_string("/var/lib/archipelago/secrets/filebrowser/password")
|
||||
.await
|
||||
.map(|p| p.trim().to_string())
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let credentials = crate::container::filebrowser::cloud_credentials(
|
||||
std::path::Path::new("/var/lib/archipelago/secrets/filebrowser"),
|
||||
)
|
||||
.await?;
|
||||
return Ok(serde_json::json!({
|
||||
"title": "File Browser credentials",
|
||||
"description": "Use these credentials when File Browser asks you to sign in.",
|
||||
"credentials": [
|
||||
{ "label": "Username", "value": "admin" },
|
||||
{ "label": "Password", "value": password, "sensitive": true }
|
||||
{ "label": "Username", "value": credentials.username },
|
||||
{ "label": "Password", "value": credentials.password, "sensitive": true }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1576,41 +1576,110 @@ async fn repair_netbird_network() {
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_container() {
|
||||
repair_nginx_proxy_manager_dirs().await;
|
||||
// Quadlet owns managed containers; its backed-up reconciliation applies
|
||||
// port and mount changes. Never remove a systemd-owned container here.
|
||||
if crate::container::quadlet::unit_exists("nginx-proxy-manager").await {
|
||||
return;
|
||||
}
|
||||
// Serialize repair so a second caller cannot overlap a replacement.
|
||||
static REPAIR: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
let _repair = REPAIR.lock().await;
|
||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||
return;
|
||||
}
|
||||
|
||||
install_log(
|
||||
"START REPAIR: nginx-proxy-manager - recreating stale container using host port 8081",
|
||||
)
|
||||
.await;
|
||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
|
||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||
if let Err(error) = repair_legacy_nginx_proxy_manager().await {
|
||||
tracing::warn!(error = %error, "NPM legacy repair failed; persistent state preserved");
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_nginx_proxy_manager_dirs() {
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
const NPM_PREVIOUS_CONTAINER: &str = "archy-npm-upgrade-previous";
|
||||
|
||||
async fn restore_failed_npm_repair() -> Result<()> {
|
||||
let removed = podman_control(&["rm", "-f", "--ignore", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
removed.status.success(),
|
||||
"cannot remove failed NPM replacement; previous container retained"
|
||||
);
|
||||
let renamed =
|
||||
podman_control(&["rename", NPM_PREVIOUS_CONTAINER, "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
renamed.status.success(),
|
||||
"cannot restore previous NPM container name"
|
||||
);
|
||||
let started = podman_control(&["start", "nginx-proxy-manager"]).await?;
|
||||
anyhow::ensure!(
|
||||
started.status.success(),
|
||||
"previous NPM container restored but failed to start"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repair_legacy_nginx_proxy_manager() -> Result<()> {
|
||||
let previous = podman_control(&["container", "exists", NPM_PREVIOUS_CONTAINER]).await?;
|
||||
anyhow::ensure!(previous.status.code() == Some(1),
|
||||
"NPM previous-container slot is occupied or cannot be inspected; preserve it and review interrupted repair before proceeding");
|
||||
let inspection = podman_control(&[
|
||||
"inspect",
|
||||
"nginx-proxy-manager",
|
||||
"--format",
|
||||
"{{json .Config.Env}}",
|
||||
])
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
inspection.status.success(),
|
||||
"cannot preserve NPM environment before repair"
|
||||
);
|
||||
let environment: Vec<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 {
|
||||
@@ -1645,36 +1714,75 @@ async fn nginx_proxy_manager_has_legacy_admin_port() -> bool {
|
||||
ports.contains(":81->81/tcp") || ports.contains(":8443->443/tcp")
|
||||
}
|
||||
|
||||
async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"mkdir",
|
||||
"-p",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge",
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("failed to create nginx-proxy-manager data directories")?;
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args([
|
||||
"chown",
|
||||
"-R",
|
||||
"1000:1000",
|
||||
"/var/lib/archipelago/nginx-proxy-manager",
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
fn npm_repair_environment(values: &[String]) -> Result<Vec<(String, String)>> {
|
||||
values.iter().map(|value| {
|
||||
let (key, value) = value.split_once('=').context("invalid NPM environment entry")?;
|
||||
anyhow::ensure!(!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
|
||||
"unsupported NPM environment name; original container preserved");
|
||||
anyhow::ensure!(!value.contains(['\n', '\r', '\0']),
|
||||
"NPM environment requires explicit migration of a multiline value; original container preserved");
|
||||
Ok((key.to_owned(), value.to_owned()))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
let image = crate::container::image_versions::pinned_image_for_app("nginx-proxy-manager")
|
||||
.unwrap_or_else(|| "docker.io/jc21/nginx-proxy-manager:latest".to_string());
|
||||
struct NpmRepairEnvironmentFile(std::path::PathBuf);
|
||||
|
||||
impl NpmRepairEnvironmentFile {
|
||||
fn create(environment: &[(String, String)]) -> Result<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![
|
||||
"run".to_string(),
|
||||
"-d".to_string(),
|
||||
"--name".to_string(),
|
||||
"nginx-proxy-manager".to_string(),
|
||||
"--restart=unless-stopped".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true".to_string(),
|
||||
"--network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24".to_string(),
|
||||
"--cap-drop=ALL".to_string(),
|
||||
"--security-opt=no-new-privileges:true".to_string(),
|
||||
"--pids-limit=4096".to_string(),
|
||||
@@ -1682,24 +1790,32 @@ async fn recreate_nginx_proxy_manager_container() -> Result<()> {
|
||||
args.extend(get_app_capabilities("nginx-proxy-manager"));
|
||||
args.extend([
|
||||
"-p".to_string(),
|
||||
"8081:81".to_string(),
|
||||
"127.0.0.1:8081:81".to_string(),
|
||||
"-p".to_string(),
|
||||
"8084:80".to_string(),
|
||||
"127.0.0.1:8088:80".to_string(),
|
||||
"-p".to_string(),
|
||||
"8444:443".to_string(),
|
||||
"127.0.0.1:8444:443".to_string(),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
|
||||
format!("{}:/data", storage.data),
|
||||
"-v".to_string(),
|
||||
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
|
||||
format!("{}:/etc/letsencrypt", storage.certificates),
|
||||
"--memory".to_string(),
|
||||
get_memory_limit("nginx-proxy-manager").to_string(),
|
||||
"--cpus=2".to_string(),
|
||||
]);
|
||||
args.extend(get_health_check_args("nginx-proxy-manager", ""));
|
||||
// Keep values out of argv/logs AND out of Podman's host environment
|
||||
// (a container's PATH or LD_PRELOAD must never alter the host command).
|
||||
let env_file = NpmRepairEnvironmentFile::create(environment)?;
|
||||
args.extend([
|
||||
"--env-file".to_string(),
|
||||
env_file.0.to_string_lossy().into_owned(),
|
||||
]);
|
||||
args.push(image);
|
||||
|
||||
let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let output = podman_control(&refs).await?;
|
||||
let mut command = tokio::process::Command::new("podman");
|
||||
command.args(&args);
|
||||
let output = command_with_timeout(command, Duration::from_secs(120), "NPM replacement").await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"podman run nginx-proxy-manager failed: {}",
|
||||
@@ -1767,7 +1883,7 @@ fn runtime_host_ports(container_name: &str) -> Vec<u16> {
|
||||
"vaultwarden" => vec![8082],
|
||||
"gitea" => vec![3001, 2222, 3000],
|
||||
"nextcloud" => vec![8085],
|
||||
"nginx-proxy-manager" => vec![8081, 8084, 8444],
|
||||
"nginx-proxy-manager" => vec![8081, 8088, 8444],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ports
|
||||
@@ -1778,7 +1894,7 @@ fn with_legacy_extra_ports(container_name: &str, mut ports: Vec<u16>) -> Vec<u16
|
||||
ports.push(3000);
|
||||
}
|
||||
if container_name == "nginx-proxy-manager" {
|
||||
for port in [8084, 8444] {
|
||||
for port in [8088, 8444] {
|
||||
if !ports.contains(&port) {
|
||||
ports.push(port);
|
||||
}
|
||||
@@ -1843,6 +1959,7 @@ async fn wait_for_runtime_host_port(container_name: &str, port: u16, timeout_sec
|
||||
loop {
|
||||
let ready = match container_name {
|
||||
"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))
|
||||
.await
|
||||
.is_ok(),
|
||||
@@ -2151,6 +2268,38 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn npm_environment_backup_is_private_exact_and_removed_on_drop() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let values = vec![
|
||||
"DB_PASSWORD=fixture=a b".to_string(),
|
||||
"PATH=/container/only".to_string(),
|
||||
];
|
||||
let parsed = super::npm_repair_environment(&values).unwrap();
|
||||
let host_path = std::env::var_os("PATH");
|
||||
let path = {
|
||||
let file = super::NpmRepairEnvironmentFile::create(&parsed).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(&file.0).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&file.0).unwrap(),
|
||||
"DB_PASSWORD=fixture=a b\nPATH=/container/only\n"
|
||||
);
|
||||
assert_eq!(std::env::var_os("PATH"), host_path);
|
||||
file.0.clone()
|
||||
};
|
||||
assert!(!path.exists());
|
||||
for value in [
|
||||
"INVALID",
|
||||
"=empty key",
|
||||
"KEY=value\nINJECTED=true",
|
||||
"--env=value",
|
||||
] {
|
||||
assert!(super::npm_repair_environment(&[value.to_string()]).is_err());
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -142,11 +142,40 @@ const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\
|
||||
/// catalog refresh/reconciliation. Replacing the app tree in the background
|
||||
/// could let a reload observe its temporary empty state and forget disk-only apps.
|
||||
pub async fn ensure_runtime_assets_ready() {
|
||||
// Install the guard before any startup path can reload an older dashboard
|
||||
// vhost. The canonical OTA/ISO config contains the same guard inline.
|
||||
if Path::new(NGINX_CONF_PATH).exists() || Path::new(NGINX_ENABLED_CONF_PATH).exists() {
|
||||
match host_sudo(&[
|
||||
"python3",
|
||||
"-c",
|
||||
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||
])
|
||||
.await
|
||||
{
|
||||
Ok(status) if status.success() => debug!("Dashboard public source guard verified"),
|
||||
Ok(status) => warn!("Dashboard public source guard needs attention: {status}"),
|
||||
Err(error) => warn!("Dashboard public source guard could not run: {error}"),
|
||||
}
|
||||
}
|
||||
match run_runtime_assets().await {
|
||||
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
|
||||
Ok(_) => debug!("No OTA runtime payload to synchronize"),
|
||||
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
|
||||
}
|
||||
// A binary-only qualification or OTA rollback can precede the matching
|
||||
// script payload. Install the exact embedded helper before Quadlet
|
||||
// reconciliation can introduce its required ExecStartPre command.
|
||||
if let Err(error) = write_root_if_needed(
|
||||
"/opt/archipelago/scripts/filebrowser-credentials.py",
|
||||
include_str!("../../../scripts/filebrowser-credentials.py"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("File Browser credential helper installation failed: {error:#}");
|
||||
}
|
||||
if let Err(error) = run_npm_bridge_bootstrap().await {
|
||||
warn!("NPM public routing bootstrap needs attention: {error:#}");
|
||||
}
|
||||
// Repair the narrowly recognized legacy NPM tunnel override before app
|
||||
// reconciliation. The embedded script ships in both OTA and ISO binaries.
|
||||
// It preserves native wallet services and refuses unknown custom routing.
|
||||
@@ -176,6 +205,52 @@ pub async fn ensure_runtime_assets_ready() {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_npm_bridge_bootstrap() -> Result<()> {
|
||||
if !Path::new("/opt/archipelago/scripts").is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut units_changed = false;
|
||||
for (path, content) in [
|
||||
(
|
||||
"/opt/archipelago/scripts/npm-public-bridge.py",
|
||||
include_str!("../../../scripts/npm-public-bridge.py"),
|
||||
),
|
||||
(
|
||||
"/opt/archipelago/scripts/dashboard-public-guard.py",
|
||||
include_str!("../../../scripts/dashboard-public-guard.py"),
|
||||
),
|
||||
(
|
||||
"/etc/systemd/system/archipelago-npm-bridge.service",
|
||||
include_str!("../../../image-recipe/configs/archipelago-npm-bridge.service"),
|
||||
),
|
||||
(
|
||||
"/etc/systemd/system/archipelago-npm-bridge.timer",
|
||||
include_str!("../../../image-recipe/configs/archipelago-npm-bridge.timer"),
|
||||
),
|
||||
] {
|
||||
let changed = write_root_if_needed(path, content).await?;
|
||||
units_changed |= changed && (path.ends_with(".service") || path.ends_with(".timer"));
|
||||
}
|
||||
if units_changed {
|
||||
anyhow::ensure!(
|
||||
host_sudo(&["systemctl", "daemon-reload"]).await?.success(),
|
||||
"NPM bridge daemon reload failed"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
host_sudo(&[
|
||||
"systemctl",
|
||||
"enable",
|
||||
"--now",
|
||||
"archipelago-npm-bridge.timer"
|
||||
])
|
||||
.await?
|
||||
.success(),
|
||||
"NPM bridge timer could not start"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Entry point called from main startup. Never returns an error to the caller —
|
||||
/// failing to bootstrap host artifacts must not prevent the backend from serving.
|
||||
pub async fn ensure_doctor_installed() {
|
||||
@@ -432,6 +507,17 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
if !status.success() {
|
||||
anyhow::bail!("install nginx-archipelago.conf exited with {}", status);
|
||||
}
|
||||
let acme_status = host_sudo(&[
|
||||
"python3",
|
||||
"-c",
|
||||
include_str!("../../../scripts/npm-public-bridge.py"),
|
||||
"--acme-only",
|
||||
])
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
acme_status.success(),
|
||||
"active NPM ACME root migration failed"
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -448,6 +534,8 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
"archipelago-doctor.service",
|
||||
"archipelago-doctor.timer",
|
||||
"archipelago-host-secrets-audit.service",
|
||||
"archipelago-npm-bridge.service",
|
||||
"archipelago-npm-bridge.timer",
|
||||
] {
|
||||
let src = configs.join(unit);
|
||||
if src.exists() {
|
||||
@@ -475,7 +563,7 @@ async fn run_runtime_assets() -> Result<bool> {
|
||||
// or directory"). Skipped when byte-identical; a running daemon is
|
||||
// unaffected (install replaces the inode) and picks the new binary up
|
||||
// on its next spawn.
|
||||
for tool in ["archy-reticulum-daemon", "archy-rnodeconf"] {
|
||||
for tool in ["archy-reticulum-daemon", "archy-rnodeconf", "archy-esptool"] {
|
||||
let src = runtime_dir.join("radio-tools").join(tool);
|
||||
if !src.exists() {
|
||||
continue;
|
||||
|
||||
@@ -118,10 +118,12 @@ fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
|
||||
// Never let an unknown future requirement become an unsafe partial match.
|
||||
for variant in entry.manifest_variants.into_iter().rev() {
|
||||
if !variant.requires.is_empty()
|
||||
&& variant
|
||||
.requires
|
||||
.iter()
|
||||
.all(|capability| capability == "runtime-migration-backup-v1")
|
||||
&& variant.requires.iter().all(|capability| {
|
||||
matches!(
|
||||
capability.as_str(),
|
||||
"runtime-migration-backup-v1" | "npm-legacy-host-gateway-v1"
|
||||
)
|
||||
})
|
||||
{
|
||||
return Some(variant.manifest);
|
||||
}
|
||||
@@ -468,6 +470,12 @@ pub struct CatalogRefresh {
|
||||
/// changed. Best-effort: a fetch failure leaves the existing cache untouched
|
||||
/// (origin-always-wins; updates simply aren't refreshed this cycle).
|
||||
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<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)
|
||||
.await
|
||||
.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")))
|
||||
}
|
||||
|
||||
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)> {
|
||||
let resp = client.get(url).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
@@ -582,6 +633,77 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signed_candidate(key_byte: u8) -> serde_json::Value {
|
||||
// Same test anchor as trust::signed_doc tests; never a production key.
|
||||
let anchor = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]);
|
||||
std::env::set_var(
|
||||
"ARCHY_RELEASE_ROOT_PUBKEY",
|
||||
hex::encode(anchor.verifying_key().to_bytes()),
|
||||
);
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&[key_byte; 32]);
|
||||
let mut value = serde_json::json!({"schema":1,"apps":{"demo":{"version":"2"}},"future_field":{"retain":true}});
|
||||
let (sig, did) = crate::trust::signed_doc::sign_detached(&key, &value).unwrap();
|
||||
value["signature"] = sig.into();
|
||||
value["signed_by"] = did.into();
|
||||
value
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_catalog_preserves_signed_bytes_and_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("candidate.json");
|
||||
let body = serde_json::to_string_pretty(&signed_candidate(7)).unwrap();
|
||||
std::fs::write(&path, &body).unwrap();
|
||||
let first = refresh_candidate_catalog(dir.path(), &path).await.unwrap();
|
||||
assert!(first.changed);
|
||||
assert_eq!(first.apps, 1);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
body
|
||||
);
|
||||
assert!(
|
||||
!refresh_candidate_catalog(dir.path(), &path)
|
||||
.await
|
||||
.unwrap()
|
||||
.changed
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejected_candidate_never_replaces_previous_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("candidate.json");
|
||||
let previous = "previous cached bytes";
|
||||
write_cache(dir.path(), previous).unwrap();
|
||||
let mut tampered = signed_candidate(7);
|
||||
tampered["apps"]["demo"]["version"] = "tampered".into();
|
||||
for body in [
|
||||
"malformed".into(),
|
||||
r#"{"schema":1,"apps":{}}"#.into(),
|
||||
signed_candidate(11).to_string(),
|
||||
tampered.to_string(),
|
||||
" ".repeat(4 * 1024 * 1024 + 1),
|
||||
] {
|
||||
std::fs::write(&path, body).unwrap();
|
||||
assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
previous
|
||||
);
|
||||
}
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(refresh_candidate_catalog(dir.path(), &path).await.is_err());
|
||||
assert!(
|
||||
refresh_candidate_catalog(dir.path(), Path::new("relative.json"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(APP_CATALOG_FILE)).unwrap(),
|
||||
previous
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
|
||||
let raw = serde_json::json!({
|
||||
@@ -608,6 +730,25 @@ mod tests {
|
||||
assert!(chosen["app"]["container"].get("network").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_gateway_variant_requires_explicit_runtime_support() {
|
||||
let mut raw = serde_json::json!({
|
||||
"version": "2.12.1", "manifest": {"network":"pasta"},
|
||||
"manifest_variants": [{"requires":["runtime-migration-backup-v1", "npm-legacy-host-gateway-v1"],
|
||||
"manifest":{"network":"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"}}]
|
||||
});
|
||||
assert_eq!(
|
||||
selected_manifest(serde_json::from_value(raw.clone()).unwrap()).unwrap()["network"],
|
||||
"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
);
|
||||
// A runtime missing any required capability must retain the base.
|
||||
raw["manifest_variants"][0]["requires"][1] = serde_json::json!("unknown-gateway-v2");
|
||||
assert_eq!(
|
||||
selected_manifest(serde_json::from_value(raw).unwrap()).unwrap()["network"],
|
||||
"pasta"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_ignores_unknown_fields() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -24,6 +24,7 @@ fn canonical_package_id(name: &str) -> &str {
|
||||
"immich-postgres" => "immich_postgres",
|
||||
"immich-redis" => "immich_redis",
|
||||
"mempool-web" | "mempool-frontend" => "mempool",
|
||||
"btcpay" | "btcpayserver" => "btcpay-server",
|
||||
name => name,
|
||||
}
|
||||
}
|
||||
@@ -445,6 +446,15 @@ mod lifecycle_regression_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[test]
|
||||
fn btcpay_aliases_share_one_package_without_promoting_dependencies() {
|
||||
for name in ["btcpay", "btcpayserver", "btcpay-server", "archy-btcpay"] {
|
||||
assert_eq!(canonical_package_id(name), "btcpay-server");
|
||||
}
|
||||
assert_eq!(canonical_package_id("archy-btcpay-db"), "btcpay-db");
|
||||
assert_eq!(canonical_package_id("archy-nbxplorer"), "nbxplorer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immich_dependency_aliases_share_the_hidden_component_ids() {
|
||||
for id in [
|
||||
|
||||
@@ -17,6 +17,84 @@ pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.fi
|
||||
const DEFAULT_CONFIG_JSON: &str =
|
||||
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n";
|
||||
|
||||
/// One atomically published record shared by setup, Cloud and credentials UI.
|
||||
/// Deliberately has no Debug implementation: the password must never be logged.
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CloudCredentials {
|
||||
pub schema: u32,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub async fn cloud_credentials(directory: &Path) -> Result<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)]
|
||||
pub struct EnsurePaths {
|
||||
pub srv_root: PathBuf,
|
||||
@@ -82,7 +160,18 @@ async fn create_dir_all_or_sudo(path: &std::path::Path) -> Result<()> {
|
||||
|
||||
async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||
let tmp = paths.config_path.with_extension("tmp");
|
||||
match fs::write(&tmp, DEFAULT_CONFIG_JSON).await {
|
||||
let legacy = paths.data_dir.join("database.db").exists();
|
||||
let canonical = paths.data_dir.join("filebrowser.db").exists();
|
||||
anyhow::ensure!(
|
||||
!(legacy && canonical),
|
||||
"Multiple File Browser databases need explicit config selection"
|
||||
);
|
||||
let config = if legacy {
|
||||
DEFAULT_CONFIG_JSON.replace("/data/filebrowser.db", "/data/database.db")
|
||||
} else {
|
||||
DEFAULT_CONFIG_JSON.to_string()
|
||||
};
|
||||
match fs::write(&tmp, &config).await {
|
||||
Ok(()) => {
|
||||
fs::rename(&tmp, &paths.config_path)
|
||||
.await
|
||||
@@ -99,7 +188,7 @@ async fn write_config_atomically(paths: &EnsurePaths) -> Result<()> {
|
||||
let script = format!(
|
||||
"set -eu\ncat > '{}' <<'FILEBROWSERCONF'\n{}FILEBROWSERCONF\n",
|
||||
shell_quote(&paths.config_path.to_string_lossy()),
|
||||
DEFAULT_CONFIG_JSON
|
||||
config
|
||||
);
|
||||
let status = host_sudo(&["sh", "-lc", &script])
|
||||
.await
|
||||
@@ -312,6 +401,62 @@ async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cloud_credentials_use_unique_record_and_never_default_password() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(cloud_credentials(dir.path()).await.is_err());
|
||||
fs::write(dir.path().join("password"), "admin")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(cloud_credentials(dir.path()).await.is_err());
|
||||
fs::write(dir.path().join("password"), "legacy-unique-password")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cloud_credentials(dir.path()).await.unwrap().schema, 0);
|
||||
let value = serde_json::json!({"schema":1,"username":format!("archy-{}", "a".repeat(32)),"password":"b".repeat(64)});
|
||||
fs::write(dir.path().join("credentials.json"), value.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = cloud_credentials(dir.path()).await.unwrap();
|
||||
assert_eq!(loaded.username, value["username"].as_str().unwrap());
|
||||
assert_eq!(loaded.password, value["password"].as_str().unwrap());
|
||||
fs::write(dir.path().join("credentials.json"), "{}")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
cloud_credentials(dir.path()).await.is_err(),
|
||||
"damaged managed record must not fall back to old credentials"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_config_preserves_legacy_database_and_refuses_ambiguity() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let paths = EnsurePaths {
|
||||
srv_root: tmp.path().join("srv"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
config_path: tmp.path().join("data/.filebrowser.json"),
|
||||
};
|
||||
fs::create_dir_all(&paths.data_dir).await.unwrap();
|
||||
fs::write(paths.data_dir.join("database.db"), b"legacy fixture")
|
||||
.await
|
||||
.unwrap();
|
||||
ensure_config(&paths).await.unwrap();
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&paths.config_path).await.unwrap()).unwrap();
|
||||
assert_eq!(config["database"], "/data/database.db");
|
||||
fs::remove_file(&paths.config_path).await.unwrap();
|
||||
fs::write(paths.data_dir.join("filebrowser.db"), b"other fixture")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ensure_config(&paths).await.is_err());
|
||||
assert!(!paths.config_path.exists());
|
||||
assert_eq!(
|
||||
fs::read(paths.data_dir.join("database.db")).await.unwrap(),
|
||||
b"legacy fixture"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_config_creates_dirs_and_file() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod image_policy;
|
||||
pub mod image_versions;
|
||||
pub mod lnd;
|
||||
pub mod migration_backup;
|
||||
pub mod npm;
|
||||
pub mod prod_orchestrator;
|
||||
pub mod quadlet;
|
||||
pub mod registry;
|
||||
|
||||
@@ -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 {
|
||||
matches!(
|
||||
network,
|
||||
"host" | "bridge" | "none" | "slirp4netns" | "pasta"
|
||||
"host"
|
||||
| "bridge"
|
||||
| "none"
|
||||
| "slirp4netns"
|
||||
| "slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
| "pasta"
|
||||
)
|
||||
}
|
||||
|
||||
// Only an explicitly selected rootless mode establishes drift. An omitted
|
||||
// network delegates to Podman and must not recreate unrelated installed apps.
|
||||
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
|
||||
matches!(expected, Some("slirp4netns" | "pasta"))
|
||||
&& !actual.trim().is_empty()
|
||||
&& actual.trim().split(':').next() != expected
|
||||
let actual = actual.trim();
|
||||
if actual.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match expected {
|
||||
Some(
|
||||
expected @ ("slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"),
|
||||
) => {
|
||||
let (mode, options) = actual.split_once(':').unwrap_or((actual, ""));
|
||||
let required = expected.split_once(':').unwrap().1;
|
||||
mode != "slirp4netns"
|
||||
|| required.split(',').any(|wanted| {
|
||||
let key = wanted.split_once('=').unwrap().0;
|
||||
!options.split(',').any(|option| option == wanted)
|
||||
|| options.split(',').any(|option| {
|
||||
option.split_once('=').is_some_and(|(name, _)| name == key)
|
||||
&& option != wanted
|
||||
})
|
||||
})
|
||||
}
|
||||
Some(mode @ ("slirp4netns" | "pasta")) => actual.split(':').next() != Some(mode),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// API-created containers may omit rootless options from HostConfig.NetworkMode.
|
||||
// generate spec retains them; inspect alone would cause an endless repair loop.
|
||||
fn rootless_network_from_spec(bytes: &[u8]) -> Option<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 {
|
||||
@@ -175,6 +230,11 @@ fn manifest_dependency_app_ids(manifest: &AppManifest) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn host_port_wait_timeout_secs(manifest: &AppManifest) -> u64 {
|
||||
// First NPM initialization generates keys and migrates its database before
|
||||
// exposing nginx. Its readiness budget must not depend on the network driver.
|
||||
if manifest.app.id == "nginx-proxy-manager" {
|
||||
return 180;
|
||||
}
|
||||
if manifest.app.id == "uptime-kuma" {
|
||||
return 420;
|
||||
}
|
||||
@@ -2425,6 +2485,49 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
match status.state {
|
||||
ContainerState::Running => {
|
||||
// Legacy runtime path: migrate credentials once without
|
||||
// recreating accounts or changing operator passwords.
|
||||
// Quadlet installations receive the same helper through
|
||||
// the required ExecStartPre drift/restart above.
|
||||
if app_id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) {
|
||||
let secrets = self.secrets_dir.join("filebrowser");
|
||||
let managed = filebrowser::cloud_credentials(&secrets)
|
||||
.await
|
||||
.map(|value| value.schema == 1)
|
||||
.unwrap_or(false);
|
||||
if !managed {
|
||||
if !self.should_attempt_repair(&name).await {
|
||||
return Ok(ReconcileAction::Left(
|
||||
"filebrowser-credential-repair-budget-exhausted".into(),
|
||||
));
|
||||
}
|
||||
let unit_managed = self.runtime.cli_name() == "podman"
|
||||
&& quadlet::unit_exists(&name).await;
|
||||
let service = format!("{name}.service");
|
||||
if unit_managed {
|
||||
quadlet::stop_service(&service).await?;
|
||||
} else {
|
||||
self.runtime.stop_container(&name).await?;
|
||||
}
|
||||
let prepared = filebrowser::prepare_credentials(
|
||||
&self.filebrowser_paths,
|
||||
&secrets,
|
||||
&status.image,
|
||||
self.runtime.cli_name(),
|
||||
)
|
||||
.await;
|
||||
// Restore service availability even when setup
|
||||
// rolled back. Preserve the setup failure itself.
|
||||
let started = if unit_managed {
|
||||
quadlet::restart_service(&service).await
|
||||
} else {
|
||||
self.runtime.start_container(&name).await
|
||||
};
|
||||
prepared?;
|
||||
started?;
|
||||
return Ok(ReconcileAction::Started);
|
||||
}
|
||||
}
|
||||
// Zombie guard: podman can report a container "running"
|
||||
// after its process has died (conmon SIGKILLed in a
|
||||
// cgroup cascade on archipelago restart, etc.). Such a
|
||||
@@ -2971,6 +3074,18 @@ impl ProdContainerOrchestrator {
|
||||
self.ensure_manifest_files(manifest).await?;
|
||||
self.apply_data_uid(manifest).await?;
|
||||
self.run_post_data_uid_hooks(&manifest.app.id).await?;
|
||||
if manifest.app.id == "filebrowser" && !self.use_quadlet_backends && !cfg!(test) {
|
||||
let image = manifest.app.container.image.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!("File Browser needs a pinned image for credential setup")
|
||||
})?;
|
||||
filebrowser::prepare_credentials(
|
||||
&self.filebrowser_paths,
|
||||
&self.secrets_dir.join("filebrowser"),
|
||||
image,
|
||||
self.runtime.cli_name(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3068,6 +3183,11 @@ impl ProdContainerOrchestrator {
|
||||
container = %name,
|
||||
"Phase 3.3 migration: replacing pre-Quadlet container with systemd-managed unit"
|
||||
);
|
||||
// Resolve active persistent mounts before the old inspect record is
|
||||
// removed. NPM may use a legacy or operator-selected data directory.
|
||||
let mut resolved = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved).await?;
|
||||
self.backup_runtime_change(name, &resolved).await?;
|
||||
// Stop+remove the old container record. Volumes survive (host
|
||||
// bind mounts are not touched by podman rm).
|
||||
self.runtime
|
||||
@@ -3077,8 +3197,6 @@ impl ProdContainerOrchestrator {
|
||||
|
||||
// Re-render the manifest with dynamic env baked in, then go
|
||||
// through the same install path a fresh install would.
|
||||
let mut resolved = lm.manifest.clone();
|
||||
self.resolve_dynamic_env(&mut resolved).await?;
|
||||
self.install_via_quadlet(&resolved, name)
|
||||
.await
|
||||
.with_context(|| format!("Phase 3.3: re-install {name} via Quadlet"))?;
|
||||
@@ -3797,6 +3915,11 @@ impl ProdContainerOrchestrator {
|
||||
}
|
||||
|
||||
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
|
||||
if manifest.app.id == "nginx-proxy-manager" {
|
||||
crate::container::npm::resolve_storage()
|
||||
.await?
|
||||
.apply(manifest)?;
|
||||
}
|
||||
// Idempotency guard: partitioning already ran on this instance.
|
||||
// Re-running would re-taint against an environment that no longer
|
||||
// contains the composite entries and silently drop them. Callers
|
||||
@@ -4068,7 +4191,12 @@ impl ProdContainerOrchestrator {
|
||||
if unmanaged
|
||||
&& matches!(
|
||||
manifest.app.container.network.as_deref(),
|
||||
Some("slirp4netns" | "pasta")
|
||||
Some(
|
||||
"slirp4netns"
|
||||
| "slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
| "pasta"
|
||||
)
|
||||
)
|
||||
{
|
||||
if let Ok(output) = tokio::process::Command::new("podman")
|
||||
@@ -4076,13 +4204,36 @@ impl ProdContainerOrchestrator {
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
if output.status.success()
|
||||
&& rootless_network_mode_drifted(
|
||||
if output.status.success() {
|
||||
let mut actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if matches!(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&String::from_utf8_lossy(&output.stdout),
|
||||
)
|
||||
{
|
||||
return true;
|
||||
Some(
|
||||
"slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
)
|
||||
) && actual == "slirp4netns"
|
||||
{
|
||||
let spec = tokio::process::Command::new("podman")
|
||||
.args(["generate", "spec", name])
|
||||
.output()
|
||||
.await;
|
||||
actual = match spec {
|
||||
Ok(spec) if spec.status.success() => {
|
||||
rootless_network_from_spec(&spec.stdout).unwrap_or_default()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
if actual.is_empty() {
|
||||
tracing::warn!(app = %name, "Could not verify rootless network options; retaining the existing container");
|
||||
}
|
||||
}
|
||||
if rootless_network_mode_drifted(
|
||||
manifest.app.container.network.as_deref(),
|
||||
&actual,
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5242,8 +5393,68 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_legacy_gateway_converges_and_rejects_conflicting_network_options() {
|
||||
let expected = Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24");
|
||||
assert!(is_builtin_network_mode(expected.unwrap()));
|
||||
for actual in [
|
||||
"pasta",
|
||||
"slirp4netns:allow_host_loopback=true",
|
||||
"slirp4netns:allow_host_loopback=true,cidr=10.0.2.0/24",
|
||||
"slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24,cidr=10.0.2.0/24",
|
||||
] {
|
||||
assert!(rootless_network_mode_drifted(expected, actual), "{actual}");
|
||||
}
|
||||
let actual = "slirp4netns:cidr=169.254.1.0/24,allow_host_loopback=true,mtu=65520";
|
||||
assert!(!rootless_network_mode_drifted(expected, actual));
|
||||
let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["cidr=169.254.1.0/24","allow_host_loopback=true"]}}"#;
|
||||
assert!(!rootless_network_mode_drifted(
|
||||
expected,
|
||||
&rootless_network_from_spec(spec).unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_initialization_budget_survives_network_migration() {
|
||||
let mut manifest = AppManifest::parse(include_str!(
|
||||
"../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))
|
||||
.unwrap();
|
||||
for network in ["pasta", "slirp4netns:allow_host_loopback=true"] {
|
||||
manifest.app.container.network = Some(network.to_string());
|
||||
assert_eq!(host_port_wait_timeout_secs(&manifest), 180);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_created_rootless_options_do_not_cause_repeated_recreation() {
|
||||
let expected = Some("slirp4netns:allow_host_loopback=true");
|
||||
let spec = br#"{"netns":{"nsmode":"slirp4netns"},"network_options":{"slirp4netns":["allow_host_loopback=true"]}}"#;
|
||||
let actual = rootless_network_from_spec(spec).unwrap();
|
||||
assert!(!rootless_network_mode_drifted(expected, &actual));
|
||||
let plain = rootless_network_from_spec(br#"{"netns":{"nsmode":"slirp4netns"}}"#).unwrap();
|
||||
assert!(rootless_network_mode_drifted(expected, &plain));
|
||||
assert!(rootless_network_from_spec(b"invalid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
|
||||
let npm = Some("slirp4netns:allow_host_loopback=true");
|
||||
assert!(is_builtin_network_mode(npm.unwrap()));
|
||||
for actual in [
|
||||
"pasta",
|
||||
"bridge",
|
||||
"slirp4netns",
|
||||
"slirp4netns:allow_host_loopback=false",
|
||||
] {
|
||||
assert!(rootless_network_mode_drifted(npm, actual), "{actual}");
|
||||
}
|
||||
for actual in [
|
||||
"slirp4netns:allow_host_loopback=true",
|
||||
"slirp4netns:mtu=65520,allow_host_loopback=true",
|
||||
] {
|
||||
assert!(!rootless_network_mode_drifted(npm, actual), "{actual}");
|
||||
}
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
|
||||
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
|
||||
assert!(!rootless_network_mode_drifted(
|
||||
|
||||
@@ -68,6 +68,10 @@ pub enum NetworkMode {
|
||||
/// Rootless slirp4netns networking. Podman rejects network aliases with
|
||||
/// this mode, so render only Network=slirp4netns.
|
||||
Slirp4netns,
|
||||
/// Permit explicit host aliases as well as LAN upstreams for NPM.
|
||||
Slirp4netnsHostLoopback,
|
||||
/// Preserve existing NPM upstreams using pasta's former host gateway.
|
||||
Slirp4netnsLegacyGateway,
|
||||
/// Rootless pasta networking. This is more reliable than slirp4netns for
|
||||
/// host port forwarding on long-running web apps.
|
||||
Pasta,
|
||||
@@ -229,6 +233,15 @@ impl QuadletUnit {
|
||||
NetworkMode::Host => {
|
||||
let _ = writeln!(s, "Network=host");
|
||||
}
|
||||
NetworkMode::Slirp4netnsHostLoopback => {
|
||||
let _ = writeln!(s, "Network=slirp4netns:allow_host_loopback=true");
|
||||
}
|
||||
NetworkMode::Slirp4netnsLegacyGateway => {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
);
|
||||
}
|
||||
NetworkMode::Slirp4netns => {
|
||||
let _ = writeln!(s, "Network=slirp4netns");
|
||||
}
|
||||
@@ -348,6 +361,26 @@ impl QuadletUnit {
|
||||
}
|
||||
let _ = writeln!(s);
|
||||
let _ = writeln!(s, "[Service]");
|
||||
if self.name == "filebrowser" {
|
||||
// Runs while the managed server is stopped, before it can expose
|
||||
// a default account. The helper verifies real login and root access.
|
||||
let mut argv = vec![
|
||||
"/usr/bin/python3".to_string(),
|
||||
"/opt/archipelago/scripts/filebrowser-credentials.py".to_string(),
|
||||
"--image".to_string(),
|
||||
self.image.clone(),
|
||||
];
|
||||
for (target, flag) in [("/data", "--data-dir"), ("/srv", "--srv-root")] {
|
||||
if let Some(mount) = self
|
||||
.bind_mounts
|
||||
.iter()
|
||||
.find(|m| m.container == Path::new(target))
|
||||
{
|
||||
argv.extend([flag.to_string(), mount.host.display().to_string()]);
|
||||
}
|
||||
}
|
||||
let _ = writeln!(s, "ExecStartPre={}", shell_join(&argv));
|
||||
}
|
||||
// Dependency-gated apps may legitimately keep their container entrypoint
|
||||
// in a wait loop before the actual daemon binds ports. Fedimint waits
|
||||
// for Bitcoin IBD to finish before execing fedimintd; systemd's default
|
||||
@@ -447,6 +480,12 @@ impl QuadletUnit {
|
||||
other if !other.is_empty() && other != "isolated" => NetworkMode::Bridge(other.into()),
|
||||
_ => match app.container.network.as_deref() {
|
||||
Some("slirp4netns") => NetworkMode::Slirp4netns,
|
||||
Some("slirp4netns:allow_host_loopback=true") => {
|
||||
NetworkMode::Slirp4netnsHostLoopback
|
||||
}
|
||||
Some("slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24") => {
|
||||
NetworkMode::Slirp4netnsLegacyGateway
|
||||
}
|
||||
Some("pasta") => NetworkMode::Pasta,
|
||||
Some(n) if !n.is_empty() && n != "host" => NetworkMode::Bridge(n.into()),
|
||||
_ => NetworkMode::Default,
|
||||
@@ -1071,7 +1110,8 @@ pub fn exec_changed(old_body: &str, new_body: &str) -> bool {
|
||||
// Entrypoint= and Exec= together define what the container runs, so a drift
|
||||
// in either must recreate the container (e.g. when this renderer first
|
||||
// splits a folded `Exec=sh -lc ...` into `Entrypoint=sh` + `Exec=-lc ...`).
|
||||
directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||
directive_values(old_body, "ExecStartPre=") != directive_values(new_body, "ExecStartPre=")
|
||||
|| directive_values(old_body, "Exec=") != directive_values(new_body, "Exec=")
|
||||
|| directive_values(old_body, "Entrypoint=") != directive_values(new_body, "Entrypoint=")
|
||||
}
|
||||
|
||||
@@ -1142,6 +1182,28 @@ pub async fn is_active(service: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn filebrowser_prestart_credentials_are_a_required_runtime_change() {
|
||||
let unit = super::QuadletUnit {
|
||||
name: "filebrowser".into(),
|
||||
image: "registry.example/filebrowser:v2.63.23".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let rendered = unit.render();
|
||||
assert!(rendered.contains("ExecStartPre=/usr/bin/python3 /opt/archipelago/scripts/filebrowser-credentials.py --image registry.example/filebrowser:v2.63.23"));
|
||||
let old = rendered
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("ExecStartPre="))
|
||||
.collect::<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 tempfile::tempdir;
|
||||
|
||||
@@ -1709,6 +1771,24 @@ app:
|
||||
assert!(!s.contains("--network-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_network_preserves_same_node_upstreams_without_aliases() {
|
||||
let m = AppManifest::parse(include_str!(
|
||||
"../../../../apps/nginx-proxy-manager/manifest.yml"
|
||||
))
|
||||
.unwrap();
|
||||
let rendered = QuadletUnit::from_manifest(&m, "nginx-proxy-manager").render();
|
||||
assert_eq!(
|
||||
rendered
|
||||
.lines()
|
||||
.filter(|line| line.starts_with("Network="))
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["Network=slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"]
|
||||
);
|
||||
assert!(!rendered.contains("NetworkAlias="));
|
||||
assert!(!rendered.contains("--network-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_manifest_pasta_omits_network_alias() {
|
||||
let yaml = r#"
|
||||
|
||||
@@ -162,6 +162,11 @@ pub async fn record_purchase(
|
||||
save_index(data_dir, &index).await
|
||||
}
|
||||
|
||||
/// Payment decisions must not interpret an unreadable index as no purchases.
|
||||
pub async fn list_owned_checked(data_dir: &Path) -> Result<Vec<OwnedItem>> {
|
||||
Ok(load_index_checked(data_dir).await?.items)
|
||||
}
|
||||
|
||||
/// Every item this node owns.
|
||||
pub async fn list_owned(data_dir: &Path) -> Vec<OwnedItem> {
|
||||
load_index(data_dir).await.items
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
//! to a full chip erase before write.
|
||||
//!
|
||||
//! MeshCore and Meshtastic are flashed the same way: download a released
|
||||
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
|
||||
//! 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
|
||||
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
||||
@@ -49,32 +50,18 @@ impl FlashBoard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a detected USB vid:pid to a known flashable board, using the same
|
||||
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
||||
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
||||
/// auto-match since that vid:pid is bridge-chip-specific.
|
||||
///
|
||||
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
|
||||
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
|
||||
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
|
||||
/// with product string "USB JTAG/serial debug unit" — that descriptor is
|
||||
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
|
||||
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
|
||||
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
|
||||
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
|
||||
/// board's image. Callers (the RPC layer / frontend) must let the user pick
|
||||
/// the board manually whenever this returns `None`.
|
||||
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
match (info.vid.as_deref(), info.pid.as_deref()) {
|
||||
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
|
||||
_ => None,
|
||||
}
|
||||
/// Generic CP2102 and native ESP32-S3 USB IDs identify adapters/chips, not
|
||||
/// board wiring. Require an explicit board until a board-specific identity is
|
||||
/// available; guessing a Heltec model can write incompatible firmware.
|
||||
pub fn resolve_flash_board(_info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashStage {
|
||||
Downloading,
|
||||
Preparing,
|
||||
Erasing,
|
||||
Writing,
|
||||
Autoinstalling,
|
||||
@@ -101,11 +88,10 @@ const LOG_TAIL_MAX: usize = 200;
|
||||
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Absolute ceiling on a whole flash job (download + erase + write, or
|
||||
/// autoinstall), regardless of what it's doing internally. Last-resort
|
||||
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
||||
/// forever — generous enough to never trigger on a legitimately slow
|
||||
/// multi-hundred-MB transfer.
|
||||
/// Deadline for preparation and warning threshold for the active flasher.
|
||||
/// A download can be cancelled safely. An active write retains ownership until
|
||||
/// its subprocess exits, even after this threshold: reporting an aborted job
|
||||
/// while a detached writer continues would let a retry corrupt the device.
|
||||
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
|
||||
/// How long to wait for MeshService::stop() to release the serial port
|
||||
@@ -247,6 +233,16 @@ fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("mesh").join("firmware-cache")
|
||||
}
|
||||
|
||||
async fn invalidate_radio_settings_marker(data_dir: &Path) -> Result<()> {
|
||||
// A full-chip flash erased the device's preferences. A previous host-side
|
||||
// marker is no longer evidence that this radio has the requested settings.
|
||||
match tokio::fs::remove_file(data_dir.join("meshcore-radio-params.json")).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error).context("Firmware written, but old radio-settings marker could not be cleared; reconnect is paused"),
|
||||
}
|
||||
}
|
||||
|
||||
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||
/// the *entire* request including streaming the response body, which would
|
||||
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
||||
@@ -314,6 +310,10 @@ pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
#[serde(default)]
|
||||
size: Option<u64>,
|
||||
#[serde(default)]
|
||||
digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -322,8 +322,24 @@ struct GithubRelease {
|
||||
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
|
||||
/// registered and the listener released — callers poll `FlashJobHandle` via
|
||||
/// registered — preparation and listener shutdown run in the background.
|
||||
/// Callers poll `FlashJobHandle` via
|
||||
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||
pub async fn start_flash_job(
|
||||
handle: &FlashJobHandle,
|
||||
@@ -333,21 +349,44 @@ pub async fn start_flash_job(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let existing = handle.read().await;
|
||||
if let Some(job) = existing.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||
}
|
||||
}
|
||||
// Missing/corrupt tooling must fail before stopping a working radio or
|
||||
// registering a job that can never reach the serial device.
|
||||
if matches!(family, DeviceType::Meshtastic | DeviceType::Meshcore) {
|
||||
preflight_esptool().await?;
|
||||
}
|
||||
|
||||
let job = FlashJob::new(board, family, path.clone());
|
||||
*handle.write().await = Some(Arc::clone(&job));
|
||||
register_flash_job(handle, &job).await?;
|
||||
|
||||
let bg_job = Arc::clone(&job);
|
||||
let bg_service = Arc::clone(mesh_service);
|
||||
let task = tokio::spawn(async move {
|
||||
// Downloads and checksum checks do not need exclusive serial access.
|
||||
// Keep a working listener alive if upstream/download verification fails.
|
||||
let prepared_image = if matches!(family, DeviceType::Meshcore | DeviceType::Meshtastic) {
|
||||
match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
fetch_esptool_image(board, family, &data_dir, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(image)) => Some(image),
|
||||
result => {
|
||||
let error = match result {
|
||||
Ok(Err(error)) => error,
|
||||
_ => anyhow::anyhow!(
|
||||
"Firmware download exceeded the time limit; radio was not changed"
|
||||
),
|
||||
};
|
||||
bg_job.fail(&error).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Cancellation is safe during download. Once listener shutdown starts,
|
||||
// allow that bounded operation to finish instead of orphaning its task.
|
||||
bg_job.set_stage(FlashStage::Preparing).await;
|
||||
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||
// the listener's hold on the port before touching it. This USED
|
||||
// TO run synchronously in start_flash_job before the job was even
|
||||
@@ -404,17 +443,31 @@ pub async fn start_flash_job(
|
||||
// subsequent mesh.flash-device call failed with "already in
|
||||
// progress" until the service was restarted). Generous enough that
|
||||
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||
let result = match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
run_flash(board, family, &data_dir, &path, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let flash = run_flash(
|
||||
board,
|
||||
family,
|
||||
&data_dir,
|
||||
&path,
|
||||
prepared_image.as_deref(),
|
||||
&bg_job,
|
||||
);
|
||||
tokio::pin!(flash);
|
||||
let result = match tokio::time::timeout(MAX_JOB_DURATION, &mut flash).await {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"Flash job exceeded the {}-minute ceiling — aborted",
|
||||
MAX_JOB_DURATION.as_secs() / 60
|
||||
)),
|
||||
Err(_) if bg_job.snapshot().await.stage == FlashStage::Downloading => Err(
|
||||
anyhow::anyhow!("Firmware download exceeded the time limit; radio was not written"),
|
||||
),
|
||||
Err(_) => {
|
||||
// Dropping this future does NOT stop its flash subprocess.
|
||||
// Keep the job busy until it exits, rather than allowing a
|
||||
// second writer while the first one still owns the device.
|
||||
bg_job.push_log("Flashing is taking longer than expected; waiting for the active tool to exit before allowing another operation").await;
|
||||
flash.await
|
||||
}
|
||||
};
|
||||
let result = match result {
|
||||
Ok(()) => invalidate_radio_settings_marker(&data_dir).await,
|
||||
error => error,
|
||||
};
|
||||
let succeeded = result.is_ok();
|
||||
|
||||
@@ -423,7 +476,6 @@ pub async fn start_flash_job(
|
||||
bg_job
|
||||
.push_log("Flash completed successfully".to_string())
|
||||
.await;
|
||||
bg_job.finish().await;
|
||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -434,7 +486,6 @@ pub async fn start_flash_job(
|
||||
// erase_flash failed", no actual esptool stderr).
|
||||
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(e).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +501,9 @@ pub async fn start_flash_job(
|
||||
}
|
||||
|
||||
if !succeeded {
|
||||
if let Err(error) = &result {
|
||||
bg_job.fail(error).await;
|
||||
}
|
||||
// Deliberately do NOT auto-restart the listener here. A failed
|
||||
// flash means we can't vouch for the board's state — reopening
|
||||
// the port immediately (esptool/rnodeconf's own reset sequence
|
||||
@@ -499,6 +553,7 @@ pub async fn start_flash_job(
|
||||
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
|
||||
}
|
||||
}
|
||||
bg_job.finish().await;
|
||||
});
|
||||
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||
|
||||
@@ -510,12 +565,13 @@ async fn run_flash(
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
path: &str,
|
||||
prepared_image: Option<&Path>,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
match family {
|
||||
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
||||
esptool_erase_and_write(path, &image, job).await
|
||||
let image = prepared_image.context("Firmware was not prepared before serial access")?;
|
||||
esptool_erase_and_write(path, image, job).await
|
||||
}
|
||||
DeviceType::Reticulum => {
|
||||
let lora_region = super::load_config(data_dir)
|
||||
@@ -664,15 +720,65 @@ async fn fetch_meshcore_image(
|
||||
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
|
||||
})?;
|
||||
|
||||
anyhow::ensure!(
|
||||
Path::new(&asset.name)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
== Some(asset.name.as_str()),
|
||||
"Invalid firmware asset filename"
|
||||
);
|
||||
let out_path = cache.join(&asset.name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
job.push_log(format!("Using cached {}", asset.name)).await;
|
||||
return Ok(out_path);
|
||||
if verify_meshcore_asset(&out_path, asset).await.is_ok() {
|
||||
job.push_log(format!("Using verified cached {}", asset.name))
|
||||
.await;
|
||||
return Ok(out_path);
|
||||
}
|
||||
tokio::fs::remove_file(&out_path)
|
||||
.await
|
||||
.context("Removing invalid cached firmware")?;
|
||||
job.push_log("Cached firmware failed verification; downloading a fresh copy")
|
||||
.await;
|
||||
}
|
||||
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
|
||||
if let Err(error) = verify_meshcore_asset(&out_path, asset).await {
|
||||
// A partial/unverified download must not become next attempt's cache.
|
||||
let _ = tokio::fs::remove_file(&out_path).await;
|
||||
return Err(error);
|
||||
}
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn verify_meshcore_asset(path: &Path, asset: &GithubAsset) -> Result<()> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let size = asset
|
||||
.size
|
||||
.context("MeshCore release did not provide firmware size")?;
|
||||
anyhow::ensure!(
|
||||
(1..=16 * 1024 * 1024).contains(&size),
|
||||
"Invalid MeshCore firmware size"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(path).await?.len() == size,
|
||||
"Firmware size mismatch; radio was not written"
|
||||
);
|
||||
let expected = asset
|
||||
.digest
|
||||
.as_deref()
|
||||
.and_then(|value| value.strip_prefix("sha256:"))
|
||||
.context("MeshCore release did not provide a SHA-256 checksum")?;
|
||||
anyhow::ensure!(
|
||||
expected.len() == 64 && expected.bytes().all(|byte| byte.is_ascii_hexdigit()),
|
||||
"Invalid MeshCore release checksum"
|
||||
);
|
||||
let actual = hex::encode(Sha256::digest(tokio::fs::read(path).await?));
|
||||
anyhow::ensure!(
|
||||
actual.eq_ignore_ascii_case(expected),
|
||||
"Firmware checksum mismatch; radio was not written"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_to_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
@@ -722,7 +828,8 @@ async fn download_to_file(
|
||||
}
|
||||
}
|
||||
}
|
||||
file.flush().await.ok();
|
||||
file.flush().await.context("Flushing firmware download")?;
|
||||
file.sync_all().await.context("Saving firmware download")?;
|
||||
tokio::fs::rename(&tmp, dest)
|
||||
.await
|
||||
.context("Finalizing firmware download")?;
|
||||
@@ -773,19 +880,10 @@ async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>)
|
||||
/// Building global args separately from subcommand args keeps this correct
|
||||
/// by construction instead of relying on call-site ordering.
|
||||
///
|
||||
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
||||
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
||||
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
||||
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
||||
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
|
||||
/// fetches the exact same file from the matching upstream esptool release
|
||||
/// tag and installs it alongside the apt package (see the esptool install
|
||||
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
|
||||
/// stub) was tried first and works for connecting, but the ROM bootloader
|
||||
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
|
||||
/// so --no-stub broke our "always erase before write" default outright
|
||||
/// rather than just being slower. Restoring the real stub file is the
|
||||
/// correct fix, not routing around its absence.
|
||||
/// Normal stub-loader mode is required for full-chip erase. The packaged
|
||||
/// archy-esptool includes and self-tests Espressif's ESP32-S3 stub. Debian's
|
||||
/// stripped esptool package alone did not provide that resource, so changing
|
||||
/// flags to --no-stub cannot repair this operation.
|
||||
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||
if let Some(b) = baud {
|
||||
@@ -795,24 +893,96 @@ fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str>
|
||||
args
|
||||
}
|
||||
|
||||
fn esptool_executable() -> Result<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<()> {
|
||||
let mut cmd = Command::new("esptool");
|
||||
let executable = preflight_esptool().await?;
|
||||
let mut cmd = Command::new(&executable);
|
||||
cmd.args(esptool_global_args(path, None));
|
||||
cmd.args(subcommand);
|
||||
match run_streamed(cmd, None, job).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_err) => {
|
||||
Err(first_err) if retryable_flash_error(&first_err) => {
|
||||
job.push_log(format!(
|
||||
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||
))
|
||||
.await;
|
||||
let mut retry = Command::new("esptool");
|
||||
let mut retry = Command::new(&executable);
|
||||
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||
retry.args(subcommand);
|
||||
run_streamed(retry, None, job)
|
||||
.await
|
||||
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -990,3 +1160,159 @@ async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJ
|
||||
}
|
||||
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"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,6 +1092,14 @@ impl MeshService {
|
||||
let (new_tx, new_rx) = tokio::sync::mpsc::channel(32);
|
||||
*self.state.cmd_tx.write().await = new_tx;
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
self.config = config.clone();
|
||||
|
||||
@@ -2335,10 +2346,13 @@ impl MeshService {
|
||||
.unwrap_or_else(|| "archipelago".to_string());
|
||||
}
|
||||
|
||||
// If enabled state changed, start/stop the listener
|
||||
if config.enabled && !was_enabled {
|
||||
// Reconcile desired state with the actual task, not the old 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()?;
|
||||
} else if !config.enabled && was_enabled {
|
||||
} else if !config.enabled {
|
||||
self.stop().await;
|
||||
// Clear connected state
|
||||
let mut status = self.state.status.write().await;
|
||||
@@ -2347,7 +2361,7 @@ impl MeshService {
|
||||
status.firmware_version = None;
|
||||
status.self_node_id = None;
|
||||
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");
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
@@ -2537,6 +2551,41 @@ mod tests {
|
||||
}
|
||||
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]
|
||||
fn session_config_change_detection() {
|
||||
let base = MeshConfig::default();
|
||||
|
||||
@@ -378,6 +378,19 @@ fn decode_mesh_name(bytes: &[u8], fallback: &str) -> String {
|
||||
/// Parse RESP_DEVICE_INFO (0x0D) response.
|
||||
/// Returns firmware version string and device capabilities.
|
||||
pub fn parse_device_info(data: &[u8]) -> Result<(String, u16)> {
|
||||
// Official companion v3+ binary layout: protocol, half-capacity,
|
||||
// channels, PIN (4), build date (12), model (40), version (20).
|
||||
// Verified against companion-v1.17.1 MyMesh.cpp and Heltec V3 readback.
|
||||
if data
|
||||
.first()
|
||||
.is_some_and(|version| (3..=31).contains(version))
|
||||
{
|
||||
anyhow::ensure!(data.len() >= 79, "Truncated MeshCore device info");
|
||||
return Ok((
|
||||
decode_mesh_name(&data[59..79], "unknown"),
|
||||
u16::from(data[1]) * 2,
|
||||
));
|
||||
}
|
||||
// Device info format varies by firmware version.
|
||||
// Minimum: firmware version string (null-terminated) + max_contacts (u16 LE)
|
||||
if data.is_empty() {
|
||||
@@ -404,6 +417,15 @@ pub fn parse_self_info(data: &[u8]) -> Result<(u32, String)> {
|
||||
anyhow::bail!("Self info response too short: {} bytes", data.len());
|
||||
}
|
||||
|
||||
// Current companions send type/power/max-power, public key (32),
|
||||
// position (8), four preference bytes, RF parameters (10), then name.
|
||||
if data.len() >= 57 {
|
||||
let node_id = u32::from_le_bytes(data[3..7].try_into().unwrap());
|
||||
return Ok((
|
||||
node_id,
|
||||
decode_mesh_name(&data[57..], &format!("node-{node_id:08x}")),
|
||||
));
|
||||
}
|
||||
let node_id = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
|
||||
// Name follows after fixed fields. A firmware whose fixed-field layout
|
||||
@@ -966,4 +988,24 @@ mod tests {
|
||||
fn test_parse_self_info_too_short() {
|
||||
assert!(parse_self_info(&[0x01, 0x02]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_companion_binary_metadata_is_not_a_name_or_version() {
|
||||
let mut info = vec![0u8; 81];
|
||||
info[0] = 13;
|
||||
info[1] = 150;
|
||||
info[19..28].copy_from_slice(b"Heltec V3");
|
||||
info[59..74].copy_from_slice(b"v1.17.1-d929643");
|
||||
assert_eq!(
|
||||
parse_device_info(&info).unwrap(),
|
||||
("v1.17.1-d929643".into(), 300)
|
||||
);
|
||||
assert!(parse_device_info(&info[..78]).is_err());
|
||||
let mut own = vec![0u8; 57];
|
||||
own[0..3].copy_from_slice(&[1, 22, 22]);
|
||||
own[3..7].copy_from_slice(&42u32.to_le_bytes());
|
||||
own[47..51].copy_from_slice(&869618u32.to_le_bytes());
|
||||
own.extend_from_slice(b"My radio");
|
||||
assert_eq!(parse_self_info(&own).unwrap(), (42, "My radio".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
#[derive(Clone)]
|
||||
struct ReticulumPeer {
|
||||
@@ -517,10 +530,10 @@ impl ReticulumLink {
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
|
||||
let mut starting = StartingDaemon(Some(child));
|
||||
|
||||
// Wait for the socket to appear, then for the daemon's "ready" event.
|
||||
// Runs as a block so every failure path tears the just-spawned daemon
|
||||
// group down via `terminate_group` (the child has no `kill_on_drop`).
|
||||
// StartingDaemon tears the group down on errors AND cancellation.
|
||||
let init = async {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||
let stream = loop {
|
||||
@@ -560,20 +573,17 @@ impl ReticulumLink {
|
||||
dest_hash = %dest_hash_hex,
|
||||
"Reticulum daemon ready"
|
||||
);
|
||||
Ok((write_half, reader, dest_hash, display_name))
|
||||
};
|
||||
let (write_half, reader, dest_hash, display_name) = match init.await {
|
||||
Ok(parts) => parts,
|
||||
Err(e) => {
|
||||
terminate_group(&child);
|
||||
return Err(e);
|
||||
}
|
||||
Ok::<_, anyhow::Error>((write_half, reader, dest_hash, display_name))
|
||||
};
|
||||
let (write_half, reader, dest_hash, display_name) = init.await?;
|
||||
|
||||
let mut link = Self {
|
||||
device_path: label,
|
||||
socket_path,
|
||||
child,
|
||||
child: starting
|
||||
.0
|
||||
.take()
|
||||
.expect("starting daemon is owned until ready"),
|
||||
writer: write_half,
|
||||
reader,
|
||||
dest_hash,
|
||||
@@ -1557,6 +1567,33 @@ impl Drop for ReticulumLink {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_daemon_handshake_terminates_child() {
|
||||
let (started, ready) = tokio::sync::oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let child = Command::new("sleep")
|
||||
.arg("60")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id().unwrap();
|
||||
let _starting = StartingDaemon(Some(child));
|
||||
started.send(pid).unwrap();
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let pid = ready.await.unwrap();
|
||||
assert_eq!(unsafe { libc::kill(pid as i32, 0) }, 0);
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
tokio::time::timeout(Duration::from_secs(3), async {
|
||||
while unsafe { libc::kill(pid as i32, 0) } == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("cancelled handshake left its child alive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announced_name_precedence() {
|
||||
// Daemon-decoded LXMF name always wins.
|
||||
|
||||
@@ -146,12 +146,16 @@ impl MeshcoreDevice {
|
||||
}
|
||||
}
|
||||
|
||||
let info = DeviceInfo {
|
||||
firmware_version: name.clone(),
|
||||
let mut info = DeviceInfo {
|
||||
firmware_version: "unknown".to_string(),
|
||||
node_id,
|
||||
max_contacts: 100,
|
||||
device_type: super::types::DeviceType::Meshcore,
|
||||
};
|
||||
if let Some((version, capacity)) = self.query_device_info().await {
|
||||
info.firmware_version = version;
|
||||
info.max_contacts = capacity;
|
||||
}
|
||||
self.device_info = Some(info.clone());
|
||||
|
||||
info!("Meshcore initialization complete on {}", self.device_path);
|
||||
|
||||
@@ -64,7 +64,16 @@ async fn relay_cannot_substitute_forged_fields_for_a_known_event_id() {
|
||||
let relay = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.unwrap();
|
||||
let mut socket = accept_async(socket).await.unwrap();
|
||||
while let Some(Ok(Message::Text(text))) = socket.next().await {
|
||||
while let Some(message) = socket.next().await {
|
||||
let text = match message.unwrap() {
|
||||
Message::Text(text) => text,
|
||||
Message::Ping(payload) => {
|
||||
socket.send(Message::Pong(payload)).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
};
|
||||
let message: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
if message[0] != "REQ" {
|
||||
continue;
|
||||
|
||||
@@ -83,6 +83,8 @@ impl EndpointRateLimiter {
|
||||
limits.insert("wallet.send".to_string(), (5usize, 300u64));
|
||||
limits.insert("wallet.ecash-send".to_string(), (10, 300));
|
||||
limits.insert("lnd.sendcoins".to_string(), (5, 300));
|
||||
limits.insert("lnd.bump-submit".to_string(), (5, 300));
|
||||
limits.insert("lnd.bump-quote".to_string(), (30, 60));
|
||||
limits.insert("lnd.payinvoice".to_string(), (10, 300));
|
||||
limits.insert("lnd.openchannel".to_string(), (3, 300));
|
||||
limits.insert("lnd.closechannel".to_string(), (3, 300));
|
||||
|
||||
@@ -1475,6 +1475,48 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|
||||
|| path.starts_with("/dwn/")
|
||||
}
|
||||
|
||||
/// The ordinary API listener is a management surface even when contacted
|
||||
/// directly, without host nginx. Peer traffic has its own path-restricted
|
||||
/// listener and retains its existing cryptographic authentication.
|
||||
fn management_peer_is_private(address: std::net::IpAddr) -> bool {
|
||||
match address {
|
||||
std::net::IpAddr::V4(ip) => {
|
||||
ip.is_loopback()
|
||||
|| ip.is_private()
|
||||
|| ip.is_link_local()
|
||||
|| (ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1]))
|
||||
}
|
||||
std::net::IpAddr::V6(ip) => {
|
||||
if let Some(mapped) = ip.to_ipv4_mapped() {
|
||||
management_peer_is_private(std::net::IpAddr::V4(mapped))
|
||||
} else {
|
||||
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_surface_allowed(
|
||||
peer_only: bool,
|
||||
peer: std::net::IpAddr,
|
||||
request: &hyper::Request<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(
|
||||
handler: Arc<ApiHandler>,
|
||||
listener: TcpListener,
|
||||
@@ -1552,7 +1594,7 @@ async fn accept_loop(
|
||||
// forwarded headers on loopback (nginx) connections.
|
||||
req.extensions_mut()
|
||||
.insert(crate::api::rpc::PeerAddr(peer_addr));
|
||||
if peer_only && !is_peer_allowed_path(req.uri().path()) {
|
||||
if !request_surface_allowed(peer_only, peer_addr.ip(), &req) {
|
||||
let resp = hyper::Response::builder()
|
||||
.status(hyper::StatusCode::NOT_FOUND)
|
||||
.body(hyper::Body::empty())
|
||||
@@ -2520,3 +2562,97 @@ mod merge_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod management_surface_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_api_listener_rejects_management_despite_forged_headers() {
|
||||
for peer in [
|
||||
"198.18.0.2",
|
||||
"2001:db8::2",
|
||||
"::ffff:198.18.0.2",
|
||||
"100.63.255.255",
|
||||
"100.128.0.1",
|
||||
] {
|
||||
for path in ["/", "/login", "/assets/index.js", "/rpc/v1", "/ws"] {
|
||||
for method in ["GET", "POST"] {
|
||||
let request = hyper::Request::builder()
|
||||
.uri(path)
|
||||
.method(method)
|
||||
.header("host", "127.0.0.1")
|
||||
.header("x-forwarded-for", "127.0.0.1")
|
||||
.header("x-real-ip", "192.168.1.10")
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap();
|
||||
assert!(
|
||||
!request_surface_allowed(false, peer.parse().unwrap(), &request),
|
||||
"{peer} {method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_management_and_restricted_peer_transport_remain_available() {
|
||||
let mut request = hyper::Request::builder()
|
||||
.uri("/rpc/v1")
|
||||
.body(hyper::Body::empty())
|
||||
.unwrap();
|
||||
for peer in [
|
||||
"127.0.0.1",
|
||||
"10.0.0.2",
|
||||
"172.16.0.2",
|
||||
"192.168.1.2",
|
||||
"169.254.1.2",
|
||||
"100.64.0.1",
|
||||
"100.127.255.254",
|
||||
"::1",
|
||||
"fd00::1",
|
||||
"fe80::1",
|
||||
"::ffff:192.168.1.2",
|
||||
] {
|
||||
assert!(request_surface_allowed(
|
||||
false,
|
||||
peer.parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
}
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("x-archipelago-public-ingress", "1".parse().unwrap());
|
||||
assert!(!request_surface_allowed(
|
||||
false,
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
// The dedicated peer listener retains its existing signed RPC contract.
|
||||
assert!(request_surface_allowed(
|
||||
true,
|
||||
"198.18.0.2".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
for path in [
|
||||
"/content",
|
||||
"/content/fixture/invoice",
|
||||
"/blob/fixture",
|
||||
"/dwn/health",
|
||||
"/archipelago/node-message",
|
||||
] {
|
||||
*request.uri_mut() = path.parse().unwrap();
|
||||
assert!(request_surface_allowed(
|
||||
false,
|
||||
"198.18.0.2".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
}
|
||||
*request.uri_mut() = "/login".parse().unwrap();
|
||||
assert!(!request_surface_allowed(
|
||||
true,
|
||||
"198.18.0.2".parse().unwrap(),
|
||||
&request
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2654,6 +2654,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_newer() {
|
||||
assert!(is_newer("1.9.0-alpha", "1.8.22-alpha"));
|
||||
assert!(!is_newer("1.9.0-alpha", "1.9.0-alpha"));
|
||||
assert!(is_newer("1.7.19-alpha", "1.7.18-alpha"));
|
||||
assert!(is_newer("1.8.0-alpha", "1.7.99-alpha"));
|
||||
assert!(is_newer("1.7.10-alpha", "1.7.9-alpha")); // numeric, not lexical
|
||||
|
||||
@@ -450,6 +450,17 @@ impl PodmanClient {
|
||||
"nsmode": net_mode
|
||||
},
|
||||
});
|
||||
if matches!(
|
||||
manifest.app.container.network.as_deref(),
|
||||
Some(
|
||||
"slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24"
|
||||
)
|
||||
) {
|
||||
body["network_options"] = serde_json::json!({
|
||||
"slirp4netns": manifest.app.container.network.as_deref().unwrap().split_once(':').unwrap().1.split(',').collect::<Vec<_>>()
|
||||
});
|
||||
}
|
||||
if let Some(network) = custom_network {
|
||||
// The container always answers to its own name; manifest
|
||||
// network_aliases add extra short hostnames peers may bake in
|
||||
@@ -727,7 +738,11 @@ fn podman_network_settings(
|
||||
Some("host") => ("host", None),
|
||||
Some("bridge") => ("bridge", None),
|
||||
Some("none") => ("none", None),
|
||||
Some("slirp4netns") => ("slirp4netns", None),
|
||||
Some(
|
||||
"slirp4netns"
|
||||
| "slirp4netns:allow_host_loopback=true"
|
||||
| "slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24",
|
||||
) => ("slirp4netns", None),
|
||||
Some("pasta") => ("pasta", None),
|
||||
Some("private") => ("private", None),
|
||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||
@@ -1077,6 +1092,14 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_rootless_options_are_not_a_named_bridge() {
|
||||
assert_eq!(
|
||||
podman_network_settings(Some("slirp4netns:allow_host_loopback=true"), "isolated"),
|
||||
("slirp4netns", None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
|
||||
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
|
||||
|
||||
@@ -40,6 +40,11 @@ pub fn stop_grace_secs_for(container_name: &str) -> u64 {
|
||||
|
||||
#[async_trait]
|
||||
pub trait ContainerRuntime: Send + Sync {
|
||||
/// CLI used for offline app provisioning in this runtime's storage scope.
|
||||
fn cli_name(&self) -> &'static str {
|
||||
"podman"
|
||||
}
|
||||
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
|
||||
async fn create_container(
|
||||
&self,
|
||||
@@ -628,7 +633,13 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
|
||||
.as_deref()
|
||||
.filter(|v| !v.is_empty())
|
||||
.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})");
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
@@ -660,6 +671,10 @@ fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<S
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for DockerRuntime {
|
||||
fn cli_name(&self) -> &'static str {
|
||||
"docker"
|
||||
}
|
||||
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
// Same signature gate as the podman path — the docker fallback is
|
||||
// dev-only, but a declared signature must never be skippable by
|
||||
@@ -991,6 +1006,10 @@ impl AutoRuntime {
|
||||
|
||||
#[async_trait]
|
||||
impl ContainerRuntime for AutoRuntime {
|
||||
fn cli_name(&self) -> &'static str {
|
||||
self.runtime.cli_name()
|
||||
}
|
||||
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
self.runtime.pull_image(image, signature).await
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -167,3 +167,46 @@ and observed its explicit acknowledgement. The owner then recorded receipt in
|
||||
`/tmp/npm-release-handoff-ack.txt` and in the acknowledgement section above.
|
||||
Publication remains held for the NPM release gate. Unavailable external acceptance
|
||||
must be stated explicitly and cannot be silently treated as passed.
|
||||
|
||||
## Urgent public dashboard exposure gate — 2026-10-01
|
||||
|
||||
Investigator reports the Angor relay hostname reached the default Archipelago
|
||||
login because its certificate existed without a corresponding host-nginx route.
|
||||
The investigator owns the immediate Shorty nginx repair; the release session
|
||||
will not modify that configuration concurrently. Exact final evidence is pending.
|
||||
|
||||
- [ ] Unknown public HTTP Host / TLS SNI and direct public-IP requests cannot
|
||||
expose the dashboard, login assets or RPC, including IPv6 and any trusted
|
||||
reverse-proxy/tunnel path. Test spoofed forwarding headers explicitly.
|
||||
- [ ] LAN/private/tailnet dashboard access remains available as intended.
|
||||
- [ ] Public HTTP ACME challenge access survives those restrictions.
|
||||
- [ ] NPM host creation/edits automatically propagate HTTP/TLS routing.
|
||||
- [ ] Relay hostname serves the intended relay and WebSocket upgrade using its
|
||||
correct certificate; certificate existence is not route acceptance.
|
||||
- [ ] These protections survive manager/nginx restart, renewal and OTA/ISO.
|
||||
|
||||
## Live security containment and project discovery follow-up
|
||||
|
||||
2026-10-01: relay certificate existed but named public route was absent; default
|
||||
HTTP/HTTPS vhosts exposed the dashboard to public clients, including direct WAN
|
||||
IP access. Investigator added live `angor-relay-npm.conf` forwarding TLS cert11
|
||||
to loopback8091 with WebSocket upgrade; added `00-dashboard-source-guard.conf`
|
||||
private-source geo/map guard to both management default vhosts, preserving public
|
||||
ACME challenge paths. Backup: `/etc/nginx/sites-available/archipelago.before-public-guard-1790879144`.
|
||||
Nginx syntax validation/reload passed. External tests: public IP root and RPC
|
||||
404 over HTTP and HTTPS (IP HTTPS certificate validation bypassed only for this
|
||||
negative routing probe); spoofed private Host, X-Forwarded-For and X-Real-IP and
|
||||
unknown Host POST RPC all404. Tailnet dashboard200; indexer health200 height969475;
|
||||
relay trusted TLS NIP11 metadata200, WebSocket101, read-only Nostr REQ returned EOSE.
|
||||
These are live containment results, not fleet/IPv6/reboot/security-audit completion.
|
||||
Release owner acknowledged security scope in `/tmp/npm-release-handoff-ack.txt`.
|
||||
|
||||
User then reported no Angor projects after changing BOTH indexer and relays.
|
||||
Read-only kind3030 subscription limit5: new relay returned zero events + EOSE;
|
||||
`wss://relay.angor.io/` returned five events + EOSE. Advised retaining original
|
||||
Angor relays alongside own relay; a newly hosted relay does not automatically
|
||||
contain global project metadata. Full app project discovery acceptance remains
|
||||
required. Also observed own `/api/v1/query/Angor/projects?limit=10` returns404;
|
||||
reference MempoolIndexerAngorApi.GetProjectsAsync uses this older specialized
|
||||
route, whereas current deployment docs recommend stock Mempool. Verify actual
|
||||
client version/discovery path rather than claiming fees/health prove compatibility.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
# Archipelago 1.8.23-alpha acceptance
|
||||
# Archipelago 1.9.0 acceptance
|
||||
|
||||
The operator changed the release target from 1.8.23-alpha to **1.9.0**. This file retains its historical path so handoff links remain valid.
|
||||
|
||||
Status: PREPARING. Do not publish until artifact checks and offline signatures pass.
|
||||
|
||||
@@ -58,3 +60,529 @@ The release owner acknowledged `docs/npm-certificate-handoff-20261001.md` in
|
||||
were reported by the operator; durable data-path resolution, safe host routing,
|
||||
automatic certificate renewal/reload, and the handoff acceptance matrix remain
|
||||
required before publication. Earlier authorization does not waive this new gate.
|
||||
|
||||
## Urgent public dashboard exposure gate — 2026-10-01
|
||||
|
||||
Investigator reports the Angor relay hostname reached the default Archipelago
|
||||
login because its certificate existed without a corresponding host-nginx route.
|
||||
The investigator owns the immediate Shorty nginx repair; the release session
|
||||
will not modify that configuration concurrently. Exact final evidence is pending.
|
||||
|
||||
- [ ] Unknown public HTTP Host / TLS SNI and direct public-IP requests cannot
|
||||
expose the dashboard, login assets or RPC, including IPv6 and any trusted
|
||||
reverse-proxy/tunnel path. Test spoofed forwarding headers explicitly.
|
||||
- [ ] LAN/private/tailnet dashboard access remains available as intended.
|
||||
- [ ] Public HTTP ACME challenge access survives those restrictions.
|
||||
- [ ] NPM host creation/edits automatically propagate HTTP/TLS routing.
|
||||
- [ ] Relay hostname serves the intended relay and WebSocket upgrade using its
|
||||
correct certificate; certificate existence is not route acceptance.
|
||||
- [ ] These protections survive manager/nginx restart, renewal and OTA/ISO.
|
||||
|
||||
## Additional isolated security checks — not deployed
|
||||
|
||||
The management source-guard prototype passed five unit checks including legacy
|
||||
address-specific HTTPS, idempotence, backup permissions and syntax/reload rollback.
|
||||
An actual nginx instance in a private network namespace passed 120 negative
|
||||
HTTP/TLS cases across IPv4/IPv6, raw/unknown/spoofed Host/SNI and forwarded headers,
|
||||
including POST RPC and WebSocket upgrade requests. Exact ACME token reads,
|
||||
private LAN/tailnet/ULA access, named public HTTP app routing and reload passed.
|
||||
These are scoped checks, not complete fleet, trusted-tunnel, reboot or artifact
|
||||
acceptance. Shorty's containment was not modified.
|
||||
|
||||
The NPM storage/routing prototype passed eight focused tests: fresh/flat/nested/
|
||||
custom mount selection without mutation, ambiguity/wrong-mount refusal, corrupt
|
||||
or uninitialized database preservation, duplicate/missing mounts, deleted/disabled
|
||||
host exclusion, domain injection rejection, and rejection of mixed public and
|
||||
loopback listener bindings. Automatic application/migration and end-to-end NPM
|
||||
security/routing remain unfinished and block release.
|
||||
|
||||
Final Angor handoff was read and acknowledged in
|
||||
`/tmp/angor-final-handoff-ack.txt`; see `angor-client-acceptance-20261001.md`.
|
||||
One complete project flow is investigator-verified; 34 original announcements
|
||||
remain unrecovered from queried sources. Full recovery acceptance remains open.
|
||||
|
||||
## Additional Angor public explorer gate
|
||||
|
||||
- [ ] Public indexer hostname serves Mempool UI and its assets/deep links/live
|
||||
WebSocket updates while preserving Angor API/CORS/broadcast/readiness.
|
||||
- [ ] Existing stack reused; no duplicate Mempool app/database and no management
|
||||
or Bitcoin RPC exposure.
|
||||
- [ ] Documentation/catalog/runtime metadata and exact OTA/ISO reflect the tested
|
||||
implementation; repeat official-client browser acceptance afterward.
|
||||
|
||||
Confirmed official deployment guide describes a shared frontend/API origin.
|
||||
Current adapter 1.0.1 is API-only; this requirement is not yet implemented.
|
||||
|
||||
## NPM real-image integration progress
|
||||
|
||||
Disposable real NPM API tests passed for both flat and legacy nested `/data`
|
||||
layouts: initial account/host creation, multiple domains, custom location,
|
||||
forwarded-client spoof rejection, exact challenge file access under forced HTTPS,
|
||||
trusted TLS and WSS upgrade/frame, certificate replacement/reload, password and
|
||||
network access lists, disable/enable/delete propagation, and restart with the
|
||||
original database and account authentication preserved. Local fixture certificates
|
||||
are not public Let's Encrypt staging issuance/renewal evidence; that gate is open.
|
||||
|
||||
Certificate replacement initially failed because the updated bridge could not
|
||||
complete the upstream TLS request after replacing the fixture certificate.
|
||||
Reloading and validating NPM's own TLS listener on certificate fingerprint changes,
|
||||
as well as host nginx, resolved the test. Rollback/retry unit coverage was added.
|
||||
|
||||
The initial dev guard deployment changed only the inactive sites-available copy;
|
||||
private HTTP 200 and unchanged entry bytes were insufficient acceptance evidence.
|
||||
A later public-ingress-marker probe caught this: it incorrectly returned 200.
|
||||
The resolver now chooses the active sites-enabled copy or resolves its symlink
|
||||
without replacing the link. Guard backups live outside nginx include directories.
|
||||
After the correction, live private requests return200 and marked requests 404.
|
||||
No app was restarted. Direct-backend protection still awaits its candidate build.
|
||||
|
||||
Angor candidate UI was exercised against the actual dev Mempool stack in a
|
||||
throwaway gateway: desktop/mobile rendered, zero failed JS/CSS assets, one
|
||||
WebSocket connection each. The gateway was removed afterward; this is candidate
|
||||
integration evidence, not a published or permanently installed app update.
|
||||
|
||||
### Same-node NPM networking correction
|
||||
|
||||
Read-only inspection of the production NPM namespace reproduced HTTP 502 for its
|
||||
own configured indexer route. Host requests to the LAN upstream returned 200;
|
||||
requests from the existing pasta namespace to that same LAN address were refused.
|
||||
A disposable container on the proposed `slirp4netns:allow_host_loopback=true`
|
||||
network returned 200 for both the LAN upstream and `host.containers.internal`.
|
||||
No production NPM/nginx configuration or container was changed in this check.
|
||||
|
||||
The candidate now declares this network in the manifest and first-boot path,
|
||||
with explicit Quadlet/API support and legacy drift detection. The Podman API
|
||||
`network_options` shape was checked against `podman generate spec` locally.
|
||||
The real NPM integration fixture now uses the proposed network and a LAN-bound
|
||||
same-node upstream, rather than placing both fixtures on one custom bridge.
|
||||
Flat-layout integration passed namespace reachability, host routing, verified
|
||||
TLS/WSS, ACME file access, certificate replacement/reload, custom routes, password
|
||||
and IP ACLs, spoof rejection, host lifecycle and NPM restart with preserved DB.
|
||||
The earlier loopback-only fixture failed because this machine resolves the host
|
||||
alias to its LAN address; its bind was corrected before repeating the test.
|
||||
|
||||
The latest isolated nginx guard test passed 120 public IPv4/IPv6 negative cases
|
||||
plus private access, ACME, proxy-marker rejection and reload. Python guard/bridge
|
||||
regressions passed 23 tests, including exact emergency-route retirement, failed
|
||||
migration rollback and preserving operator-modified routes. Backend compilation
|
||||
and new direct-listener tests are still pending. Required public staging renewal,
|
||||
actual upgrade/reboot, yaya and exact OTA/ISO acceptance remain open.
|
||||
|
||||
|
||||
### Active nginx site layout regression
|
||||
|
||||
The dev node has a regular `sites-enabled/archipelago` file, not a symlink to
|
||||
`sites-available`. Both the guard and ACME resolver now select the active file.
|
||||
Unit coverage verifies copied sites and symlink targets, preserving inactive
|
||||
operator copies and the links themselves. Nginx configuration backups must not
|
||||
be created inside `sites-enabled`, whose wildcard include would load them.
|
||||
The active guard was applied on dev, with private HTTP 200 and public-ingress
|
||||
marker 404 verified after reload. Shorty remains untouched. Do not count the
|
||||
initial inactive-file edit as a security deployment pass.
|
||||
|
||||
### LoRa flasher added to release gates
|
||||
|
||||
Both dev and Framework lack the esptool executable and Python module. The
|
||||
backend invokes a bare `esptool` and retries even process-spawn failures. The
|
||||
shell updater installs it opportunistically, but the OTA runtime tool list
|
||||
omits it. Candidate packaging adds a pinned self-contained `archy-esptool`
|
||||
with its ESP32-S3 stub to mandatory OTA/ISO payloads. Candidate preflight runs
|
||||
before stopping the radio; retries are limited to recognized serial transport
|
||||
failures. Concurrent flash registration now uses one exclusive lock.
|
||||
|
||||
CP2102 USB identity does not uniquely identify a Heltec V3. The candidate removes
|
||||
that unsafe inference from backend and UI and requires explicit board selection.
|
||||
Actual board models were requested before firmware writes. Dev exposes one
|
||||
CP2102 serial radio. Framework SSH works but currently exposes no mesh-radio,
|
||||
ttyUSB or ttyACM port. No radio has been erased or flashed in this investigation.
|
||||
Physical MeshCore UK acceptance on both nodes remains required and pending.
|
||||
|
||||
Dev connection diagnosis: the failed flash stopped its listener before spawn
|
||||
failed and left the enabled setting true. A read-only protocol probe identifies
|
||||
the existing radio as Reticulum/RNode. An explicit listener disable/enable restored
|
||||
`device_connected: true` on `/dev/mesh-radio`, without flashing or native-service
|
||||
restarts. Candidate configure logic now compares desired enabled state with the
|
||||
actual listener task, including stopped/finished handles; same-settings reconnect
|
||||
is covered by a new isolated regression. Probe/configure and flash registration
|
||||
are coordinated to prevent concurrent serial owners.
|
||||
|
||||
The packaged `archy-esptool` build passes in a clean environment, including loading
|
||||
the actual ESP32-S3 stub through esptool's own loader. It is installed and self-tested
|
||||
on dev and Framework; a compatibility command supports their current backends.
|
||||
This verifies tooling availability, not physical flashing. Framework's USB sysfs
|
||||
inventory shows its hub/storage/network/keyboard/display devices but no serial
|
||||
radio. Board confirmation and Framework radio detection remain pending.
|
||||
|
||||
Additional Podman API acceptance: a disposable API service created a container
|
||||
with the candidate `netns`/`network_options` payload. Its effective generated
|
||||
specification preserves `allow_host_loopback=true`. The normal inspect network
|
||||
mode omits options for API-created containers, unlike the CLI-created case;
|
||||
candidate drift detection now consults the effective specification before
|
||||
recreating such a container. Added a regression against repeated recreation.
|
||||
The probe container and temporary API service were removed. The real isolated
|
||||
nftables tunnel regression also passed (NPM peer port and LND remain separate).
|
||||
|
||||
### Latest acceptance checkpoint: radio connection and release status
|
||||
|
||||
The complete frontend suite passed: 143 files, 1,159 tests. Type checking and
|
||||
both new radio setup tests also passed. The final isolated backend build/test
|
||||
run is still pending; the previous run exposed an NPM/Router port collision,
|
||||
which was corrected by assigning NPM's local HTTP listener port 8088. Do not
|
||||
report the previous run as fully passing or the final run as completed.
|
||||
|
||||
Yaya's identity has been confirmed. Its existing managed web-tunnel drop-in
|
||||
clears manifest port publications and supplies private tunnel HTTP/HTTPS ports
|
||||
plus the local admin port. This would suppress the candidate bridge's new
|
||||
loopback HTTP/TLS listeners. Migration must preserve the working tunnel/site,
|
||||
add the required local listeners, validate the managed firewall/lifecycle,
|
||||
retain custom overrides, and cover repeat upgrade and rollback. The current
|
||||
bridge rejects non-loopback listeners, so the supported tunnel topology also
|
||||
needs explicit qualification. No tunnel or NPM runtime change was applied to
|
||||
yaya during this diagnosis. Its management source guard was applied and tested:
|
||||
private dashboard HTTP 200, public-ingress-marked request 404.
|
||||
|
||||
Dev radio connection has been restored on its existing Reticulum firmware.
|
||||
Framework still does not enumerate a USB serial radio. Both nodes now have the
|
||||
self-tested packaged flasher, but physical MeshCore UK flashing, post-flash
|
||||
handshake and reconnect acceptance remain open pending board identification and
|
||||
Framework USB detection. No device firmware has been written.
|
||||
|
||||
OTA, app catalog and raw ISO publication remain held. Outstanding acceptance
|
||||
includes NPM migration/renewal/reboot and exact artifacts, end-to-end delivery
|
||||
of the reported paid file, physical companion uploads, full Angor discovery
|
||||
(the 34 missing original announcements), radio hardware tests, and the final
|
||||
dev/yaya candidate deployment checks. Retained tasks above remain in scope.
|
||||
|
||||
### Dev Heltec V3 physical flashing result
|
||||
|
||||
Full 8 MiB pre-flash backup saved privately with mode 0600 and checksum. After
|
||||
removing the confirmed stale radio sidecar, the exclusive read completed.
|
||||
The normal mesh.flash-device RPC then flashed the official Heltec V3 Companion
|
||||
USB v1.17.1-d929643 merged image successfully (100%, no error). The image's
|
||||
size and SHA-256 were checked against the official GitHub release metadata.
|
||||
|
||||
Independent serial protocol queries confirmed model Heltec V3, firmware
|
||||
v1.17.1-d929643 and actual RF readback: 869618 kHz, 62500 Hz bandwidth, SF8,
|
||||
CR8 (EU/UK Narrow). This is device readback, not merely saved host settings.
|
||||
The listener was then re-enabled and reported connected as meshcore. No wallet
|
||||
or native Bitcoin/LND service restart was used. MeshCore remote reboot is not
|
||||
supported by the current API; that attempted check returned an explicit error.
|
||||
Physical unplug/replug and communication to Framework remain pending.
|
||||
|
||||
Live qualification additionally found incorrect binary DEVICE_INFO/SELF_INFO
|
||||
parsing and a stale host-side RF-applied marker after full-chip flashing.
|
||||
Candidate changes decode the current official binary layout, query the actual
|
||||
firmware version during initialization, and invalidate the RF marker after a
|
||||
successful flash. The dev marker was backed up and cleared before provisioning;
|
||||
the independent readback above confirms settings applied. New parser, startup
|
||||
cancellation and marker lifecycle regressions are queued/running; the prior
|
||||
1,647 passing tests do not cover these later changes.
|
||||
|
||||
Framework's V4 official USB image has been downloaded and verified. Despite the
|
||||
operator confirming it is plugged in, repeated sysfs/device checks show no
|
||||
ESP32 USB or serial port. USER/BOOT plus RST bootloader entry was requested;
|
||||
Framework has NOT been flashed and the two-device acceptance remains open.
|
||||
|
||||
### Operator deferral and latest qualification
|
||||
|
||||
Operator explicitly deferred Framework radio/hardware work and instructed us to
|
||||
continue all other release tasks. Framework V4 flashing, USB reconnect and
|
||||
radio-to-radio acceptance remain UNVERIFIED / OPERATOR-DEFERRED; this is not a
|
||||
pass. Do not request further Framework radio operations unless needed and the
|
||||
operator resumes that work. Dev V3 acceptance and durable fleet fixes remain.
|
||||
|
||||
The final radio backend suite passed 1,650 tests, zero failed, four ignored,
|
||||
including sidecar-startup cancellation, current MeshCore metadata parsing, and
|
||||
post-flash RF-marker invalidation. Frontend baseline remains 1,159 passed.
|
||||
|
||||
Correction to the earlier yaya tunnel concern: direct inspection of the active
|
||||
Quadlet and all drop-ins confirms web-tunnel.conf ADDS private tunnel ports; it
|
||||
does not contain an empty PublishPort reset. The earlier statement that it
|
||||
cleared manifest listeners was incorrect. The new loopback publications therefore
|
||||
coexist declaratively without editing that working tunnel drop-in. The bridge
|
||||
validator now permits only the known HTTP/TLS tunnel ports bound to a currently
|
||||
assigned RFC1918 address on an actual WireGuard interface named wg-web; it still
|
||||
requires a separate loopback upstream, and rejects wildcard/public/unassigned
|
||||
bindings and any admin-port exception. Python bridge/guard tests: 27 passed.
|
||||
Actual yaya candidate upgrade and public route acceptance remain pending.
|
||||
|
||||
### NPM public certificate and restart qualification checkpoint
|
||||
|
||||
- Main backend: 1,651 passed, zero failed, four explicitly ignored hardware /
|
||||
external integration tests. Container runtime library: 80 passed, zero failed.
|
||||
- Bridge/management guard Python suite: 27 passed. Shell syntax and actual ISO
|
||||
overlay-content test passed.
|
||||
- Candidate validator inspected yaya's real runtime/WireGuard interface and
|
||||
accepted its existing web tunnel publications while selecting proposed
|
||||
loopback HTTP/TLS upstreams. This was read-only, not a runtime upgrade.
|
||||
- Existing yaya public HTTP ACME route returned the exact random token body
|
||||
written inside NPM. Lets Encrypt STAGING initial issuance and renewal dry run
|
||||
succeeded using separate temporary account/config/work/log storage. Current
|
||||
production certificate and host records were not replaced. Public site HTTP
|
||||
and trusted HTTPS retain their authentication requirement (401).
|
||||
- First renewal harness timed out while Certbot used a 292.7-second randomized
|
||||
delay; the remote log confirmed successful simulated renewal. A deterministic
|
||||
rerun with --no-random-sleep-on-renew returned exit 0 and success confirmation.
|
||||
Only the temporary staging directory was removed afterward.
|
||||
- Real disposable NPM nested-layout test completed: namespace LAN upstream,
|
||||
API host creation, custom locations, client-IP spoof rejection, exact ACME
|
||||
token, trusted TLS/WSS, certificate replacement, password/network ACLs,
|
||||
enable/disable/delete, and restart with retained DB. Latest restart took 7.6s.
|
||||
Earlier rerun exceeded the fixture's 90-second restart window; the test now
|
||||
uses the manifest's 180-second budget and records both route/admin statuses
|
||||
and container state on failure. Do not erase that earlier observed failure.
|
||||
- Cleanup was hardened to continue cleaning other fixtures after a timeout.
|
||||
Two early test runs passed functional assertions but failed cleanup; their
|
||||
leftover disposable containers/networks were explicitly removed. The latest
|
||||
full run exited successfully.
|
||||
|
||||
Legacy non-Quadlet repair now retains the old container for rollback, restores
|
||||
it after replacement failure, preserves its exact image and environment values,
|
||||
and waits for HTTP API readiness. Environment values use an exclusive mode0600
|
||||
file cleaned on drop, not argv or the host process environment. Invalid/multiline
|
||||
entries fail before stopping the original. An occupied rollback slot is preserved
|
||||
for review rather than deleting an unknown container. Live interrupted-migration,
|
||||
additional operator-override and rollback acceptance remain OPEN; unit success
|
||||
is not proof of those deployment paths.
|
||||
|
||||
The deployment backend and frontend build are in progress. Full candidate dev/
|
||||
yaya upgrade acceptance, reboot, exact signed OTA/catalog and booted raw ISO
|
||||
remain open. Framework radio is operator-deferred, not passed. The original paid
|
||||
file bytes, physical companion upload and 34 missing Angor announcements remain
|
||||
separately tracked.
|
||||
|
||||
### Further completed acceptance
|
||||
|
||||
The complete disposable NPM test now includes a deliberately failed replacement
|
||||
with an occupied host port. Restoring the retained container preserved its exact
|
||||
container ID, database, configured hosts and authenticated API access; the test
|
||||
exited successfully and cleaned its fixtures. This verifies the Podman rollback
|
||||
mechanism, not yet the full installed backend's legacy-repair entry point.
|
||||
|
||||
Dev frontend build completed and was deployed with a separate rollback backup.
|
||||
Served production Transactions layout passed at widths 390 and 1440: transparent
|
||||
background, no image/blur/shadow/border, nowrap and exactly one row. Mobile rail
|
||||
is 324px wide with 419px scroll content. Backend candidate compilation is still
|
||||
in progress, so the latest backend changes are not yet deployed.
|
||||
|
||||
Dev Bitcoin remains unpruned and in IBD (observed block543676/header969495,
|
||||
verification progress0.2284). This is not full-chain Angor acceptance. The operator
|
||||
identified the seller of the failed Lightning purchase as Amish Paradise.
|
||||
On 2026-10-02 the operator confirmed the other tester received the file and
|
||||
accepted closure of this individual recovery. Seller access is no longer needed
|
||||
for that recovery. This does not establish that the candidate fix delivered it;
|
||||
durable settlement/delivery regression acceptance remains required before
|
||||
release. No additional payment was made. Earlier references in this document
|
||||
to missing original purchase bytes are superseded by this operator acceptance.
|
||||
|
||||
### Read-only Shorty migration preflight: remaining ownership conflict
|
||||
|
||||
Preflight found both flat and nested NPM databases. The live container's explicit
|
||||
/data mount identifies the active one, so the candidate resolver now uses that
|
||||
verified mount (or its saved validated receipt after a managed stop), preserves
|
||||
both databases, and still refuses multiple databases without an authoritative
|
||||
selection. Python coverage verifies both explicit choices and unchanged bytes.
|
||||
This helper update occurred after the deployment binary build started; a final
|
||||
release rebuild must include it. Do not claim the in-progress binary contains it.
|
||||
|
||||
After resolving storage, the two Angor emergency routes match the exact known
|
||||
handoff templates. Another existing file, shop-btcpay.conf, conflicts with an
|
||||
enabled NPM record: the manual route supplies HTTPS using certificate10, while
|
||||
the NPM host currently has certificate_id0 and SSL forcing disabled. The manual
|
||||
route and NPM record cover the same two public names and backend web port. Blindly
|
||||
retiring the route would break its HTTPS. No database, host, certificate, route
|
||||
or runtime was changed on Shorty. The bridge correctly refuses this ownership
|
||||
conflict and now names its configuration file in the diagnostic. Align TLS/route
|
||||
ownership and verify the public shop before retiring that manual configuration;
|
||||
Shorty's full migration acceptance remains OPEN. Preserve live containment.
|
||||
|
||||
### Candidate deployment and additional real-upgrade ACME regression
|
||||
|
||||
The operator explicitly deferred Framework's physical radio investigation and
|
||||
requested continuation of all other work. Framework radio remains unverified.
|
||||
Dev and yaya now run the backed-up unpublished backend candidate SHA256
|
||||
4c47269b3480ca0362df18dae160c073a19ea33507e04cacdad5b96837990ca6 and production
|
||||
frontend index 3099c4ba44528a4a4c524f9a26159414558efa16abdd12cc7bfd64376cbb6089.
|
||||
Both management health checks passed; native Bitcoin/LND container identities
|
||||
and start times were unchanged. Yaya public site retains trusted TLS and its
|
||||
401 authentication requirement; NPM admin API200, private dashboard200 and
|
||||
public-ingress-marked dashboard404. The first yaya staging attempt stopped
|
||||
before binary replacement because rsync was absent; deployment now uses Python
|
||||
copying without that dependency and completed successfully.
|
||||
|
||||
Actual startup exposed an additional regression: the canonical nginx template
|
||||
had only HTTP ACME, while the bridge demanded two locations. Startup rewrote the
|
||||
previously repaired config and the bridge rejected it before fixing the nested
|
||||
root. Source now adds HTTPS ACME to the shipped template and migrates the exact
|
||||
recognized legacy default-server layout; custom/ambiguous layouts still fail
|
||||
closed. The missing-token route must return404 rather than the dashboard SPA.
|
||||
28 Python checks passed. The real isolated nginx suite now starts from the
|
||||
legacy missing-HTTPS layout, applies the migration, and passes all120 public
|
||||
negative cases plus HTTP/TLS exact-token, private-access and reload checks.
|
||||
|
||||
Applied the latest helper and its atomic ACME-only repair to yaya: actual token
|
||||
written inside NPM returned exact200 over host HTTP, host HTTPS and public HTTP;
|
||||
missing tokens returned404 for allthree. Public site trustedTLS/auth preserved.
|
||||
Local self-signed host HTTPS was tested with certificate verification disabled;
|
||||
public site HTTPS used normal certificate verification. Shorty was not modified.
|
||||
The running backend still embeds the older helper/template; final rebuild is
|
||||
REQUIRED before restart/reboot/persistent upgrade acceptance can pass.
|
||||
|
||||
The prepared unsigned catalog validates with zero metadata drift and trusted
|
||||
registry hosts. Its only changed entries are NPM and Angor indexer1.0.2. It has
|
||||
not been signed, installed or published. Yaya's current signed catalog retains
|
||||
NPM's old pasta network/tunnel-only HTTP+TLS listeners; full NPM runtime/bridge
|
||||
migration acceptance awaits the reviewed signed catalog. Do not mistake the
|
||||
backend/UI deployment or ACME-only fix for completed catalog migration.
|
||||
|
||||
### 2026-10-02: rebuilt candidate deployed; private catalog qualification prepared
|
||||
|
||||
Release-profile candidate build completed successfully. SHA256:
|
||||
af0648ad4ef8b6183d3c1ff485721fa40b12bb730c6e0322bc5f209ed06fce39.
|
||||
Focused bootstrap tests:10 passed. Full isolated backend rerun:1651 passed,
|
||||
zero failed, four explicit hardware/external ignores. Python guard/bridge28
|
||||
passed again. No new source changes occurred between these checks and deployment.
|
||||
|
||||
Deployed this rebuilt backend on dev and yaya, with private previous-binary,
|
||||
nginx and native-container baselines. Both manager health checks passed. A later
|
||||
post-startup comparison confirmed Bitcoin/LND identities/start times unchanged.
|
||||
The installed bridge helper now matches latest source bytes after restart.
|
||||
Private UI200, marked-public HTTP/HTTPS404 and missing HTTPS challenge404 passed.
|
||||
Dev HTTPS intentionally binds its LAN/WireGuard addresses, not127.0.0.1; an
|
||||
initial loopback probe got connection refused, corrected to the actual listener.
|
||||
This was a test-address error, not a product outage. Local self-signed HTTPS
|
||||
checks skip certificate verification; public-site TLS checks use normal trust.
|
||||
Full-machine reboot qualification is still pending.
|
||||
|
||||
Prepared a fresh candidate catalog with only NPM and Angor indexer entries changed.
|
||||
Metadata drift0; registry trust check passed. Unsigned SHA256:
|
||||
5801309bf21d3f6fd03ce5702d68b383a518f734092bf265f5e0ad243095a25e.
|
||||
NPM's new manifest is capability-gated by runtime-migration-backup-v1; older
|
||||
nodes retain the original manifest. This candidate is for private qualification,
|
||||
not fleet publication. An operator-only hidden-input signer validates the exact
|
||||
catalog and binary hashes, checks the pinned release root and restores the
|
||||
unsigned original on failure. Its noninteractive refusal was tested. Signature
|
||||
is required before testing through the nodes' normal trusted-catalog path.
|
||||
No catalog, app image, OTA or ISO was published. Framework remains deferred;
|
||||
all other open requirements retain their previous status.
|
||||
|
||||
### Signed catalog and private qualification selector
|
||||
|
||||
The operator signed the qualification catalog. Cryptographic release-root
|
||||
verification passed locally and on yaya; after removing signature envelope fields,
|
||||
its contents exactly match the reviewed unsigned candidate. No publication.
|
||||
The catalog is staged under /var/lib/archipelago/qualification on both test nodes,
|
||||
with previous catalog/app metadata and container identities privately backed up.
|
||||
|
||||
Normal mirror loading deliberately forces the public origin first, so simply
|
||||
prepending a private mirror cannot reliably test an unpublished candidate.
|
||||
Implemented ARCHY_APP_CATALOG_CANDIDATE as an explicit absolute-file selection:
|
||||
requires an anchored release-root signature, validates before cache replacement,
|
||||
retains exact signed bytes, does not alter mirrors/trust, and fails without
|
||||
public fallback when the selected file is invalid. Added unsigned, tampered,
|
||||
wrong-key, malformed, missing, oversized, relative-path, valid and idempotent
|
||||
coverage. Full isolated backend suite:1653 passed,0 failed,4 explicit ignores.
|
||||
The optimized selector build is still in progress; selection is not enabled yet.
|
||||
See docs/candidate-catalog-qualification.md for activation and mandatory removal
|
||||
once the tested public catalog is available. Do not leave test nodes pinned.
|
||||
|
||||
Yaya NPM preflight:8088/8444 are free, active public host has no conflicting
|
||||
host-nginx ownership, database tables and certificate-file hashes saved privately.
|
||||
No Shorty mutation. Dev public Angor reference acceptance passed TLS/WSS, exact
|
||||
funding commitment, official Explore and detail/statistics again; this still
|
||||
checks only the known original project, not all35. Dev Bitcoin continues syncing
|
||||
(reported sync_progress approximately0.307); full-chain acceptance remains open.
|
||||
Current tested hardware product codes:dev20CLS7S900 and yaya20CLS6BH00, both Podman
|
||||
5.4.2; kernels6.12.74+deb13+1-amd64 and6.12.107+deb13-amd64 respectively. Framework
|
||||
remains deferred. No Docker or new ISO-boot acceptance is implied.
|
||||
|
||||
|
||||
### Signed qualification deployment and legacy upstream compatibility
|
||||
|
||||
The optimized candidate selector build completed, SHA256
|
||||
`9cc9271ee1b4f8f13d798193cef97c1e4304a89a240f11f851eb71568bd105e1`.
|
||||
Both development acceptance nodes now run it with the exact root-verified private
|
||||
catalog. The isolated backend suite passed 1,653 tests, zero failures, four
|
||||
explicit ignores. This is unpublished qualification, not a release.
|
||||
|
||||
Actual NPM migration exposed an additional regression that the LAN-upstream
|
||||
fixture missed: a saved site uses pasta's former host gateway `169.254.1.2`.
|
||||
Default slirp's gateway differs, so the request timed out from inside NPM even
|
||||
though admin readiness passed. A disposable container verified the same saved
|
||||
upstream with `slirp4netns:allow_host_loopback=true,cidr=169.254.1.0/24`.
|
||||
A temporary qualification Quadlet drop-in now selects that network, using an
|
||||
empty `Network=` reset before the replacement to avoid multiple network modes.
|
||||
The existing site's trusted public HTTPS authentication response is restored.
|
||||
|
||||
Post-repair checks passed: request from NPM's actual namespace; exact saved user,
|
||||
host, certificate, ACL and settings rows; unchanged certificate bytes; private
|
||||
migration backup and prior unit; unchanged unrelated container IDs/start times;
|
||||
retained WireGuard tunnel ports; host bridge completion; private dashboard200,
|
||||
marked public HTTP/HTTPS404; missing challenge404; exact challenge body from
|
||||
inside NPM over local HTTP/HTTPS and public HTTP. Native Bitcoin/LND identities
|
||||
and start times remain unchanged.
|
||||
|
||||
**Release blocker:** integrate and test legacy gateway compatibility in all
|
||||
supported runtime paths and signed manifest, including fresh/upgrade/restart
|
||||
cases and LAN/host.containers.internal upstreams. The current signed candidate
|
||||
alone is insufficient. Temporary user-unit drop-in
|
||||
`nginx-proxy-manager.container.d/90-qualification-host-gateway.conf` must be
|
||||
removed after the corrected managed configuration is verified. Do not remove
|
||||
it before then or claim the migration passed without it. Candidate catalog
|
||||
service selectors also require the cleanup described in
|
||||
`docs/candidate-catalog-qualification.md` after final publication.
|
||||
|
||||
|
||||
### Development Angor candidate update
|
||||
|
||||
The supported `package.update` RPC selected the private signed catalog and
|
||||
upgraded only `angor-indexer` to the locally built 1.0.2 image. The actual dev
|
||||
endpoint rendered the Mempool explorer at widths 390 and 1440 with zero failed
|
||||
JavaScript/CSS requests and one WebSocket connection each. All unrelated dev
|
||||
containers retained their exact IDs and start times. This verifies the local
|
||||
explorer presentation, not complete blockchain indexing or recovery of the 34
|
||||
missing original Angor project announcements. Bitcoin remains in IBD.
|
||||
|
||||
The repaired NPM namespace also reached the saved gateway,
|
||||
`host.containers.internal`, and the node LAN address with the expected site
|
||||
authentication response. The durable compatibility blocker remains open.
|
||||
|
||||
|
||||
## Live Lightning purchase: Framework to Shorty — 2026-10-02
|
||||
|
||||
Operator explicitly authorized a very small new test purchase, then performed
|
||||
it from Framework. This is separate from recovering the Amish Paradise sale.
|
||||
Fixture: `archy-lightning-delivery-test-20261002.txt`, price 1 sat, Lightning only.
|
||||
Read-only checks confirmed one matching seller invoice, SETTLED for exactly
|
||||
1 sat, and Framework payment SUCCEEDED for 1 sat with 1 sat routing fee
|
||||
(1,000 msat). Total spent was 2 sats. The assistant sent no payment.
|
||||
|
||||
Framework has exactly one durable purchased-content ownership entry for the
|
||||
fixture, with backend `lightning`, paid_sats=1 and size_bytes=121. Its cached
|
||||
file SHA256 equals the original seller fixture:
|
||||
`d55f7a6acd77bdc3c35c65ecac6d1492096e99252d07d433e6286544540cde7e`.
|
||||
**PASS: actual node-wallet payment, seller settlement, delivered bytes and
|
||||
persisted buyer ownership/cache.** Framework runs the candidate backend
|
||||
`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`;
|
||||
Shorty runs `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
|
||||
This also exercises the candidate buyer against the existing seller version.
|
||||
|
||||
Live reopen without payment and restart acceptance are not established by
|
||||
this check. Shorty had no matching durable entitlement JSON in the inspected
|
||||
location; do not attribute the candidate seller persistence implementation to
|
||||
this older seller binary. No node or wallet was restarted. The tiny fixture
|
||||
remains available for a free cached reopen check; remove only its catalog
|
||||
entry/source file afterwards, preserving buyer ownership and payment records.
|
||||
|
||||
|
||||
### Operator-confirmed free reopen — 2026-10-02
|
||||
|
||||
After the verified one-sat purchase, the operator reopened the file on Framework
|
||||
and confirmed it worked without another payment. **PASS: live paid delivery,
|
||||
durable buyer ownership/cache, and free repeat access**, with independent
|
||||
settlement/byte checks above and operator confirmation of the reopen UI.
|
||||
This closes that specific live acceptance check; it does not establish an
|
||||
untested restart, outage, or seller-upgrade scenario.
|
||||
|
||||
The temporary seller catalog entry and source fixture were removed after
|
||||
acceptance. Buyer purchased bytes/ownership and all payment records were preserved.
|
||||
|
||||
@@ -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"
|
||||
fi
|
||||
|
||||
# Mandatory offline flasher: cached rootfs images may lack system esptool.
|
||||
ESPTOOL_BUNDLE="${ARCHY_ESPTOOL:-$SCRIPT_DIR/../../reticulum-daemon/dist/archy-esptool}"
|
||||
if [ ! -x "$ESPTOOL_BUNDLE" ]; then
|
||||
echo "ERROR: packaged archy-esptool missing; build reticulum-daemon/build-esptool.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$ESPTOOL_BUNDLE" --archy-self-test || exit 1
|
||||
install -m 755 "$ESPTOOL_BUNDLE" "$ARCH_DIR/bin/archy-esptool"
|
||||
|
||||
if [ "$BACKEND_CAPTURED" = "0" ]; then
|
||||
if [ "$BUILD_FROM_SOURCE" != "1" ]; then
|
||||
echo " ⚠️ Could not capture from live server, building from source..."
|
||||
@@ -2449,42 +2458,24 @@ runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && syst
|
||||
# Ensure podman socket is active for archipelago user
|
||||
runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && systemctl --user enable --now podman.socket' 2>>"$LOG" || true
|
||||
|
||||
# Create FileBrowser container as archipelago user (rootless podman)
|
||||
# Generate random FileBrowser password and store for auto-login
|
||||
FB_PASS_DIR="/var/lib/archipelago/secrets/filebrowser"
|
||||
mkdir -p "$FB_PASS_DIR"
|
||||
if [ ! -f "$FB_PASS_DIR/password" ]; then
|
||||
head -c 24 /dev/urandom | base64 | tr -d '/+=' | head -c 24 > "$FB_PASS_DIR/password"
|
||||
chmod 600 "$FB_PASS_DIR/password"
|
||||
chown 1000:1000 "$FB_PASS_DIR/password"
|
||||
fi
|
||||
|
||||
if ! runuser -u archipelago -- bash -c 'export XDG_RUNTIME_DIR=/run/user/1000 && podman ps -a --format "{{.Names}}"' 2>/dev/null | grep -q filebrowser; then
|
||||
echo "[$(date)] Creating FileBrowser container ($FILEBROWSER_IMAGE)..." >> "$LOG"
|
||||
runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman run -d --name filebrowser --restart unless-stopped \
|
||||
--cap-drop=ALL \
|
||||
--cap-add=DAC_OVERRIDE \
|
||||
--cap-add=NET_BIND_SERVICE \
|
||||
# Provision the same unique verified Cloud login used by app installation/OTA.
|
||||
if ! runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman container exists filebrowser; then
|
||||
install -d -o 100000 -g 100000 /var/lib/archipelago/filebrowser /var/lib/archipelago/filebrowser-data
|
||||
install -d -o archipelago -g archipelago -m 700 /var/lib/archipelago/secrets/filebrowser
|
||||
runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 \
|
||||
python3 /opt/archipelago/scripts/filebrowser-credentials.py --image "$FILEBROWSER_IMAGE" >>"$LOG" 2>&1 || exit 1
|
||||
runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/1000 podman run -d \
|
||||
--name filebrowser --restart unless-stopped \
|
||||
--cap-drop=ALL --cap-add=DAC_OVERRIDE --cap-add=NET_BIND_SERVICE \
|
||||
--security-opt=no-new-privileges:true \
|
||||
--read-only \
|
||||
--tmpfs=/tmp:rw,noexec,nosuid,size=64m \
|
||||
--health-cmd='curl -sf http://localhost:80/ || exit 1' \
|
||||
--health-cmd='wget -q --spider http://localhost:80/health || exit 1' \
|
||||
--health-interval=30s --health-timeout=5s --health-retries=3 \
|
||||
--memory=256m \
|
||||
-p 8083:80 \
|
||||
--memory=256m -p 127.0.0.1:8083:80 \
|
||||
-v /var/lib/archipelago/filebrowser:/srv \
|
||||
-v /var/lib/archipelago/filebrowser-data:/data \
|
||||
-v /var/lib/archipelago/data/cloud:/srv/cloud \
|
||||
$FILEBROWSER_IMAGE \
|
||||
--database=/data/database.db --root=/srv --address=0.0.0.0 --port=80" 2>>"$LOG" && \
|
||||
echo "[$(date)] FileBrowser created successfully" >> "$LOG" || \
|
||||
echo "[$(date)] WARNING: FileBrowser creation failed" >> "$LOG"
|
||||
# Set FileBrowser password to match the stored random password
|
||||
sleep 5
|
||||
FB_PASS=$(cat "$FB_PASS_DIR/password" 2>/dev/null || echo "admin")
|
||||
runuser -u archipelago -- bash -c "export XDG_RUNTIME_DIR=/run/user/1000 && podman exec filebrowser filebrowser users update admin --password '$FB_PASS' --database /data/database.db" 2>>"$LOG" && \
|
||||
echo "[$(date)] FileBrowser admin password set" >> "$LOG" || \
|
||||
echo "[$(date)] WARNING: Could not set FileBrowser password" >> "$LOG"
|
||||
"$FILEBROWSER_IMAGE" --config /data/.filebrowser.json >>"$LOG" 2>&1 || exit 1
|
||||
fi
|
||||
echo "[$(date)] Minimal first-boot complete" >> "$LOG"
|
||||
FBUNBUNDLED
|
||||
@@ -2611,6 +2602,12 @@ fi
|
||||
cp "$SCRIPT_DIR/../../scripts/container-doctor.sh" "$ARCH_DIR/scripts/"
|
||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.service" "$ARCH_DIR/scripts/"
|
||||
cp "$SCRIPT_DIR/../configs/archipelago-doctor.timer" "$ARCH_DIR/scripts/"
|
||||
for npm_file in dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh filebrowser-credentials.py; do
|
||||
cp "$SCRIPT_DIR/../../scripts/$npm_file" "$ARCH_DIR/scripts/"
|
||||
done
|
||||
for npm_unit in archipelago-npm-bridge.service archipelago-npm-bridge.timer; do
|
||||
cp "$SCRIPT_DIR/../configs/$npm_unit" "$ARCH_DIR/scripts/"
|
||||
done
|
||||
|
||||
# Build-source apps need their complete contexts even on unbundled ISOs.
|
||||
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
||||
@@ -3142,6 +3139,10 @@ if [ -d "$BOOT_MEDIA/archipelago/bin" ]; then
|
||||
chmod +x /mnt/target/usr/local/bin/* 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Required even when the cached rootfs never had esptool installed.
|
||||
install -m 755 "$BOOT_MEDIA/archipelago/bin/archy-esptool" /mnt/target/usr/local/bin/archy-esptool || exit 1
|
||||
chroot /mnt/target /usr/local/bin/archy-esptool --archy-self-test || exit 1
|
||||
|
||||
if [ -d "$BOOT_MEDIA/archipelago/web-ui" ]; then
|
||||
cp -r "$BOOT_MEDIA/archipelago/web-ui" /mnt/target/opt/archipelago/
|
||||
fi
|
||||
@@ -3245,6 +3246,13 @@ done
|
||||
for doctor_unit in archipelago-doctor.service archipelago-doctor.timer; do
|
||||
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$doctor_unit" "/mnt/target/etc/systemd/system/$doctor_unit" || exit 1
|
||||
done
|
||||
for npm_file in dashboard-public-guard.py npm-public-bridge.py sync-npm-public-hosts.sh filebrowser-credentials.py; do
|
||||
install -m 755 "$BOOT_MEDIA/archipelago/scripts/$npm_file" "/mnt/target/opt/archipelago/scripts/$npm_file" || exit 1
|
||||
done
|
||||
for npm_unit in archipelago-npm-bridge.service archipelago-npm-bridge.timer; do
|
||||
install -m 644 "$BOOT_MEDIA/archipelago/scripts/$npm_unit" "/mnt/target/etc/systemd/system/$npm_unit" || exit 1
|
||||
done
|
||||
systemctl --root=/mnt/target enable archipelago-npm-bridge.timer || exit 1
|
||||
# END DOCTOR OVERLAY
|
||||
|
||||
# Copy self-update script
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
QEMU_TMPDIR="${TMPDIR:-/tmp}"
|
||||
SSH_FORWARD_PORT="${QEMU_SSH_PORT:-2222}"
|
||||
HTTP_FORWARD_PORT="${QEMU_HTTP_PORT:-8100}"
|
||||
SERIAL_LOG="$QEMU_TMPDIR/archipelago-qemu-serial.log"
|
||||
FORCE_BIOS=false
|
||||
NOGRAPHIC=false
|
||||
@@ -67,6 +69,10 @@ echo " CPU: 2 cores"
|
||||
echo " Serial: $SERIAL_LOG"
|
||||
echo ""
|
||||
|
||||
# Never accept boot markers left by an earlier VM.
|
||||
mkdir -p "$QEMU_TMPDIR"
|
||||
: > "$SERIAL_LOG"
|
||||
|
||||
# Create test disk if it doesn't exist
|
||||
DISK="$QEMU_TMPDIR/archipelago-test-disk.qcow2"
|
||||
if [ ! -f "$DISK" ]; then
|
||||
@@ -81,8 +87,9 @@ QEMU_ARGS=(
|
||||
-boot d
|
||||
-cdrom "$ISO"
|
||||
-drive if=virtio,format=qcow2,file="$DISK"
|
||||
-net nic,model=virtio -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8100-:80
|
||||
-net nic,model=virtio -net "user,hostfwd=tcp:127.0.0.1:${SSH_FORWARD_PORT}-:22,hostfwd=tcp:127.0.0.1:${HTTP_FORWARD_PORT}-:80"
|
||||
-serial file:"$SERIAL_LOG"
|
||||
-qmp "unix:$QEMU_TMPDIR/archipelago-qmp.sock,server=on,wait=off"
|
||||
)
|
||||
|
||||
# Display mode
|
||||
@@ -99,28 +106,37 @@ echo ""
|
||||
|
||||
# Detect UEFI firmware
|
||||
OVMF=""
|
||||
OVMF_VARS=""
|
||||
if [ "$FORCE_BIOS" = false ]; then
|
||||
if [ -f "/opt/homebrew/share/qemu/edk2-x86_64-code.fd" ]; then
|
||||
OVMF="/opt/homebrew/share/qemu/edk2-x86_64-code.fd"
|
||||
elif [ -f "/usr/share/OVMF/OVMF_CODE.fd" ]; then
|
||||
OVMF="/usr/share/OVMF/OVMF_CODE.fd"
|
||||
OVMF_VARS="/usr/share/OVMF/OVMF_VARS.fd"
|
||||
elif [ -f "/usr/share/OVMF/OVMF_CODE_4M.fd" ]; then
|
||||
OVMF="/usr/share/OVMF/OVMF_CODE_4M.fd"
|
||||
OVMF_VARS="/usr/share/OVMF/OVMF_VARS_4M.fd"
|
||||
fi
|
||||
fi
|
||||
|
||||
run_qemu() {
|
||||
if [ -n "$OVMF" ]; then
|
||||
echo " Boot: UEFI ($OVMF)"
|
||||
qemu-system-x86_64 \
|
||||
-machine q35 \
|
||||
-drive if=pflash,format=raw,readonly=on,file="$OVMF" \
|
||||
"${QEMU_ARGS[@]}"
|
||||
else
|
||||
echo " Boot: Legacy BIOS"
|
||||
qemu-system-x86_64 \
|
||||
-machine pc \
|
||||
"${QEMU_ARGS[@]}"
|
||||
QEMU_COMMAND=(qemu-system-x86_64)
|
||||
if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
|
||||
QEMU_COMMAND+=(-enable-kvm)
|
||||
fi
|
||||
if [ -n "$OVMF" ]; then
|
||||
echo " Boot: UEFI ($OVMF)"
|
||||
QEMU_COMMAND+=(-machine q35 -drive "if=pflash,format=raw,readonly=on,file=$OVMF")
|
||||
if [ -f "$OVMF_VARS" ]; then
|
||||
if [ ! -f "$QEMU_TMPDIR/archipelago-uefi-vars.fd" ]; then
|
||||
cp "$OVMF_VARS" "$QEMU_TMPDIR/archipelago-uefi-vars.fd" || exit 1
|
||||
fi
|
||||
QEMU_COMMAND+=(-drive "if=pflash,format=raw,file=$QEMU_TMPDIR/archipelago-uefi-vars.fd")
|
||||
fi
|
||||
}
|
||||
else
|
||||
echo " Boot: Legacy BIOS"
|
||||
QEMU_COMMAND+=(-machine pc)
|
||||
fi
|
||||
QEMU_COMMAND+=("${QEMU_ARGS[@]}")
|
||||
|
||||
# Wrap the QEMU invocation in `timeout` when a CI caller passed one so
|
||||
# the script always returns instead of hanging on a VM that never exits
|
||||
@@ -129,14 +145,16 @@ run_qemu() {
|
||||
# the serial log shows a kernel reaching userspace — we inspect that
|
||||
# after the QEMU process ends.
|
||||
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null; then
|
||||
timeout --foreground --preserve-status "${TIMEOUT}s" bash -c "$(declare -f run_qemu); run_qemu"
|
||||
# A new bash -c loses the unexportable argument array and firmware path.
|
||||
# Invoke the complete command directly and keep timeout's distinct status.
|
||||
timeout --foreground --kill-after=10 "${TIMEOUT}s" "${QEMU_COMMAND[@]}"
|
||||
rc=$?
|
||||
if [ $rc -eq 124 ] || [ $rc -eq 137 ]; then
|
||||
if [ $rc -eq 124 ]; then
|
||||
echo "(QEMU terminated after ${TIMEOUT}s boot-test window)"
|
||||
rc=0
|
||||
fi
|
||||
else
|
||||
run_qemu
|
||||
"${QEMU_COMMAND[@]}"
|
||||
rc=$?
|
||||
fi
|
||||
|
||||
@@ -150,12 +168,15 @@ tail -20 "$SERIAL_LOG" 2>/dev/null
|
||||
# by live-boot/systemd early in the sequence. If the marker never
|
||||
# appeared, surface the real failure; otherwise treat "timeout reached
|
||||
# with a live kernel" as a pass.
|
||||
if [ "${rc:-0}" -ne 0 ]; then
|
||||
exit "$rc"
|
||||
fi
|
||||
if [ "$TIMEOUT" -gt 0 ] 2>/dev/null && [ -f "$SERIAL_LOG" ]; then
|
||||
if grep -qE "Welcome to Debian|Reached target|systemd\[1\]:" "$SERIAL_LOG"; then
|
||||
echo " Boot sanity: OK (systemd reached in serial log)"
|
||||
if grep -qE 'Welcome to Debian|Reached target|systemd\[1\]:|Debian GNU/Linux [0-9]+ archipelago-installer ttyS0' "$SERIAL_LOG"; then
|
||||
echo " Boot sanity: OK (userspace reached in serial log; installation not yet tested)"
|
||||
exit 0
|
||||
fi
|
||||
echo " Boot sanity: FAIL — no systemd markers in serial log within ${TIMEOUT}s"
|
||||
echo " Boot sanity: FAIL — no userspace markers in serial log within ${TIMEOUT}s"
|
||||
exit 1
|
||||
fi
|
||||
exit "${rc:-0}"
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,42 @@
|
||||
# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||
# Use the original socket peer, before any real_ip / forwarded-header rewrite.
|
||||
geo $realip_remote_addr $archy_management_private_source {
|
||||
default 0;
|
||||
127.0.0.0/8 1;
|
||||
169.254.0.0/16 1;
|
||||
10.0.0.0/8 1;
|
||||
172.16.0.0/12 1;
|
||||
192.168.0.0/16 1;
|
||||
100.64.0.0/10 1;
|
||||
::1/128 1;
|
||||
fc00::/7 1;
|
||||
fe80::/10 1;
|
||||
}
|
||||
# A configured trusted proxy may have rewritten remote_addr. Require both
|
||||
# the original peer and the validated effective client to be private.
|
||||
geo $remote_addr $archy_management_private_client {
|
||||
default 0;
|
||||
127.0.0.0/8 1;
|
||||
169.254.0.0/16 1;
|
||||
10.0.0.0/8 1;
|
||||
172.16.0.0/12 1;
|
||||
192.168.0.0/16 1;
|
||||
100.64.0.0/10 1;
|
||||
::1/128 1;
|
||||
fc00::/7 1;
|
||||
fe80::/10 1;
|
||||
}
|
||||
map $http_x_archipelago_public_ingress $archy_management_public_ingress {
|
||||
default 1;
|
||||
'' 0;
|
||||
}
|
||||
map "$archy_management_private_source:$archy_management_private_client:$archy_management_public_ingress:$uri" $archy_management_denied {
|
||||
default 1;
|
||||
~^1:1:0: 0;
|
||||
"~^[01]:[01]:[01]:/\.well-known/acme-challenge/[A-Za-z0-9_-]+$" 0;
|
||||
}
|
||||
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||
|
||||
# Rate limit zones
|
||||
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
|
||||
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
|
||||
@@ -8,6 +47,8 @@ resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
server {
|
||||
if ($archy_management_denied) { return 404; }
|
||||
|
||||
listen 80 default_server;
|
||||
# IPv6 listener is REQUIRED: companion phones reach this node over the
|
||||
# FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that
|
||||
@@ -48,7 +89,7 @@ server {
|
||||
# Serve Nginx Proxy Manager HTTP-01 challenge files before the SPA fallback.
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type text/plain;
|
||||
root /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge;
|
||||
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -1021,6 +1062,8 @@ server {
|
||||
|
||||
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
|
||||
server {
|
||||
if ($archy_management_denied) { return 404; }
|
||||
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
server_name _;
|
||||
@@ -1035,6 +1078,12 @@ server {
|
||||
include snippets/archipelago-pwa.conf;
|
||||
|
||||
# Same CA download over HTTPS — see the note in the HTTP block above.
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type text/plain;
|
||||
root /var/lib/archipelago/nginx-proxy-manager/letsencrypt-acme-challenge;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /ca.crt {
|
||||
alias /etc/archipelago/ssl/ca-download.crt;
|
||||
default_type application/x-x509-ca-cert;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.22-alpha",
|
||||
"version": "1.9.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.8.22-alpha",
|
||||
"version": "1.9.0-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.8.22-alpha",
|
||||
"version": "1.9.0-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
@@ -10,7 +10,7 @@
|
||||
"test:watch": "vitest",
|
||||
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
|
||||
"dev": "vite",
|
||||
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
|
||||
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI \u2014 chat will show placeholder'\"",
|
||||
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
|
||||
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
|
||||
"backend:mock": "node mock-backend.js",
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export function useTxExplorer() { return { openTx() {} } }
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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 }
|
||||
} }
|
||||
@@ -436,13 +436,13 @@
|
||||
{
|
||||
"id": "nginx-proxy-manager",
|
||||
"title": "Nginx Proxy Manager",
|
||||
"version": "2.12.1",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
|
||||
"version": "2.14.0",
|
||||
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. The node's public web server forwards configured domains through this service, preserving its access lists, certificates and custom routes.",
|
||||
"icon": "/assets/img/app-icons/nginx.svg",
|
||||
"author": "Nginx Proxy Manager",
|
||||
"category": "networking",
|
||||
"tier": "optional",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager@sha256:8b91afcca90f5f2a7b2b8937999824f623c8a8748ae8013a1c9bf94f62177f08",
|
||||
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
|
||||
},
|
||||
{
|
||||
@@ -648,9 +648,9 @@
|
||||
{
|
||||
"id": "angor-indexer",
|
||||
"title": "Angor Indexer",
|
||||
"version": "1.0.1",
|
||||
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Bitcoin indexer endpoint for Angor with the existing Mempool explorer. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
|
||||
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.2",
|
||||
"author": "Angor / Archipelago",
|
||||
"requires": [
|
||||
"Mempool API",
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
<AppLauncherOverlay />
|
||||
<AppCredentialInterstitial />
|
||||
|
||||
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
|
||||
|
||||
<!-- Global toast notifications -->
|
||||
<ToastStack />
|
||||
|
||||
@@ -98,7 +96,7 @@
|
||||
</template>
|
||||
|
||||
<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 { useRouter, useRoute } from 'vue-router'
|
||||
import SplashScreen from './components/SplashScreen.vue'
|
||||
@@ -248,6 +246,7 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
useUploadNotifications()
|
||||
const isSignerBroker = computed(() => route.meta.signerBroker === true)
|
||||
// Start with splash hidden — onMounted decides whether to show it
|
||||
const showSplash = ref(false)
|
||||
|
||||
@@ -46,9 +46,45 @@ describe('FileBrowserClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
;(fileBrowserClient as any)._authenticated = false
|
||||
;(fileBrowserClient as any)._lastLoginFailure = 0
|
||||
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', () => {
|
||||
it('authenticates via backend RPC and stores token', async () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
|
||||
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 () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resumableUpload, type ResumableUploadOptions } from './resumable-upload'
|
||||
|
||||
export interface FileBrowserItem {
|
||||
name: string
|
||||
path: string
|
||||
@@ -35,8 +37,11 @@ export function sanitizePath(path: string): string {
|
||||
return '/' + resolved.join('/')
|
||||
}
|
||||
|
||||
const encodeFilePath = (path: string) => path.split('/').map(encodeURIComponent).join('/')
|
||||
|
||||
class FileBrowserClient {
|
||||
private _authenticated = false
|
||||
private _loginPromise: Promise<{ authenticated: boolean; retryable: boolean }> | null = null
|
||||
private baseUrl: string
|
||||
|
||||
constructor() {
|
||||
@@ -53,6 +58,20 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
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 {
|
||||
// Get a filebrowser JWT via the authenticated backend (no credentials exposed to browser)
|
||||
// Use credentials: 'include' and CSRF token for proper auth
|
||||
@@ -67,18 +86,21 @@ class FileBrowserClient {
|
||||
body: JSON.stringify({ method: 'app.filebrowser-token' }),
|
||||
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 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 secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
document.cookie = `auth=${token}; path=/; SameSite=Lax${secure}; expires=${expires}`
|
||||
this._authenticated = true
|
||||
return true
|
||||
this._lastLoginFailure = 0
|
||||
return { authenticated: true, retryable: false }
|
||||
} catch {
|
||||
return false
|
||||
return { authenticated: false, retryable: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +123,11 @@ class FileBrowserClient {
|
||||
if (Date.now() - this._lastLoginFailure < FileBrowserClient.LOGIN_RETRY_COOLDOWN_MS) {
|
||||
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
}
|
||||
const ok = await this.login()
|
||||
if (!ok) {
|
||||
const outcome = await this.authenticate()
|
||||
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()
|
||||
throw new Error('FileBrowser authentication failed — please open Cloud to log in')
|
||||
}
|
||||
@@ -125,7 +150,7 @@ class FileBrowserClient {
|
||||
|
||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||
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})`)
|
||||
// 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.
|
||||
@@ -148,7 +173,7 @@ class FileBrowserClient {
|
||||
*/
|
||||
downloadUrl(path: string): string {
|
||||
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> {
|
||||
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}`)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
@@ -183,7 +208,7 @@ class FileBrowserClient {
|
||||
async streamUrl(path: string): Promise<string> {
|
||||
await this.ensureAuth()
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
await this.ensureAuth()
|
||||
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
@@ -242,7 +271,7 @@ class FileBrowserClient {
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
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 },
|
||||
)
|
||||
if (!res.ok) {
|
||||
@@ -255,7 +284,7 @@ class FileBrowserClient {
|
||||
const sanitized = sanitizePath(parentPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
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',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Create folder failed: ${res.status}`)
|
||||
@@ -263,7 +292,7 @@ class FileBrowserClient {
|
||||
|
||||
async deleteItem(path: string): Promise<void> {
|
||||
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',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
|
||||
@@ -304,7 +333,7 @@ class FileBrowserClient {
|
||||
throw new Error(`Cannot read binary file: ${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}`)
|
||||
const blob = await res.blob()
|
||||
const size = blob.size
|
||||
@@ -318,7 +347,7 @@ class FileBrowserClient {
|
||||
const safePath = sanitizePath(oldPath)
|
||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,11 +432,15 @@ class RPCClient {
|
||||
total_amount_sats: 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({
|
||||
method: 'lnd.create-psbt',
|
||||
params: {
|
||||
outputs: params.outputs,
|
||||
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
|
||||
...(params.feeRateSatPerVbyte === undefined ? {} : { fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
amount: 100000,
|
||||
private: false,
|
||||
feePreset: 'standard' as FeePreset,
|
||||
feePreset: 'fast' as FeePreset,
|
||||
customConfTarget: null as number | null,
|
||||
customSatPerVbyte: null as number | null,
|
||||
})
|
||||
@@ -624,7 +624,7 @@ function feeParams(
|
||||
setError: (message: string) => void = message => { openError.value = message },
|
||||
): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (form.feePreset !== 'custom') {
|
||||
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
|
||||
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 1 }
|
||||
}
|
||||
const rate = form.customSatPerVbyte
|
||||
const conf = form.customConfTarget
|
||||
@@ -673,7 +673,7 @@ async function openChannel() {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
|
||||
const defaultCloseForm = () => ({ feePreset: 'fast' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
|
||||
const closeForm = ref(defaultCloseForm())
|
||||
|
||||
function confirmClose(ch: Channel) {
|
||||
|
||||
@@ -337,7 +337,7 @@ watch(() => props.show, (shown) => {
|
||||
successInfo.value = null
|
||||
sendAll.value = false
|
||||
onchainBalance.value = null
|
||||
feePreset.value = 'standard'
|
||||
feePreset.value = 'fast'
|
||||
customConfTarget.value = null
|
||||
customSatPerVbyte.value = null
|
||||
resolvedFeeParams.value = {}
|
||||
@@ -359,7 +359,7 @@ const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string;
|
||||
{ key: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
const feePreset = ref<OnchainFeePreset>('standard')
|
||||
const feePreset = ref<OnchainFeePreset>('fast')
|
||||
const customConfTarget = ref<number | null>(null)
|
||||
const customSatPerVbyte = ref<number | null>(null)
|
||||
// Resolved at review time so confirm + send use the same params.
|
||||
@@ -367,16 +367,16 @@ const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>(
|
||||
|
||||
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
|
||||
if (feePreset.value !== 'custom') {
|
||||
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
|
||||
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 1 }
|
||||
}
|
||||
const rate = customSatPerVbyte.value
|
||||
const conf = customConfTarget.value
|
||||
if (rate != null && rate !== 0) {
|
||||
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
|
||||
if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be a whole number between 1 and 5000'; return null }
|
||||
return { sat_per_vbyte: Math.floor(rate) }
|
||||
}
|
||||
if (conf != null && conf !== 0) {
|
||||
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
|
||||
if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { error.value = 'Target blocks must be a whole number between 1 and 1008'; return null }
|
||||
return { target_conf: Math.floor(conf) }
|
||||
}
|
||||
error.value = 'Custom fee requires target blocks or sats per vByte'
|
||||
@@ -409,7 +409,7 @@ async function loadFeeEstimate() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
|
||||
method: 'lnd.estimatefee',
|
||||
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
|
||||
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 1 },
|
||||
timeout: 10000,
|
||||
})
|
||||
if (res.fee_sat > 0) feeEstimate.value = res
|
||||
@@ -569,6 +569,11 @@ function sendAnother() {
|
||||
dest.value = ''
|
||||
amount.value = 0
|
||||
sendAll.value = false
|
||||
feePreset.value = 'fast'
|
||||
customConfTarget.value = null
|
||||
customSatPerVbyte.value = null
|
||||
resolvedFeeParams.value = {}
|
||||
feeEstimate.value = null
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
class="text-sm font-medium whitespace-nowrap"
|
||||
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
||||
>
|
||||
{{ 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="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
||||
</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 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">
|
||||
<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>
|
||||
@@ -103,12 +115,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
<BumpFeeModal :show="show && !!bumpTxid" :txid="bumpTxid" @close="bumpTxid = ''" @updated="emit('updated')" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import BumpFeeModal from '@/components/BumpFeeModal.vue'
|
||||
import { useTxExplorer } from '@/composables/useTxExplorer'
|
||||
|
||||
interface WalletTransaction {
|
||||
@@ -118,6 +132,10 @@ interface WalletTransaction {
|
||||
num_confirmations: number
|
||||
time_stamp: 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[]
|
||||
label: string
|
||||
block_height: number
|
||||
@@ -130,7 +148,8 @@ const props = defineProps<{
|
||||
transactions: WalletTransaction[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const emit = defineEmits<{ close: []; updated: [] }>()
|
||||
const bumpTxid = ref('')
|
||||
const { t } = useI18n()
|
||||
|
||||
type FilterKey = 'all' | 'onchain' | 'lightning' | 'ecash' | 'ark'
|
||||
@@ -160,6 +179,7 @@ function countFor(f: FilterKey): number {
|
||||
}
|
||||
|
||||
function close() {
|
||||
bumpTxid.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -169,14 +189,14 @@ function isOnchain(tx: WalletTransaction): boolean {
|
||||
}
|
||||
|
||||
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
|
||||
* itself is broken out on its own tag. Incoming rows are untouched. */
|
||||
function displayAmount(tx: WalletTransaction): number {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) })
|
||||
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) => {
|
||||
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
|
||||
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>
|
||||
<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 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}`" />
|
||||
@@ -13,8 +13,9 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useCloudStore } from '@/stores/cloud'
|
||||
defineProps<{ floating?: boolean }>()
|
||||
const route = useRoute()
|
||||
const store = useCloudStore()
|
||||
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)
|
||||
@@ -23,7 +24,8 @@ const label = computed(() => {
|
||||
if (!t) return ''
|
||||
if (t.error) return t.error
|
||||
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}` : ''}`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -149,9 +149,8 @@
|
||||
<option value="heltec-v3">Heltec LoRa 32 V3</option>
|
||||
<option value="heltec-v4">Heltec LoRa 32 V4</option>
|
||||
</select>
|
||||
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
|
||||
Couldn't confirm the board automatically — double check before flashing.
|
||||
Flashing the wrong board's image can brick it.
|
||||
<p class="text-[11px] text-amber-400/80 mt-1">
|
||||
Select the model printed on your board. USB adapters are shared across models.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +299,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
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)
|
||||
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(() => {
|
||||
switch (flashJob.value?.stage) {
|
||||
case 'downloading': return 'Downloading firmware…'
|
||||
case 'preparing': return 'Preparing radio…'
|
||||
case 'erasing': return 'Erasing chip…'
|
||||
case 'writing': return 'Writing firmware…'
|
||||
case 'autoinstalling': return 'Installing (rnodeconf)…'
|
||||
@@ -572,7 +551,7 @@ const flashStageLabel = computed(() => {
|
||||
|
||||
function openFlashStep() {
|
||||
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
|
||||
flashBoard.value = resolvedFlashBoard.value
|
||||
flashBoard.value = ''
|
||||
flashConfirmed.value = false
|
||||
flashJob.value = null
|
||||
error.value = ''
|
||||
@@ -635,6 +614,11 @@ async function cancelFlash() {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFlashPoll()
|
||||
stopProbeProgress()
|
||||
})
|
||||
|
||||
function closeFlashStep() {
|
||||
stopFlashPoll()
|
||||
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' })
|
||||
}
|
||||
@@ -8,23 +8,23 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
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
|
||||
function cancelUpload() { uploadController?.abort() }
|
||||
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
|
||||
const destination = currentPath.value
|
||||
const controller = new AbortController()
|
||||
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
|
||||
let completedBytes = 0
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
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
|
||||
} })
|
||||
completedBytes += file.size
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface MeshDeviceProbe {
|
||||
|
||||
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
|
||||
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. */
|
||||
export interface FlashJobStatus {
|
||||
|
||||
@@ -120,7 +120,9 @@ export interface MeshcoreRfPreset {
|
||||
* and operator-attested deployment plans.
|
||||
*/
|
||||
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: '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 },
|
||||
|
||||
@@ -366,7 +366,7 @@ function onDrop(e: DragEvent) {
|
||||
}
|
||||
|
||||
async function handleUpload(files: File[]) {
|
||||
await cloudStore.uploadFiles(files)
|
||||
await cloudStore.uploadFiles(files, route.fullPath)
|
||||
}
|
||||
|
||||
async function handleDelete(path: string) {
|
||||
|
||||
@@ -331,6 +331,7 @@ let discoverAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeStoreApps } from './apps/serviceNames'
|
||||
import AppSearchField from '@/components/AppSearchField.vue'
|
||||
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
@@ -407,7 +408,7 @@ const catalogStorefront = useCachedResource<CatalogStorefront | null>({
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
}).data
|
||||
const communityApps = computed(() => catalogResource.data.value ?? [])
|
||||
const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
|
||||
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
|
||||
// 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.
|
||||
@@ -535,9 +536,9 @@ const allApps = computed(() => {
|
||||
const nostrMerged = nostrApps.value
|
||||
.filter(app => !existingIds.has(app.id))
|
||||
.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(() => {
|
||||
|
||||
@@ -288,7 +288,7 @@
|
||||
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
|
||||
<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" />
|
||||
<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()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,12 +50,10 @@
|
||||
{{ section.name }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
<AppSearchField
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-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"
|
||||
:label="t('marketplace.searchApps')"
|
||||
/>
|
||||
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
|
||||
</div>
|
||||
@@ -82,12 +80,10 @@
|
||||
type="button"
|
||||
>{{ section.name }}</button>
|
||||
</div>
|
||||
<input
|
||||
<AppSearchField
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('marketplace.searchPlaceholder')"
|
||||
:aria-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"
|
||||
:label="t('marketplace.searchApps')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,6 +164,7 @@ let marketplaceAnimationDone = false
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeStoreApps } from './apps/serviceNames'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRouter, useRoute, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -181,6 +178,7 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||
import AppSearchField from '@/components/AppSearchField.vue'
|
||||
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||
@@ -275,7 +273,7 @@ const catalogResource = useCachedResource<MarketplaceApp[]>({
|
||||
ttlMs: 300_000,
|
||||
persist: true,
|
||||
})
|
||||
const communityApps = computed(() => catalogResource.data.value ?? [])
|
||||
const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
|
||||
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
|
||||
// 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.
|
||||
@@ -383,10 +381,10 @@ const allApps = computed(() => {
|
||||
const category = app.category || categorizeCommunityApp(app)
|
||||
return { ...app, category, source: 'nostr' }
|
||||
})
|
||||
return [...base, ...nostrMerged]
|
||||
return normalizeStoreApps([...base, ...nostrMerged])
|
||||
}
|
||||
|
||||
return base
|
||||
return normalizeStoreApps(base)
|
||||
})
|
||||
|
||||
const filteredApps = computed(() => {
|
||||
|
||||
@@ -89,11 +89,25 @@ describe('appsConfig service filtering', () => {
|
||||
const entries: Array<[string, PackageDataEntry]> = [
|
||||
['cuprate', makePkg('cuprate', 'Cuprate', '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, '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', () => {
|
||||
const pkg = makePkg('gitea', 'Gitea', 'dev')
|
||||
pkg['static-files']!.icon = 'git-branch'
|
||||
|
||||
@@ -35,7 +35,7 @@ const INTERNAL_TOOLING_NAMES = new Set([
|
||||
// Cuprate's dashboard is bundled as a companion of the primary cuprate
|
||||
// package; showing the generated container as a second Services entry
|
||||
// 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 {
|
||||
@@ -54,7 +54,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
|
||||
// Known app -> category mappings (matches App Store categorisation)
|
||||
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',
|
||||
'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',
|
||||
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
|
||||
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
|
||||
@@ -115,11 +115,13 @@ export function filterEntriesForTab(
|
||||
selectedCategory: string,
|
||||
): Array<[string, PackageDataEntry]> {
|
||||
const hasMempool = entries.some(([id]) => id === 'mempool')
|
||||
const hasBtcpay = entries.some(([id]) => id === 'btcpay-server')
|
||||
return entries.filter(([id, pkg]) => {
|
||||
// Older daemons can retain the frontend manifest alias during a restart.
|
||||
// Keep one tile while the updated scanner converges; a legacy-only node
|
||||
// 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 (hasBtcpay && ['btcpay', 'btcpayserver'].includes(id)) return false
|
||||
if (isInternalToolingPackage(id, pkg)) return false
|
||||
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
|
||||
const isWebsite = isWebsitePackage(id, pkg)
|
||||
|
||||
@@ -29,6 +29,7 @@ export const SERVICE_NAMES = new Set([
|
||||
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
|
||||
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
|
||||
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
|
||||
'cuprate-ui', 'netbird-dashboard', 'netbird-server',
|
||||
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
|
||||
'indeedhub-api', 'indeedhub-ffmpeg',
|
||||
'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 {
|
||||
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' }])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MarketplaceApp } from './types'
|
||||
import { isStoreListedApp } from '../apps/serviceNames'
|
||||
import { isStoreListedApp, normalizeStoreApps } from '../apps/serviceNames'
|
||||
|
||||
const R = 'source.archipelago-foundation.org/lfg2025'
|
||||
|
||||
@@ -98,7 +98,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
|
||||
source: 'signed-catalog',
|
||||
})
|
||||
}
|
||||
return out
|
||||
return normalizeStoreApps(out)
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
// 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
|
||||
// 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.
|
||||
const byId = new Map<string, MarketplaceApp>()
|
||||
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)
|
||||
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 = {
|
||||
version: community?.version ?? 1,
|
||||
registry: community?.registry ?? R,
|
||||
featured: signedFeatured ?? community?.featured,
|
||||
storefront: signedStorefront ?? community?.storefront,
|
||||
apps: [...byId.values()],
|
||||
apps: normalizeStoreApps([...byId.values()]),
|
||||
}
|
||||
cachedCatalog = merged
|
||||
catalogFetchedAt = Date.now()
|
||||
@@ -261,6 +261,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
|
||||
const stored = localStorage.getItem('archy_catalog')
|
||||
if (stored) {
|
||||
cachedCatalog = JSON.parse(stored) as AppCatalog
|
||||
cachedCatalog.apps = normalizeStoreApps(cachedCatalog.apps)
|
||||
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
|
||||
return cachedCatalog
|
||||
}
|
||||
@@ -314,6 +315,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
|
||||
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
btcpay: ['btcpay-server'],
|
||||
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
|
||||
immich: ['immich-server', 'immich-app', 'immich_server'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
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'],
|
||||
bitcoin: ['bitcoin-knots'],
|
||||
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'],
|
||||
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
|
||||
fedimint: ['fedimint-gateway'],
|
||||
|
||||
@@ -362,16 +362,27 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<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 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 text-white/40">October 1, 2026</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 5, 2026</span>
|
||||
</div>
|
||||
<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>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>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>
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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()
|
||||
Executable
+11
@@ -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
|
||||
@@ -63,3 +63,6 @@ if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
|
||||
else
|
||||
echo "WARNING: rnodeconf.py not found at $RNODECONF_SRC (RNS version mismatch?) — skipping archy-rnodeconf build" >&2
|
||||
fi
|
||||
|
||||
# Bundle the ESP32 flasher and its stub; OTA nodes must not depend on apt/pip.
|
||||
bash build-esptool.sh
|
||||
|
||||
@@ -162,22 +162,36 @@ ISO="$(find_iso)"
|
||||
# ── Stage 4: mount-level smoke test ──────────────────────────────────
|
||||
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
|
||||
# sanity grep can miss a perfectly healthy boot. Run it, report it,
|
||||
# but don't fail an otherwise-green build on it.
|
||||
# sanity grep can miss a healthy boot. That requires separate evidence;
|
||||
# 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
|
||||
echo
|
||||
echo "═══ [qemu-boot] (best-effort) test-iso-qemu.sh $ISO 180"
|
||||
if bash image-recipe/_archived/test-iso-qemu.sh "$ISO" 180; then
|
||||
echo "═══ [qemu-boot] test-iso-qemu.sh $ISO 180"
|
||||
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"
|
||||
PASS+=("qemu-boot")
|
||||
else
|
||||
echo "═══ [qemu-boot] INCONCLUSIVE (not gating — verify on real hardware)"
|
||||
PASS+=("qemu-boot(inconclusive)")
|
||||
echo "═══ [qemu-boot] NOT VERIFIED — inspect boot evidence before publication"
|
||||
FAIL+=("qemu-boot")
|
||||
summary 1
|
||||
fi
|
||||
else
|
||||
elif [ "$NO_QEMU" = "1" ]; then
|
||||
echo; echo "═══ [qemu-boot] SKIPPED"
|
||||
else
|
||||
echo "═══ [qemu-boot] NOT VERIFIED — qemu-system-x86_64 is missing"
|
||||
FAIL+=("qemu-boot")
|
||||
summary 1
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -83,7 +83,7 @@ def load_catalog(path: Path) -> dict[str, dict[str, Any]]:
|
||||
manifest = entry.get("manifest")
|
||||
for variant in reversed(entry.get("manifest_variants", [])):
|
||||
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")
|
||||
break
|
||||
if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict):
|
||||
|
||||
@@ -558,7 +558,6 @@ fix_npm_public_hosts() {
|
||||
local script="/opt/archipelago/scripts/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || script="$SCRIPT_DIR/sync-npm-public-hosts.sh"
|
||||
[ -x "$script" ] || return 1
|
||||
[ -f /var/lib/archipelago/nginx-proxy-manager/data/database.sqlite ] || return 1
|
||||
|
||||
if "$script" >/dev/null 2>&1; then
|
||||
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
Reference in New Issue
Block a user