Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc962c53b0 | ||
|
|
6ac26f637c | ||
|
|
27d81e956d | ||
|
|
eb39391223 | ||
|
|
d42f448e31 | ||
|
|
1566f1bb00 | ||
|
|
f12042f194 | ||
|
|
e7cf336665 | ||
|
|
8ca20de82e | ||
|
|
1fa654cb6a | ||
|
|
c993d9dd0d | ||
|
|
33d2b3ce60 | ||
|
|
c7ce35bd43 | ||
|
|
ad1d71a462 | ||
|
|
bded929812 | ||
|
|
3612458e86 | ||
|
|
8d9fad1749 | ||
|
|
d25ed492c9 | ||
|
|
1f9abefc35 | ||
|
|
b634f41a1c | ||
|
|
0f85f588fb | ||
|
|
540639d2c1 | ||
|
|
562871b1ce | ||
|
|
cca3f8bfcd | ||
|
|
89c08be712 | ||
|
|
b4ecf86c13 | ||
|
|
b14fe78306 | ||
|
|
3f0c1038c3 | ||
|
|
1fbefce6df | ||
|
|
63cb68451a | ||
|
|
17cfebbe26 | ||
|
|
379fb930fc | ||
|
|
1bebdeac0f | ||
|
|
f458591132 | ||
|
|
6155539254 | ||
|
|
76e0f1f3b6 | ||
|
|
ba6ce2cdb6 | ||
|
|
8212049f57 | ||
|
|
5814f47659 | ||
|
|
94f5e892c3 | ||
|
|
a3b6467047 | ||
|
|
66db6497ec | ||
|
|
81be17f09f | ||
|
|
4237fb5e79 | ||
|
|
4302138b4f |
@@ -0,0 +1,31 @@
|
|||||||
|
# Blocking incident: Framework LND startup and false zero balance
|
||||||
|
|
||||||
|
Before starting or resuming work in this repository, read
|
||||||
|
`docs/incident-framework-lnd-startup.md` and check its status.
|
||||||
|
|
||||||
|
The user explicitly required this incident to take priority over later work.
|
||||||
|
While its status is OPEN:
|
||||||
|
|
||||||
|
- Surface this blocker at session start, including when the user asks for unrelated work.
|
||||||
|
- Prioritize investigation and repair on the actual Framework node. If access is
|
||||||
|
unavailable, ask for it and continue useful offline investigation; defer unrelated
|
||||||
|
work unless the user explicitly overrides this priority.
|
||||||
|
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
|
||||||
|
does not resolve the incident. Do not mark it fixed until the Framework's startup,
|
||||||
|
Receive flow, and balance behavior are verified on the node, including a controlled
|
||||||
|
reboot with access and recovery arrangements in place.
|
||||||
|
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
|
||||||
|
Never run wallet wipe/recreation as an automatic investigation or recovery step.
|
||||||
|
- Record evidence, changes, validation, and remaining work in the incident document.
|
||||||
|
|
||||||
|
This priority comes from the user's explicit instruction on 2026-09-15. It remains
|
||||||
|
in effect across sessions until the documented acceptance criteria are met or the
|
||||||
|
user explicitly changes it.
|
||||||
|
|
||||||
|
## Unit tests on a live node
|
||||||
|
|
||||||
|
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
|
||||||
|
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
|
||||||
|
tests still reached real service commands. The runner isolates wallet data,
|
||||||
|
service buses, container storage, networking, and process IDs. Compilation with
|
||||||
|
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
|
||||||
@@ -2,6 +2,36 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## v1.8.21-alpha (2026-09-30)
|
||||||
|
|
||||||
|
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
|
||||||
|
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
|
||||||
|
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
|
||||||
|
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
|
||||||
|
|
||||||
|
## v1.8.20-alpha (2026-09-29)
|
||||||
|
|
||||||
|
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
|
||||||
|
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
|
||||||
|
- Improved saving paid files into Files and reopening purchases without paying again.
|
||||||
|
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
|
||||||
|
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
|
||||||
|
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
|
||||||
|
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
|
||||||
|
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
|
||||||
|
|
||||||
|
## v1.8.19-alpha (2026-09-28)
|
||||||
|
|
||||||
|
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
|
||||||
|
- Embedded AIUI now stays transparent so the dashboard background appears once.
|
||||||
|
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
|
||||||
|
|
||||||
|
## v1.8.18-alpha (2026-09-18)
|
||||||
|
|
||||||
|
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
|
||||||
|
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
|
||||||
|
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
|
||||||
|
|
||||||
## v1.8.17-alpha (2026-09-15)
|
## v1.8.17-alpha (2026-09-15)
|
||||||
|
|
||||||
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ interface RateBucket {
|
|||||||
|
|
||||||
const rateBuckets = new Map<string, RateBucket>()
|
const rateBuckets = new Map<string, RateBucket>()
|
||||||
|
|
||||||
// Clean up stale buckets every 5 minutes
|
// Vite imports this module during builds too; cleanup must not keep the
|
||||||
|
// process alive once compilation has finished.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [key, bucket] of rateBuckets) {
|
for (const [key, bucket] of rateBuckets) {
|
||||||
if (now > bucket.resetAt) rateBuckets.delete(key)
|
if (now > bucket.resetAt) rateBuckets.delete(key)
|
||||||
}
|
}
|
||||||
}, 5 * 60_000)
|
}, 5 * 60_000).unref()
|
||||||
|
|
||||||
function getClientIp(req: IncomingMessage): string {
|
function getClientIp(req: IncomingMessage): string {
|
||||||
return req.socket.remoteAddress ?? 'unknown'
|
return req.socket.remoteAddress ?? 'unknown'
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
|
|||||||
// Only embedded when explicitly requested via ?embedded param
|
// Only embedded when explicitly requested via ?embedded param
|
||||||
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
|
||||||
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
|
||||||
|
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div
|
<div
|
||||||
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
|
||||||
:class="[]"
|
:class="[]"
|
||||||
:style="isDark
|
:style="isEmbedded
|
||||||
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
? { background: 'transparent' }
|
||||||
: isEmbedded
|
: isDark
|
||||||
? { background: 'transparent' }
|
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
|
||||||
: { backgroundColor: '#f5f4f1' }"
|
: { backgroundColor: '#f5f4f1' }"
|
||||||
>
|
>
|
||||||
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
|
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
|
||||||
|
|
||||||
<!-- Desktop layout -->
|
<!-- Desktop layout -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -57,12 +57,8 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
|
/* Standalone canvas fallback. Embedded mode overrides this below so
|
||||||
EXCEPT the embedded Chat page, which intentionally goes transparent so
|
Archy's wallpaper remains visible through the iframe. */
|
||||||
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
|
|
||||||
no background-color here, "transparent" fell through to the browser's
|
|
||||||
default white canvas instead. Match the theme's own dark/light default so
|
|
||||||
nothing above this ever needs to guess. */
|
|
||||||
background-color: #0a0a0a;
|
background-color: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +66,19 @@ html.light body {
|
|||||||
background-color: #faf9f6;
|
background-color: #faf9f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The host owns the wallpaper when AIUI is embedded. The document canvas
|
||||||
|
must be transparent too, otherwise it hides the host behind ChatPage. */
|
||||||
|
html.aiui-embedded {
|
||||||
|
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
|
||||||
|
with a different scheme an opaque canvas despite transparent CSS. */
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.aiui-embedded,
|
||||||
|
html.aiui-embedded body {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ app:
|
|||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ app:
|
|||||||
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
if [ -n "$RPC_TXRELAY_AUTH" ]; then
|
||||||
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
|
||||||
fi;
|
fi;
|
||||||
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
else
|
else
|
||||||
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
|
||||||
|
|||||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.17-alpha"
|
version = "1.8.21-alpha"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"archipelago-container",
|
"archipelago-container",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "archipelago"
|
name = "archipelago"
|
||||||
version = "1.8.17-alpha"
|
version = "1.8.21-alpha"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||||
|
|||||||
@@ -138,6 +138,19 @@ impl ApiHandler {
|
|||||||
cors_origin: &str,
|
cors_origin: &str,
|
||||||
) -> Result<Response<hyper::Body>> {
|
) -> Result<Response<hyper::Body>> {
|
||||||
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
|
||||||
|
if suffix == "/archy-status" {
|
||||||
|
return Ok(Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Cache-Control", "no-store")
|
||||||
|
.header("Access-Control-Allow-Origin", cors_origin)
|
||||||
|
.header("Access-Control-Allow-Credentials", "true")
|
||||||
|
.header("Vary", "Origin")
|
||||||
|
.body(hyper::Body::from(
|
||||||
|
rpc.handle_lnd_readiness().await.to_string(),
|
||||||
|
))?);
|
||||||
|
}
|
||||||
|
|
||||||
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
let url = format!("{LND_REST_BASE_URL}{suffix}");
|
||||||
// LND REST serves a self-signed cert and requires the admin macaroon.
|
// LND REST serves a self-signed cert and requires the admin macaroon.
|
||||||
// A bare reqwest::get() uses the default client, which rejects the
|
// A bare reqwest::get() uses the default client, which rejects the
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
|
|||||||
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||||
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||||
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||||
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
|
||||||
/// the seller already claimed the token (then the value is genuinely gone).
|
/// recovered amount, or explicitly say when a refund could not be confirmed.
|
||||||
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
|
||||||
let res = match backend {
|
let res = match backend {
|
||||||
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
.await
|
.await
|
||||||
@@ -32,16 +32,101 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
|
|||||||
_ => ecash::receive_token(data_dir, token).await,
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
};
|
};
|
||||||
match res {
|
match res {
|
||||||
Ok(sats) => tracing::info!(
|
Ok(sats) => {
|
||||||
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
|
||||||
),
|
format!("Refunded {sats} sats to your wallet.")
|
||||||
Err(e) => tracing::warn!(
|
}
|
||||||
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
Err(e) => {
|
||||||
claimed it): {e:#}"
|
tracing::warn!("paid download: refund not confirmed: {e}");
|
||||||
),
|
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep first purchases and cached repeats compatible with both existing clients.
|
||||||
|
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
|
||||||
|
use base64::Engine;
|
||||||
|
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||||
|
serde_json::json!({
|
||||||
|
"data": data, "data_base64": data,
|
||||||
|
"size": bytes.len(), "size_bytes": bytes.len(),
|
||||||
|
"mime_type": mime, "paid_sats": paid_sats, "owned": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated
|
||||||
|
/// API rather than writing host paths with the backend's unrelated UID. Its
|
||||||
|
/// override=false upload atomically refuses existing names, including races.
|
||||||
|
async fn file_purchase_in_files(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
|
token: &str,
|
||||||
|
filename: &str,
|
||||||
|
mime: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<String> {
|
||||||
|
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
|
||||||
|
"Photos"
|
||||||
|
} else if mime.starts_with("audio/") {
|
||||||
|
"Music"
|
||||||
|
} else {
|
||||||
|
"Documents"
|
||||||
|
};
|
||||||
|
let mut folder_url = reqwest::Url::parse(base_url)?;
|
||||||
|
folder_url
|
||||||
|
.path_segments_mut()
|
||||||
|
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))?
|
||||||
|
.extend(["api", "resources", folder, ""]);
|
||||||
|
let response = client
|
||||||
|
.get(folder_url.clone())
|
||||||
|
.header("X-Auth", token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
let response = client
|
||||||
|
.post(folder_url.clone())
|
||||||
|
.header("X-Auth", token)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status() != reqwest::StatusCode::CONFLICT {
|
||||||
|
response.error_for_status()?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response.error_for_status()?;
|
||||||
|
}
|
||||||
|
let base = std::path::Path::new(filename)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.filter(|n| !n.is_empty())
|
||||||
|
.unwrap_or("download");
|
||||||
|
let (stem, extension) = match base.rsplit_once('.') {
|
||||||
|
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
||||||
|
_ => (base, String::new()),
|
||||||
|
};
|
||||||
|
for attempt in 1..=100 {
|
||||||
|
let name = if attempt == 1 {
|
||||||
|
base.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{stem} ({attempt}){extension}")
|
||||||
|
};
|
||||||
|
let mut url = folder_url.clone();
|
||||||
|
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
|
||||||
|
url.query_pairs_mut().append_pair("override", "false");
|
||||||
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.header("X-Auth", token)
|
||||||
|
.body(bytes.to_vec())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status() == reqwest::StatusCode::CONFLICT {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
response.error_for_status()?;
|
||||||
|
return Ok(format!("{folder}/{name}"));
|
||||||
|
}
|
||||||
|
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// List content I'm sharing.
|
/// List content I'm sharing.
|
||||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||||
@@ -463,17 +548,10 @@ impl RpcHandler {
|
|||||||
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
use base64::Engine;
|
let mut result = paid_content_response(&bytes, &mime, 0);
|
||||||
return Ok(serde_json::json!({
|
result["already_owned"] = serde_json::json!(true);
|
||||||
"owned": true,
|
result["filename"] = serde_json::json!(o.filename);
|
||||||
"already_owned": true,
|
return Ok(result);
|
||||||
"filename": o.filename,
|
|
||||||
"mime_type": mime,
|
|
||||||
"size_bytes": bytes.len(),
|
|
||||||
"paid_sats": 0,
|
|
||||||
"data_base64":
|
|
||||||
base64::engine::general_purpose::STANDARD.encode(&bytes),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
// Cache record exists but bytes are gone — fall through and
|
// Cache record exists but bytes are gone — fall through and
|
||||||
// repurchase rather than stranding the user.
|
// repurchase rather than stranding the user.
|
||||||
@@ -547,29 +625,27 @@ impl RpcHandler {
|
|||||||
// Surface a real reason instead of the generic sanitized error (#30):
|
// Surface a real reason instead of the generic sanitized error (#30):
|
||||||
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
|
||||||
// here means the peer is genuinely unreachable on both transports.
|
// here means the peer is genuinely unreachable on both transports.
|
||||||
let (response, transport) = match crate::fips::dial::PeerRequest::new(
|
let (response, transport) =
|
||||||
fips_npub.as_deref(),
|
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
|
||||||
onion,
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
&path,
|
.header("X-Federation-DID", local_did)
|
||||||
)
|
.header("X-Payment-Token", token_str.clone())
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
.header("X-Federation-DID", local_did)
|
.send_get()
|
||||||
.header("X-Payment-Token", token_str.clone())
|
.await
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
{
|
||||||
.send_get()
|
Ok(v) => v,
|
||||||
.await
|
Err(e) => {
|
||||||
{
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
Ok(v) => v,
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
Err(e) => {
|
// doesn't lose the value when the seller was simply unreachable.
|
||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
let refund =
|
||||||
// The token was already minted/spent — reclaim it so the buyer
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
// doesn't lose the value when the seller was simply unreachable.
|
return Ok(serde_json::json!({
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
|
||||||
return Ok(serde_json::json!({
|
}));
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
}
|
||||||
}));
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
// Record which transport actually reached the peer (B14).
|
// Record which transport actually reached the peer (B14).
|
||||||
if let Err(e) = crate::federation::record_peer_transport(
|
if let Err(e) = crate::federation::record_peer_transport(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
@@ -583,25 +659,17 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
// Payment was rejected by the seller. Surface the most likely cause
|
// A 402 can mean mint validation, network failure, underpayment,
|
||||||
// per backend — for ecash both sides must share a redemption network
|
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
|
||||||
// (a Cashu mint, or a Fedimint federation).
|
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||||
);
|
);
|
||||||
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
// their funds (the spent-but-unredeemed-notes case the user hit).
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
let hint = match used_backend {
|
|
||||||
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
|
||||||
_ => "the seller doesn't accept your Cashu mint",
|
|
||||||
};
|
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!(
|
"error": format!("The seller could not verify the payment. {refund}")
|
||||||
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
|
||||||
your wallet. Try the other ecash type, or use a shared mint/federation."
|
|
||||||
)
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,9 +677,9 @@ impl RpcHandler {
|
|||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||||
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
"error": format!("Peer returned an error ({status}). {refund}")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,63 +726,41 @@ impl RpcHandler {
|
|||||||
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-file the purchase into the user's Files area (2026-07-22):
|
// The durable purchased-content cache above is primary. A Files copy
|
||||||
// Photos for images/video, Music for audio, Documents otherwise —
|
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||||
// same buckets the Cloud view uses. The in-app viewer still plays
|
let filed = async {
|
||||||
// from the purchase cache; this makes the file ALSO show up where
|
let auth = self.handle_filebrowser_token().await?;
|
||||||
// files live, on every device, without relying on a browser
|
let token = auth
|
||||||
// download. Best-effort: never fail a paid download over it.
|
.get("token")
|
||||||
{
|
.and_then(|v| v.as_str())
|
||||||
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
|
.context("FileBrowser omitted its authentication token")?;
|
||||||
"Photos"
|
let client = reqwest::Client::builder()
|
||||||
} else if mime_type.starts_with("audio/") {
|
.no_proxy()
|
||||||
"Music"
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
} else {
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
"Documents"
|
.build()?;
|
||||||
};
|
file_purchase_in_files(
|
||||||
let base = std::path::Path::new(&filename)
|
&client,
|
||||||
.file_name()
|
"http://127.0.0.1:8083",
|
||||||
.and_then(|n| n.to_str())
|
token,
|
||||||
.unwrap_or("download")
|
&filename,
|
||||||
.to_string();
|
&mime_type,
|
||||||
let dir = self.config.data_dir.join("filebrowser").join(folder);
|
&bytes,
|
||||||
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
|
)
|
||||||
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
|
.await
|
||||||
} else {
|
}
|
||||||
// Don't clobber an existing file of the same name: "x.jpg"
|
.await;
|
||||||
// → "x (2).jpg" etc.
|
match filed {
|
||||||
let mut target = dir.join(&base);
|
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||||
let (stem, ext) = match base.rsplit_once('.') {
|
Err(error) => tracing::warn!(
|
||||||
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
|
"paid download: optional Files copy failed; purchase cache retained: {error}"
|
||||||
_ => (base.clone(), String::new()),
|
),
|
||||||
};
|
|
||||||
let mut n = 2;
|
|
||||||
while target.exists() {
|
|
||||||
target = dir.join(format!("{stem} ({n}){ext}"));
|
|
||||||
n += 1;
|
|
||||||
}
|
|
||||||
match tokio::fs::write(&target, &bytes).await {
|
|
||||||
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
|
|
||||||
Err(e) => tracing::warn!(
|
|
||||||
"paid download: filing into {} failed (non-fatal): {e}",
|
|
||||||
target.display()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
use base64::Engine;
|
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
|
||||||
|
|
||||||
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||||
Ok(serde_json::json!({
|
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
|
||||||
"data": encoded,
|
result["ecash_backend"] = serde_json::json!(used_backend);
|
||||||
"size": bytes.len(),
|
Ok(result)
|
||||||
"paid_sats": price_sats,
|
|
||||||
"ecash_backend": used_backend,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"owned": true,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
|
||||||
@@ -1387,3 +1433,7 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "content_tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
use super::*;
|
||||||
|
use hyper::{
|
||||||
|
service::{make_service_fn, service_fn},
|
||||||
|
Body, Response, Server,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
convert::Infallible,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct FilesApi {
|
||||||
|
url: String,
|
||||||
|
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||||
|
task: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
impl Drop for FilesApi {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn files_api(statuses: Vec<u16>) -> FilesApi {
|
||||||
|
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
|
||||||
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let history = seen.clone();
|
||||||
|
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||||
|
let address = server.local_addr();
|
||||||
|
let service = make_service_fn(move |_| {
|
||||||
|
let statuses = statuses.clone();
|
||||||
|
let seen = history.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||||
|
let statuses = statuses.clone();
|
||||||
|
let seen = seen.clone();
|
||||||
|
async move {
|
||||||
|
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
|
||||||
|
let method = request.method().to_string();
|
||||||
|
let uri = request.uri().to_string();
|
||||||
|
let body = hyper::body::to_bytes(request.into_body())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.to_vec();
|
||||||
|
seen.lock().unwrap().push((method, uri, body));
|
||||||
|
let status = statuses
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.pop_front()
|
||||||
|
.expect("unexpected extra Files request");
|
||||||
|
Ok::<_, Infallible>(
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
FilesApi {
|
||||||
|
url: format!("http://{address}"),
|
||||||
|
seen,
|
||||||
|
task: tokio::spawn(async move {
|
||||||
|
server.serve(service).await.unwrap();
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||||
|
use base64::Engine;
|
||||||
|
for paid in [0, 1] {
|
||||||
|
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
|
||||||
|
assert_eq!(response["data"], response["data_base64"]);
|
||||||
|
assert_eq!(
|
||||||
|
base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(response["data"].as_str().unwrap())
|
||||||
|
.unwrap(),
|
||||||
|
[0, 255, 123]
|
||||||
|
);
|
||||||
|
assert_eq!(response["size"], 3);
|
||||||
|
assert_eq!(response["size_bytes"], 3);
|
||||||
|
assert_eq!(response["paid_sats"], paid);
|
||||||
|
assert_eq!(response["owned"], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() {
|
||||||
|
let api = files_api(vec![200, 409, 200]);
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let path = file_purchase_in_files(
|
||||||
|
&client,
|
||||||
|
&api.url,
|
||||||
|
"test-session",
|
||||||
|
"../my #file?.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"paid bytes",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, "Documents/my #file? (2).txt");
|
||||||
|
let seen = api.seen.lock().unwrap();
|
||||||
|
assert_eq!(seen[0].0, "GET");
|
||||||
|
assert_eq!(seen[0].1, "/api/resources/Documents/");
|
||||||
|
assert_eq!(seen.len(), 3);
|
||||||
|
for (_, uri, body) in &seen[1..] {
|
||||||
|
assert!(uri.contains("override=false"));
|
||||||
|
assert!(uri.contains("%23file%3F"));
|
||||||
|
assert!(!uri.contains("../"));
|
||||||
|
assert_eq!(body, b"paid bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_copy_creates_missing_media_folder() {
|
||||||
|
for (mime, folder) in [
|
||||||
|
("image/png", "Photos"),
|
||||||
|
("video/mp4", "Photos"),
|
||||||
|
("audio/ogg", "Music"),
|
||||||
|
] {
|
||||||
|
let api = files_api(vec![404, 200, 200]);
|
||||||
|
let path = file_purchase_in_files(
|
||||||
|
&reqwest::Client::new(),
|
||||||
|
&api.url,
|
||||||
|
"test-session",
|
||||||
|
"file",
|
||||||
|
mime,
|
||||||
|
b"bytes",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(path, format!("{folder}/file"));
|
||||||
|
let seen = api.seen.lock().unwrap();
|
||||||
|
assert_eq!(seen[1].0, "POST");
|
||||||
|
assert!(seen[1].1.ends_with('/'));
|
||||||
|
assert!(seen[1].2.is_empty());
|
||||||
|
assert_eq!(seen[2].2, b"bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
||||||
|
for statuses in [
|
||||||
|
vec![401],
|
||||||
|
vec![503],
|
||||||
|
vec![404, 500],
|
||||||
|
vec![200, 507],
|
||||||
|
vec![200, 403],
|
||||||
|
] {
|
||||||
|
let expected = statuses.len();
|
||||||
|
let api = files_api(statuses);
|
||||||
|
assert!(file_purchase_in_files(
|
||||||
|
&reqwest::Client::new(),
|
||||||
|
&api.url,
|
||||||
|
"test-session",
|
||||||
|
"file.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"bytes"
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(api.seen.lock().unwrap().len(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
|
|||||||
pending_open_local_balance: Option<LndAmount>,
|
pending_open_local_balance: Option<LndAmount>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
|
||||||
|
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
macaroon_hex: &str,
|
||||||
|
) -> Result<T> {
|
||||||
|
client
|
||||||
|
.get(url)
|
||||||
|
.header("Grpc-Metadata-macaroon", macaroon_hex)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("LND is unavailable; balance could not be checked")?
|
||||||
|
.error_for_status()
|
||||||
|
.context("LND is not ready; balance could not be checked")?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("LND returned invalid wallet data")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_balances(
|
||||||
|
wallet: LndBalanceResponse,
|
||||||
|
channels: LndChannelBalanceResponse,
|
||||||
|
) -> Result<(i64, i64, i64)> {
|
||||||
|
fn sats(value: Option<String>) -> Result<i64> {
|
||||||
|
let value = value.context("LND omitted a balance; balance is unavailable")?;
|
||||||
|
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
|
||||||
|
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
|
||||||
|
Ok(amount)
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
sats(wallet.total_balance)?,
|
||||||
|
sats(channels.local_balance.and_then(|a| a.sat))?,
|
||||||
|
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bitcoin_wait_state(
|
||||||
|
installed: bool,
|
||||||
|
running: bool,
|
||||||
|
fresh: bool,
|
||||||
|
ibd: Option<bool>,
|
||||||
|
) -> (&'static str, &'static str) {
|
||||||
|
if !installed {
|
||||||
|
("waiting_install", "Waiting for Bitcoin to be installed")
|
||||||
|
} else if !running {
|
||||||
|
("waiting_start", "Waiting for Bitcoin to start")
|
||||||
|
} else if !fresh || ibd.is_none() {
|
||||||
|
("waiting_start", "Waiting for Bitcoin to start")
|
||||||
|
} else if ibd == Some(true) {
|
||||||
|
("waiting_sync", "Waiting for Bitcoin to sync")
|
||||||
|
} else {
|
||||||
|
("bitcoin_ready", "Bitcoin is ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
|
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
|
||||||
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
|
if !data.server_info.status_info.containers_scanned {
|
||||||
|
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
|
||||||
|
}
|
||||||
|
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| data.package_data.get(*id))
|
||||||
|
.collect();
|
||||||
|
let installed = !nodes.is_empty();
|
||||||
|
let running = nodes
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.state == crate::data_model::PackageState::Running);
|
||||||
|
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||||
|
let ibd = bitcoin
|
||||||
|
.blockchain_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("initialblockdownload"))
|
||||||
|
.and_then(|v| v.as_bool());
|
||||||
|
let (state, message) =
|
||||||
|
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
|
||||||
|
serde_json::json!({"state": state, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
|
||||||
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
let macaroon_bytes = read_lnd_admin_macaroon().await?;
|
||||||
let macaroon_hex = hex::encode(&macaroon_bytes);
|
let macaroon_hex = hex::encode(&macaroon_bytes);
|
||||||
@@ -85,45 +164,26 @@ impl RpcHandler {
|
|||||||
.build()
|
.build()
|
||||||
.context("Failed to create HTTP client")?;
|
.context("Failed to create HTTP client")?;
|
||||||
|
|
||||||
let get_info: LndGetInfoResponse = client
|
let get_info: LndGetInfoResponse = get_lnd_json(
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
|
&client,
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
|
||||||
.send()
|
&macaroon_hex,
|
||||||
.await
|
)
|
||||||
.context("LND REST connection failed")?
|
.await?;
|
||||||
.json()
|
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
|
||||||
.await
|
&client,
|
||||||
.context("Failed to parse LND getinfo response")?;
|
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
|
||||||
|
&macaroon_hex,
|
||||||
let channel_balance: LndChannelBalanceResponse = match client
|
)
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
|
.await?;
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
let wallet_balance: LndBalanceResponse = get_lnd_json(
|
||||||
.send()
|
&client,
|
||||||
.await
|
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
|
||||||
{
|
&macaroon_hex,
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
|
)
|
||||||
local_balance: None,
|
.await?;
|
||||||
pending_open_local_balance: None,
|
let (balance_sats, channel_balance_sats, pending_open_balance) =
|
||||||
}),
|
checked_balances(wallet_balance, channel_balance)?;
|
||||||
Err(_) => LndChannelBalanceResponse {
|
|
||||||
local_balance: None,
|
|
||||||
pending_open_local_balance: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let wallet_balance: LndBalanceResponse = match client
|
|
||||||
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
|
|
||||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
|
|
||||||
total_balance: None,
|
|
||||||
}),
|
|
||||||
Err(_) => LndBalanceResponse {
|
|
||||||
total_balance: None,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||||
|
|
||||||
@@ -135,18 +195,9 @@ impl RpcHandler {
|
|||||||
num_peers: get_info.num_peers.unwrap_or(0),
|
num_peers: get_info.num_peers.unwrap_or(0),
|
||||||
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
|
||||||
block_height: get_info.block_height.unwrap_or(0),
|
block_height: get_info.block_height.unwrap_or(0),
|
||||||
balance_sats: wallet_balance
|
balance_sats,
|
||||||
.total_balance
|
channel_balance_sats,
|
||||||
.and_then(|s| s.parse().ok())
|
pending_open_balance,
|
||||||
.unwrap_or(0),
|
|
||||||
channel_balance_sats: channel_balance
|
|
||||||
.local_balance
|
|
||||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
pending_open_balance: channel_balance
|
|
||||||
.pending_open_local_balance
|
|
||||||
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
|
|
||||||
.unwrap_or(0),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(serde_json::to_value(info)?)
|
Ok(serde_json::to_value(info)?)
|
||||||
@@ -268,6 +319,76 @@ impl RpcHandler {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unavailable_balances_are_not_zero() {
|
||||||
|
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
|
||||||
|
assert!(checked_balances(
|
||||||
|
serde_json::from_str(body).unwrap(),
|
||||||
|
serde_json::from_str(body).unwrap(),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
for value in ["bad", "-1", "9223372036854775808"] {
|
||||||
|
let wallet = LndBalanceResponse {
|
||||||
|
total_balance: Some(value.into()),
|
||||||
|
};
|
||||||
|
let channels = serde_json::from_str(
|
||||||
|
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(checked_balances(wallet, channels).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_zero_and_nonzero_balances_survive() {
|
||||||
|
for expected in [0, 42] {
|
||||||
|
let wallet = LndBalanceResponse {
|
||||||
|
total_balance: Some(expected.to_string()),
|
||||||
|
};
|
||||||
|
let channels = serde_json::from_value(serde_json::json!({
|
||||||
|
"local_balance":{"sat":expected.to_string()},
|
||||||
|
"pending_open_local_balance":{"sat":"0"}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
checked_balances(wallet, channels).unwrap(),
|
||||||
|
(expected, expected, 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn locked_wallet_http_response_is_not_successful_getinfo() {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0; 2048];
|
||||||
|
stream.read(&mut buf).await.unwrap();
|
||||||
|
let body =
|
||||||
|
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
|
||||||
|
stream.write_all(format!(
|
||||||
|
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(), body
|
||||||
|
).as_bytes()).await.unwrap();
|
||||||
|
});
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(get_lnd_json::<LndGetInfoResponse>(
|
||||||
|
&client,
|
||||||
|
&format!("http://{addr}/v1/getinfo"),
|
||||||
|
"test"
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||||
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||||
|
|
||||||
@@ -341,3 +462,44 @@ mod tests {
|
|||||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod dependency_readiness_tests {
|
||||||
|
use super::bitcoin_wait_state;
|
||||||
|
#[test]
|
||||||
|
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(false, false, false, None).0,
|
||||||
|
"waiting_install"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, false, false, None).0,
|
||||||
|
"waiting_start"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, true, false, None).0,
|
||||||
|
"waiting_start"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, true, true, Some(true)).0,
|
||||||
|
"waiting_sync"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||||
|
"bitcoin_ready"
|
||||||
|
);
|
||||||
|
// Previously synced cached information must not hide a current outage.
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, true, false, Some(false)).0,
|
||||||
|
"waiting_start"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, true, true, None).0,
|
||||||
|
"waiting_start"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bitcoin_wait_state(true, true, true, Some(false)).0,
|
||||||
|
"bitcoin_ready"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
|
|||||||
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
|
||||||
/// here — container-down is crash-recovery's job, and unlocking needs the
|
/// here — container-down is crash-recovery's job, and unlocking needs the
|
||||||
/// operator.
|
/// operator.
|
||||||
|
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
|
||||||
|
status.ok
|
||||||
|
&& !status.stale
|
||||||
|
&& status.age_ms < 30_000
|
||||||
|
&& status
|
||||||
|
.blockchain_info
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("initialblockdownload"))
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
== Some(false)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn spawn_lnd_health_watchdog() {
|
pub(crate) fn spawn_lnd_health_watchdog() {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut bad_minutes: u32 = 0;
|
let mut bad_minutes: u32 = 0;
|
||||||
let mut last_restart: Option<tokio::time::Instant> = None;
|
let mut last_restart: Option<tokio::time::Instant> = None;
|
||||||
|
let mut last_height: Option<u64> = None;
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||||
|
// Initial Bitcoin sync, warmup, and outages are dependencies to
|
||||||
|
// wait for, never evidence that LND is wedged. Do not accumulate
|
||||||
|
// restart pressure during a days-long initial block download.
|
||||||
|
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
|
||||||
|
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|
||||||
|
|| crate::app_ops::lifecycle_op_in_flight("lnd")
|
||||||
|
{
|
||||||
|
bad_minutes = 0;
|
||||||
|
last_height = None;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
let Ok(bytes) = read_lnd_admin_macaroon().await else {
|
||||||
bad_minutes = 0; // no LND on this node (or not set up yet)
|
bad_minutes = 0; // no LND on this node (or not set up yet)
|
||||||
continue;
|
continue;
|
||||||
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
|||||||
bad_minutes = 0; // down/locked — not the wedge signature
|
bad_minutes = 0; // down/locked — not the wedge signature
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
bad_minutes = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
let Ok(info) = resp.json::<serde_json::Value>().await else {
|
||||||
bad_minutes = 0;
|
bad_minutes = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
|
|||||||
.get("num_pending_channels")
|
.get("num_pending_channels")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let wedged = !synced || (channels > 0 && peers == 0);
|
let height = info.get("block_height").and_then(|v| v.as_u64());
|
||||||
|
let progressing = height
|
||||||
|
.zip(last_height)
|
||||||
|
.is_some_and(|(now, before)| now > before);
|
||||||
|
last_height = height;
|
||||||
|
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
|
||||||
if !wedged {
|
if !wedged {
|
||||||
bad_minutes = 0;
|
bad_minutes = 0;
|
||||||
continue;
|
continue;
|
||||||
@@ -239,3 +272,31 @@ impl RpcHandler {
|
|||||||
Ok((client, macaroon_hex))
|
Ok((client, macaroon_hex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod watchdog_dependency_tests {
|
||||||
|
use super::bitcoin_ready_for_lnd_watchdog;
|
||||||
|
use crate::bitcoin_status::BitcoinNodeStatus;
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
|
||||||
|
let mut status = BitcoinNodeStatus::default();
|
||||||
|
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
status.ok = true;
|
||||||
|
status.blockchain_info = Some(json!({"initialblockdownload":true}));
|
||||||
|
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
status.blockchain_info = Some(json!({"initialblockdownload":false}));
|
||||||
|
assert!(bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
status.stale = true;
|
||||||
|
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
status.stale = false;
|
||||||
|
status.ok = false;
|
||||||
|
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
status.ok = true;
|
||||||
|
status.age_ms = 30_000;
|
||||||
|
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
status.age_ms = 0;
|
||||||
|
status.blockchain_info = Some(json!({}));
|
||||||
|
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ impl RpcHandler {
|
|||||||
match handler.handle_package_install(params).await {
|
match handler.handle_package_install(params).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("package.install {}: complete", package_id_spawn);
|
info!("package.install {}: complete", package_id_spawn);
|
||||||
|
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
|
||||||
|
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
crate::crash_recovery::mark_installed(
|
||||||
|
&handler.config.data_dir,
|
||||||
|
&package_id_spawn,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
// The install pipeline has verified the container is up
|
// The install pipeline has verified the container is up
|
||||||
// and healthy (see install.rs post-start exit check).
|
// and healthy (see install.rs post-start exit check).
|
||||||
// Kick the scanner first so the fresh manifest (with
|
// Kick the scanner first so the fresh manifest (with
|
||||||
@@ -184,17 +193,20 @@ impl RpcHandler {
|
|||||||
// phase is cleared (None) so no stale InstallPhase
|
// phase is cleared (None) so no stale InstallPhase
|
||||||
// lingers on the card.
|
// lingers on the card.
|
||||||
let err_msg = format!("Install failed: {:#}", e);
|
let err_msg = format!("Install failed: {:#}", e);
|
||||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
handler
|
||||||
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
.state_manager
|
||||||
entry.state = PackageState::Stopped;
|
.mutate_data(|data| {
|
||||||
entry.install_progress = Some(crate::data_model::InstallProgress {
|
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
|
||||||
size: 0,
|
entry.state = PackageState::Stopped;
|
||||||
downloaded: 0,
|
entry.install_progress = Some(crate::data_model::InstallProgress {
|
||||||
phase: None,
|
size: 0,
|
||||||
message: Some(err_msg),
|
downloaded: 0,
|
||||||
});
|
phase: None,
|
||||||
handler.state_manager.update_data(data).await;
|
message: Some(err_msg),
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -252,6 +264,11 @@ impl RpcHandler {
|
|||||||
match handler.handle_package_uninstall(params).await {
|
match handler.handle_package_uninstall(params).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
info!("package.uninstall {}: complete", package_id_spawn);
|
info!("package.uninstall {}: complete", package_id_spawn);
|
||||||
|
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
|
||||||
|
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
|
||||||
|
.await;
|
||||||
|
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
|
||||||
|
}
|
||||||
// Inner handler already removed the package entry on
|
// Inner handler already removed the package entry on
|
||||||
// success. Nothing more to do here.
|
// success. Nothing more to do here.
|
||||||
}
|
}
|
||||||
@@ -382,52 +399,56 @@ impl RpcHandler {
|
|||||||
/// call, but fires before the spawn so the UI sees it immediately.
|
/// call, but fires before the spawn so the UI sees it immediately.
|
||||||
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
|
||||||
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| PackageDataEntry {
|
.entry(package_id.to_string())
|
||||||
state: PackageState::Installing,
|
.or_insert_with(|| PackageDataEntry {
|
||||||
health: None,
|
ui_ready: None,
|
||||||
exit_code: None,
|
state: PackageState::Installing,
|
||||||
static_files: StaticFiles {
|
health: None,
|
||||||
license: String::new(),
|
exit_code: None,
|
||||||
instructions: String::new(),
|
static_files: StaticFiles {
|
||||||
// Leave icon empty during the transient Installing window:
|
license: String::new(),
|
||||||
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
instructions: String::new(),
|
||||||
// `.svg` / `.webp`), producing a broken-image flicker until
|
// Leave icon empty during the transient Installing window:
|
||||||
// the scanner refreshes the entry. The frontend's `icon`
|
// hardcoding `<id>.png` is wrong for ~half our apps (many use
|
||||||
// computed falls through to `curatedMap.get(id)?.icon` which
|
// `.svg` / `.webp`), producing a broken-image flicker until
|
||||||
// has the correct extensions for known apps.
|
// the scanner refreshes the entry. The frontend's `icon`
|
||||||
icon: String::new(),
|
// computed falls through to `curatedMap.get(id)?.icon` which
|
||||||
},
|
// has the correct extensions for known apps.
|
||||||
manifest: Manifest {
|
icon: String::new(),
|
||||||
id: package_id.to_string(),
|
},
|
||||||
title: package_id.to_string(),
|
manifest: Manifest {
|
||||||
version: String::new(),
|
id: package_id.to_string(),
|
||||||
description: Description {
|
title: package_id.to_string(),
|
||||||
short: "Installing...".to_string(),
|
version: String::new(),
|
||||||
long: String::new(),
|
description: Description {
|
||||||
},
|
short: "Installing...".to_string(),
|
||||||
release_notes: String::new(),
|
long: String::new(),
|
||||||
license: String::new(),
|
},
|
||||||
wrapper_repo: String::new(),
|
release_notes: String::new(),
|
||||||
upstream_repo: String::new(),
|
license: String::new(),
|
||||||
support_site: String::new(),
|
wrapper_repo: String::new(),
|
||||||
marketing_site: String::new(),
|
upstream_repo: String::new(),
|
||||||
donation_url: None,
|
support_site: String::new(),
|
||||||
author: None,
|
marketing_site: String::new(),
|
||||||
website: None,
|
donation_url: None,
|
||||||
interfaces: None,
|
author: None,
|
||||||
tier: None,
|
website: None,
|
||||||
},
|
interfaces: None,
|
||||||
installed: None,
|
tier: None,
|
||||||
install_progress: None,
|
},
|
||||||
uninstall_stage: None,
|
installed: None,
|
||||||
available_update: None,
|
install_progress: None,
|
||||||
});
|
uninstall_stage: None,
|
||||||
entry.state = PackageState::Installing;
|
available_update: None,
|
||||||
state_manager.update_data(data).await;
|
});
|
||||||
|
entry.ui_ready = Some(false);
|
||||||
|
entry.state = PackageState::Installing;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the failed install still has a real footprint: any container
|
/// True when the failed install still has a real footprint: any container
|
||||||
@@ -485,20 +506,23 @@ async fn remove_entry_with_notification(
|
|||||||
id_prefix: &str,
|
id_prefix: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = handler.state_manager.get_snapshot().await;
|
handler
|
||||||
data.package_data.remove(package_id);
|
.state_manager
|
||||||
data.notifications.push(crate::data_model::Notification {
|
.mutate_data(|data| {
|
||||||
id: format!("{id_prefix}-{package_id}"),
|
data.package_data.remove(package_id);
|
||||||
level: crate::data_model::NotificationLevel::Error,
|
data.notifications.push(crate::data_model::Notification {
|
||||||
title: format!("Could not install {package_id}"),
|
id: format!("{id_prefix}-{package_id}"),
|
||||||
message: message.to_string(),
|
level: crate::data_model::NotificationLevel::Error,
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
title: format!("Could not install {package_id}"),
|
||||||
app_id: Some(package_id.to_string()),
|
message: message.to_string(),
|
||||||
});
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
while data.notifications.len() > 20 {
|
app_id: Some(package_id.to_string()),
|
||||||
data.notifications.remove(0);
|
});
|
||||||
}
|
while data.notifications.len() > 20 {
|
||||||
handler.state_manager.update_data(data).await;
|
data.notifications.remove(0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flip an existing entry's state and return the pre-flip value (or None if
|
/// Flip an existing entry's state and return the pre-flip value (or None if
|
||||||
@@ -508,18 +532,23 @@ async fn flip_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||||
entry.state = new_state;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
} else {
|
entry.ui_ready = Some(false);
|
||||||
warn!(
|
}
|
||||||
"flip_package_state: no entry for {} — cannot flip",
|
entry.state = new_state;
|
||||||
package_id
|
} else {
|
||||||
);
|
warn!(
|
||||||
}
|
"flip_package_state: no entry for {} — cannot flip",
|
||||||
prev
|
package_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set state unconditionally (no-op if entry no longer exists).
|
/// Set state unconditionally (no-op if entry no longer exists).
|
||||||
@@ -528,13 +557,18 @@ async fn set_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
/// Set state and clear the uninstall_stage label. Used when an uninstall
|
||||||
@@ -545,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.state = new_state;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.uninstall_stage = None;
|
if new_state != PackageState::Running {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
entry.uninstall_stage = None;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kick the container scanner to run immediately and wait for it to finish
|
/// Kick the container scanner to run immediately and wait for it to finish
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ impl RpcHandler {
|
|||||||
// an older version pins it so install_fresh resolves that image and the
|
// an older version pins it so install_fresh resolves that image and the
|
||||||
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
|
||||||
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
|
||||||
|
if let Some(value) = params.get("prune") {
|
||||||
|
let prune = value.as_bool().context("prune must be a boolean")?;
|
||||||
|
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
|
||||||
|
}
|
||||||
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
|
||||||
persist_install_version_selection(package_id, version).await;
|
persist_install_version_selection(package_id, version).await;
|
||||||
}
|
}
|
||||||
@@ -541,7 +545,7 @@ impl RpcHandler {
|
|||||||
// Keep legacy install flow as default while migration is in progress.
|
// Keep legacy install flow as default while migration is in progress.
|
||||||
if orchestrator_managed {
|
if orchestrator_managed {
|
||||||
let orchestrator_app_id = orchestrator_install_app_id(package_id);
|
let orchestrator_app_id = orchestrator_install_app_id(package_id);
|
||||||
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
|
self.set_install_phase(package_id, InstallPhase::PreparingApp)
|
||||||
.await;
|
.await;
|
||||||
install_log(&format!(
|
install_log(&format!(
|
||||||
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
"INSTALL ORCH: {} — attempting orchestrator install as {}",
|
||||||
@@ -2049,25 +2053,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_stale_package_ports(package_id: &str) {
|
async fn cleanup_stale_package_ports(package_id: &str) {
|
||||||
match package_id {
|
// Never kill by port: another app or the management gate may own it.
|
||||||
"grafana" => cleanup_stale_pasta_port("3000").await,
|
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||||
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
|
|
||||||
"searxng" => cleanup_stale_pasta_port("8888").await,
|
|
||||||
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
|
|
||||||
"gitea" => {
|
|
||||||
cleanup_stale_pasta_port("3001").await;
|
|
||||||
cleanup_stale_pasta_port("2222").await;
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
}
|
|
||||||
"nginx-proxy-manager" => {
|
|
||||||
cleanup_stale_pasta_port("8081").await;
|
|
||||||
cleanup_stale_pasta_port("8084").await;
|
|
||||||
cleanup_stale_pasta_port("8444").await;
|
|
||||||
}
|
|
||||||
"nextcloud" => cleanup_stale_pasta_port("8085").await,
|
|
||||||
"portainer" => cleanup_stale_pasta_port("9000").await,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn install_command_tail(
|
fn install_command_tail(
|
||||||
@@ -2192,93 +2179,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
match package_id {
|
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
|
||||||
"grafana"
|
crate::container::ghost_reaper::reap_for_app(package_id).await;
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
return true;
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"homeassistant" | "home-assistant"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8123").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"searxng"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8888").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"uptime-kuma"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("3002").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
|
|
||||||
cleanup_stale_pasta_port("3001").await;
|
|
||||||
cleanup_stale_pasta_port("2222").await;
|
|
||||||
cleanup_stale_pasta_port("3000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"nginx-proxy-manager"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8081").await;
|
|
||||||
cleanup_stale_pasta_port("8084").await;
|
|
||||||
cleanup_stale_pasta_port("8444").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"nextcloud"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("8085").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
"portainer"
|
|
||||||
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
|
|
||||||
{
|
|
||||||
cleanup_stale_pasta_port("9000").await;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
}
|
||||||
}
|
false
|
||||||
|
|
||||||
async fn cleanup_stale_pasta_port(port: &str) {
|
|
||||||
// NEVER kill our own process. The daemon holds catalog app ports over
|
|
||||||
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
|
|
||||||
// terminate archipelago itself mid-install — installs failed and apps
|
|
||||||
// vanished on a test node 2026-07-27. Kill every listener on the port
|
|
||||||
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
|
|
||||||
let self_pid = std::process::id();
|
|
||||||
let kill_listener = format!(
|
|
||||||
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
|
|
||||||
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &kill_listener])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
|
|
||||||
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
|
|
||||||
let fuser_kill = format!(
|
|
||||||
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &fuser_kill])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let pattern = format!("pasta.*{}", port);
|
|
||||||
let _ = tokio::process::Command::new("pkill")
|
|
||||||
.args(["-f", &pattern])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn repair_nextcloud_permissions() {
|
async fn repair_nextcloud_permissions() {
|
||||||
|
|||||||
@@ -14,20 +14,23 @@ impl RpcHandler {
|
|||||||
/// the rare case where the pull stream actually parses, but podman
|
/// the rare case where the pull stream actually parses, but podman
|
||||||
/// almost never emits parseable progress on a piped stderr.
|
/// almost never emits parseable progress on a piped stderr.
|
||||||
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
entry.state = PackageState::Installing;
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
entry.ui_ready = Some(false);
|
||||||
entry.install_progress = Some(InstallProgress {
|
entry.state = PackageState::Installing;
|
||||||
size,
|
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase: existing_phase,
|
size,
|
||||||
message: None,
|
downloaded,
|
||||||
});
|
phase: existing_phase,
|
||||||
self.state_manager.update_data(data).await;
|
message: None,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the install pipeline phase and broadcast. This is the
|
/// Set the install pipeline phase and broadcast. This is the
|
||||||
@@ -35,76 +38,86 @@ impl RpcHandler {
|
|||||||
/// percentage and a user-facing label. Byte counters are retained
|
/// percentage and a user-facing label. Byte counters are retained
|
||||||
/// for the rare case podman emits parseable progress.
|
/// for the rare case podman emits parseable progress.
|
||||||
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
// WaitingHealthy / PostInstall all map to the Installing state.
|
// Preparing / PullingImage / CreatingContainer / StartingContainer /
|
||||||
// Updates use Updating state — the wrapper has already flipped
|
// WaitingHealthy / PostInstall all map to the Installing state.
|
||||||
// state to Updating, so don't clobber it.
|
// Updates use Updating state — the wrapper has already flipped
|
||||||
if entry.state != PackageState::Updating {
|
// state to Updating, so don't clobber it.
|
||||||
entry.state = PackageState::Installing;
|
if entry.state != PackageState::Updating {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
let (size, downloaded) = entry
|
entry.state = PackageState::Installing;
|
||||||
.install_progress
|
}
|
||||||
.as_ref()
|
let (size, downloaded) = entry
|
||||||
.map(|p| (p.size, p.downloaded))
|
.install_progress
|
||||||
.unwrap_or((0, 0));
|
.as_ref()
|
||||||
entry.install_progress = Some(InstallProgress {
|
.map(|p| (p.size, p.downloaded))
|
||||||
size,
|
.unwrap_or((0, 0));
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase: Some(phase),
|
size,
|
||||||
message: None,
|
downloaded,
|
||||||
});
|
phase: Some(phase),
|
||||||
self.state_manager.update_data(data).await;
|
message: None,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
|
||||||
/// to start…") without disturbing the current phase/byte counters.
|
/// to start…") without disturbing the current phase/byte counters.
|
||||||
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
if entry.state != PackageState::Updating {
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
entry.state = PackageState::Installing;
|
if entry.state != PackageState::Updating {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
let (size, downloaded, phase) = entry
|
entry.state = PackageState::Installing;
|
||||||
.install_progress
|
}
|
||||||
.as_ref()
|
let (size, downloaded, phase) = entry
|
||||||
.map(|p| (p.size, p.downloaded, p.phase))
|
.install_progress
|
||||||
.unwrap_or((0, 0, None));
|
.as_ref()
|
||||||
entry.install_progress = Some(InstallProgress {
|
.map(|p| (p.size, p.downloaded, p.phase))
|
||||||
size,
|
.unwrap_or((0, 0, None));
|
||||||
downloaded,
|
entry.install_progress = Some(InstallProgress {
|
||||||
phase,
|
size,
|
||||||
message: Some(message.to_string()),
|
downloaded,
|
||||||
});
|
phase,
|
||||||
self.state_manager.update_data(data).await;
|
message: Some(message.to_string()),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear install progress after pull completes or fails.
|
/// Clear install progress after pull completes or fails.
|
||||||
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
pub(super) async fn clear_install_progress(&self, package_id: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.install_progress = None;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
}
|
entry.install_progress = None;
|
||||||
self.state_manager.update_data(data).await;
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the uninstall stage label so the UI can show what's happening
|
/// Set the uninstall stage label so the UI can show what's happening
|
||||||
/// instead of a generic spinner. Each call broadcasts a state change
|
/// instead of a generic spinner. Each call broadcasts a state change
|
||||||
/// — call sparingly (one per pipeline phase, not per container).
|
/// — call sparingly (one per pipeline phase, not per container).
|
||||||
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
|
||||||
let (mut data, _rev) = self.state_manager.get_snapshot().await;
|
self.state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
entry.uninstall_stage = Some(stage.to_string());
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = crate::data_model::PackageState::Removing;
|
entry.uninstall_stage = Some(stage.to_string());
|
||||||
}
|
entry.state = crate::data_model::PackageState::Removing;
|
||||||
self.state_manager.update_data(data).await;
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update install progress (static method for use in async closures).
|
/// Update install progress (static method for use in async closures).
|
||||||
@@ -114,25 +127,28 @@ impl RpcHandler {
|
|||||||
downloaded: u64,
|
downloaded: u64,
|
||||||
total: u64,
|
total: u64,
|
||||||
) {
|
) {
|
||||||
let (mut data, _rev) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let entry = data
|
.mutate_data(|data| {
|
||||||
.package_data
|
let entry = data
|
||||||
.entry(package_id.to_string())
|
.package_data
|
||||||
.or_insert_with(|| create_installing_entry(package_id));
|
.entry(package_id.to_string())
|
||||||
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
.or_insert_with(|| create_installing_entry(package_id));
|
||||||
entry.install_progress = Some(InstallProgress {
|
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
|
||||||
size: total,
|
entry.install_progress = Some(InstallProgress {
|
||||||
downloaded,
|
size: total,
|
||||||
phase: existing_phase,
|
downloaded,
|
||||||
message: None,
|
phase: existing_phase,
|
||||||
});
|
message: None,
|
||||||
state_manager.update_data(data).await;
|
});
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a minimal PackageDataEntry for a package being installed.
|
/// Create a minimal PackageDataEntry for a package being installed.
|
||||||
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state: PackageState::Installing,
|
state: PackageState::Installing,
|
||||||
health: None,
|
health: None,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
|
|||||||
@@ -1431,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
|
|||||||
// published port and the data-dir file locks, so the replacement either
|
// published port and the data-dir file locks, so the replacement either
|
||||||
// fails to bind (`address already in use`) or starts and dies on the
|
// fails to bind (`address already in use`) or starts and dies on the
|
||||||
// lock — and `Restart=always` loops it there forever. Ordered before
|
// lock — and `Restart=always` loops it there forever. Ordered before
|
||||||
// the port cleanup below: killing the owner is what actually frees the
|
// starting the replacement. A port sweep cannot distinguish a ghost
|
||||||
// port, and the port sweep alone cannot tell a ghost from a live app.
|
// from the dashboard gate or another live app and must never kill it.
|
||||||
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||||
cleanup_runtime_host_ports(container_name).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_before_package_start(container_name: &str) {
|
async fn wait_before_package_start(container_name: &str) {
|
||||||
@@ -1579,7 +1578,6 @@ async fn repair_netbird_network() {
|
|||||||
async fn repair_nginx_proxy_manager_container() {
|
async fn repair_nginx_proxy_manager_container() {
|
||||||
repair_nginx_proxy_manager_dirs().await;
|
repair_nginx_proxy_manager_dirs().await;
|
||||||
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
if !nginx_proxy_manager_has_legacy_admin_port().await {
|
||||||
cleanup_nginx_proxy_manager_ports().await;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1588,7 +1586,7 @@ async fn repair_nginx_proxy_manager_container() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
|
||||||
cleanup_nginx_proxy_manager_ports().await;
|
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
|
||||||
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
if let Err(err) = recreate_nginx_proxy_manager_container().await {
|
||||||
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
|
||||||
}
|
}
|
||||||
@@ -1812,6 +1810,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
|
|||||||
|
|
||||||
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||||
|
dirs.push(root.into());
|
||||||
|
}
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||||
}
|
}
|
||||||
@@ -2032,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ports = runtime_host_ports(container_name);
|
// Only reap processes proven to belong to an absent container. The app
|
||||||
if !ports.is_empty() {
|
// gate shares the app's port on other addresses and lives in this daemon;
|
||||||
cleanup_ports(&ports).await;
|
// killing port owners (or matching argv with pkill) kills the dashboard.
|
||||||
return;
|
crate::container::ghost_reaper::reap_for_app(container_name).await;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_runtime_host_ports(container_name: &str) {
|
|
||||||
let ports = runtime_host_ports(container_name);
|
|
||||||
if !ports.is_empty() {
|
|
||||||
cleanup_ports(&ports).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_nginx_proxy_manager_ports() {
|
|
||||||
cleanup_ports(&[8081, 8084, 8444]).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_ports(ports: &[u16]) {
|
|
||||||
for port in ports {
|
|
||||||
cleanup_stale_pasta_port(&port.to_string()).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_stale_pasta_port(port: &str) {
|
|
||||||
let kill_listener = format!(
|
|
||||||
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
|
|
||||||
port
|
|
||||||
);
|
|
||||||
let _ = tokio::process::Command::new("sh")
|
|
||||||
.args(["-c", &kill_listener])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let pattern = format!("pasta.*{}", port);
|
|
||||||
let _ = tokio::process::Command::new("pkill")
|
|
||||||
.args(["-f", &pattern])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
let pattern = format!("rootlessport.*{}", port);
|
|
||||||
let _ = tokio::process::Command::new("pkill")
|
|
||||||
.args(["-f", &pattern])
|
|
||||||
.output()
|
|
||||||
.await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
|
||||||
@@ -2095,13 +2055,16 @@ async fn flip_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
transitional: PackageState,
|
transitional: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
|
||||||
entry.state = transitional;
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
entry.state = transitional;
|
||||||
prev
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the package entry's final state. No-op if the entry has since
|
/// Write the package entry's final state. No-op if the entry has since
|
||||||
@@ -2111,13 +2074,18 @@ async fn set_package_state(
|
|||||||
package_id: &str,
|
package_id: &str,
|
||||||
new_state: PackageState,
|
new_state: PackageState,
|
||||||
) {
|
) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(package_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(package_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
pub(super) async fn reconcile_companions_for(package_id: &str) {
|
||||||
@@ -2185,6 +2153,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn port_conflict_cleanup_preserves_live_host_listener() {
|
||||||
|
// The previous ss|kill sweep terminated the daemon's app gate on a
|
||||||
|
// restart. Keep a real listening socket owned by this test process.
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
cleanup_start_conflict("photoprism", "address already in use").await;
|
||||||
|
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||||
|
let _connection = listener.accept().await.unwrap();
|
||||||
|
drop(client);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_container_classifier_covers_podman5_phrasings() {
|
fn missing_container_classifier_covers_podman5_phrasings() {
|
||||||
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
|
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
|
||||||
|
|||||||
@@ -153,8 +153,18 @@ impl RpcHandler {
|
|||||||
let default = app_catalog::catalog_default_version(app_id);
|
let default = app_catalog::catalog_default_version(app_id);
|
||||||
let cfg = version_config::read(app_id);
|
let cfg = version_config::read(app_id);
|
||||||
let installed = installed_version(app_id).await;
|
let installed = installed_version(app_id).await;
|
||||||
|
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
|
||||||
|
Some(
|
||||||
|
crate::settings::bitcoin_storage::load(&self.config.data_dir)
|
||||||
|
.await?
|
||||||
|
.prune,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
|
"bitcoinPrune": bitcoin_prune,
|
||||||
"id": app_id,
|
"id": app_id,
|
||||||
"supportsVersions": supports_versions(app_id),
|
"supportsVersions": supports_versions(app_id),
|
||||||
"default": default,
|
"default": default,
|
||||||
|
|||||||
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
|
|||||||
app_id: &str,
|
app_id: &str,
|
||||||
transitional: PackageState,
|
transitional: PackageState,
|
||||||
) -> Option<PackageState> {
|
) -> Option<PackageState> {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
.mutate_data(|data| {
|
||||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
|
||||||
entry.state = transitional;
|
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||||
state_manager.update_data(data).await;
|
entry.ui_ready = Some(false);
|
||||||
}
|
entry.state = transitional;
|
||||||
prev
|
}
|
||||||
|
prev
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
/// Set the entry's state to `new_state`. No-ops if the entry has since been
|
||||||
/// removed (e.g. uninstall ran concurrently).
|
/// removed (e.g. uninstall ran concurrently).
|
||||||
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
|
||||||
let (mut data, _) = state_manager.get_snapshot().await;
|
state_manager
|
||||||
if let Some(entry) = data.package_data.get_mut(app_id) {
|
.mutate_data(|data| {
|
||||||
if entry.state != new_state {
|
if let Some(entry) = data.package_data.get_mut(app_id) {
|
||||||
entry.state = new_state;
|
if entry.state != new_state {
|
||||||
state_manager.update_data(data).await;
|
if new_state != PackageState::Running {
|
||||||
}
|
entry.ui_ready = Some(false);
|
||||||
}
|
}
|
||||||
|
entry.state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ impl PortMap {
|
|||||||
/// there.
|
/// there.
|
||||||
fn apps_dirs() -> Vec<PathBuf> {
|
fn apps_dirs() -> Vec<PathBuf> {
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
|
||||||
|
dirs.push(root.into());
|
||||||
|
}
|
||||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
|
|||||||
.clone()
|
.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
|
||||||
|
std::sync::LazyLock::new(tokio::sync::Notify::new);
|
||||||
|
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
|
||||||
|
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
|
||||||
|
|
||||||
|
/// Installation must not wait for the minute sweep before becoming reachable.
|
||||||
|
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
|
||||||
|
pub async fn refresh_now() {
|
||||||
|
let mut completed = REFRESH_REV.subscribe();
|
||||||
|
REFRESH_KICK.notify_one();
|
||||||
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
|
||||||
|
let mut external = false;
|
||||||
|
let mut tor = false;
|
||||||
|
for (claimed_port, address) in &status.claimed {
|
||||||
|
if *claimed_port != port {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(ip) = address.parse::<IpAddr>() {
|
||||||
|
tor |= ip == GATE_TOR_UPSTREAM;
|
||||||
|
external |= !ip.is_loopback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
external && tor
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the gate. Returns only on shutdown.
|
/// Run the gate. Returns only on shutdown.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
gate: Arc<AppGate>,
|
gate: Arc<AppGate>,
|
||||||
@@ -162,11 +190,12 @@ pub async fn run(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = interval.tick() => {
|
_ = interval.tick() => {}
|
||||||
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
_ = REFRESH_KICK.notified() => {}
|
||||||
}
|
|
||||||
_ = shutdown_rx.changed() => return,
|
_ = shutdown_rx.changed() => return,
|
||||||
}
|
}
|
||||||
|
sweep(&gate, &status, &mut held, &shutdown_rx).await;
|
||||||
|
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,3 +490,19 @@ mod tests {
|
|||||||
assert!(!status.is_fully_enforced());
|
assert!(!status.is_fully_enforced());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod readiness_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
|
||||||
|
let mut status = GateStatus::default();
|
||||||
|
assert!(!port_claimed(&status, 3001));
|
||||||
|
status.claimed.push((3001, "127.0.0.2".into()));
|
||||||
|
assert!(!port_claimed(&status, 3001));
|
||||||
|
status.claimed.push((3002, "192.0.2.10".into()));
|
||||||
|
assert!(!port_claimed(&status, 3001));
|
||||||
|
status.claimed.push((3001, "192.0.2.10".into()));
|
||||||
|
assert!(port_claimed(&status, 3001));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
|
|||||||
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
|
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
|
||||||
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
|
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state: PackageState::Running,
|
state: PackageState::Running,
|
||||||
health: None,
|
health: None,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
|
|||||||
@@ -1069,6 +1069,7 @@ mod tests {
|
|||||||
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
|
||||||
};
|
};
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state: PackageState::Running,
|
state: PackageState::Running,
|
||||||
health: None,
|
health: None,
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
|
|||||||
.trim()
|
.trim()
|
||||||
.trim_end_matches('.');
|
.trim_end_matches('.');
|
||||||
let lower = detail.to_lowercase();
|
let lower = detail.to_lowercase();
|
||||||
let state = if lower.contains("verifying blocks") {
|
let state = if lower.contains("loading block index") {
|
||||||
|
Some("loading its block index. This can take a while after installation or restart")
|
||||||
|
} else if lower.contains("replaying blocks") {
|
||||||
|
Some("checking saved blocks before startup completes")
|
||||||
|
} else if lower.contains("verifying blocks") {
|
||||||
Some("verifying blocks after restart")
|
Some("verifying blocks after restart")
|
||||||
} else if lower.contains("connection reset") {
|
} else if lower.contains("connection reset") {
|
||||||
Some("starting up and not yet accepting RPC connections")
|
Some("starting up and not yet accepting RPC connections")
|
||||||
@@ -340,3 +344,21 @@ mod tests {
|
|||||||
assert!(msg.len() < 260);
|
assert!(msg.len() < 260);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod startup_message_tests {
|
||||||
|
#[test]
|
||||||
|
fn loading_block_index_is_explained_without_rpc_error_dump() {
|
||||||
|
for cached in [false, true] {
|
||||||
|
let message = super::friendly_transient_error(
|
||||||
|
cached,
|
||||||
|
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
|
||||||
|
);
|
||||||
|
assert!(message.contains("loading its block index"));
|
||||||
|
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
|
||||||
|
assert!(!message.contains(raw));
|
||||||
|
}
|
||||||
|
assert_eq!(message.contains("last known state"), cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
|
|||||||
/// should reference (`localhost/<base>:latest` for build, registry
|
/// should reference (`localhost/<base>:latest` for build, registry
|
||||||
/// URL for pull).
|
/// URL for pull).
|
||||||
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
||||||
let local_image = format!("localhost/{}:latest", spec.image_base);
|
let mut local_image = format!("localhost/{}:latest", spec.image_base);
|
||||||
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
let local_image_compat = format!("localhost/{}:local", spec.image_base);
|
||||||
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||||
|
|
||||||
@@ -322,11 +322,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
|
|||||||
for dir in spec.build_dir_candidates {
|
for dir in spec.build_dir_candidates {
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||||
// `:local` is a deliberate manual override — never auto-rebuild it.
|
// Older installers and self-update create :local themselves. It
|
||||||
|
// must receive source updates too; treating it as a permanent
|
||||||
|
// manual override silently kept the old LND UI after an OTA.
|
||||||
if image_exists(&local_image_compat).await {
|
if image_exists(&local_image_compat).await {
|
||||||
return Ok(local_image_compat);
|
local_image = local_image_compat.clone();
|
||||||
}
|
}
|
||||||
// Reuse the auto-built `:latest` only when the build context has NOT
|
// Reuse either local tag only when the build context has NOT
|
||||||
// changed since it was built. Without this staleness check an
|
// changed since it was built. Without this staleness check an
|
||||||
// already-present image is reused forever, so edits to the baked-in
|
// already-present image is reused forever, so edits to the baked-in
|
||||||
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
// context (Dockerfile, nginx.conf, …) never reach the node — this is
|
||||||
@@ -849,20 +851,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
|
|||||||
if !matches_known_shape {
|
if !matches_known_shape {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
|
if let Some(image) = managed_local_image(spec, &on_disk) {
|
||||||
for dir in spec.build_dir_candidates {
|
for dir in spec.build_dir_candidates {
|
||||||
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
let dockerfile = PathBuf::from(dir).join("Dockerfile");
|
||||||
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
|
||||||
// Conservative on any timeout/error inside: reuse the cache.
|
// Conservative on any timeout/error inside: reuse the cache.
|
||||||
return Ok(context_is_newer_than_image(dir, &local_image).await);
|
return Ok(context_is_newer_than_image(dir, &image).await);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
|
||||||
|
["latest", "local"]
|
||||||
|
.iter()
|
||||||
|
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
|
||||||
|
.find(|image| build_unit(spec, image).render() == unit)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
|
||||||
|
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
|
||||||
|
for tag in ["local", "latest"] {
|
||||||
|
let image = format!("localhost/{}:{tag}", spec.image_base);
|
||||||
|
let unit = build_unit(spec, &image).render();
|
||||||
|
assert_eq!(managed_local_image(spec, &unit), Some(image));
|
||||||
|
}
|
||||||
|
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
|
||||||
|
assert_eq!(
|
||||||
|
managed_local_image(spec, &build_unit(spec, ®istry).render()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use archipelago_container::{
|
use archipelago_container::{
|
||||||
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
|
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
|
||||||
};
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
@@ -25,8 +26,15 @@ impl DockerPackageScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Scan Docker containers and convert to package data
|
/// Scan Docker containers and convert to package data
|
||||||
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
|
pub async fn scan_containers(
|
||||||
let containers = self.runtime.list_containers().await?;
|
&self,
|
||||||
|
data_dir: &std::path::Path,
|
||||||
|
cached: &HashMap<String, PackageDataEntry>,
|
||||||
|
) -> Result<HashMap<String, PackageDataEntry>> {
|
||||||
|
let mut containers = self.runtime.list_containers().await?;
|
||||||
|
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
|
||||||
|
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
|
||||||
|
restore_absent_installed(&mut containers, &installed, &uninstalled);
|
||||||
|
|
||||||
debug!("Found {} containers", containers.len());
|
debug!("Found {} containers", containers.len());
|
||||||
|
|
||||||
@@ -139,6 +147,18 @@ impl DockerPackageScanner {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if container.id.is_empty() {
|
||||||
|
if let Some(previous) = cached.get(&app_id) {
|
||||||
|
let mut held = previous.clone();
|
||||||
|
held.state = PackageState::Stopped;
|
||||||
|
held.ui_ready = Some(false);
|
||||||
|
held.health = None;
|
||||||
|
held.exit_code = None;
|
||||||
|
packages.insert(app_id.clone(), held);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get metadata for this app
|
// Get metadata for this app
|
||||||
let metadata = get_app_metadata(&app_id);
|
let metadata = get_app_metadata(&app_id);
|
||||||
// Manifest-owned metadata (icon) wins over the static table: the
|
// Manifest-owned metadata (icon) wins over the static table: the
|
||||||
@@ -179,14 +199,22 @@ impl DockerPackageScanner {
|
|||||||
let tor_address = read_tor_address(&app_id).await;
|
let tor_address = read_tor_address(&app_id).await;
|
||||||
|
|
||||||
// Extract actual version from container image tag
|
// Extract actual version from container image tag
|
||||||
let running_version = image_versions::extract_version_from_image(&container.image);
|
let running_version = if container.id.is_empty() {
|
||||||
|
String::new() // Absence cannot establish the installed image version.
|
||||||
|
} else {
|
||||||
|
image_versions::extract_version_from_image(&container.image)
|
||||||
|
};
|
||||||
|
|
||||||
// Decoupled from the binary OTA: prefer the remote app catalog,
|
// Decoupled from the binary OTA: prefer the remote app catalog,
|
||||||
// falling back to the image-versions.sh pin when uncovered/offline.
|
// falling back to the image-versions.sh pin when uncovered/offline.
|
||||||
let available_update =
|
let available_update = if container.id.is_empty() {
|
||||||
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
|
None
|
||||||
|
} else {
|
||||||
|
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
|
||||||
|
};
|
||||||
|
|
||||||
let package = PackageDataEntry {
|
let package = PackageDataEntry {
|
||||||
|
ui_ready: Some(false),
|
||||||
state: package_state.clone(),
|
state: package_state.clone(),
|
||||||
health: container.health.clone(),
|
health: container.health.clone(),
|
||||||
exit_code: if package_state == PackageState::Exited {
|
exit_code: if package_state == PackageState::Exited {
|
||||||
@@ -283,10 +311,215 @@ impl DockerPackageScanner {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let probes: Vec<_> = packages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, pkg)| {
|
||||||
|
if pkg.state != PackageState::Running {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let url = pkg
|
||||||
|
.installed
|
||||||
|
.as_ref()?
|
||||||
|
.interface_addresses
|
||||||
|
.get("main")?
|
||||||
|
.lan_address
|
||||||
|
.clone()?;
|
||||||
|
Some((id.clone(), url))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut results = futures_util::stream::iter(
|
||||||
|
probes
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
|
||||||
|
)
|
||||||
|
.buffer_unordered(8);
|
||||||
|
while let Some((id, ready)) = results.next().await {
|
||||||
|
if let Some(pkg) = packages.get_mut(&id) {
|
||||||
|
pkg.ui_ready = Some(ready);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// HTTP on loopback can precede the LAN/Tor listener after install.
|
||||||
|
let port_map = crate::appgate::identity::build_port_map();
|
||||||
|
let gated: Vec<_> = packages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(id, pkg)| {
|
||||||
|
if pkg.ui_ready != Some(true) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let url = pkg
|
||||||
|
.installed
|
||||||
|
.as_ref()?
|
||||||
|
.interface_addresses
|
||||||
|
.get("main")?
|
||||||
|
.lan_address
|
||||||
|
.as_deref()?;
|
||||||
|
let port = launch_url_port(url)?;
|
||||||
|
port_map
|
||||||
|
.gated(port)
|
||||||
|
.filter(|gate| gate.declared)
|
||||||
|
.map(|_| (id.clone(), port))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !gated.is_empty() {
|
||||||
|
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
|
||||||
|
let status = shared_status();
|
||||||
|
let needs_refresh = {
|
||||||
|
let current = status.read().await;
|
||||||
|
gated.iter().any(|(_, port)| !port_claimed(¤t, *port))
|
||||||
|
};
|
||||||
|
if needs_refresh {
|
||||||
|
refresh_now().await;
|
||||||
|
}
|
||||||
|
let current = status.read().await;
|
||||||
|
for (id, port) in gated {
|
||||||
|
if !port_claimed(¤t, port) {
|
||||||
|
packages.get_mut(&id).unwrap().ui_ready = Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(packages)
|
Ok(packages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
|
||||||
|
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
|
||||||
|
fn restore_absent_installed(
|
||||||
|
containers: &mut Vec<ContainerStatus>,
|
||||||
|
installed: &std::collections::HashSet<String>,
|
||||||
|
uninstalled: &std::collections::HashSet<String>,
|
||||||
|
) {
|
||||||
|
fn canonical(name: &str) -> &str {
|
||||||
|
let name = name.strip_prefix("archy-").unwrap_or(name);
|
||||||
|
match name {
|
||||||
|
"immich_server" => "immich",
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut present: std::collections::HashSet<String> = containers
|
||||||
|
.iter()
|
||||||
|
.map(|c| canonical(&c.name).to_owned())
|
||||||
|
.collect();
|
||||||
|
let removed: std::collections::HashSet<_> =
|
||||||
|
uninstalled.iter().map(|id| canonical(id)).collect();
|
||||||
|
for name in installed {
|
||||||
|
let id = canonical(name);
|
||||||
|
if removed.contains(id) || !present.insert(id.to_owned()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
containers.push(ContainerStatus {
|
||||||
|
id: String::new(),
|
||||||
|
name: id.to_owned(),
|
||||||
|
state: ContainerState::Stopped,
|
||||||
|
health: None,
|
||||||
|
exit_code: None,
|
||||||
|
started_at: None,
|
||||||
|
image: String::new(),
|
||||||
|
created: String::new(),
|
||||||
|
ports: Vec::new(),
|
||||||
|
lan_address: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the actual loopback upstream, not the app gate's login page. A bound
|
||||||
|
/// TCP socket alone can still reset requests or serve a startup 503.
|
||||||
|
async fn launch_http_ready(candidate: &str) -> bool {
|
||||||
|
let Ok(mut url) = reqwest::Url::parse(candidate) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if url.set_host(Some("127.0.0.1")).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
|
||||||
|
let client = CLIENT.get_or_init(|| {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
// Self-signed local app certificates are normal. This client only
|
||||||
|
// contacts loopback and never sends credentials or follows redirects.
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()
|
||||||
|
.expect("local readiness client")
|
||||||
|
});
|
||||||
|
match client.get(url).send().await {
|
||||||
|
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod lifecycle_regression_tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
|
||||||
|
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
|
||||||
|
.into_iter()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect();
|
||||||
|
let removed = ["removed".to_owned()].into_iter().collect();
|
||||||
|
let mut containers = Vec::new();
|
||||||
|
restore_absent_installed(&mut containers, &installed, &removed);
|
||||||
|
assert_eq!(containers.len(), 2);
|
||||||
|
assert!(containers
|
||||||
|
.iter()
|
||||||
|
.all(|c| c.state == ContainerState::Stopped));
|
||||||
|
containers[0].state = ContainerState::Running;
|
||||||
|
restore_absent_installed(&mut containers, &installed, &removed);
|
||||||
|
assert_eq!(containers.len(), 2);
|
||||||
|
assert_eq!(containers[0].state, ContainerState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
|
||||||
|
for (status, expected) in [
|
||||||
|
(200, true),
|
||||||
|
(302, true),
|
||||||
|
(401, true),
|
||||||
|
(403, true),
|
||||||
|
(404, false),
|
||||||
|
(500, false),
|
||||||
|
(502, false),
|
||||||
|
(503, false),
|
||||||
|
] {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = [0; 2048];
|
||||||
|
let n = stream.read(&mut buf).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
|
||||||
|
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
launch_http_ready(&format!("http://localhost:{port}/start")).await,
|
||||||
|
expected,
|
||||||
|
"status {status}"
|
||||||
|
);
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn readiness_rejects_tcp_accept_without_http() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
drop(stream);
|
||||||
|
});
|
||||||
|
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
|
||||||
|
task.await.unwrap();
|
||||||
|
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
|
||||||
|
assert!(!launch_http_ready("file:///tmp/test").await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AppMetadata {
|
struct AppMetadata {
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String,
|
||||||
|
|||||||
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
|
|||||||
Ok(EnsureOutcome::Written)
|
Ok(EnsureOutcome::Written)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
|
||||||
|
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
|
||||||
|
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
|
||||||
|
async fn bitcoin_rpc_ready() -> bool {
|
||||||
|
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let response = client.post(crate::constants::BITCOIN_RPC_URL)
|
||||||
|
.basic_auth(user, Some(password))
|
||||||
|
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
|
||||||
|
.send().await;
|
||||||
|
match response {
|
||||||
|
Ok(response) if response.status().is_success() => response
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.is_ok_and(|value| bitcoin_readiness_response(&value)),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
|
||||||
|
value.get("error").is_none_or(|e| e.is_null())
|
||||||
|
&& value
|
||||||
|
.pointer("/result/blocks")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.is_some()
|
||||||
|
&& value
|
||||||
|
.pointer("/result/initialblockdownload")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn ensure_wallet_initialized() -> Result<()> {
|
pub async fn ensure_wallet_initialized() -> Result<()> {
|
||||||
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
|
||||||
if file_exists_as_root(wallet_db).await {
|
if file_exists_as_root(wallet_db).await {
|
||||||
|
// GetInfo can wait for Bitcoin sync even though the wallet is already
|
||||||
|
// unlocked. State RPC stays available during that normal startup phase.
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()?;
|
||||||
|
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
match unlock_existing_wallet().await? {
|
if !bitcoin_rpc_ready().await {
|
||||||
true => {
|
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
|
||||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
return Ok(());
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
false => {
|
|
||||||
// Every candidate password was actively rejected: this wallet was
|
|
||||||
// created with a password this node no longer has, so it can never
|
|
||||||
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
|
|
||||||
// locked with an unknown password is already inaccessible, so wipe +
|
|
||||||
// recreate it on the per-node secret to self-heal at boot.
|
|
||||||
recreate_wallet_destructively().await?;
|
|
||||||
wait_for_admin_macaroon(admin_macaroon).await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
|
wait_for_admin_macaroon(admin_macaroon).await?;
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !bitcoin_rpc_ready().await {
|
||||||
|
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
init_wallet_via_rest().await?;
|
init_wallet_via_rest().await?;
|
||||||
wait_for_admin_macaroon(admin_macaroon).await
|
wait_for_admin_macaroon(admin_macaroon).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LND data subdirectories holding wallet + channel + graph state. Removing them
|
|
||||||
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
|
|
||||||
/// so deletion is destructive — only done once the wallet is already unrecoverable.
|
|
||||||
const LND_STATE_DIRS: &[&str] = &[
|
|
||||||
"/var/lib/archipelago/lnd/data/chain",
|
|
||||||
"/var/lib/archipelago/lnd/data/graph",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Podman container name for the core LND app (see `compute_container_name`:
|
|
||||||
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
|
|
||||||
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
|
|
||||||
const LND_CONTAINER: &str = "lnd";
|
|
||||||
|
|
||||||
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
/// Canonical on-host admin macaroon — same path the RPC layer reads.
|
||||||
const LND_ADMIN_MACAROON: &str =
|
const LND_ADMIN_MACAROON: &str =
|
||||||
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
|
||||||
|
|
||||||
/// Archipelago data dir (default; not overridden in prod). Holds the
|
|
||||||
/// `user-stopped.json` that gates health-monitor auto-restart.
|
|
||||||
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
|
||||||
|
|
||||||
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
|
|
||||||
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
|
|
||||||
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
|
|
||||||
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
|
|
||||||
/// candidate password can open the existing wallet.
|
|
||||||
async fn recreate_wallet_destructively() -> Result<()> {
|
|
||||||
tracing::warn!(
|
|
||||||
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
|
|
||||||
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The health monitor restarts any container it sees stopped; mark LND
|
|
||||||
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
|
|
||||||
// Always cleared below so LND auto-recovers normally afterwards.
|
|
||||||
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
|
|
||||||
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
|
|
||||||
let result = wipe_and_reinit_wallet().await;
|
|
||||||
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wipe_and_reinit_wallet() -> Result<()> {
|
|
||||||
podman_user_scoped(&["stop", LND_CONTAINER])
|
|
||||||
.await
|
|
||||||
.context("stopping lnd before wallet wipe")?;
|
|
||||||
|
|
||||||
for dir in LND_STATE_DIRS {
|
|
||||||
let status = host_sudo(&["rm", "-rf", dir])
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("removing {dir}"))?;
|
|
||||||
if !status.success() {
|
|
||||||
anyhow::bail!("removing {dir} exited with {status}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
podman_user_scoped(&["start", LND_CONTAINER])
|
|
||||||
.await
|
|
||||||
.context("restarting lnd after wallet wipe")?;
|
|
||||||
|
|
||||||
wait_for_wallet_state("NON_EXISTING").await?;
|
|
||||||
init_wallet_via_rest().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
|
|
||||||
/// how the orchestrator/health-monitor manage rootless containers (keeps the
|
|
||||||
/// container out of the archipelago service's cgroup).
|
|
||||||
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
|
|
||||||
let out = tokio::process::Command::new("systemd-run")
|
|
||||||
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
|
|
||||||
.args(args)
|
|
||||||
.output()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
|
|
||||||
if !out.status.success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"podman {} failed: {}",
|
|
||||||
args.join(" "),
|
|
||||||
String::from_utf8_lossy(&out.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
|
|
||||||
async fn wait_for_wallet_state(target: &str) -> Result<()> {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.no_proxy()
|
|
||||||
.timeout(std::time::Duration::from_secs(5))
|
|
||||||
.danger_accept_invalid_certs(true)
|
|
||||||
.build()
|
|
||||||
.context("building LND REST client")?;
|
|
||||||
for _ in 0..120 {
|
|
||||||
if wallet_state(&client).await.as_deref() == Some(target) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
|
||||||
anyhow::bail!("LND did not reach state {target} after wallet wipe")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn file_exists_as_root(path: &str) -> bool {
|
async fn file_exists_as_root(path: &str) -> bool {
|
||||||
if std::path::Path::new(path).exists() {
|
if std::path::Path::new(path).exists() {
|
||||||
return true;
|
return true;
|
||||||
@@ -366,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
|||||||
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
// exactly the nodes least able to afford it. Waiting longer costs nothing —
|
||||||
// a wrong password still exits on the first pass via `all_rejected`.
|
// a wrong password still exits on the first pass via `all_rejected`.
|
||||||
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
|
||||||
|
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
let mut all_rejected = true;
|
let mut all_rejected = true;
|
||||||
for pw in &candidates {
|
for pw in &candidates {
|
||||||
match try_unlock_once(&client, pw).await {
|
match try_unlock_once(&client, pw).await {
|
||||||
@@ -390,14 +341,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unlock an existing wallet WITHOUT the destructive fallback.
|
/// Unlock the existing wallet, preserving its identity and channel data when
|
||||||
///
|
/// passwords are unavailable or rejected. Used by boot and credential rotation.
|
||||||
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
|
|
||||||
/// password can open — correct for a boot path that must self-heal, and exactly
|
|
||||||
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
|
|
||||||
/// still wants. Rotation calls this instead, so there is no code path from
|
|
||||||
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
|
|
||||||
/// as an error the caller reports, never as a wipe.
|
|
||||||
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
||||||
match unlock_existing_wallet().await? {
|
match unlock_existing_wallet().await? {
|
||||||
true => Ok(()),
|
true => Ok(()),
|
||||||
@@ -408,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn wallet_is_unlocked(state: Option<&str>) -> bool {
|
||||||
|
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
|
||||||
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
|
||||||
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
|
||||||
@@ -538,7 +487,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
{
|
{
|
||||||
UnlockerResponse::Value(seed) => seed,
|
UnlockerResponse::Value(seed) => seed,
|
||||||
UnlockerResponse::WalletAlreadyExists => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
UnlockerResponse::WalletAlreadyExists => {
|
UnlockerResponse::WalletAlreadyExists => {
|
||||||
unlock_existing_wallet().await?;
|
unlock_existing_wallet_no_wipe().await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1203,3 +1152,44 @@ mod tests {
|
|||||||
.is_empty());
|
.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bitcoin_readiness_tests {
|
||||||
|
use super::bitcoin_readiness_response;
|
||||||
|
use serde_json::json;
|
||||||
|
#[test]
|
||||||
|
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
|
||||||
|
for response in [
|
||||||
|
json!({}),
|
||||||
|
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
|
||||||
|
json!({"result":{"blocks":null}}),
|
||||||
|
] {
|
||||||
|
assert!(!bitcoin_readiness_response(&response));
|
||||||
|
}
|
||||||
|
// Initial sync is supported by LND. Loading the database is not.
|
||||||
|
for ibd in [true, false] {
|
||||||
|
assert!(bitcoin_readiness_response(
|
||||||
|
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod syncing_wallet_state_tests {
|
||||||
|
#[test]
|
||||||
|
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
|
||||||
|
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
|
||||||
|
assert!(super::wallet_is_unlocked(Some(state)));
|
||||||
|
}
|
||||||
|
for state in [
|
||||||
|
None,
|
||||||
|
Some("LOCKED"),
|
||||||
|
Some("NON_EXISTING"),
|
||||||
|
Some("WAITING_TO_START"),
|
||||||
|
Some("unknown"),
|
||||||
|
] {
|
||||||
|
assert!(!super::wallet_is_unlocked(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -798,6 +798,10 @@ fn host_port_bindings_drifted(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_user_podman_socket() -> Result<()> {
|
async fn ensure_user_podman_socket() -> Result<()> {
|
||||||
|
// Unit tests inject a runtime; they must not restart the host Podman API.
|
||||||
|
if cfg!(test) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let socket_path = "/run/user/1000/podman/podman.sock";
|
let socket_path = "/run/user/1000/podman/podman.sock";
|
||||||
if podman_socket_accepts_connections(socket_path).await {
|
if podman_socket_accepts_connections(socket_path).await {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -1170,15 +1174,21 @@ impl ReconcileReport {
|
|||||||
fn cascade_pairs_for_report<'r>(
|
fn cascade_pairs_for_report<'r>(
|
||||||
report: &'r ReconcileReport,
|
report: &'r ReconcileReport,
|
||||||
user_stopped: &std::collections::HashSet<String>,
|
user_stopped: &std::collections::HashSet<String>,
|
||||||
|
changed_backends: &HashSet<String>,
|
||||||
) -> Vec<(&'r str, &'static str)> {
|
) -> Vec<(&'r str, &'static str)> {
|
||||||
let mut pairs = Vec::new();
|
let mut pairs = Vec::new();
|
||||||
for (backend, action) in &report.actions {
|
for (backend, action) in &report.actions {
|
||||||
if !matches!(
|
if !matches!(
|
||||||
action,
|
action,
|
||||||
ReconcileAction::Installed | ReconcileAction::Started
|
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A successful systemctl start can be a no-op after a transient
|
||||||
|
// Podman inspect failure. Require a witnessed lifecycle change.
|
||||||
|
if !changed_backends.contains(backend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for dep in crate::app_ops::address_caching_dependents(backend) {
|
for dep in crate::app_ops::address_caching_dependents(backend) {
|
||||||
let dep_untouched = report
|
let dep_untouched = report
|
||||||
.actions
|
.actions
|
||||||
@@ -1192,6 +1202,25 @@ fn cascade_pairs_for_report<'r>(
|
|||||||
pairs
|
pairs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Only positive runtime evidence permits disrupting an address-caching wallet.
|
||||||
|
/// A known absent/stopped backend becoming running, a new container ID, or a
|
||||||
|
/// changed start timestamp qualifies. A failed observation never does.
|
||||||
|
fn backend_instance_changed(before: Option<&ContainerStatus>, after: &ContainerStatus) -> bool {
|
||||||
|
if after.state != ContainerState::Running || after.id.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(before) = before else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
if before.id.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if before.id != after.id || before.state != ContainerState::Running {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
matches!((&before.started_at, &after.started_at), (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() && a != b)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct AdoptionReport {
|
pub struct AdoptionReport {
|
||||||
pub adopted: Vec<String>,
|
pub adopted: Vec<String>,
|
||||||
@@ -1864,7 +1893,7 @@ impl ProdContainerOrchestrator {
|
|||||||
// Durable installation record, consulted alongside the perishable
|
// Durable installation record, consulted alongside the perishable
|
||||||
// `was_running` snapshot for desired-state recovery below.
|
// `was_running` snapshot for desired-state recovery below.
|
||||||
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
|
||||||
let (manifests, container_name_by_app_id): (
|
let (mut manifests, container_name_by_app_id): (
|
||||||
Vec<LoadedManifest>,
|
Vec<LoadedManifest>,
|
||||||
std::collections::HashMap<String, String>,
|
std::collections::HashMap<String, String>,
|
||||||
) = {
|
) = {
|
||||||
@@ -1895,15 +1924,50 @@ impl ProdContainerOrchestrator {
|
|||||||
.collect();
|
.collect();
|
||||||
(filtered, names)
|
(filtered, names)
|
||||||
};
|
};
|
||||||
|
// Wallet readiness must not wait behind unrelated image pulls/builds.
|
||||||
|
// A running LND container can still be locked after boot; its post-start
|
||||||
|
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
|
||||||
|
// rest of the catalog. Each app still honors stopped/uninstalled markers.
|
||||||
|
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
|
||||||
|
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
|
||||||
|
"lnd" => 1,
|
||||||
|
_ => 2,
|
||||||
|
});
|
||||||
// Live container names (any state), for the same recovery check.
|
// Live container names (any state), for the same recovery check.
|
||||||
let present_containers: std::collections::HashSet<String> = self
|
let listed_containers = self.runtime.list_containers().await.ok();
|
||||||
.runtime
|
let present_containers: HashSet<String> = listed_containers
|
||||||
.list_containers()
|
.as_ref()
|
||||||
.await
|
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
|
||||||
.map(|cs| cs.into_iter().map(|c| c.name).collect())
|
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
// Keep unknown distinct from confirmed absence. Runtime queries can
|
||||||
|
// fail under load while systemd still has a healthy running backend.
|
||||||
|
let mut backend_before: HashMap<String, Option<ContainerStatus>> = HashMap::new();
|
||||||
|
for lm in &manifests {
|
||||||
|
let id = &lm.manifest.app.id;
|
||||||
|
if crate::app_ops::address_caching_dependents(id).is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = compute_container_name(&lm.manifest);
|
||||||
|
match self.runtime.get_container_status(&name).await {
|
||||||
|
Ok(status) => {
|
||||||
|
backend_before.insert(id.clone(), Some(status));
|
||||||
|
}
|
||||||
|
Err(_) if listed_containers.is_some() && !present_containers.contains(&name) => {
|
||||||
|
backend_before.insert(id.clone(), None);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(backend = %id, error = %err,
|
||||||
|
"cannot observe backend before reconcile; will not infer a dependency restart from an action report");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut report = ReconcileReport::default();
|
let mut report = ReconcileReport::default();
|
||||||
let disk_gb = self.disk_gb().await;
|
let disk_gb = self.disk_gb().await;
|
||||||
|
let bitcoin_pruned = disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
||||||
|
|| crate::settings::bitcoin_storage::load(&self.data_dir)
|
||||||
|
.await
|
||||||
|
.map(|settings| settings.prune)
|
||||||
|
.unwrap_or(true);
|
||||||
// Register every candidate before the (sequential, possibly slow)
|
// Register every candidate before the (sequential, possibly slow)
|
||||||
// pass so the scanner overlays queued-but-down apps as Restarting
|
// pass so the scanner overlays queued-but-down apps as Restarting
|
||||||
// instead of Stopped. Each app is deregistered as its turn finishes,
|
// instead of Stopped. Each app is deregistered as its turn finishes,
|
||||||
@@ -1943,7 +2007,7 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
if mode == ReconcileMode::ExistingOnly
|
if mode == ReconcileMode::ExistingOnly
|
||||||
&& requires_archival_bitcoin(&app_id)
|
&& requires_archival_bitcoin(&app_id)
|
||||||
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
|
&& bitcoin_pruned
|
||||||
{
|
{
|
||||||
report.record(
|
report.record(
|
||||||
&app_id,
|
&app_id,
|
||||||
@@ -2078,7 +2142,20 @@ impl ProdContainerOrchestrator {
|
|||||||
// state recovery, repair recreate, boot InstallMissing) moves the
|
// state recovery, repair recreate, boot InstallMissing) moves the
|
||||||
// address behind a running dependent's back — §C "restart lnd after
|
// address behind a running dependent's back — §C "restart lnd after
|
||||||
// ANY bitcoin recreate".
|
// ANY bitcoin recreate".
|
||||||
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
|
let mut changed_backends = HashSet::new();
|
||||||
|
for (backend, before) in &backend_before {
|
||||||
|
let Some(name) = container_name_by_app_id.get(backend) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Ok(after) = self.runtime.get_container_status(name).await {
|
||||||
|
if backend_instance_changed(before.as_ref(), &after) {
|
||||||
|
changed_backends.insert(backend.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A user stop during a slow reconcile pass still takes precedence.
|
||||||
|
let user_stopped = crate::crash_recovery::load_user_stopped(&self.data_dir).await;
|
||||||
|
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped, &changed_backends) {
|
||||||
// Same rule as the RPC cascade: hold the dependent's op lock
|
// Same rule as the RPC cascade: hold the dependent's op lock
|
||||||
// across the restart; skip when a worker is mid-sequence.
|
// across the restart; skip when a worker is mid-sequence.
|
||||||
let lock = crate::app_ops::op_lock(dep);
|
let lock = crate::app_ops::op_lock(dep);
|
||||||
@@ -3217,6 +3294,9 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
|
||||||
|
if cfg!(test) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let Some(network) = manifest.app.container.network.as_deref() else {
|
let Some(network) = manifest.app.container.network.as_deref() else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -3403,11 +3483,9 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cleanup_stale_grafana_port(&self) {
|
async fn cleanup_stale_grafana_port(&self) {
|
||||||
let _ = tokio::process::Command::new("pkill")
|
// Port 3001 can belong to Gitea or the daemon's gate. Reap only a
|
||||||
.args(["-f", "pasta.*3001"])
|
// Grafana container proven absent from Podman's inventory.
|
||||||
.output()
|
crate::container::ghost_reaper::reap_for_app("grafana").await;
|
||||||
.await;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn detect_host_facts(&self) -> HostFacts {
|
async fn detect_host_facts(&self) -> HostFacts {
|
||||||
@@ -3711,6 +3789,17 @@ impl ProdContainerOrchestrator {
|
|||||||
}
|
}
|
||||||
let mut env = manifest.app.environment.clone();
|
let mut env = manifest.app.environment.clone();
|
||||||
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
env.extend(manifest.app.container.resolve_derived_env(&facts));
|
||||||
|
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
|
||||||
|
let storage = crate::settings::bitcoin_storage::load(&self.data_dir).await?;
|
||||||
|
env.retain(|entry| !entry.starts_with("BITCOIN_PRUNE="));
|
||||||
|
if storage.prune {
|
||||||
|
anyhow::ensure!(
|
||||||
|
manifest.app.container.custom_args.iter().any(|arg| arg.contains("BITCOIN_PRUNE")),
|
||||||
|
"This Bitcoin app definition cannot honor the pruning choice. Refresh the app catalog and try again."
|
||||||
|
);
|
||||||
|
env.push("BITCOIN_PRUNE=1".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
// FM_BITCOIND_URL now comes from the manifest's {{BITCOIN_HOST}}
|
||||||
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
// derived_env (works on Knots/Core/any distro). The old hardcoded
|
||||||
@@ -6064,6 +6153,48 @@ app:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn bitcoin_storage_choice_is_applied_and_old_catalog_cannot_silently_ignore_it() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
let mut orch = orch_with(rt).await;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
orch.set_data_dir(dir.path().to_path_buf());
|
||||||
|
for id in ["bitcoin-core", "bitcoin-knots"] {
|
||||||
|
let mut old = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||||
|
// No preference: existing containers need no new environment flag.
|
||||||
|
crate::settings::bitcoin_storage::save(dir.path(), false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
orch.resolve_dynamic_env(&mut old).await.unwrap();
|
||||||
|
assert!(!old
|
||||||
|
.app
|
||||||
|
.environment
|
||||||
|
.iter()
|
||||||
|
.any(|s| s.starts_with("BITCOIN_PRUNE=")));
|
||||||
|
crate::settings::bitcoin_storage::save(dir.path(), true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(orch
|
||||||
|
.resolve_dynamic_env(&mut old)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("cannot honor"));
|
||||||
|
let mut current = pull_manifest(id, "docker.io/bitcoin/bitcoin:28");
|
||||||
|
current
|
||||||
|
.app
|
||||||
|
.container
|
||||||
|
.custom_args
|
||||||
|
.push("if [ ${BITCOIN_PRUNE:-0} = 1 ]; then :; fi".into());
|
||||||
|
orch.resolve_dynamic_env(&mut current).await.unwrap();
|
||||||
|
assert!(current
|
||||||
|
.app
|
||||||
|
.environment
|
||||||
|
.iter()
|
||||||
|
.any(|s| s == "BITCOIN_PRUNE=1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn install_resolves_derived_and_secret_env_before_create() {
|
async fn install_resolves_derived_and_secret_env_before_create() {
|
||||||
let rt = Arc::new(MockRuntime::default());
|
let rt = Arc::new(MockRuntime::default());
|
||||||
@@ -6335,6 +6466,67 @@ app:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_cascade_requires_observed_instance_change() {
|
||||||
|
let running = ContainerStatus {
|
||||||
|
id: "container-1".into(),
|
||||||
|
name: "bitcoin-core".into(),
|
||||||
|
state: ContainerState::Running,
|
||||||
|
started_at: Some("start-1".into()),
|
||||||
|
health: None,
|
||||||
|
exit_code: None,
|
||||||
|
image: "bitcoin:1".into(),
|
||||||
|
created: "created-1".into(),
|
||||||
|
ports: vec![],
|
||||||
|
lan_address: None,
|
||||||
|
};
|
||||||
|
assert!(!backend_instance_changed(Some(&running), &running));
|
||||||
|
assert!(backend_instance_changed(None, &running));
|
||||||
|
let mut before = running.clone();
|
||||||
|
before.state = ContainerState::Exited;
|
||||||
|
assert!(backend_instance_changed(Some(&before), &running));
|
||||||
|
before = running.clone();
|
||||||
|
before.id = "old-container".into();
|
||||||
|
assert!(backend_instance_changed(Some(&before), &running));
|
||||||
|
before = running.clone();
|
||||||
|
before.started_at = Some("earlier-start".into());
|
||||||
|
assert!(backend_instance_changed(Some(&before), &running));
|
||||||
|
before.started_at = None;
|
||||||
|
assert!(!backend_instance_changed(Some(&before), &running));
|
||||||
|
before.id.clear();
|
||||||
|
assert!(!backend_instance_changed(Some(&before), &running));
|
||||||
|
let mut after = running.clone();
|
||||||
|
after.state = ContainerState::Exited;
|
||||||
|
assert!(!backend_instance_changed(None, &after));
|
||||||
|
after = running.clone();
|
||||||
|
after.id.clear();
|
||||||
|
assert!(!backend_instance_changed(None, &after));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cascade_ignores_false_started_report_but_detects_real_exec_drift() {
|
||||||
|
let none = HashSet::new();
|
||||||
|
let mut report = ReconcileReport {
|
||||||
|
actions: vec![
|
||||||
|
("bitcoin-core".into(), ReconcileAction::Started),
|
||||||
|
("lnd".into(), ReconcileAction::NoOp),
|
||||||
|
],
|
||||||
|
failures: vec![],
|
||||||
|
};
|
||||||
|
// systemctl start of an already active unit does not move its address.
|
||||||
|
assert!(cascade_pairs_for_report(&report, &none, &none).is_empty());
|
||||||
|
// A unit exec rewrite can restart Bitcoin while the outer reconcile
|
||||||
|
// action remains NoOp. Runtime evidence still requires LND to reconnect.
|
||||||
|
let changed = ["bitcoin-core".into()].into();
|
||||||
|
report.actions[0].1 = ReconcileAction::NoOp;
|
||||||
|
assert_eq!(
|
||||||
|
cascade_pairs_for_report(&report, &none, &changed),
|
||||||
|
vec![("bitcoin-core", "lnd")]
|
||||||
|
);
|
||||||
|
report.actions[0].1 = ReconcileAction::Left("lifecycle-op-in-flight".into());
|
||||||
|
assert!(cascade_pairs_for_report(&report, &none, &changed).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -6346,6 +6538,7 @@ app:
|
|||||||
failures: vec![],
|
failures: vec![],
|
||||||
};
|
};
|
||||||
let none = HashSet::new();
|
let none = HashSet::new();
|
||||||
|
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
|
||||||
|
|
||||||
// Backend recreated while lnd sat running (NoOp) → cascade.
|
// Backend recreated while lnd sat running (NoOp) → cascade.
|
||||||
let r = report(vec![
|
let r = report(vec![
|
||||||
@@ -6353,7 +6546,7 @@ app:
|
|||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cascade_pairs_for_report(&r, &none),
|
cascade_pairs_for_report(&r, &none, &changed),
|
||||||
vec![("bitcoin-knots", "lnd")]
|
vec![("bitcoin-knots", "lnd")]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6363,7 +6556,7 @@ app:
|
|||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cascade_pairs_for_report(&r, &none),
|
cascade_pairs_for_report(&r, &none, &changed),
|
||||||
vec![("bitcoin-core", "lnd")]
|
vec![("bitcoin-core", "lnd")]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6372,7 +6565,7 @@ app:
|
|||||||
("bitcoin-knots", ReconcileAction::NoOp),
|
("bitcoin-knots", ReconcileAction::NoOp),
|
||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
|
||||||
|
|
||||||
// Dependent itself (re)started this pass → it already resolved the
|
// Dependent itself (re)started this pass → it already resolved the
|
||||||
// fresh address; no cascade.
|
// fresh address; no cascade.
|
||||||
@@ -6380,7 +6573,7 @@ app:
|
|||||||
("bitcoin-knots", ReconcileAction::Installed),
|
("bitcoin-knots", ReconcileAction::Installed),
|
||||||
("lnd", ReconcileAction::Started),
|
("lnd", ReconcileAction::Started),
|
||||||
]);
|
]);
|
||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||||
|
|
||||||
// User-stopped dependent is never bounced.
|
// User-stopped dependent is never bounced.
|
||||||
let r = report(vec![
|
let r = report(vec![
|
||||||
@@ -6388,14 +6581,50 @@ app:
|
|||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
let stopped: HashSet<String> = ["lnd".to_string()].into();
|
||||||
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
|
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
|
||||||
|
|
||||||
// Non-backend recreates don't cascade anything.
|
// Non-backend recreates don't cascade anything.
|
||||||
let r = report(vec![
|
let r = report(vec![
|
||||||
("grafana", ReconcileAction::Installed),
|
("grafana", ReconcileAction::Installed),
|
||||||
("lnd", ReconcileAction::NoOp),
|
("lnd", ReconcileAction::NoOp),
|
||||||
]);
|
]);
|
||||||
assert!(cascade_pairs_for_report(&r, &none).is_empty());
|
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
|
||||||
|
let rt = Arc::new(MockRuntime::default());
|
||||||
|
rt.set_state("bitcoin-knots", ContainerState::Exited);
|
||||||
|
rt.set_state("lnd", ContainerState::Exited);
|
||||||
|
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
|
||||||
|
let mut orch = orch_with(rt.clone()).await;
|
||||||
|
orch.set_disk_gb_for_test(2000);
|
||||||
|
for id in ["unrelated", "lnd", "bitcoin-knots"] {
|
||||||
|
orch.insert_manifest_for_test(
|
||||||
|
pull_manifest(id, &format!("docker.io/example/{id}:1")),
|
||||||
|
PathBuf::from(format!("/tmp/{id}")),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
let report = orch.reconcile_all().await;
|
||||||
|
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
|
||||||
|
let calls = rt.calls();
|
||||||
|
let bitcoin = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == "start_container:bitcoin-knots")
|
||||||
|
.unwrap();
|
||||||
|
let lnd = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c == "start_container:lnd")
|
||||||
|
.unwrap();
|
||||||
|
let pull = calls
|
||||||
|
.iter()
|
||||||
|
.position(|c| c.starts_with("pull_image:"))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
bitcoin < lnd && lnd < pull,
|
||||||
|
"wallet startup was delayed by unrelated recovery: {calls:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
|
|||||||
pub no_new_privileges: bool,
|
pub no_new_privileges: bool,
|
||||||
pub cpu_quota: Option<u32>,
|
pub cpu_quota: Option<u32>,
|
||||||
pub restart_policy: RestartPolicy,
|
pub restart_policy: RestartPolicy,
|
||||||
|
pub stop_grace_secs: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QuadletUnit {
|
impl QuadletUnit {
|
||||||
@@ -216,6 +217,10 @@ impl QuadletUnit {
|
|||||||
let _ = writeln!(s, "[Container]");
|
let _ = writeln!(s, "[Container]");
|
||||||
let _ = writeln!(s, "ContainerName={}", self.name);
|
let _ = writeln!(s, "ContainerName={}", self.name);
|
||||||
let _ = writeln!(s, "Image={}", self.image);
|
let _ = writeln!(s, "Image={}", self.image);
|
||||||
|
let grace = self
|
||||||
|
.stop_grace_secs
|
||||||
|
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
|
||||||
|
let _ = writeln!(s, "StopTimeout={grace}");
|
||||||
// Pull=never: companions are pre-pulled or built. A missing image
|
// Pull=never: companions are pre-pulled or built. A missing image
|
||||||
// must surface as a unit start failure, not a silent retry storm.
|
// must surface as a unit start failure, not a silent retry storm.
|
||||||
let _ = writeln!(s, "Pull=never");
|
let _ = writeln!(s, "Pull=never");
|
||||||
@@ -350,6 +355,15 @@ impl QuadletUnit {
|
|||||||
// the unit stuck in deactivating. Health/status remains app-level state,
|
// the unit stuck in deactivating. Health/status remains app-level state,
|
||||||
// not a systemd start gate.
|
// not a systemd start gate.
|
||||||
let _ = writeln!(s, "TimeoutStartSec=0");
|
let _ = writeln!(s, "TimeoutStartSec=0");
|
||||||
|
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
|
||||||
|
// Stop explicitly before Quadlet's generated `podman rm -f`. The
|
||||||
|
// existing container may still carry Podman's old 10-second default;
|
||||||
|
// StopTimeout alone only protects containers created after migration.
|
||||||
|
let _ = writeln!(s, "ExecStop=");
|
||||||
|
let _ = writeln!(
|
||||||
|
s,
|
||||||
|
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
|
||||||
|
);
|
||||||
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
|
||||||
// from saturating the journal. Companions: Always. Backends:
|
// from saturating the journal. Companions: Always. Backends:
|
||||||
// OnFailure (clean stops stay stopped).
|
// OnFailure (clean stops stay stopped).
|
||||||
@@ -525,6 +539,9 @@ impl QuadletUnit {
|
|||||||
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
|
||||||
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
|
||||||
restart_policy: RestartPolicy::Always,
|
restart_policy: RestartPolicy::Always,
|
||||||
|
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
|
||||||
|
manifest, name,
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -676,6 +693,13 @@ pub async fn unit_exists(name: &str) -> bool {
|
|||||||
|
|
||||||
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
|
||||||
pub async fn unit_dir() -> Result<PathBuf> {
|
pub async fn unit_dir() -> Result<PathBuf> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||||
|
return Ok(TEST_UNITS
|
||||||
|
.get_or_init(|| tempfile::tempdir().unwrap().keep())
|
||||||
|
.clone());
|
||||||
|
}
|
||||||
let home = std::env::var_os("HOME")
|
let home = std::env::var_os("HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
|
||||||
@@ -785,7 +809,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
|
|||||||
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
/// corruption — so the orchestrator passes the per-app grace here. Never waits
|
||||||
/// less than `QUADLET_STOP_TIMEOUT`.
|
/// less than `QUADLET_STOP_TIMEOUT`.
|
||||||
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
|
||||||
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
|
let name = service.strip_suffix(".service").unwrap_or(service);
|
||||||
|
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let timeout = timeout.max(stop_wait_timeout(name, &body));
|
||||||
match systemctl_user_status(&["stop", service], timeout).await {
|
match systemctl_user_status(&["stop", service], timeout).await {
|
||||||
Ok(status) if status.success() => Ok(()),
|
Ok(status) if status.success() => Ok(()),
|
||||||
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
|
||||||
@@ -806,10 +834,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The command waiter must outlive both the container grace and systemd's
|
||||||
|
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
|
||||||
|
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
|
||||||
|
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
|
||||||
|
.max(QUADLET_STOP_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
|
||||||
|
directive_values(unit_body, "StopTimeout=")
|
||||||
|
.last()
|
||||||
|
.and_then(|value| value.parse::<u64>().ok())
|
||||||
|
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
|
||||||
|
}
|
||||||
|
|
||||||
async fn systemctl_user_status(
|
async fn systemctl_user_status(
|
||||||
args: &[&str],
|
args: &[&str],
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<std::process::ExitStatus> {
|
) -> Result<std::process::ExitStatus> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
use std::os::unix::process::ExitStatusExt;
|
||||||
|
return Ok(std::process::ExitStatus::from_raw(0));
|
||||||
|
}
|
||||||
let mut cmd = Command::new("systemctl");
|
let mut cmd = Command::new("systemctl");
|
||||||
cmd.arg("--user").args(args);
|
cmd.arg("--user").args(args);
|
||||||
cmd.kill_on_drop(true);
|
cmd.kill_on_drop(true);
|
||||||
@@ -856,6 +903,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
anyhow::bail!("Unit tests have no real user service manager");
|
||||||
|
}
|
||||||
let mut cmd = Command::new("systemctl");
|
let mut cmd = Command::new("systemctl");
|
||||||
cmd.arg("--user").args(args);
|
cmd.arg("--user").args(args);
|
||||||
cmd.kill_on_drop(true);
|
cmd.kill_on_drop(true);
|
||||||
@@ -923,6 +974,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
|
|||||||
/// that systemd no longer knows about.
|
/// that systemd no longer knows about.
|
||||||
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
||||||
let svc = format!("{unit_name}.service");
|
let svc = format!("{unit_name}.service");
|
||||||
|
let path = dir.join(format!("{unit_name}.container"));
|
||||||
|
let body = fs::read_to_string(&path).await.unwrap_or_default();
|
||||||
|
let timeout = stop_wait_timeout(unit_name, &body);
|
||||||
|
let grace = stop_grace_from_unit(unit_name, &body).to_string();
|
||||||
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
// Stop first; ignore failure (unit may already be down). BOUNDED — on
|
||||||
// rootless podman a generated unit can wedge in "deactivating" while
|
// rootless podman a generated unit can wedge in "deactivating" while
|
||||||
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
|
||||||
@@ -930,13 +985,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
// the package entry is stranded in `Removing` (a ghost in My Apps that also
|
||||||
// blocks reinstall). If the graceful stop times out, escalate to
|
// blocks reinstall). If the graceful stop times out, escalate to
|
||||||
// SIGKILL + reset-failed so teardown always proceeds.
|
// SIGKILL + reset-failed so teardown always proceeds.
|
||||||
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
|
if systemctl_user_status(&["stop", &svc], timeout)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
let _ = kill_and_reset_service(&svc).await;
|
let _ = kill_and_reset_service(&svc).await;
|
||||||
}
|
}
|
||||||
let path = dir.join(format!("{unit_name}.container"));
|
|
||||||
if fs::try_exists(&path).await.unwrap_or(false) {
|
if fs::try_exists(&path).await.unwrap_or(false) {
|
||||||
match fs::remove_file(&path).await {
|
match fs::remove_file(&path).await {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
@@ -949,9 +1003,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
// Bounded so a hung podman store can't re-introduce the stall this function
|
// Bounded so a hung podman store can't re-introduce the stall this function
|
||||||
// exists to avoid.
|
// exists to avoid.
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
QUADLET_STOP_TIMEOUT,
|
timeout,
|
||||||
Command::new("podman")
|
Command::new("podman")
|
||||||
.args(["rm", "-f", unit_name])
|
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
|
||||||
.status(),
|
.status(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -960,6 +1014,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
|
|||||||
|
|
||||||
/// Is the quadlet-generated service currently active?
|
/// Is the quadlet-generated service currently active?
|
||||||
pub async fn is_active(service: &str) -> bool {
|
pub async fn is_active(service: &str) -> bool {
|
||||||
|
if cfg!(test) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Command::new("systemctl")
|
Command::new("systemctl")
|
||||||
.args(["--user", "is-active", "--quiet", service])
|
.args(["--user", "is-active", "--quiet", service])
|
||||||
.status()
|
.status()
|
||||||
@@ -973,6 +1030,118 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shutdown_grace_covers_container_systemd_and_caller() {
|
||||||
|
for (name, grace) in [
|
||||||
|
("bitcoin-core", 600),
|
||||||
|
("bitcoin-knots", 600),
|
||||||
|
("lnd", 330),
|
||||||
|
("electrumx", 300),
|
||||||
|
("other", 30),
|
||||||
|
] {
|
||||||
|
let unit = QuadletUnit {
|
||||||
|
name: name.into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let body = unit.render();
|
||||||
|
assert!(body.contains(&format!("StopTimeout={grace}\n")));
|
||||||
|
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
|
||||||
|
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout(name, &body),
|
||||||
|
Duration::from_secs(grace + 30)
|
||||||
|
);
|
||||||
|
// Legacy units have no StopTimeout directive yet.
|
||||||
|
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn custom_stop_grace_survives_render_and_restart_budget() {
|
||||||
|
let manifest: AppManifest = serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
app:
|
||||||
|
id: custom-db
|
||||||
|
name: Custom database
|
||||||
|
version: 1.0.0
|
||||||
|
stop_grace_secs: 900
|
||||||
|
container:
|
||||||
|
image: example/db:1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
|
||||||
|
assert_eq!(unit.stop_grace_secs, Some(900));
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout("custom-db", &unit.render()),
|
||||||
|
Duration::from_secs(930)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
stop_wait_timeout("lnd", "StopTimeout=invalid"),
|
||||||
|
Duration::from_secs(360)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stop_grace_migration_does_not_request_an_execution_restart() {
|
||||||
|
let unit = sample_unit();
|
||||||
|
let new = unit.render();
|
||||||
|
let old = new
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
!line.starts_with("StopTimeout=")
|
||||||
|
&& !line.starts_with("TimeoutStopSec=")
|
||||||
|
&& !line.starts_with("ExecStop=")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
assert!(!exec_changed(&old, &new));
|
||||||
|
assert!(!publish_ports_changed(&old, &new));
|
||||||
|
assert!(!network_aliases_changed(&old, &new));
|
||||||
|
assert!(!health_cmd_changed(&old, &new));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn actual_quadlet_generator_stops_before_forced_removal() {
|
||||||
|
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
|
||||||
|
if !generator.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"Quadlet generator unavailable; run this regression on the Linux release host"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let unit = QuadletUnit {
|
||||||
|
name: "grace-test".into(),
|
||||||
|
image: "localhost/test:latest".into(),
|
||||||
|
stop_grace_secs: Some(600),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
|
||||||
|
let output = std::process::Command::new(generator)
|
||||||
|
.args(["--user", "--dryrun"])
|
||||||
|
.env("QUADLET_UNIT_DIRS", dir.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let generated = String::from_utf8_lossy(&output.stdout).to_string()
|
||||||
|
+ &String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stop = generated
|
||||||
|
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
|
||||||
|
.unwrap();
|
||||||
|
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
|
||||||
|
assert!(
|
||||||
|
stop < remove,
|
||||||
|
"Legacy container must stop gracefully before removal"
|
||||||
|
);
|
||||||
|
assert!(generated.contains("--stop-timeout 600"));
|
||||||
|
assert!(generated.contains("TimeoutStopSec=615"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_emits_secret_env_by_reference_never_value() {
|
fn render_emits_secret_env_by_reference_never_value() {
|
||||||
let u = QuadletUnit {
|
let u = QuadletUnit {
|
||||||
|
|||||||
@@ -296,6 +296,24 @@ pub async fn serve_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let file_path = content_file_path(data_dir, item);
|
||||||
|
if !file_path.exists() {
|
||||||
|
// The catalog entry survived (it's a separate JSON file) but its
|
||||||
|
// backing file is gone — most likely lost in an unrelated data-dir
|
||||||
|
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
||||||
|
// outlived a filebrowser reinstall that wiped the files themselves).
|
||||||
|
// Leaving the entry in place would keep advertising it as available
|
||||||
|
// to every peer forever, each hitting the exact same dead end this
|
||||||
|
// one just did. Prune it so it stops being offered.
|
||||||
|
warn!(
|
||||||
|
content_id = %id,
|
||||||
|
filename = %item.filename,
|
||||||
|
"content catalog entry's file is missing on disk — pruning the stale entry"
|
||||||
|
);
|
||||||
|
prune_missing_content_entry(data_dir, id).await;
|
||||||
|
return Ok(ServeResult::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
// Check access control
|
// Check access control
|
||||||
if !owner_session {
|
if !owner_session {
|
||||||
match &item.access {
|
match &item.access {
|
||||||
@@ -307,8 +325,12 @@ pub async fn serve_content(
|
|||||||
// Each path only counts when the sharer accepts that method.
|
// Each path only counts when the sharer accepts that method.
|
||||||
let mut authorized = false;
|
let mut authorized = false;
|
||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if (method_accepted(&item.access, "ecash")
|
let method = if token.trim().starts_with("cashu") {
|
||||||
|| method_accepted(&item.access, "fedimint"))
|
"ecash"
|
||||||
|
} else {
|
||||||
|
"fedimint"
|
||||||
|
};
|
||||||
|
if method_accepted(&item.access, method)
|
||||||
&& verify_payment_token(data_dir, token, *price_sats).await
|
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||||
{
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
@@ -336,24 +358,6 @@ pub async fn serve_content(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_path = content_file_path(data_dir, item);
|
|
||||||
if !file_path.exists() {
|
|
||||||
// The catalog entry survived (it's a separate JSON file) but its
|
|
||||||
// backing file is gone — most likely lost in an unrelated data-dir
|
|
||||||
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
|
|
||||||
// outlived a filebrowser reinstall that wiped the files themselves).
|
|
||||||
// Leaving the entry in place would keep advertising it as available
|
|
||||||
// to every peer forever, each hitting the exact same dead end this
|
|
||||||
// one just did. Prune it so it stops being offered.
|
|
||||||
warn!(
|
|
||||||
content_id = %id,
|
|
||||||
filename = %item.filename,
|
|
||||||
"content catalog entry's file is missing on disk — pruning the stale entry"
|
|
||||||
);
|
|
||||||
prune_missing_content_entry(data_dir, id).await;
|
|
||||||
return Ok(ServeResult::NotFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata = fs::metadata(&file_path)
|
let metadata = fs::metadata(&file_path)
|
||||||
.await
|
.await
|
||||||
.context("Failed to read file metadata")?;
|
.context("Failed to read file metadata")?;
|
||||||
@@ -573,7 +577,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verify a payment token covers the required amount.
|
/// Verify a payment token covers the required amount.
|
||||||
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
|
/// Accepts real Cashu tokens and Fedimint notes.
|
||||||
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
/// Swaps proofs at the mint to verify they're unspent before accepting.
|
||||||
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
|
||||||
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
|
|||||||
// Installation is a decision, not a runtime observation, so it gets a record
|
// Installation is a decision, not a runtime observation, so it gets a record
|
||||||
// of its own that no amount of downtime erodes.
|
// of its own that no amount of downtime erodes.
|
||||||
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
|
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
|
||||||
|
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||||
|
|
||||||
/// Load the durable set of installed app ids / container names.
|
/// Load the durable set of installed app ids / container names.
|
||||||
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
|
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
|
||||||
@@ -220,12 +221,23 @@ pub async fn load_installed_apps_if_recorded(
|
|||||||
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
|
||||||
let path = data_dir.join(INSTALLED_APPS_FILE);
|
let path = data_dir.join(INSTALLED_APPS_FILE);
|
||||||
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
if let Ok(json) = serde_json::to_string_pretty(installed) {
|
||||||
let _ = fs::write(&path, json).await;
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
let result = async {
|
||||||
|
fs::write(&tmp, json).await?;
|
||||||
|
fs::File::open(&tmp).await?.sync_all().await?;
|
||||||
|
fs::rename(&tmp, &path).await?;
|
||||||
|
fs::File::open(data_dir).await?.sync_all().await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
if let Err(error) = result {
|
||||||
|
warn!(%error, "could not persist installed apps");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record that an app is installed. Called when an install succeeds.
|
/// Record that an app is installed. Called when an install succeeds.
|
||||||
pub async fn mark_installed(data_dir: &Path, name: &str) {
|
pub async fn mark_installed(data_dir: &Path, name: &str) {
|
||||||
|
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||||
let mut installed = load_installed_apps(data_dir).await;
|
let mut installed = load_installed_apps(data_dir).await;
|
||||||
if installed.insert(name.to_string()) {
|
if installed.insert(name.to_string()) {
|
||||||
save_installed_apps(data_dir, &installed).await;
|
save_installed_apps(data_dir, &installed).await;
|
||||||
@@ -235,6 +247,7 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
|
|||||||
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
|
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
|
||||||
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
|
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
|
||||||
pub async fn clear_installed(data_dir: &Path, name: &str) {
|
pub async fn clear_installed(data_dir: &Path, name: &str) {
|
||||||
|
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||||
let mut installed = load_installed_apps(data_dir).await;
|
let mut installed = load_installed_apps(data_dir).await;
|
||||||
if installed.remove(name) {
|
if installed.remove(name) {
|
||||||
save_installed_apps(data_dir, &installed).await;
|
save_installed_apps(data_dir, &installed).await;
|
||||||
@@ -252,6 +265,7 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
|
|||||||
/// need it. Runs on every boot, so an app installed before the upgrade is
|
/// need it. Runs on every boot, so an app installed before the upgrade is
|
||||||
/// still picked up whenever it is next seen alive.
|
/// still picked up whenever it is next seen alive.
|
||||||
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
|
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
|
||||||
|
let _guard = INSTALLED_APPS_LOCK.lock().await;
|
||||||
if present_container_names.is_empty() {
|
if present_container_names.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1497,3 +1511,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod installed_concurrency_tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_install_records_are_not_lost() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for i in 0..24 {
|
||||||
|
let path = dir.path().to_owned();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
mark_installed(&path, &format!("app-{i}")).await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
|
||||||
|
clear_installed(dir.path(), "app-3").await;
|
||||||
|
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
|
||||||
|
assert!(!dir.path().join("installed-apps.json.tmp").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ pub enum PackageState {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct PackageDataEntry {
|
pub struct PackageDataEntry {
|
||||||
|
/// Whether the app's HTTP upstream answered this scan (independent of
|
||||||
|
/// container health and blockchain sync). Missing on older nodes.
|
||||||
|
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ui_ready: Option<bool>,
|
||||||
pub state: PackageState,
|
pub state: PackageState,
|
||||||
/// Container health: "healthy", "unhealthy", "starting", or null
|
/// Container health: "healthy", "unhealthy", "starting", or null
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -297,6 +301,8 @@ pub enum InstallPhase {
|
|||||||
/// `podman pull` in progress (the longest phase — up to several
|
/// `podman pull` in progress (the longest phase — up to several
|
||||||
/// minutes for large images on slow networks).
|
/// minutes for large images on slow networks).
|
||||||
PullingImage,
|
PullingImage,
|
||||||
|
/// Orchestrator owns download/build and startup as one operation.
|
||||||
|
PreparingApp,
|
||||||
/// Creating data directories, writing app-specific configs
|
/// Creating data directories, writing app-specific configs
|
||||||
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
|
||||||
CreatingContainer,
|
CreatingContainer,
|
||||||
|
|||||||
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
|
|||||||
};
|
};
|
||||||
|
|
||||||
crate::data_model::PackageDataEntry {
|
crate::data_model::PackageDataEntry {
|
||||||
state,
|
state: state.clone(),
|
||||||
// install_progress and uninstall_stage are also owned by the
|
// install_progress and uninstall_stage are also owned by the
|
||||||
// initiating op (same reason as state) — keep them.
|
// initiating op (same reason as state) — keep them.
|
||||||
install_progress: existing.install_progress.clone(),
|
install_progress: existing.install_progress.clone(),
|
||||||
uninstall_stage: existing.uninstall_stage.clone(),
|
uninstall_stage: existing.uninstall_stage.clone(),
|
||||||
// Everything else comes from the fresh scan.
|
// Everything else comes from the fresh scan.
|
||||||
|
ui_ready: if state == crate::data_model::PackageState::Running {
|
||||||
|
fresh.ui_ready
|
||||||
|
} else {
|
||||||
|
Some(false)
|
||||||
|
},
|
||||||
health: fresh.health.clone(),
|
health: fresh.health.clone(),
|
||||||
exit_code: fresh.exit_code,
|
exit_code: fresh.exit_code,
|
||||||
static_files: fresh.static_files.clone(),
|
static_files: fresh.static_files.clone(),
|
||||||
@@ -1809,7 +1814,10 @@ async fn scan_and_update_packages(
|
|||||||
absence_tracker: &mut HashMap<String, u32>,
|
absence_tracker: &mut HashMap<String, u32>,
|
||||||
transitional_since: &mut HashMap<String, Instant>,
|
transitional_since: &mut HashMap<String, Instant>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut packages = scanner.scan_containers().await?;
|
let (before_scan, _) = state.get_snapshot().await;
|
||||||
|
let mut packages = scanner
|
||||||
|
.scan_containers(data_dir, &before_scan.package_data)
|
||||||
|
.await?;
|
||||||
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
|
||||||
for (id, pkg) in packages.iter_mut() {
|
for (id, pkg) in packages.iter_mut() {
|
||||||
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
|
||||||
@@ -1870,11 +1878,14 @@ async fn scan_and_update_packages(
|
|||||||
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
|
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
|
||||||
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
|
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
|
||||||
if tor_changed || update_changed {
|
if tor_changed || update_changed {
|
||||||
let mut data = current_data;
|
state
|
||||||
data.server_info.tor_address = tor_addr.clone();
|
.mutate_data(|data| {
|
||||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
data.server_info.tor_address = tor_addr.clone();
|
||||||
data.server_info.status_info.updated = update_available;
|
data.server_info.node_address =
|
||||||
state.update_data(data).await;
|
tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||||
|
data.server_info.status_info.updated = update_available;
|
||||||
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -1899,6 +1910,13 @@ async fn scan_and_update_packages(
|
|||||||
// died without cleanup and let the scan override it.
|
// died without cleanup and let the scan override it.
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for (id, pkg) in &packages {
|
for (id, pkg) in &packages {
|
||||||
|
if user_uninstalled.contains(id)
|
||||||
|
|| user_uninstalled.contains(&format!("archy-{id}"))
|
||||||
|
|| (before_scan.package_data.contains_key(id)
|
||||||
|
&& !current_data.package_data.contains_key(id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
absence_tracker.remove(id);
|
absence_tracker.remove(id);
|
||||||
let existing = merged.get(id);
|
let existing = merged.get(id);
|
||||||
let overwrite = match existing {
|
let overwrite = match existing {
|
||||||
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if changed || tor_changed || first_scan || update_changed {
|
if changed || tor_changed || first_scan || update_changed {
|
||||||
let mut data = current_data;
|
state
|
||||||
data.package_data = merged;
|
.mutate_data(|data| {
|
||||||
data.server_info.tor_address = tor_addr.clone();
|
// A lifecycle operation may have started/finished while this scan
|
||||||
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
// awaited probes or disk I/O. Never overwrite that newer entry or
|
||||||
data.server_info.status_info.containers_scanned = true;
|
// resurrect one that an uninstall removed in the meantime.
|
||||||
data.server_info.status_info.updated = update_available;
|
apply_scanned_packages(&mut data.package_data, ¤t_data.package_data, &merged);
|
||||||
state.update_data(data).await;
|
data.server_info.tor_address = tor_addr.clone();
|
||||||
debug!(
|
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
|
||||||
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
|
data.server_info.status_info.containers_scanned = true;
|
||||||
changed, tor_changed, first_scan, update_changed
|
data.server_info.status_info.updated = update_available;
|
||||||
);
|
})
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_scanned_packages(
|
||||||
|
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
|
base: &HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
|
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
|
) {
|
||||||
|
for (id, fresh) in scanned {
|
||||||
|
if latest.get(id) == base.get(id) {
|
||||||
|
latest.insert(id.clone(), fresh.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in base.keys() {
|
||||||
|
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
|
||||||
|
latest.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn normalize_reachable_package_health(
|
async fn normalize_reachable_package_health(
|
||||||
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
|
||||||
) {
|
) {
|
||||||
@@ -2268,6 +2304,7 @@ mod merge_tests {
|
|||||||
|
|
||||||
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
|
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
|
||||||
PackageDataEntry {
|
PackageDataEntry {
|
||||||
|
ui_ready: None,
|
||||||
state,
|
state,
|
||||||
health: health.map(|s| s.to_string()),
|
health: health.map(|s| s.to_string()),
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
@@ -2280,6 +2317,37 @@ mod merge_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
|
||||||
|
let running = make_entry(PackageState::Running, Some("healthy"));
|
||||||
|
let restarting = make_entry(PackageState::Restarting, None);
|
||||||
|
let base = [
|
||||||
|
("restart".into(), running.clone()),
|
||||||
|
("uninstalled".into(), running.clone()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let mut latest = [
|
||||||
|
("restart".into(), restarting.clone()),
|
||||||
|
("new".into(), running.clone()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let scanned = [
|
||||||
|
("restart".into(), running.clone()),
|
||||||
|
("uninstalled".into(), running.clone()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
apply_scanned_packages(&mut latest, &base, &scanned);
|
||||||
|
assert_eq!(latest.get("restart"), Some(&restarting));
|
||||||
|
assert_eq!(latest.get("new"), Some(&running));
|
||||||
|
assert!(!latest.contains_key("uninstalled"));
|
||||||
|
apply_scanned_packages(&mut latest, &base, &HashMap::new());
|
||||||
|
assert_eq!(latest.get("restart"), Some(&restarting));
|
||||||
|
assert!(latest.contains_key("new"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn peer_path_filter_allows_content_catalog_and_items() {
|
fn peer_path_filter_allows_content_catalog_and_items() {
|
||||||
// Regression: the content *catalog* is exactly "/content" (no trailing
|
// Regression: the content *catalog* is exactly "/content" (no trailing
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
|
||||||
|
//! Missing preference preserves the existing disk-based automatic selection.
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
pub struct BitcoinStorage {
|
||||||
|
pub prune: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
|
||||||
|
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
|
||||||
|
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
|
||||||
|
let dir = data_dir.join("settings");
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let path = dir.join("bitcoin-storage.json");
|
||||||
|
let temporary = dir.join("bitcoin-storage.json.tmp");
|
||||||
|
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
|
||||||
|
tokio::fs::rename(temporary, path).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
assert!(!load(dir.path()).await.unwrap().prune);
|
||||||
|
save(dir.path(), true).await.unwrap();
|
||||||
|
assert!(load(dir.path()).await.unwrap().prune);
|
||||||
|
save(dir.path(), false).await.unwrap();
|
||||||
|
assert!(!load(dir.path()).await.unwrap().prune);
|
||||||
|
}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn corrupt_setting_is_not_silently_changed_to_archival() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
save(dir.path(), true).await.unwrap();
|
||||||
|
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(load(dir.path()).await.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,3 +7,5 @@
|
|||||||
pub mod ai_permissions;
|
pub mod ai_permissions;
|
||||||
pub mod session_policy;
|
pub mod session_policy;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
||||||
|
pub mod bitcoin_storage;
|
||||||
|
|||||||
@@ -54,6 +54,21 @@ impl StateManager {
|
|||||||
let _ = self.broadcast_tx.send(message);
|
let _ = self.broadcast_tx.send(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply a small state change while holding the write lock. A lifecycle
|
||||||
|
/// task must not replace the entire model from an earlier snapshot.
|
||||||
|
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
|
||||||
|
let mut data = self.data.write().await;
|
||||||
|
let result = change(&mut data);
|
||||||
|
let mut rev = self.revision.write().await;
|
||||||
|
*rev += 1;
|
||||||
|
let _ = self.broadcast_tx.send(WebSocketMessage {
|
||||||
|
rev: *rev,
|
||||||
|
data: Some(data.clone()),
|
||||||
|
patch: None,
|
||||||
|
});
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Get a WebSocket message with the current state
|
/// Get a WebSocket message with the current state
|
||||||
pub async fn get_initial_message(&self) -> WebSocketMessage {
|
pub async fn get_initial_message(&self) -> WebSocketMessage {
|
||||||
let (data, rev) = self.get_snapshot().await;
|
let (data, rev) = self.get_snapshot().await;
|
||||||
@@ -190,3 +205,29 @@ mod tests {
|
|||||||
assert_eq!(rev, 1);
|
assert_eq!(rev, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod atomic_mutation_tests {
|
||||||
|
use super::*;
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_updates_preserve_independent_entries() {
|
||||||
|
let state = Arc::new(StateManager::new());
|
||||||
|
let mut tasks = Vec::new();
|
||||||
|
for i in 0..24 {
|
||||||
|
let state = state.clone();
|
||||||
|
tasks.push(tokio::spawn(async move {
|
||||||
|
state
|
||||||
|
.mutate_data(|data| {
|
||||||
|
data.peer_health.insert(format!("peer-{i}"), true);
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for task in tasks {
|
||||||
|
task.await.unwrap();
|
||||||
|
}
|
||||||
|
let (data, revision) = state.get_snapshot().await;
|
||||||
|
assert_eq!(data.peer_health.len(), 24);
|
||||||
|
assert_eq!(revision, 24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
|
|||||||
/// service unit that inherits systemd's default protections (i.e. none
|
/// service unit that inherits systemd's default protections (i.e. none
|
||||||
/// of ours), escaping the namespace.
|
/// of ours), escaping the namespace.
|
||||||
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
anyhow::ensure!(
|
||||||
|
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||||
|
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||||
|
);
|
||||||
|
let (program, args) = args.split_first().context("Missing test command")?;
|
||||||
|
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||||
|
return tokio::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.context("isolated test command failed");
|
||||||
|
}
|
||||||
|
|
||||||
let mut full: Vec<&str> = vec![
|
let mut full: Vec<&str> = vec![
|
||||||
"systemd-run",
|
"systemd-run",
|
||||||
"--wait",
|
"--wait",
|
||||||
@@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
|
|||||||
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
|
||||||
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
/// (e.g. `stat`) where the answer is in the output, not the exit status.
|
||||||
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
anyhow::ensure!(
|
||||||
|
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
|
||||||
|
"Host-operation tests require scripts/test-backend-isolated.sh"
|
||||||
|
);
|
||||||
|
let (program, args) = args.split_first().context("Missing test command")?;
|
||||||
|
// Run inside the test namespace, never escape through sudo/systemd-run.
|
||||||
|
return tokio::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("isolated test command failed");
|
||||||
|
}
|
||||||
|
|
||||||
let mut full: Vec<&str> = vec![
|
let mut full: Vec<&str> = vec![
|
||||||
"systemd-run",
|
"systemd-run",
|
||||||
"--wait",
|
"--wait",
|
||||||
|
|||||||
@@ -489,45 +489,6 @@ pub fn amount_to_denominations(mut amount: u64) -> Vec<u64> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// A v4 (cashuB) token always carries a v2 keyset id in its short
|
|
||||||
/// (8-byte) form — confirmed against the real cashu 0.17.5 crate
|
|
||||||
/// (`TokenV4Token`'s `serialize_v4_keyset_id` unconditionally narrows to
|
|
||||||
/// `ShortKeysetId`) and live against mint.minibits.cash (2026-09-18).
|
|
||||||
/// That is spec-compliant, not a bug here: a receiver MUST resolve the
|
|
||||||
/// short id against the mint's keyset list before spending it (see
|
|
||||||
/// `MintClient::resolve_truncated_keyset_ids`, and its missing call site
|
|
||||||
/// that this exact round trip caught in `ecash.rs`'s payment-receive
|
|
||||||
/// path). This test documents that the short form is what actually
|
|
||||||
/// crosses the wire, so nobody re-"fixes" serialize_v4 to defeat it.
|
|
||||||
#[test]
|
|
||||||
fn v4_round_trip_shortens_a_v2_keyset_id_by_design() {
|
|
||||||
let real_v2_id = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
|
||||||
assert_eq!(real_v2_id.len(), 66);
|
|
||||||
let token = CashuToken {
|
|
||||||
token: vec![TokenEntry {
|
|
||||||
mint: "https://mint.minibits.cash/Bitcoin".to_string(),
|
|
||||||
proofs: vec![Proof {
|
|
||||||
amount: 2,
|
|
||||||
id: real_v2_id.to_string(),
|
|
||||||
secret: "abcdef1234567890".to_string(),
|
|
||||||
// secp256k1 generator point G — a genuinely valid
|
|
||||||
// compressed pubkey (the other tests' placeholder C
|
|
||||||
// value is not, and serialize_v4 is the first path
|
|
||||||
// here that actually parses it).
|
|
||||||
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
||||||
.to_string(),
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
memo: None,
|
|
||||||
unit: Some("sat".to_string()),
|
|
||||||
};
|
|
||||||
let v4 = token.serialize_v4().expect("serialize_v4 should accept a real v2 id");
|
|
||||||
let decoded = CashuToken::deserialize(&v4).unwrap();
|
|
||||||
let got_id = &decoded.token[0].proofs[0].id;
|
|
||||||
assert_eq!(got_id, "01fc0ec0e59cd6fa", "expected the short (8-byte) v2 form on the wire");
|
|
||||||
assert!(is_truncated_v2_keyset_id(got_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_serialize_deserialize_roundtrip() {
|
fn test_serialize_deserialize_roundtrip() {
|
||||||
let token = CashuToken {
|
let token = CashuToken {
|
||||||
|
|||||||
@@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
|
|||||||
let mut all_target: Vec<u64> = send_denoms.clone();
|
let mut all_target: Vec<u64> = send_denoms.clone();
|
||||||
all_target.extend(&change_denoms);
|
all_target.extend(&change_denoms);
|
||||||
|
|
||||||
let swap_result = client.swap(&selected_proofs, &all_target).await?;
|
let swap_result = client
|
||||||
|
.swap_at_least(&selected_proofs, &all_target, amount_sats)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Mark original proofs as spent
|
// Mark original proofs as spent
|
||||||
wallet.mark_spent(&indices);
|
wallet.mark_spent(&indices);
|
||||||
@@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
|||||||
// Verify all mints in the token are accepted
|
// Verify all mints in the token are accepted
|
||||||
let accepted = load_accepted_mints(data_dir).await?;
|
let accepted = load_accepted_mints(data_dir).await?;
|
||||||
for mint_url in token.mint_urls() {
|
for mint_url in token.mint_urls() {
|
||||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
if !accepted
|
||||||
|
.mints
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
|
||||||
|
{
|
||||||
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1223,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
|
|||||||
received_total += amount;
|
received_total += amount;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
|
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
|
||||||
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
|
||||||
last_reason = Some(e.to_string());
|
last_reason = Some(e.to_string());
|
||||||
// Continue with other mints if any
|
// Continue with other mints if any
|
||||||
@@ -1298,22 +1304,10 @@ pub async fn verify_and_receive_payment(
|
|||||||
token_str: &str,
|
token_str: &str,
|
||||||
required_sats: u64,
|
required_sats: u64,
|
||||||
) -> Result<u64> {
|
) -> Result<u64> {
|
||||||
// Handle legacy tokens
|
let token_str = token_str.trim();
|
||||||
|
// Synthetic legacy balances are not cryptographic proof of payment.
|
||||||
if token_str.starts_with("cashuSend_") {
|
if token_str.starts_with("cashuSend_") {
|
||||||
let amount = token_str
|
anyhow::bail!("Legacy ecash cannot authorize a paid download");
|
||||||
.split('_')
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|s| s.parse::<u64>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
if amount < required_sats {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Insufficient payment: {} sats, need {} sats",
|
|
||||||
amount,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let received = receive_legacy_token(data_dir, token_str).await?;
|
|
||||||
return Ok(received);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
|
||||||
@@ -1336,67 +1330,45 @@ pub async fn verify_and_receive_payment(
|
|||||||
|
|
||||||
// Parse and validate the token (cashuA or cashuB)
|
// Parse and validate the token (cashuA or cashuB)
|
||||||
let token = CashuToken::deserialize(token_str)?;
|
let token = CashuToken::deserialize(token_str)?;
|
||||||
let total = token.total_amount();
|
if token.unit.as_deref().unwrap_or("sat") != "sat" {
|
||||||
|
anyhow::bail!("Payment must be denominated in sats");
|
||||||
|
}
|
||||||
|
// A sale must redeem atomically at one mint. Otherwise a later mint
|
||||||
|
// failure can consume earlier inputs without delivering the purchase.
|
||||||
|
let entry = match token.token.as_slice() {
|
||||||
|
[entry] => entry,
|
||||||
|
_ => anyhow::bail!("Use a single-mint token for this payment"),
|
||||||
|
};
|
||||||
|
let total = entry
|
||||||
|
.proofs
|
||||||
|
.iter()
|
||||||
|
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?;
|
||||||
if total < required_sats {
|
if total < required_sats {
|
||||||
anyhow::bail!(
|
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
|
||||||
"Insufficient payment: {} sats, need {} sats",
|
|
||||||
total,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify mints are accepted
|
|
||||||
let accepted = load_accepted_mints(data_dir).await?;
|
let accepted = load_accepted_mints(data_dir).await?;
|
||||||
for mint_url in token.mint_urls() {
|
if !accepted
|
||||||
if !accepted.mints.iter().any(|m| m == mint_url) {
|
.mints
|
||||||
anyhow::bail!("Mint '{}' not accepted", mint_url);
|
.iter()
|
||||||
}
|
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
|
||||||
|
{
|
||||||
|
anyhow::bail!("Mint is not in the seller's accepted mints list");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
|
let client = mint_client(data_dir, &entry.mint).await?;
|
||||||
|
let result = client
|
||||||
|
.swap_at_least(
|
||||||
|
&entry.proofs,
|
||||||
|
&amount_to_denominations(total),
|
||||||
|
required_sats,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
|
||||||
|
// Load after the network call, so an unrelated wallet update during the
|
||||||
|
// swap is not overwritten with a pre-swap snapshot.
|
||||||
let mut wallet = load_wallet(data_dir).await?;
|
let mut wallet = load_wallet(data_dir).await?;
|
||||||
let mut received_total = 0u64;
|
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
|
||||||
|
|
||||||
for entry in &token.token {
|
|
||||||
let client = mint_client(data_dir, &entry.mint).await?;
|
|
||||||
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
|
||||||
let target_amounts = amount_to_denominations(entry_total);
|
|
||||||
|
|
||||||
// The reference cashu crate's V4 (cashuB) encoder always writes a
|
|
||||||
// NUT-02 v2 keyset id in its short (8-byte) form — confirmed live
|
|
||||||
// against mint.minibits.cash (2026-09-18): every cashuB payment
|
|
||||||
// carrying that mint's active v2 keyset failed verification with a
|
|
||||||
// bare 422 "NUT02: ID length invalid" because this call skipped
|
|
||||||
// straight to swap() with the short id still attached. MintClient's
|
|
||||||
// own receive_token() already resolves this correctly; this is the
|
|
||||||
// same fix, just not routed through it (the loop here also tracks
|
|
||||||
// received_total/mint-scoped errors that receive_token() doesn't).
|
|
||||||
let proofs = client.resolve_truncated_keyset_ids(&entry.proofs).await;
|
|
||||||
|
|
||||||
match client.swap(&proofs, &target_amounts).await {
|
|
||||||
Ok(result) => {
|
|
||||||
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
|
|
||||||
wallet.add_proofs(&entry.mint, result.new_proofs);
|
|
||||||
received_total += amount;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
// {:#} walks the full anyhow context chain, including the raw
|
|
||||||
// mint response body `mint_error()` attaches as the cause —
|
|
||||||
// {} prints only the friendly top-level message and silently
|
|
||||||
// discards the one thing that would explain a bare 422.
|
|
||||||
warn!("Payment verification failed at mint {}: {:#}", entry.mint, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if received_total < required_sats {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Payment verification failed: only {} of {} sats verified",
|
|
||||||
received_total,
|
|
||||||
required_sats
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
wallet.record_tx(
|
wallet.record_tx(
|
||||||
TransactionType::Receive,
|
TransactionType::Receive,
|
||||||
@@ -2480,3 +2452,7 @@ mod tests {
|
|||||||
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "payment_tests.rs"]
|
||||||
|
mod payment_tests;
|
||||||
|
|||||||
@@ -114,68 +114,27 @@ fn describe_mint_error_code(code: i64) -> Option<&'static str> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a FastAPI-style validation error list — `detail` as an array of
|
|
||||||
/// `{"loc": [...], "msg": "...", "type": "..."}` objects — into one line per
|
|
||||||
/// entry. This is the shape FastAPI (and therefore most Cashu mint
|
|
||||||
/// implementations, including Nutshell) actually sends for a 422, not the
|
|
||||||
/// plain string the rest of this file otherwise expects; without this a
|
|
||||||
/// mint's real reason (e.g. `body -> inputs -> 0 -> id: NUT02: ID length
|
|
||||||
/// invalid`) was silently replaced with "no further detail".
|
|
||||||
fn describe_validation_errors(detail: &serde_json::Value) -> Option<String> {
|
|
||||||
let items = detail.as_array()?;
|
|
||||||
if items.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let lines: Vec<String> = items
|
|
||||||
.iter()
|
|
||||||
.filter_map(|item| {
|
|
||||||
let msg = item.get("msg").and_then(|m| m.as_str())?;
|
|
||||||
let loc = item
|
|
||||||
.get("loc")
|
|
||||||
.and_then(|l| l.as_array())
|
|
||||||
.map(|parts| {
|
|
||||||
parts
|
|
||||||
.iter()
|
|
||||||
.map(|p| p.as_str().map(str::to_string).unwrap_or_else(|| p.to_string()))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" -> ")
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
Some(if loc.is_empty() {
|
|
||||||
msg.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{loc}: {msg}")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(!lines.is_empty()).then(|| lines.join("; "))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
|
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
|
||||||
/// best user-facing message: the plain-language translation when we know the
|
/// best user-facing message: the plain-language translation when we know the
|
||||||
/// code, otherwise the mint's own `detail` text (a plain string, or a
|
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
|
||||||
/// FastAPI-style validation-error array), otherwise the raw body.
|
|
||||||
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
|
||||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
||||||
let code = parsed
|
let code = parsed
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|v| v.get("code"))
|
.and_then(|v| v.get("code"))
|
||||||
.and_then(|c| c.as_i64());
|
.and_then(|c| c.as_i64());
|
||||||
let detail = parsed.as_ref().and_then(|v| v.get("detail"));
|
let detail = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|v| v.get("detail"))
|
||||||
|
.and_then(|d| d.as_str());
|
||||||
|
|
||||||
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
if let Some(friendly) = code.and_then(describe_mint_error_code) {
|
||||||
return friendly.to_string();
|
return friendly.to_string();
|
||||||
}
|
}
|
||||||
if let Some(d) = detail {
|
match detail {
|
||||||
if let Some(s) = d.as_str() {
|
Some(d) if !d.is_empty() => d.to_string(),
|
||||||
if !s.is_empty() {
|
_ => format!("mint returned {} with no further detail", status),
|
||||||
return s.to_string();
|
|
||||||
}
|
|
||||||
} else if let Some(rendered) = describe_validation_errors(d) {
|
|
||||||
return rendered;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
format!("mint returned {} with no further detail", status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
|
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
|
||||||
@@ -194,6 +153,20 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
|
|||||||
cause.context(describe_mint_error_body(status, body))
|
cause.context(describe_mint_error_body(status, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
|
||||||
|
let mut outputs = Vec::new();
|
||||||
|
for &amount in requested {
|
||||||
|
if available >= amount {
|
||||||
|
outputs.push(amount);
|
||||||
|
available -= amount;
|
||||||
|
} else {
|
||||||
|
outputs.extend(amount_to_denominations(available));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outputs
|
||||||
|
}
|
||||||
|
|
||||||
/// HTTP client for a single Cashu mint.
|
/// HTTP client for a single Cashu mint.
|
||||||
pub struct MintClient {
|
pub struct MintClient {
|
||||||
url: String,
|
url: String,
|
||||||
@@ -553,6 +526,21 @@ impl MintClient {
|
|||||||
/// Swap proofs for new proofs of different denominations.
|
/// Swap proofs for new proofs of different denominations.
|
||||||
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
|
||||||
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
|
||||||
|
self.swap_at_least(inputs, target_amounts, 0).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse a payment whose mint fees would leave the seller underpaid,
|
||||||
|
/// before consuming any input proofs.
|
||||||
|
pub async fn swap_at_least(
|
||||||
|
&self,
|
||||||
|
inputs: &[Proof],
|
||||||
|
target_amounts: &[u64],
|
||||||
|
minimum: u64,
|
||||||
|
) -> Result<SwapResult> {
|
||||||
|
// V4 tokens carry short keyset IDs. Every swap path (including paid
|
||||||
|
// files and streams) must expand these, not only wallet imports.
|
||||||
|
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
|
||||||
|
let inputs = resolved.as_slice();
|
||||||
let keyset = self.get_active_sat_keyset().await?;
|
let keyset = self.get_active_sat_keyset().await?;
|
||||||
|
|
||||||
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
|
||||||
@@ -560,16 +548,35 @@ impl MintClient {
|
|||||||
// should equal outputs less fee`). Applied here rather than at each
|
// should equal outputs less fee`). Applied here rather than at each
|
||||||
// call site so send, receive and cross-mint swaps are all covered.
|
// call site so send, receive and cross-mint swaps are all covered.
|
||||||
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
// Fee-free mints (Minibits) compute 0 and are unaffected.
|
||||||
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
|
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
|
||||||
let fee = match self.get_keysets().await {
|
let inputs_total = inputs
|
||||||
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
|
.iter()
|
||||||
Err(e) => {
|
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
|
||||||
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
|
.context("Input amount overflow")?;
|
||||||
0
|
let keysets = self.get_keysets().await?;
|
||||||
}
|
let mut fee_ppk = 0u64;
|
||||||
};
|
for proof in inputs {
|
||||||
|
let input_keyset = keysets
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.id == proof.id)
|
||||||
|
.context("The mint does not recognize an input keyset")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
input_keyset.unit == "sat",
|
||||||
|
"Input keyset is not denominated in sats"
|
||||||
|
);
|
||||||
|
fee_ppk = fee_ppk
|
||||||
|
.checked_add(input_keyset.input_fee_ppk)
|
||||||
|
.context("Mint fee overflow")?;
|
||||||
|
}
|
||||||
|
let fee = fee_ppk.div_ceil(1000);
|
||||||
let spendable = inputs_total.saturating_sub(fee);
|
let spendable = inputs_total.saturating_sub(fee);
|
||||||
let requested: u64 = target_amounts.iter().sum();
|
if spendable < minimum {
|
||||||
|
anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed.");
|
||||||
|
}
|
||||||
|
let requested = target_amounts
|
||||||
|
.iter()
|
||||||
|
.try_fold(0u64, |sum, amount| sum.checked_add(*amount))
|
||||||
|
.context("Output amount overflow")?;
|
||||||
let owned_targets: Vec<u64>;
|
let owned_targets: Vec<u64>;
|
||||||
let target_amounts: &[u64] = if requested > spendable {
|
let target_amounts: &[u64] = if requested > spendable {
|
||||||
if spendable == 0 {
|
if spendable == 0 {
|
||||||
@@ -580,7 +587,10 @@ impl MintClient {
|
|||||||
debug!(
|
debug!(
|
||||||
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
|
||||||
);
|
);
|
||||||
owned_targets = amount_to_denominations(spendable);
|
// Callers put payment outputs before change. Keep that prefix
|
||||||
|
// intact while fees reduce change; re-splitting the entire sum
|
||||||
|
// can omit a payment denomination after consuming the inputs.
|
||||||
|
owned_targets = fee_adjusted_targets(target_amounts, spendable);
|
||||||
&owned_targets
|
&owned_targets
|
||||||
} else {
|
} else {
|
||||||
target_amounts
|
target_amounts
|
||||||
@@ -625,6 +635,9 @@ impl MintClient {
|
|||||||
|
|
||||||
let mut new_proofs = Vec::new();
|
let mut new_proofs = Vec::new();
|
||||||
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
|
||||||
|
if sig.amount != *amount || sig.id != keyset.id {
|
||||||
|
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
|
||||||
|
}
|
||||||
let c_prime = sig.c_prime_as_pubkey()?;
|
let c_prime = sig.c_prime_as_pubkey()?;
|
||||||
let mint_key = keyset.key_for_amount(*amount)?;
|
let mint_key = keyset.key_for_amount(*amount)?;
|
||||||
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
|
||||||
@@ -771,43 +784,35 @@ impl MintClient {
|
|||||||
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
|
||||||
///
|
///
|
||||||
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
|
||||||
/// wallets written against the original 8-byte format truncate it when
|
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
|
||||||
/// they build a token. The mint then reads the `0x01` version, expects 33
|
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
|
||||||
/// bytes, and rejects the swap — reported as
|
/// bytes, and rejects the swap — reported as
|
||||||
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
|
||||||
/// a Minibits-issued token, 2026-08-17).
|
/// a Minibits-issued token, 2026-08-17).
|
||||||
///
|
///
|
||||||
/// The id only names which keyset signed the proof, so restoring the full
|
/// The id only names which keyset signed the proof, so restoring the full
|
||||||
/// id the mint advertises is exactly what the sender meant. It is also
|
/// id the mint advertises is exactly what the sender meant. It is also
|
||||||
/// safe to attempt: an id that names the wrong keyset fails signature
|
/// safe to attempt: the mint still verifies the proof signature. Unknown
|
||||||
/// verification at the mint and no coins move. Anything already valid, or
|
/// or ambiguous short IDs are rejected before redemption.
|
||||||
/// with no unambiguous match, is passed through untouched so the mint's
|
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
|
||||||
/// own error is what the operator sees.
|
|
||||||
pub(crate) async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
|
|
||||||
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
|
||||||
if !needs_repair {
|
if !needs_repair {
|
||||||
return proofs.to_vec();
|
return Ok(proofs.to_vec());
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mint's own keyset list, in the reference implementation's shape
|
// The mint's own keyset list, in the reference implementation's shape
|
||||||
// so its NUT-02 resolver can consume it directly.
|
// so its NUT-02 resolver can consume it directly.
|
||||||
let known = match self.get_cdk_keysets().await {
|
let known = self.get_cdk_keysets().await?;
|
||||||
Ok(k) => k,
|
|
||||||
Err(e) => {
|
|
||||||
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
|
|
||||||
return proofs.to_vec();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proofs
|
proofs
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|mut p| {
|
.map(|mut p| {
|
||||||
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
|
if is_truncated_v2_keyset_id(&p.id) {
|
||||||
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
|
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
|
||||||
p.id = full;
|
.context("The mint cannot resolve this short keyset ID unambiguously")?;
|
||||||
}
|
}
|
||||||
p
|
Ok(p)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -843,7 +848,7 @@ impl MintClient {
|
|||||||
let mut all_new_proofs = Vec::new();
|
let mut all_new_proofs = Vec::new();
|
||||||
|
|
||||||
for entry in &token.token {
|
for entry in &token.token {
|
||||||
if entry.mint != self.url {
|
if entry.mint.trim_end_matches('/') != self.url {
|
||||||
debug!(
|
debug!(
|
||||||
"Skipping proofs from different mint {} (ours: {})",
|
"Skipping proofs from different mint {} (ours: {})",
|
||||||
entry.mint, self.url
|
entry.mint, self.url
|
||||||
@@ -854,8 +859,7 @@ impl MintClient {
|
|||||||
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
|
||||||
let target_amounts = amount_to_denominations(total);
|
let target_amounts = amount_to_denominations(total);
|
||||||
|
|
||||||
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
|
let result = self.swap(&entry.proofs, &target_amounts).await?;
|
||||||
let result = self.swap(&proofs, &target_amounts).await?;
|
|
||||||
all_new_proofs.extend(result.new_proofs);
|
all_new_proofs.extend(result.new_proofs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,31 +895,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_fastapi_validation_error_array_is_rendered_not_swallowed() {
|
|
||||||
// FastAPI's actual 422 shape — `detail` is a list of
|
|
||||||
// {loc, msg, type}, not the plain string the rest of this file
|
|
||||||
// otherwise expects. Confirmed live against mint.minibits.cash
|
|
||||||
// (2026-09-18): this used to collapse to "mint returned 422
|
|
||||||
// Unprocessable Entity with no further detail", discarding the one
|
|
||||||
// piece of text that actually explains the failure.
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"detail": [
|
|
||||||
{"loc": ["body", "inputs", 0, "id"], "msg": "NUT02: ID length invalid", "type": "value_error"}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
let msg = super::describe_mint_error_body(reqwest::StatusCode::UNPROCESSABLE_ENTITY, &body);
|
|
||||||
assert_eq!(msg, "body -> inputs -> 0 -> id: NUT02: ID length invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_validation_error_array_falls_back_to_the_generic_message() {
|
|
||||||
let body = serde_json::json!({"detail": []}).to_string();
|
|
||||||
let msg = super::describe_mint_error_body(reqwest::StatusCode::UNPROCESSABLE_ENTITY, &body);
|
|
||||||
assert_eq!(msg, "mint returned 422 Unprocessable Entity with no further detail");
|
|
||||||
}
|
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
|
||||||
|
use super::*;
|
||||||
|
use crate::wallet::{bdhke, cashu::Proof};
|
||||||
|
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
|
||||||
|
use hyper::{
|
||||||
|
service::{make_service_fn, service_fn},
|
||||||
|
Body, Request, Response, Server,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::{
|
||||||
|
convert::Infallible,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE: &str = "0011223344556677";
|
||||||
|
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
|
||||||
|
|
||||||
|
struct Mint {
|
||||||
|
url: String,
|
||||||
|
requests: Arc<Mutex<Vec<Value>>>,
|
||||||
|
task: tokio::task::JoinHandle<()>,
|
||||||
|
failure: Arc<std::sync::atomic::AtomicU16>,
|
||||||
|
}
|
||||||
|
impl Drop for Mint {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn signing_key() -> SecretKey {
|
||||||
|
SecretKey::from_slice(&[7; 32]).unwrap()
|
||||||
|
}
|
||||||
|
fn signed_point(point: PublicKey) -> String {
|
||||||
|
point
|
||||||
|
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
fn proof(id: &str, amount: u64) -> Proof {
|
||||||
|
let secret = format!("test-{id}-{amount}");
|
||||||
|
Proof {
|
||||||
|
amount,
|
||||||
|
id: id.into(),
|
||||||
|
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
|
||||||
|
secret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Mint {
|
||||||
|
async fn start(fee: u64, failure: Option<u16>) -> Self {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let seen = requests.clone();
|
||||||
|
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
|
||||||
|
let rejection = failure.clone();
|
||||||
|
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
|
||||||
|
let service = make_service_fn(move |_| {
|
||||||
|
let seen = seen.clone();
|
||||||
|
let rejection = rejection.clone();
|
||||||
|
let spent = spent.clone();
|
||||||
|
async move {
|
||||||
|
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
|
||||||
|
let seen = seen.clone();
|
||||||
|
let rejection = rejection.clone();
|
||||||
|
let spent = spent.clone();
|
||||||
|
async move {
|
||||||
|
let mut status = 200;
|
||||||
|
let body = match req.uri().path() {
|
||||||
|
"/v1/keysets" => json!({"keysets":[
|
||||||
|
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
|
||||||
|
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
|
||||||
|
]}),
|
||||||
|
"/v1/keys" => {
|
||||||
|
let public =
|
||||||
|
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
|
||||||
|
.to_string();
|
||||||
|
let keys: serde_json::Map<String, Value> = (0..16)
|
||||||
|
.map(|i| ((1u64 << i).to_string(), json!(public)))
|
||||||
|
.collect();
|
||||||
|
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
|
||||||
|
}
|
||||||
|
"/v1/swap" => {
|
||||||
|
let body: Value = serde_json::from_slice(
|
||||||
|
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
seen.lock().unwrap().push(body.clone());
|
||||||
|
let inputs = body["inputs"].as_array().unwrap();
|
||||||
|
let outputs = body["outputs"].as_array().unwrap();
|
||||||
|
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
if code != 0 {
|
||||||
|
status = code;
|
||||||
|
json!({"detail":"mock mint rejection"})
|
||||||
|
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
|
||||||
|
{
|
||||||
|
status = 422;
|
||||||
|
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
|
||||||
|
} else if inputs.iter().any(|p| {
|
||||||
|
spent
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.contains(p["secret"].as_str().unwrap())
|
||||||
|
}) {
|
||||||
|
status = 400;
|
||||||
|
json!({"code":11001,"detail":"Token Already Spent"})
|
||||||
|
} else {
|
||||||
|
let total: u64 =
|
||||||
|
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||||
|
let out: u64 =
|
||||||
|
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
|
||||||
|
assert_eq!(
|
||||||
|
out,
|
||||||
|
total - (inputs.len() as u64 * fee).div_ceil(1000)
|
||||||
|
);
|
||||||
|
for p in inputs {
|
||||||
|
spent
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(p["secret"].as_str().unwrap().into());
|
||||||
|
}
|
||||||
|
json!({"signatures":outputs.iter().map(|o| json!({
|
||||||
|
"amount":o["amount"],"id":ACTIVE,
|
||||||
|
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
|
||||||
|
})).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
status = 404;
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok::<_, Infallible>(
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let server = Server::from_tcp(listener).unwrap().serve(service);
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
server.await.unwrap();
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
url,
|
||||||
|
requests,
|
||||||
|
task,
|
||||||
|
failure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn wallet(&self) -> tempfile::TempDir {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
save_accepted_mints(
|
||||||
|
dir.path(),
|
||||||
|
&AcceptedMints {
|
||||||
|
mints: vec![format!("{}/", self.url)],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let dir = mint.wallet().await;
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
|
||||||
|
.serialize_v4()
|
||||||
|
.unwrap();
|
||||||
|
let decoded = CashuToken::deserialize(&token).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decoded.token[0].proofs[0].id.len(),
|
||||||
|
16,
|
||||||
|
"reproduce the short V4 ID"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
verify_and_receive_payment(dir.path(), &token, 100)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
100
|
||||||
|
);
|
||||||
|
let wallet = load_wallet(dir.path()).await.unwrap();
|
||||||
|
assert_eq!(wallet.balance(), 100);
|
||||||
|
for p in wallet.proofs {
|
||||||
|
assert_eq!(
|
||||||
|
p.proof.c,
|
||||||
|
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(mint.requests.lock().unwrap()[0]["inputs"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|p| p["id"] == V2));
|
||||||
|
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paid_v3_full_v2_and_v1_ids_work() {
|
||||||
|
for id in [V2, ACTIVE] {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let dir = mint.wallet().await;
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||||
|
.serialize()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
verify_and_receive_payment(dir.path(), &token, 100)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
128
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
|
||||||
|
let mint = Mint::start(1000, None).await;
|
||||||
|
let dir = mint.wallet().await;
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||||
|
.serialize_v4()
|
||||||
|
.unwrap();
|
||||||
|
assert!(verify_and_receive_payment(dir.path(), &token, 128)
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("after mint fees"));
|
||||||
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
verify_and_receive_payment(dir.path(), &token, 127)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
127
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejected_mint_response_does_not_credit_wallet() {
|
||||||
|
for status in [200, 400, 422, 500, 503] {
|
||||||
|
let mint = Mint::start(0, Some(status)).await;
|
||||||
|
let dir = mint.wallet().await;
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||||
|
.serialize_v4()
|
||||||
|
.unwrap();
|
||||||
|
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let dir = mint.wallet().await;
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
|
||||||
|
let mut invalid = vec![
|
||||||
|
"cashuSend_500_abc_1700000000".into(),
|
||||||
|
"cashuBinvalid".into(),
|
||||||
|
];
|
||||||
|
let mut wrong_unit = token.clone();
|
||||||
|
wrong_unit.unit = Some("usd".into());
|
||||||
|
invalid.push(wrong_unit.serialize().unwrap());
|
||||||
|
let mut multi = token.clone();
|
||||||
|
multi.token.push(token.token[0].clone());
|
||||||
|
invalid.push(multi.serialize().unwrap());
|
||||||
|
let mut untrusted = token.clone();
|
||||||
|
untrusted.token[0].mint = "http://127.0.0.1:1".into();
|
||||||
|
invalid.push(untrusted.serialize().unwrap());
|
||||||
|
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
|
||||||
|
invalid.push(
|
||||||
|
CashuToken::new(&mint.url, vec![proof(id, 128)])
|
||||||
|
.serialize()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for value in invalid {
|
||||||
|
assert!(verify_and_receive_payment(dir.path(), &value, 100)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
|
||||||
|
let mint = Mint::start(0, Some(422)).await;
|
||||||
|
let buyer = mint.wallet().await;
|
||||||
|
let seller = mint.wallet().await;
|
||||||
|
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||||
|
wallet.mint_url = mint.url.clone();
|
||||||
|
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
|
||||||
|
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||||
|
let token = send_token(buyer.path(), 100).await.unwrap();
|
||||||
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
|
||||||
|
assert!(verify_and_receive_payment(seller.path(), &token, 100)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
|
||||||
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||||
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||||
|
assert!(receive_token(buyer.path(), &token).await.is_err());
|
||||||
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unreachable_mint_does_not_credit_seller() {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let dir = mint.wallet().await;
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||||
|
.serialize_v4()
|
||||||
|
.unwrap();
|
||||||
|
mint.task.abort();
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert!(verify_and_receive_payment(dir.path(), &token, 100)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
|
||||||
|
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
|
||||||
|
// which is needed for a 65-sat payment, after consuming the inputs.
|
||||||
|
let mint = Mint::start(1000, None).await;
|
||||||
|
let buyer = mint.wallet().await;
|
||||||
|
let mut wallet = load_wallet(buyer.path()).await.unwrap();
|
||||||
|
wallet.mint_url = mint.url.clone();
|
||||||
|
let first = proof(V2, 64);
|
||||||
|
let mut second = first.clone();
|
||||||
|
second.secret.push_str("-second");
|
||||||
|
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
|
||||||
|
wallet.add_proofs(&mint.url, vec![first, second]);
|
||||||
|
save_wallet(buyer.path(), &wallet).await.unwrap();
|
||||||
|
let encoded = send_token(buyer.path(), 65).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
CashuToken::deserialize(&encoded).unwrap().total_amount(),
|
||||||
|
65
|
||||||
|
);
|
||||||
|
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
|
||||||
|
use crate::content_server::{
|
||||||
|
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
|
||||||
|
};
|
||||||
|
for (exists, accepts_cashu, price) in [
|
||||||
|
(true, true, 100),
|
||||||
|
(true, false, 100),
|
||||||
|
(false, true, 100),
|
||||||
|
(true, true, 129),
|
||||||
|
] {
|
||||||
|
let mint = Mint::start(0, None).await;
|
||||||
|
let seller = mint.wallet().await;
|
||||||
|
let item = ContentItem {
|
||||||
|
id: "paid-test".into(),
|
||||||
|
filename: "test.txt".into(),
|
||||||
|
mime_type: "text/plain".into(),
|
||||||
|
size_bytes: 5,
|
||||||
|
description: String::new(),
|
||||||
|
added_at: String::new(),
|
||||||
|
availability: Availability::AllPeers,
|
||||||
|
access: AccessControl::Paid {
|
||||||
|
price_sats: price,
|
||||||
|
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if exists {
|
||||||
|
tokio::fs::create_dir_all(seller.path().join("content/files"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
|
||||||
|
.serialize_v4()
|
||||||
|
.unwrap();
|
||||||
|
let result = content_server::serve_content(
|
||||||
|
seller.path(),
|
||||||
|
"paid-test",
|
||||||
|
Some(&token),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
if exists && accepts_cashu && price <= 128 {
|
||||||
|
match result {
|
||||||
|
ServeResult::Ok(bytes, mime) => {
|
||||||
|
assert_eq!(bytes, b"hello");
|
||||||
|
assert_eq!(mime, "text/plain");
|
||||||
|
}
|
||||||
|
_ => panic!("paid content was not delivered"),
|
||||||
|
}
|
||||||
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
|
||||||
|
} else {
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
ServeResult::NotFound | ServeResult::PaymentRequired(_)
|
||||||
|
));
|
||||||
|
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
|
||||||
|
assert!(mint.requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,11 @@ lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
|
|||||||
GRASP, repository browser, issue, pull-request, or review interfaces.
|
GRASP, repository browser, issue, pull-request, or review interfaces.
|
||||||
|
|
||||||
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
|
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
|
||||||
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
|
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. On 2026-09-30 the lockfile was refreshed again for `brace-expansion`
|
||||||
install reports zero npm advisories; its type-check, 152 unit tests, and
|
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node
|
||||||
Archipelago subpath production build pass. Keeping this mechanical security
|
installs failed the retained dependency audit. The resulting clean install
|
||||||
|
reports zero npm advisories; its type-check, 152 unit tests, and Archipelago
|
||||||
|
subpath production build pass. The complete image also builds on the X250. Keeping this mechanical security
|
||||||
update separate makes both the upstream integration and future dependency
|
update separate makes both the upstream integration and future dependency
|
||||||
refreshes auditable.
|
refreshes auditable.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
diff --git a/package-lock.json b/package-lock.json
|
diff --git a/package-lock.json b/package-lock.json
|
||||||
index 20631bb..0933917 100644
|
index 20631bb..86b6f86 100644
|
||||||
--- a/package-lock.json
|
--- a/package-lock.json
|
||||||
+++ b/package-lock.json
|
+++ b/package-lock.json
|
||||||
@@ -63,7 +63,7 @@
|
@@ -63,7 +63,7 @@
|
||||||
@@ -495,9 +495,9 @@ index 20631bb..0933917 100644
|
|||||||
- "version": "5.0.7",
|
- "version": "5.0.7",
|
||||||
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
+ "version": "5.0.9",
|
+ "version": "5.0.12",
|
||||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -678,9 +678,9 @@ index 20631bb..0933917 100644
|
|||||||
- "version": "1.1.15",
|
- "version": "1.1.15",
|
||||||
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||||
+ "version": "1.1.18",
|
+ "version": "1.1.21",
|
||||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||||
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -833,9 +833,9 @@ index 20631bb..0933917 100644
|
|||||||
- "version": "3.1.3",
|
- "version": "3.1.3",
|
||||||
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
|
||||||
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
|
||||||
+ "version": "3.1.7",
|
+ "version": "3.1.8",
|
||||||
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -859,9 +859,9 @@ index 20631bb..0933917 100644
|
|||||||
- "version": "5.0.7",
|
- "version": "5.0.7",
|
||||||
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
+ "version": "5.0.9",
|
+ "version": "5.0.12",
|
||||||
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -893,9 +893,9 @@ index 20631bb..0933917 100644
|
|||||||
- "version": "10.2.0",
|
- "version": "10.2.0",
|
||||||
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
+ "version": "10.7.0",
|
+ "version": "10.7.2",
|
||||||
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -1445,4 +1445,3 @@ index bd7190c..6aa5a6f 100644
|
|||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
// Mock window.matchMedia
|
// Mock window.matchMedia
|
||||||
|
|
||||||
|
|||||||
+60
-18
@@ -989,7 +989,7 @@
|
|||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────
|
||||||
let unit = 'sats';
|
let unit = 'sats';
|
||||||
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
|
||||||
let peerSort = { col: 'peer', dir: 1 };
|
let peerSort = { col: 'peer', dir: 1 };
|
||||||
let activityFilter = 'all';
|
let activityFilter = 'all';
|
||||||
let logsLoaded = false;
|
let logsLoaded = false;
|
||||||
@@ -1142,9 +1142,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
|
if (state.refreshing) return;
|
||||||
|
state.refreshing = true;
|
||||||
const icon = document.getElementById('refreshIcon');
|
const icon = document.getElementById('refreshIcon');
|
||||||
if (icon) icon.classList.add('animate-spin-slow');
|
if (icon) icon.classList.add('animate-spin-slow');
|
||||||
try {
|
try {
|
||||||
|
state.readiness = await lndSafe('/archy-status', null);
|
||||||
|
if (state.readiness && state.readiness.state.startsWith('waiting_')) {
|
||||||
|
state.info = null;
|
||||||
|
state.onchainStale = true;
|
||||||
|
state.chanbalStale = true;
|
||||||
|
renderAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
|
||||||
lndSafe('/v1/getinfo', null),
|
lndSafe('/v1/getinfo', null),
|
||||||
lndSafe('/v1/channels', { channels: [] }),
|
lndSafe('/v1/channels', { channels: [] }),
|
||||||
@@ -1166,10 +1176,17 @@
|
|||||||
state.invoices = (invoices && invoices.invoices) || [];
|
state.invoices = (invoices && invoices.invoices) || [];
|
||||||
state.txns = (txns && txns.transactions) || [];
|
state.txns = (txns && txns.transactions) || [];
|
||||||
|
|
||||||
// Balances are separate so one failing endpoint can't blank the rest.
|
// Preserve known balances on outage; never decode an error as zero.
|
||||||
state.onchain = await lndSafe('/v1/balance/blockchain', null);
|
const [onchain, chanbal] = await Promise.all([
|
||||||
state.chanbal = await lndSafe('/v1/balance/channels', null);
|
lndSafe('/v1/balance/blockchain', null),
|
||||||
|
lndSafe('/v1/balance/channels', null),
|
||||||
|
]);
|
||||||
|
state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance));
|
||||||
|
state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance));
|
||||||
|
if (!state.onchainStale) state.onchain = onchain;
|
||||||
|
if (!state.chanbalStale) state.chanbal = chanbal;
|
||||||
} finally {
|
} finally {
|
||||||
|
state.refreshing = false;
|
||||||
if (icon) icon.classList.remove('animate-spin-slow');
|
if (icon) icon.classList.remove('animate-spin-slow');
|
||||||
}
|
}
|
||||||
renderAll();
|
renderAll();
|
||||||
@@ -1192,11 +1209,17 @@
|
|||||||
const pill = document.getElementById('headerStatusPill');
|
const pill = document.getElementById('headerStatusPill');
|
||||||
const dot = document.getElementById('headerStatusDot');
|
const dot = document.getElementById('headerStatusDot');
|
||||||
|
|
||||||
if (!g) {
|
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
|
||||||
setText('headerStatusText', 'Unreachable');
|
if (!g || waiting) {
|
||||||
pill.className = 'pill bad';
|
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
|
||||||
dot.className = 'status-dot-sm bg-red';
|
pill.className = 'pill warn';
|
||||||
document.getElementById('syncCard').style.display = 'none';
|
dot.className = 'status-dot-sm bg-yellow';
|
||||||
|
document.getElementById('syncCard').style.display = '';
|
||||||
|
setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.');
|
||||||
|
setText('syncBlockLabel', '');
|
||||||
|
setText('syncPercent', '');
|
||||||
|
document.getElementById('syncProgressBar').style.width = '0%';
|
||||||
|
for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1260,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Balances ────────────────────────────────────────────────────
|
// ── Balances ────────────────────────────────────────────────────
|
||||||
|
function validBalance(value) {
|
||||||
|
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
|
||||||
|
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
function renderBalances() {
|
function renderBalances() {
|
||||||
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
|
||||||
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
|
||||||
@@ -1253,22 +1281,23 @@
|
|||||||
const haveOnchain = !!state.onchain;
|
const haveOnchain = !!state.onchain;
|
||||||
const haveChan = !!cb;
|
const haveChan = !!cb;
|
||||||
|
|
||||||
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
|
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
|
||||||
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
|
||||||
setBalance('balLightning', haveChan ? lnLocal : null);
|
setBalance('balLightning', haveChan ? lnLocal : null);
|
||||||
setText('balLightningSub', !haveChan ? 'waiting for LND'
|
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
|
||||||
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
|
||||||
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
|
||||||
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
|
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
|
||||||
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
|
||||||
|
|
||||||
setText('liqLocal', fmtAmount(lnLocal));
|
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
|
||||||
setText('liqRemote', fmtAmount(lnRemote));
|
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
|
||||||
|
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
|
||||||
const total = lnLocal + lnRemote;
|
const total = lnLocal + lnRemote;
|
||||||
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
|
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
|
||||||
document.getElementById('liqBarLocal').style.width = localPct + '%';
|
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
|
||||||
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
|
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
|
||||||
setText('liqHint', total > 0
|
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
|
||||||
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
|
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
|
||||||
: 'Open a channel to start sending and receiving over Lightning.');
|
: 'Open a channel to start sending and receiving over Lightning.');
|
||||||
}
|
}
|
||||||
@@ -1284,6 +1313,15 @@
|
|||||||
|
|
||||||
function renderSummary() {
|
function renderSummary() {
|
||||||
const g = state.info;
|
const g = state.info;
|
||||||
|
if (!g) {
|
||||||
|
for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—');
|
||||||
|
for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND');
|
||||||
|
for (const id of ['healthChain', 'healthGraph']) {
|
||||||
|
const pill = document.getElementById(id);
|
||||||
|
pill.textContent = '—'; pill.className = 'pill warn';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const chans = state.channels;
|
const chans = state.channels;
|
||||||
const active = chans.filter(c => c.active).length;
|
const active = chans.filter(c => c.active).length;
|
||||||
const inactive = chans.length - active;
|
const inactive = chans.length - active;
|
||||||
@@ -1321,6 +1359,10 @@
|
|||||||
function renderChannels() {
|
function renderChannels() {
|
||||||
const el = document.getElementById('channelList');
|
const el = document.getElementById('channelList');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
if (!state.info) {
|
||||||
|
el.innerHTML = '<div class="empty-state">Waiting for LND. Existing channels will appear when it is ready.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
|
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
|
||||||
let list = state.channels.slice();
|
let list = state.channels.slice();
|
||||||
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
|
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
|
||||||
|
|||||||
@@ -3,6 +3,86 @@
|
|||||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||||
|
|
||||||
|
## Framework incident — closed with operator acceptance
|
||||||
|
|
||||||
|
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
|
||||||
|
missing Receive address / false zero balance.** Startup, native balances,
|
||||||
|
Cashu address and source integration were verified; the operator accepted the
|
||||||
|
remaining display check and authorized release. See the incident record for evidence.
|
||||||
|
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
|
||||||
|
and the repository `AGENTS.md` session-start instructions.
|
||||||
|
|
||||||
|
## Next release after 1.8.21 — reported 2026-09-30
|
||||||
|
|
||||||
|
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose
|
||||||
|
smart-HTTP reachability from Portainer's actual request namespace, then provide
|
||||||
|
one declarative topology and idempotent migration for fresh installs and
|
||||||
|
existing nodes. Preserve gate/auth boundaries, operator configuration,
|
||||||
|
repository/key/database mounts and Portainer stacks. Cover install order,
|
||||||
|
lifecycle/reboot/update convergence, clone/push and source-branch/Compose-file
|
||||||
|
acceptance with a disposable integration setup. Ship in both OTA and ISO;
|
||||||
|
a healthy Gitea root page is insufficient. Operator supplied a private handover;
|
||||||
|
deployment addresses and credentials must not be committed.
|
||||||
|
|
||||||
|
- [ ] **New X250: GitWorkshop failed at 70%; slow Nginx installation.** Missing
|
||||||
|
ISO build contexts restored on-node; package staging/smoke checks added.
|
||||||
|
GitWorkshop dependency audit refreshed and build/HTTP recovery verified;
|
||||||
|
Nginx was a slow successful image pull. Aggregate progress label corrected.
|
||||||
|
Include the validated repair in the next OTA/ISO. See lifecycle evidence.
|
||||||
|
|
||||||
|
- [ ] **Angor indexer service in the app store**, requested after the other
|
||||||
|
current repair/review work (2026-09-30). Follow the repository's app-development
|
||||||
|
and packaging documentation; treat it as a headless service unless upstream
|
||||||
|
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
|
||||||
|
whether an existing first-class relay meets Angor's requirements or a relay
|
||||||
|
must be packaged with the indexer, and use the Angor logo from angor.io for its
|
||||||
|
service icon. The mentioned setup-documentation link was not included; asked
|
||||||
|
the operator for it. Include this service in the next-release scope.
|
||||||
|
|
||||||
|
- [ ] **App lifecycle: keep installed apps visible through restart and hard
|
||||||
|
refresh; gate embedded/browser launches on actual web and listener readiness.**
|
||||||
|
Source repair and scoped live acceptance passed; full release gate pending.
|
||||||
|
Includes durable inventory reconstruction,
|
||||||
|
concurrent inventory writes, stale scan/lifecycle updates, delayed HTTP startup,
|
||||||
|
and the app gate's post-install listener delay. See
|
||||||
|
[app lifecycle repair evidence](app-lifecycle-repair-20260930.md).
|
||||||
|
|
||||||
|
- [x] Review and repair open paid-download PRs #161 and #162, refresh both
|
||||||
|
branches from main, run independent and combined isolated suites, and verify
|
||||||
|
rootless file permissions in disposable scratch storage. Combined result:
|
||||||
|
1,585 passed, zero failed, four existing tests ignored. See the
|
||||||
|
[review evidence and remaining acceptance work](pr-review-20260930.md).
|
||||||
|
- [ ] Integrate the reviewed PR branches into the next release and run funded
|
||||||
|
candidate acceptance, including Tor-only transport and payments with change.
|
||||||
|
Operator authorized completing the normal merge/closure workflow on
|
||||||
|
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
|
||||||
|
local repair commits and sync git/ngit before release. The reviewed code has not yet been deployed to live wallets.
|
||||||
|
- [ ] Design durable recovery for an accepted payment whose response is lost.
|
||||||
|
Preserve the truthful unconfirmed-refund warning and prevent automatic
|
||||||
|
duplicate payment while that recovery work is outstanding.
|
||||||
|
- [x] **ThinkPad X250 kiosk: Bitcoin version choices readable above pruning.**
|
||||||
|
Replaced the native popup with inline radio choices. Actual Chromium 152 kiosk
|
||||||
|
assertions and screenshot verify white-on-dark choices, selection changes and
|
||||||
|
layout above pruning controls. Focused component tests pass. Included in the
|
||||||
|
next-release source; published 1.8.21 artifacts remain unchanged.
|
||||||
|
|
||||||
|
## 1.8.21 repair and release tasks — completed 2026-09-30
|
||||||
|
|
||||||
|
See the [execution record](repair-release-20260929.md) for evidence and limits.
|
||||||
|
|
||||||
|
- [x] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
|
||||||
|
mint errors, fees, and refund reporting before live validation.
|
||||||
|
- [x] Record Framework verification and the operator's acceptance of the
|
||||||
|
remaining display check before release.
|
||||||
|
- [x] Replace the unavailable tx1138.com explorer default with mempool.space;
|
||||||
|
migrate the old default with fresh consent and preserve custom/local explorers.
|
||||||
|
- [x] Offer pruning in the Bitcoin installation version modal, using the same
|
||||||
|
pruning settings as automatic pruning even on large disks.
|
||||||
|
- [x] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
|
||||||
|
RPC readiness and show install/start/sync waiting states with automatic recovery.
|
||||||
|
- [x] Test the completed changes on this development box, then publish a new
|
||||||
|
signed OTA and raw ISO release. Record any remaining verification gaps.
|
||||||
|
|
||||||
## Dev & build process (priority)
|
## Dev & build process (priority)
|
||||||
|
|
||||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# App lifecycle repair — 2026-09-30
|
||||||
|
|
||||||
|
Status: source repairs, optimized build, new-node recovery and scoped live
|
||||||
|
lifecycle acceptance verified. Full release gate remains pending.
|
||||||
|
These are next-release changes. Published 1.8.21 artifacts remain unchanged.
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
The operator reports that restarting an app can make it disappear, and a hard
|
||||||
|
refresh offers installation again. Newly installed apps sometimes fail to
|
||||||
|
connect in both embedded views and browser tabs. The new X250 additionally reproduced GitWorkshop disappearing during install
|
||||||
|
and Nginx Proxy Manager spending approximately 14 minutes at 70%. A disposable
|
||||||
|
app on the dev box exposed a separate restart failure.
|
||||||
|
|
||||||
|
## Findings and repairs
|
||||||
|
|
||||||
|
- Quadlet removes containers during stop/restart. The scanner protected existing
|
||||||
|
in-memory entries but did not reconstruct an absent app on a fresh daemon.
|
||||||
|
It now synthesizes stopped entries from the durable installed set, respecting
|
||||||
|
uninstall records, normalizing container prefixes, and preserving cached
|
||||||
|
metadata. Absence does not establish an image version or available update.
|
||||||
|
- Concurrent read/modify/write operations could lose installed-app records;
|
||||||
|
in-place writes could expose truncated JSON to readers. Serialize writers,
|
||||||
|
publish by atomic rename, and sync the file and parent directory. Legacy
|
||||||
|
package install/uninstall success paths update the durable record too.
|
||||||
|
- Scans and lifecycle/progress operations could replace a newer model from an
|
||||||
|
older snapshot. Use locked mutations for lifecycle/progress, and merge scan
|
||||||
|
results only into entries unchanged since the scan's merge snapshot.
|
||||||
|
- Container running state and TCP accept alone did not establish HTTP readiness.
|
||||||
|
Add explicit `ui-ready` based on bounded HTTP probes of the loopback upstream;
|
||||||
|
reject connection failures and server errors, accept normal redirects and
|
||||||
|
authentication challenges, and do not follow redirects or send credentials.
|
||||||
|
Self-signed HTTPS apps are probed locally without certificate validation.
|
||||||
|
- The app gate swept new listeners only every 60 seconds. Wake that sweep
|
||||||
|
immediately for a ready upstream whose declared gate port is not yet claimed,
|
||||||
|
and withhold readiness until external and Tor listener claims exist.
|
||||||
|
- Fixed launch URLs could bypass suppressed runtime URLs. Enforce readiness in
|
||||||
|
app cards, details, centralized embedded/browser launchers, and session frames.
|
||||||
|
Starting/restarting clears readiness immediately. A waiting frame does not
|
||||||
|
load an iframe and resumes when the backend reports readiness.
|
||||||
|
|
||||||
|
### New X250 findings
|
||||||
|
|
||||||
|
- The published ISO copied only `bitcoin-ui`, `lnd-ui` and `electrs-ui` build
|
||||||
|
directories. GitWorkshop failed because `/opt/archipelago/docker/archipelago-source`
|
||||||
|
was missing. Copy the complete docker source tree for bundled and unbundled
|
||||||
|
ISOs, matching OTA packaging. Validate every manifest build context and
|
||||||
|
Dockerfile in OTA staging, ISO staging and the mounted ISO smoke test.
|
||||||
|
- After restoring the omitted contexts, GitWorkshop's retained npm audit rejected
|
||||||
|
newly reported brace-expansion, fast-uri and ip-address vulnerabilities.
|
||||||
|
Refresh the existing pinned dependency patch, keeping the audit enabled.
|
||||||
|
Clean install/audit (zero advisories), type-check, 152 upstream tests and
|
||||||
|
subpath production build pass. The image builds on the X250 and `/healthz`
|
||||||
|
returns 200. No wallet or Bitcoin container restart was needed.
|
||||||
|
- Nginx was receiving data, not frozen: over 1 GB read during the pull. It
|
||||||
|
completed at 12:40:46 UTC after starting at 12:26:27; its web endpoint returns
|
||||||
|
200. The orchestrated path previously labelled the entire download/build/start
|
||||||
|
operation "Creating container" at 70%. Give that aggregate operation its own
|
||||||
|
truthful label and earlier phase; no byte-level download estimate is claimed.
|
||||||
|
- Restore install progress immediately from an already-loaded server snapshot,
|
||||||
|
so a new store created after hard refresh does not wait for another mutation.
|
||||||
|
- Replace the install modal's native version popup with inline radio choices.
|
||||||
|
On this actual X250's Chromium 152 kiosk renderer, selection changes work,
|
||||||
|
options have white text on dark backgrounds, and remain above pruning controls.
|
||||||
|
Screenshot and browser assertions captured; no install confirmation was clicked.
|
||||||
|
|
||||||
|
### Restart safety
|
||||||
|
|
||||||
|
The disposable fixture restart at 12:38:05 UTC stopped its container, then
|
||||||
|
`ss | kill` in runtime port cleanup sent SIGTERM to the management daemon at
|
||||||
|
12:38:35. The daemon owned the gate listener on the same port at other addresses.
|
||||||
|
Systemd restarted management; Bitcoin and LND container IDs/start times were
|
||||||
|
unchanged. Remove port-owner kills and broad `pkill` patterns from restart,
|
||||||
|
install recovery and Grafana preparation. Recovery now uses the existing
|
||||||
|
container-ID-aware ghost reaper: absent container ownership must be established
|
||||||
|
before a process is terminated. A real listening-socket regression checks that
|
||||||
|
conflict cleanup preserves the host listener. App-gate manifest lookup now honors
|
||||||
|
`ARCHIPELAGO_APPS_DIR`, matching the orchestrator's configured manifest root.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Full frontend suite: 139 files, 1,126 tests passed; final focused kiosk/store
|
||||||
|
checks: nine passed. Production frontend build passed.
|
||||||
|
- Final isolated backend suite: 1,567 passed, zero failed, four existing ignored
|
||||||
|
tests. Optimized backend build passed and was deployed to the development node.
|
||||||
|
- Tests cover empty runtime inventory, alias deduplication, uninstall exclusion,
|
||||||
|
concurrent durable writes, concurrent state changes, stale scan publication,
|
||||||
|
TCP-without-HTTP, HTTP statuses including 502/503, and gate listener claims.
|
||||||
|
- Live disposable Node fixture delayed HTTP startup by 25 seconds. Desktop and
|
||||||
|
mobile retained the waiting screen through hard refresh without mounting an
|
||||||
|
iframe, then opened the exact fixture page automatically when ready.
|
||||||
|
- Restart retained the app in both state APIs throughout and returned to ready;
|
||||||
|
the management PID did not change. Stopping removed the Quadlet container;
|
||||||
|
restarting management reconstructed its installed/stopped entry without a
|
||||||
|
false update offer. Starting it again succeeded. Desktop and mobile continued
|
||||||
|
to show the installed app after hard refresh.
|
||||||
|
- LAN access required node authentication and returned exact fixture bytes after
|
||||||
|
authentication. The fixture was uninstalled through the package lifecycle API;
|
||||||
|
its temporary manifest root and service override were removed.
|
||||||
|
- Bitcoin and LND container IDs and start times stayed unchanged through all
|
||||||
|
scoped checks and management restarts. No wallet data was used by the fixture.
|
||||||
|
- X250 kiosk checks also opened the repaired GitWorkshop and Nginx Proxy Manager
|
||||||
|
pages successfully, with no failed local resource loads.
|
||||||
|
|
||||||
|
## Limits
|
||||||
|
|
||||||
|
This prevents the identified lifecycle/readiness failures; it cannot guarantee
|
||||||
|
that an app or network never fails after a successful readiness check. Actual
|
||||||
|
application failures must remain visible rather than being labelled successful.
|
||||||
|
The full lifecycle/reboot release gate and funded acceptance of the reviewed
|
||||||
|
paid-download PRs remain pending. The X250 kiosk fix has live rendering evidence.
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
# Framework: LND startup, missing Receive address, false zero balance
|
||||||
|
|
||||||
|
**Status: CLOSED WITH OPERATOR ACCEPTANCE — startup, native balances, Cashu address and source integration verified; user accepted the remaining display check and authorized release on 2026-09-30.**
|
||||||
|
|
||||||
|
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
|
||||||
|
The Framework's installed version and exact incident time have not been verified.
|
||||||
|
|
||||||
|
## Mandatory priority across sessions
|
||||||
|
|
||||||
|
The user explicitly requested that this be investigated and fixed on the node
|
||||||
|
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
|
||||||
|
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
|
||||||
|
Only live verification below, or an explicit user change of priority, clears it.
|
||||||
|
|
||||||
|
## Reported observations
|
||||||
|
|
||||||
|
- Framework stopped showing its Lightning address in Receive.
|
||||||
|
- After a restart, LND did not initialize and the UI displayed a balance of zero.
|
||||||
|
- Manually restarting LND restored operation.
|
||||||
|
- Node access will be supplied later. No Framework connection, restart, wallet
|
||||||
|
operation, or deployment was performed during this offline investigation.
|
||||||
|
- Still clarify whether the restart was a full reboot or management-service
|
||||||
|
restart, and which Receive item vanished: a Lightning invoice, an on-chain
|
||||||
|
address, or the Cashu tab's `@minibits.cash` address.
|
||||||
|
|
||||||
|
A successful manual restart is a workaround, not a root cause or durable fix.
|
||||||
|
The zero display does not establish that any funds were lost. Its relation to
|
||||||
|
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
|
||||||
|
|
||||||
|
## Confirmed source findings
|
||||||
|
|
||||||
|
### 1. LND errors can be presented as successful zero balances
|
||||||
|
|
||||||
|
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
|
||||||
|
|
||||||
|
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
|
||||||
|
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
|
||||||
|
can deserialize with every expected field absent instead of rejecting the call.
|
||||||
|
- Channel and blockchain balance requests suppress connection/JSON failures and
|
||||||
|
substitute responses with absent balances. HTTP status is not checked here either.
|
||||||
|
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
|
||||||
|
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
|
||||||
|
success, sets the wallet connected flag, overwrites prior balances, and can
|
||||||
|
persist the false zero in the wallet snapshot. Its existing failure handling
|
||||||
|
preserves prior balances only when the RPC actually rejects.
|
||||||
|
|
||||||
|
This is a confirmed code defect and a plausible explanation for the reported
|
||||||
|
display. It is not proof of the Framework's failure sequence.
|
||||||
|
|
||||||
|
Required fix: reject unsuccessful/incomplete LND balance responses or model
|
||||||
|
availability explicitly end to end. Never translate unavailable data into a
|
||||||
|
verified zero. Preserve known balances with a clear unavailable/stale indication;
|
||||||
|
show an unknown state when no valid balance is known. Genuine successful zeros
|
||||||
|
must still render as zero. Cover outage, partial failure, cold load, and recovery.
|
||||||
|
|
||||||
|
### 2. Startup readiness and wallet unlock need live evidence
|
||||||
|
|
||||||
|
- `main.rs` runs crash/container boot recovery before starting the reconciler.
|
||||||
|
- `crash_recovery.rs` can start existing containers directly.
|
||||||
|
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
|
||||||
|
and on normal reconciliation of already-running containers. Therefore it is
|
||||||
|
incorrect to conclude that running containers categorically skip unlock.
|
||||||
|
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
|
||||||
|
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
|
||||||
|
minutes; per-request timeouts can extend elapsed time. Historical comments
|
||||||
|
describe slow database startup and restart loops, but that is not Framework evidence.
|
||||||
|
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
|
||||||
|
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
|
||||||
|
|
||||||
|
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
|
||||||
|
mount availability, stopped markers, restart counters, and actual reconcile logs.
|
||||||
|
|
||||||
|
### 3. Destructive automatic recovery exists; exclude it from diagnosis
|
||||||
|
|
||||||
|
`container/lnd.rs::ensure_wallet_initialized` calls
|
||||||
|
`recreate_wallet_destructively` when all candidate passwords are rejected. That
|
||||||
|
function can delete the LND chain and graph data directories. Its comment assumes
|
||||||
|
alpha wallets hold no real funds; that assumption must not guide this investigation.
|
||||||
|
|
||||||
|
No evidence establishes that it ran on Framework. Preserve the original wallet
|
||||||
|
and channels; rejected passwords must lead to a recoverable error, not automatic
|
||||||
|
wallet deletion. Review and disable this destructive fallback before using a
|
||||||
|
modified initialization path as a repair. The existing
|
||||||
|
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
|
||||||
|
|
||||||
|
### 4. The missing address must be identified precisely
|
||||||
|
|
||||||
|
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
|
||||||
|
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
|
||||||
|
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
|
||||||
|
Do not assume the Minibits address disappears because LND is down. Trace the actual
|
||||||
|
tab and response once the user clarifies and the node can be inspected.
|
||||||
|
|
||||||
|
## Next session: live investigation order
|
||||||
|
|
||||||
|
1. Request Framework access and verify node identity without publishing its hostname,
|
||||||
|
address, credentials, or wallet identifiers. Do not substitute the development box.
|
||||||
|
2. Record installed backend/image versions, boot and incident timestamps, and exact
|
||||||
|
restart/action sequence. Capture current and previous-boot management/LND logs
|
||||||
|
before another restart can obscure evidence. Keep raw logs private and redact
|
||||||
|
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
|
||||||
|
3. Read container/service state, restart counters, mounts, stopped markers, listener
|
||||||
|
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
|
||||||
|
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
|
||||||
|
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
|
||||||
|
visible Receive/balance state. Distinguish unavailable data, locked wallet,
|
||||||
|
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
|
||||||
|
5. Establish whether the manual restart ran a missing/failed hook, waited out a
|
||||||
|
dependency, refreshed networking/credentials, or masked another failure.
|
||||||
|
6. Implement the evidenced startup repair and unavailable-balance handling with
|
||||||
|
regressions. Preserve wallet/channel state and arrange recovery access before
|
||||||
|
deploying or deliberately rebooting the node.
|
||||||
|
|
||||||
|
## Acceptance criteria — all required to close
|
||||||
|
|
||||||
|
- [x] Root cause of Framework startup failure supported by node evidence.
|
||||||
|
- [x] Fix implemented and focused regression tests pass.
|
||||||
|
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
|
||||||
|
fresh zero balance; genuine zero remains correct.
|
||||||
|
- [x] Existing wallet identity and channel state preserved through the repair.
|
||||||
|
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
|
||||||
|
full reboot, without manually restarting LND.
|
||||||
|
- [ ] The originally affected Receive flow works after boot and after recovery;
|
||||||
|
outages show an actionable state and recover without requiring a page reload.
|
||||||
|
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
|
||||||
|
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
|
||||||
|
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
|
||||||
|
informed of live results. Only then set status RESOLVED and clear the blockers.
|
||||||
|
|
||||||
|
## Work completed so far
|
||||||
|
|
||||||
|
2026-09-15: source investigation and persistent session-start instructions only.
|
||||||
|
No code fix, release, node deployment, or live reproduction for this incident yet.
|
||||||
|
|
||||||
|
## Live evidence captured 2026-09-15
|
||||||
|
|
||||||
|
Access was provided during the same session. Read-only inspection confirmed:
|
||||||
|
|
||||||
|
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
|
||||||
|
- LND opened its databases in 6.7 seconds and requested its wallet password at
|
||||||
|
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
|
||||||
|
- The management service's first sequential reconcile pass was occupied by
|
||||||
|
unrelated image recovery, including a missing voice image from 18:40:24 and
|
||||||
|
later a missing Core Lightning image. Manifests are iterated from a HashMap;
|
||||||
|
wallet readiness has no initial priority. Boot recovery itself completed at
|
||||||
|
18:40:18; the first full app-reconcile report appeared at 18:44:34.
|
||||||
|
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
|
||||||
|
process started at 18:42:40, requested its password at 18:43:05, and unlocked
|
||||||
|
at 18:43:07 through the explicit restart hook. This supports delayed unlock
|
||||||
|
behind unrelated recovery, rather than a missing wallet or bad password.
|
||||||
|
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
|
||||||
|
graph sync and two active channels. Both authenticated balance endpoints
|
||||||
|
report nonzero balances. No wallet-recreation event was found in captured logs.
|
||||||
|
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
|
||||||
|
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
|
||||||
|
ecash wallet is present with proofs and an August modification timestamp.
|
||||||
|
Do not overwrite it or generate an unrelated recovery identity. Still identify
|
||||||
|
which Receive item the user meant before declaring this part repaired.
|
||||||
|
|
||||||
|
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
|
||||||
|
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
|
||||||
|
|
||||||
|
Candidate changes on `investigate/framework-lnd-startup`:
|
||||||
|
|
||||||
|
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
|
||||||
|
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
|
||||||
|
- Preserve known Home balances on invalid responses, visibly label unavailable
|
||||||
|
balances, and clear the warning after a successful refresh.
|
||||||
|
- Remove automatic destructive wallet recreation; failed unlock preserves data.
|
||||||
|
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
|
||||||
|
|
||||||
|
These changes are not yet deployed or verified through a Framework reboot.
|
||||||
|
|
||||||
|
### Candidate validation and staging
|
||||||
|
|
||||||
|
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
|
||||||
|
|
||||||
|
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
|
||||||
|
- 58 additional reconciliation/update tests passed.
|
||||||
|
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
|
||||||
|
- Rust formatting, frontend type checking and production build passed.
|
||||||
|
- Optimized backend build passed in 8m02s.
|
||||||
|
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
|
||||||
|
- Private on-node baseline and static channel backup are under
|
||||||
|
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
|
||||||
|
backend, dashboard, and `rollback.sh`. This directory is root-only.
|
||||||
|
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
|
||||||
|
- A timing confirmation for the maintenance restart/full reboot was requested
|
||||||
|
because it interrupts all node services. Do not reboot while that is pending.
|
||||||
|
- SSH works through the temporary control socket
|
||||||
|
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
|
||||||
|
- The supplied SSH password did not authenticate to the dashboard. Do not guess
|
||||||
|
additional passwords or alter dashboard authentication. Native LND diagnostics
|
||||||
|
are authenticated using its existing local macaroon without printing it.
|
||||||
|
|
||||||
|
Status remains OPEN until deployment and live boot/Receive/balance verification.
|
||||||
|
|
||||||
|
### Authorized deployment and full reboot — 2026-09-15
|
||||||
|
|
||||||
|
The user answered “yes please” to applying the staged fix and rebooting. Timing
|
||||||
|
approval is no longer pending. Applied the staged backend and dashboard after
|
||||||
|
rechecking both checksums and rollback copies. There were no pending channel
|
||||||
|
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
|
||||||
|
|
||||||
|
Live results:
|
||||||
|
|
||||||
|
- A different boot ID confirms a full reboot occurred.
|
||||||
|
- Running backend on disk matches candidate SHA-256
|
||||||
|
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
|
||||||
|
- Management service started around 19:23:57 UTC; LND asked for its wallet
|
||||||
|
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
|
||||||
|
restart or interactive unlock was used after this reboot.
|
||||||
|
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
|
||||||
|
are identical to the private pre-reboot baseline; both channels are active.
|
||||||
|
- On-chain and Lightning balances exactly equal the pre-reboot values.
|
||||||
|
- LND container and systemd restart counts are zero after recovery.
|
||||||
|
- Public HTTP checks on the node returned 200 for the dashboard index and new
|
||||||
|
Home bundle; their bytes match the installed candidate, including the new
|
||||||
|
unavailable-balance notice.
|
||||||
|
- Captured post-reboot management and LND journals in the private local evidence
|
||||||
|
directory. Detailed before/after identity, channel, and balance records remain
|
||||||
|
in the root-only support directory on Framework.
|
||||||
|
|
||||||
|
The user was asked to refresh the dashboard and confirm the originally missing
|
||||||
|
Receive item and displayed balances. Keep OPEN until that reply is assessed;
|
||||||
|
Minibits seed absence was a separate finding and must not be mistaken for an
|
||||||
|
LND startup failure. Candidate is a direct node deployment, not a newly signed
|
||||||
|
fleet release. The source branch must be integrated before a subsequent release
|
||||||
|
can preserve this fix across the fleet.
|
||||||
|
|
||||||
|
### Cashu Receive follow-up
|
||||||
|
|
||||||
|
The user confirmed that the remaining error is specifically on the Ecash tab:
|
||||||
|
“Lightning address unavailable — you can still paste a token below.”
|
||||||
|
|
||||||
|
Read-only checks confirm Framework has an encrypted node master seed, existing
|
||||||
|
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
|
||||||
|
The existing Minibits handler requires an ecash seed, but setup was available
|
||||||
|
only through the Settings backup screen; Receive hid the actionable cause.
|
||||||
|
|
||||||
|
UI fix commit: `a3b64670`.
|
||||||
|
|
||||||
|
- Receive checks the non-secret seed status when registration fails.
|
||||||
|
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
|
||||||
|
component directly in Receive, with import/restore controls excluded from this
|
||||||
|
focused setup screen. Setup derives from the saved node seed when present.
|
||||||
|
- The recovery words stay in the existing local reveal UI, are cleared on Done,
|
||||||
|
and are never emitted to Receive. Receive retries registration after Done.
|
||||||
|
- Seeded wallets with service outages get Retry, without offering a new identity.
|
||||||
|
- Ten focused Receive/backup tests and the production UI build passed.
|
||||||
|
- Deployed the dashboard change without restarting services; live HTTP index and
|
||||||
|
setup bundle returned 200 and byte-matched the candidate.
|
||||||
|
- Backed up original Cashu proofs to the root-only support directory as
|
||||||
|
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
|
||||||
|
the assistant. Prior LND-fixed dashboard is also backed up there.
|
||||||
|
|
||||||
|
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
|
||||||
|
that node UI, and click Done. Dashboard password is required to decrypt the node
|
||||||
|
seed; the SSH password did not authenticate to the dashboard. Do not request or
|
||||||
|
print recovery words, bypass authentication, or create an unrelated random seed.
|
||||||
|
After completion, verify saved seed/profile presence, registration success,
|
||||||
|
address display, and unchanged original proofs before closing the incident.
|
||||||
|
|
||||||
|
### Cashu setup completed and verified — 2026-09-15
|
||||||
|
|
||||||
|
The user initially reported a forgotten passphrase, then said “did it now”. No
|
||||||
|
independent-seed fallback was implemented or used. The user completed the existing
|
||||||
|
password-verified setup themselves; the assistant did not receive recovery words.
|
||||||
|
|
||||||
|
Read-only node verification confirmed:
|
||||||
|
|
||||||
|
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
|
||||||
|
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
|
||||||
|
- The original ecash wallet file is byte-for-byte unchanged from the protected
|
||||||
|
pre-setup copy; every original proof is preserved.
|
||||||
|
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
|
||||||
|
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
|
||||||
|
and no funded payment test was performed.
|
||||||
|
|
||||||
|
LND automatic startup and native balances were already verified after the full
|
||||||
|
reboot. Cashu setup and address registration are now also verified on Framework.
|
||||||
|
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
|
||||||
|
|
||||||
|
Remaining: integrate the tested source branch before the next fleet release;
|
||||||
|
record final human confirmation of the rendered dashboard balance (native balances
|
||||||
|
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
|
||||||
|
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
|
||||||
|
to repeat already completed checks.
|
||||||
|
|
||||||
|
### Backup copy and layout — 2026-09-15
|
||||||
|
|
||||||
|
At the user's request, shortened the ecash backup explanations and stacked each
|
||||||
|
card section's text and full-width action vertically. Kept the distinction
|
||||||
|
between node-derived and separate phrases, and the warning that a newly created
|
||||||
|
phrase covers future coins rather than existing legacy coins.
|
||||||
|
|
||||||
|
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
|
||||||
|
Framework without a restart; served index and backup-component bundle match the
|
||||||
|
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
|
||||||
|
protected incident directory. Source integration and final rendered dashboard
|
||||||
|
balance confirmation remain pending as above.
|
||||||
|
|
||||||
|
### LNURL comment-length report — 2026-09-15
|
||||||
|
|
||||||
|
User reports a maximum-comment-length error in some sending wallets. Live
|
||||||
|
Framework address metadata advertises integer `commentAllowed: 100`. The QR
|
||||||
|
contains the address only; Archy's Receive UI does not add a comment. The
|
||||||
|
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
|
||||||
|
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
|
||||||
|
invoice requests at the advertised minimum amount; no funds were sent.
|
||||||
|
|
||||||
|
The callback did not reproduce the error, including beyond its advertised limit.
|
||||||
|
Sending-wallet validation against the advertised 100-character limit is therefore
|
||||||
|
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
|
||||||
|
an empty comment also fails. Need that result before selecting a code fix.
|
||||||
|
The service controls the advertised limit; changing local Receive text or QR
|
||||||
|
cannot raise it for other wallets.
|
||||||
|
|
||||||
|
### Primal Spark: automatic recipient note exceeds the address limit
|
||||||
|
|
||||||
|
User clarified that no comment was entered and the sender is Primal Spark.
|
||||||
|
Checked Framework's management journal over the preceding 20 minutes: no
|
||||||
|
comment-length errors, service active, and zero pending Minibits claims. Recent
|
||||||
|
claim polling connected to and disconnected from the relay normally. Historical
|
||||||
|
seed-authentication failures preceded the successful setup already documented.
|
||||||
|
|
||||||
|
The live address's Minibits `text/plain` description is **101 ASCII characters**,
|
||||||
|
while `commentAllowed` is **100**. Description template (address redacted):
|
||||||
|
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
|
||||||
|
|
||||||
|
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
|
||||||
|
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
|
||||||
|
`DraftTx.noteRecipient`, including for Lightning-address input.
|
||||||
|
- `TransactionEditor` initializes its editable recipient note from that value.
|
||||||
|
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
|
||||||
|
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
|
||||||
|
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
|
||||||
|
limit with the exact reported error before requesting the callback.
|
||||||
|
|
||||||
|
This identifies a concrete compatibility failure: the address description can
|
||||||
|
become an automatic over-limit comment without the sender typing anything.
|
||||||
|
The user confirmed that explicitly clearing the prefilled recipient note made
|
||||||
|
the payment work, and supplied the same description observed in live metadata.
|
||||||
|
This confirms the automatic-comment compatibility failure. The installed Primal
|
||||||
|
platform/version was not captured. Node logs alone cannot show sender-side
|
||||||
|
validation or requests to the external Minibits callback.
|
||||||
|
|
||||||
|
Durable upstream correction: Primal should keep receiver metadata separate from
|
||||||
|
the sender's comment and enforce the limit on actual user comments. Minibits can
|
||||||
|
also shorten its description or raise its advertised comment limit. Archy does
|
||||||
|
not serve this external LNURL metadata; do not rename an existing wallet address,
|
||||||
|
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
|
||||||
|
|
||||||
|
### Primal workaround confirmed by user
|
||||||
|
|
||||||
|
The user confirmed successful payment after removing the automatic description.
|
||||||
|
The permanent sender-side correction is to leave the recipient comment empty by
|
||||||
|
default and retain receiver metadata only as display text. In Primal Android,
|
||||||
|
remove the assignment of the LNURL description to the draft recipient note in
|
||||||
|
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
|
||||||
|
against the endpoint's limit. No upstream change has been submitted or deployed.
|
||||||
|
Existing Framework addresses and wallet identities remain unchanged.
|
||||||
|
|
||||||
|
### Can Archy shorten the current address description?
|
||||||
|
|
||||||
|
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
|
||||||
|
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
|
||||||
|
fields are name, lud16, and avatar; there is no exposed LNURL description or
|
||||||
|
comment-limit setting. Its public web repository also contains no implementation
|
||||||
|
of the LNURL metadata endpoint or description template.
|
||||||
|
|
||||||
|
For the existing `@minibits.cash` address, no supported client-side mechanism
|
||||||
|
to shorten this text was found. Do not send guessed profile-update fields or
|
||||||
|
rename the address to disguise the problem. A Minibits server change could use
|
||||||
|
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
|
||||||
|
Archy would instead require an Archy-hosted LNURL service/address and correct
|
||||||
|
invoice metadata binding; rewriting the QR label or only proxying edited metadata
|
||||||
|
is insufficient. No wallet/profile mutations were made during this investigation.
|
||||||
|
|
||||||
|
### Source integration confirmed — 2026-09-29
|
||||||
|
|
||||||
|
`git merge-base --is-ancestor 4237fb5e HEAD` succeeds on main at
|
||||||
|
`540639d2`. The previously tested startup ordering, safe unlock, and unavailable
|
||||||
|
balance fixes are integrated and included in the intervening releases. The
|
||||||
|
earlier “source integration pending” notes above are historical, not current.
|
||||||
|
The user reports no further Framework incidents. Requested final confirmation
|
||||||
|
of rendered balances and Receive; do not mark closed without that response.
|
||||||
|
|
||||||
|
A separate startup failure was observed on the development box today when Core
|
||||||
|
was installed against existing block data: Core made steady replay progress,
|
||||||
|
while LND exited on its short “bitcoind start timeout”. Candidate work defers
|
||||||
|
unlock until authenticated Bitcoin RPC answers, with dependency waiting states
|
||||||
|
in the LND UI. This is not evidence of a new failure on Framework.
|
||||||
|
|
||||||
|
### Operator acceptance and release authorization — 2026-09-30
|
||||||
|
|
||||||
|
After being told that final rendered balance/Receive confirmation remained and
|
||||||
|
SSH access was unavailable, the user replied: “that's fine I believe it'd fixed,
|
||||||
|
please release”. This explicitly accepts proceeding past the remaining human
|
||||||
|
display check. Close this incident with operator acceptance based on the earlier
|
||||||
|
controlled reboot, preserved identity/channels/native balances, working Receive
|
||||||
|
address/payment, source integration, and the user's report of no further issues.
|
||||||
|
No new direct Framework inspection or on-screen verification is claimed today.
|
||||||
|
Reopen investigation if the original startup, Receive, or false-zero symptom
|
||||||
|
recurs; preserve the wallet and channels.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Paid-download PR review — 2026-09-30
|
||||||
|
|
||||||
|
## Scope and result
|
||||||
|
|
||||||
|
Reviewed both open PRs from the repository pull-request list: [#161](https://source.archipelago-foundation.org/lfg2025/archy/pulls/161)
|
||||||
|
and [#162](https://source.archipelago-foundation.org/lfg2025/archy/pulls/162).
|
||||||
|
Both branches were updated from main, repaired and tested independently and
|
||||||
|
together. Their existing remote branches were advanced without rewriting the
|
||||||
|
contributors' history. They remain open for integration into the release after
|
||||||
|
1.8.21; no reviewed code was merged into main or deployed to a live wallet.
|
||||||
|
The signed 1.8.21 artifacts are unchanged.
|
||||||
|
|
||||||
|
| Candidate | Tested commit | Isolated backend result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PR #161 | `971d4777` | 1,576 passed, 0 failed, 4 existing tests ignored |
|
||||||
|
| PR #162 | `0677924a` | 1,568 passed, 0 failed, 4 existing tests ignored |
|
||||||
|
| Both together | `4bf4bf1a` | 1,585 passed, 0 failed, 4 existing tests ignored |
|
||||||
|
|
||||||
|
Both individual branches also passed production `cargo check`, with the
|
||||||
|
repository's existing 16 warnings. The combined merge required no conflict
|
||||||
|
resolution. Backend tests ran through `scripts/test-backend-isolated.sh` so they
|
||||||
|
could not access host wallets, native services or production container storage.
|
||||||
|
|
||||||
|
## Findings and repairs
|
||||||
|
|
||||||
|
### #161 — payment delivery and file readability
|
||||||
|
|
||||||
|
- The branch conflicted with newer mint-fee, keyset-ID and truthful refund
|
||||||
|
reporting fixes. Preserve those implementations from main; do not reintroduce
|
||||||
|
its older unconditional “refunded” messages or duplicate keyset resolution.
|
||||||
|
- Opening a file before charging, then reopening/reading it afterward, still
|
||||||
|
permits a read failure after payment. Prepare the complete requested bytes
|
||||||
|
before redemption, including ranged reads. Tests delete or alter the backing
|
||||||
|
file during payment verification and still receive the prepared original data.
|
||||||
|
- Empty/out-of-bounds/reversed ranges could fail after redemption, and empty
|
||||||
|
files could underflow the range calculation. Validate ranges before charging
|
||||||
|
and return HTTP 416 when unsatisfiable.
|
||||||
|
- `chmod a+r` unnecessarily changed the permissions of shared paid/private
|
||||||
|
files. Read restricted FileBrowser files through the rootless namespace while
|
||||||
|
retaining their mode. Scope the fallback to regular files canonically inside
|
||||||
|
FileBrowser storage, and reject unauthorized peers before reading.
|
||||||
|
- A single-delivery flag must also prevent redirects and ambiguous transport
|
||||||
|
retries. Payment-bearing GET and POST requests now retain the first HTTP
|
||||||
|
response and do not retry after timeouts or disconnects that might follow
|
||||||
|
delivery. Refused connections and normal nonpayment browsing retain the
|
||||||
|
appropriate retry behavior.
|
||||||
|
- Interrupted response bodies now report the outcome using the actual local
|
||||||
|
refund result. Seller explanations are bounded, stripped of control
|
||||||
|
characters and explicitly identified as peer text.
|
||||||
|
- Original permission tests silently returned when run as root. Replacement
|
||||||
|
tests inject read/payment boundary failures, exercise them under the isolated
|
||||||
|
runner, and assert that read failures never invoke redemption.
|
||||||
|
|
||||||
|
### #162 — saving purchases in Files
|
||||||
|
|
||||||
|
- Its host-permission repair overlapped 1.8.21's authenticated Files API path.
|
||||||
|
Review of [FileBrowser v2.63.23's resource handler](https://github.com/filebrowser/filebrowser/blob/v2.63.23/http/resource.go)
|
||||||
|
showed that `override=false` checks for existence separately from opening
|
||||||
|
with truncation. It does not guarantee no overwrites under concurrent saves.
|
||||||
|
- The proposed direct path exposed the final filename before the write
|
||||||
|
completed. Both direct and namespace paths now finish a private temporary
|
||||||
|
file and publish it through a no-clobber hard link, retrying numbered names.
|
||||||
|
- Plain `ln` could place a temporary file inside an existing directory instead
|
||||||
|
of treating the destination as a collision. Use `ln -T`; existing directories
|
||||||
|
and dangling symlinks are conflicts, never replacement targets.
|
||||||
|
- Add filename and destination checks, unique temporary names, bounded name
|
||||||
|
retries, synchronization before publication, and exact input-length checks.
|
||||||
|
Truncated pipe input cannot become a completed purchased file.
|
||||||
|
- Files storage remains optional. An unavailable copy destination does not
|
||||||
|
undo the purchase or create a fake FileBrowser installation; the durable
|
||||||
|
purchased-content cache remains primary.
|
||||||
|
|
||||||
|
## Additional verification on the development node
|
||||||
|
|
||||||
|
Used disposable scratch directories only, then removed them:
|
||||||
|
|
||||||
|
- Reproduced a FileBrowser-style rootless-owned 0640 upload. The host backend
|
||||||
|
UID could not read it. `podman unshare cat` returned identical bytes without
|
||||||
|
changing its 0640 mode.
|
||||||
|
- Ran the exact namespace writer script with four concurrent writers against
|
||||||
|
a directory owned by the container UID range. Every file had unique naming,
|
||||||
|
exact bytes, the expected owner and mode, and no remaining temporary file.
|
||||||
|
- Sent truncated input to the namespace writer and verified refusal, no final
|
||||||
|
file and temporary-file cleanup.
|
||||||
|
|
||||||
|
The isolated tests additionally exercised 24 simultaneous direct writes,
|
||||||
|
existing-file preservation, symlink/directory conflicts, collision exhaustion,
|
||||||
|
root-independent permission failures, read-before-redemption ordering,
|
||||||
|
authorization, redirects and peer disconnects.
|
||||||
|
|
||||||
|
Logs on the development box:
|
||||||
|
`/tmp/archy-pr161-tests.log`, `/tmp/archy-pr162-tests.log`,
|
||||||
|
`/tmp/archy-pr-integration-tests.log`, `/tmp/archy-pr161-check.log`,
|
||||||
|
`/tmp/archy-pr162-check.log`, `/tmp/archy-pr-userns-scratch-test.log`.
|
||||||
|
|
||||||
|
## Next-release acceptance and limits
|
||||||
|
|
||||||
|
- Integrate the reviewed branches and repeat the release gates against the
|
||||||
|
final release commit if additional code changes land.
|
||||||
|
- Perform funded peer-to-peer acceptance on the candidate build, including a
|
||||||
|
Tor-only purchase and a purchase requiring change, before the next release.
|
||||||
|
The new review branches were not deployed to funded live wallets here.
|
||||||
|
- These PRs do not implement durable payment receipts. If a seller redeems a
|
||||||
|
payment and the connection subsequently loses the response, the buyer may
|
||||||
|
receive an unconfirmed-refund warning. Do not represent that warning as proof
|
||||||
|
of a refund or automatically charge the buyer again. Receipt-based recovery
|
||||||
|
remains separate follow-up work.
|
||||||
|
- Abrupt process termination can leave a hidden namespace temporary file;
|
||||||
|
ordinary write failures and truncated input are tested to clean up. The final
|
||||||
|
filename is published only after complete input, and existing files remain
|
||||||
|
protected.
|
||||||
|
- The separately reported X250 kiosk version-selector rendering issue remains
|
||||||
|
open in `TODO.md` and requires validation on the actual kiosk.
|
||||||
|
|
||||||
|
## Authorized merge — 2026-09-30
|
||||||
|
|
||||||
|
The operator explicitly requested normal merged/closed PR status after review.
|
||||||
|
Re-read both PRs and verified their heads still exactly matched the reviewed
|
||||||
|
commits. Changes from the integration-test base to main were documentation only.
|
||||||
|
Gitea normal merges completed and read-back confirmed `merged=true`, `state=closed`:
|
||||||
|
|
||||||
|
- #161: `3daea6623be3e2c7222101b8e6ac411423c7e16c`.
|
||||||
|
- #162: `b02ba4100d922dd1b75c6a78121ef446c2159a54`.
|
||||||
|
|
||||||
|
Local next-release lifecycle work will be integrated with this main before the
|
||||||
|
next release. Funded release acceptance and the documented delivery-receipt
|
||||||
|
limitation remain as recorded above; merging does not claim a new release.
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# Repair and release execution — 2026-09-29
|
||||||
|
|
||||||
|
**Status: IN PROGRESS. Do not publish an OTA or ISO until the release gates pass.**
|
||||||
|
|
||||||
|
User requires all tasks completed and tested on the development box before the
|
||||||
|
next OTA and raw ISO. Passing unit tests alone does not establish live correctness.
|
||||||
|
|
||||||
|
## Confirmed evidence
|
||||||
|
|
||||||
|
- Dev-to-Shorty 100-sat Cashu file purchases failed twice. Both sellers' and
|
||||||
|
buyers' accepted mints match. Shorty's mint swap returned HTTP 422; both
|
||||||
|
attempted purchases were refunded 100 sats. The old message guessed a mint
|
||||||
|
mismatch without evidence.
|
||||||
|
- Wallet import repaired truncated V2 keyset IDs, while paid-content redemption
|
||||||
|
bypassed that repair. Central swap repair and protocol-level regression tests now pass.
|
||||||
|
- Core installation on dev reused existing chain data. At 17:42 UTC it was
|
||||||
|
advancing through block replay with no Core container restarts. At 17:49 UTC
|
||||||
|
it had connected to peers and started transaction-index synchronization.
|
||||||
|
- LND exited repeatedly with `bitcoind start timeout` while Core loaded. After
|
||||||
|
Core became available LND stayed running and reported waiting for backend sync.
|
||||||
|
- Framework source fix 4237fb5e is already an ancestor of main. Existing live
|
||||||
|
reboot/native balance evidence is in the incident document. Final display
|
||||||
|
confirmation remains pending.
|
||||||
|
|
||||||
|
## Changes under validation
|
||||||
|
|
||||||
|
- Cashu V4/V2 ID expansion at every swap; fee-aware underpayment rejection;
|
||||||
|
single-mint/sat-only/cryptographic paid tokens; no false mint-mismatch or
|
||||||
|
unconditional refund claims. Missing content checked before redemption.
|
||||||
|
- mempool.space default; migrate old tx1138 default with fresh consent, retain
|
||||||
|
local explorer priority and custom preferences.
|
||||||
|
- Core/Knots optional pruning on the version modal and app detail install path;
|
||||||
|
persist choice across runtime restarts; use identical 50,000 MiB automatic
|
||||||
|
pruning entrypoint behavior on large and small disks.
|
||||||
|
- Plain Bitcoin block-index startup message; defer LND wallet initialization or
|
||||||
|
unlock until Bitcoin RPC is usable; authenticated dependency status and LND UI
|
||||||
|
waiting states; no partial total displayed as a complete balance.
|
||||||
|
|
||||||
|
## Validation and release gates
|
||||||
|
|
||||||
|
- [x] Final backend regression suite passes (including mock mint HTTP and real
|
||||||
|
curve signatures, v1/full-v2/truncated-v2, fees, errors, duplicate redemption).
|
||||||
|
- [x] Initial explorer and pruning modal tests pass: 15 tests.
|
||||||
|
- [x] Both actual manifest entrypoints tested with isolated fake bitcoind across
|
||||||
|
6 disk/choice combinations each. No existing chain pruned for this test.
|
||||||
|
- [x] Initial LND UI install/start/sync/recovery and invalid-balance tests pass.
|
||||||
|
- [x] Frontend production build and relevant existing wallet tests pass (34
|
||||||
|
focused tests, including 12 Home failure/recovery checks). Final UI suite: 1,120 passed; production build passed. Full release harness and final frontend follow-up passed.
|
||||||
|
- [x] Fault tests and final source review complete.
|
||||||
|
- [x] Candidate deployed with rollback to dev and Shorty; hashes verified.
|
||||||
|
- [x] Live paid-file purchase succeeds; failed purchase/refund behavior verified.
|
||||||
|
- [x] Live waiting/UI verified on dev; recovery covered by deterministic tests.
|
||||||
|
- [x] Framework operator acceptance and authorization to release recorded.
|
||||||
|
- [x] Release version/changelog, catalog/image implications, signing prepared.
|
||||||
|
- [ ] Signed OTA built, tested, published to git and ngit.
|
||||||
|
- [ ] Raw ISO built, boot-tested, signed and published; download command supplied.
|
||||||
|
|
||||||
|
Tests must not wipe/recreate wallets, prune the operator's existing full chain,
|
||||||
|
or claim that arbitrary failures can never happen. Record material gaps before
|
||||||
|
release. Signing keys remain with the user; prepare concrete artifacts first.
|
||||||
|
|
||||||
|
### Further startup findings
|
||||||
|
|
||||||
|
Live dev `/v1/state` returned `RPC_ACTIVE` while `/v1/getinfo` timed out during
|
||||||
|
Bitcoin initial sync. Candidate startup now recognizes the already-unlocked
|
||||||
|
state instead of repeating unlock attempts for ten minutes. The health watchdog
|
||||||
|
also now excludes Bitcoin initial sync, warmup, unavailable/stale status and
|
||||||
|
LND height progress from its restart criteria. A later observed `podman restart`
|
||||||
|
was externally initiated; its precise caller has not yet been established, so
|
||||||
|
the watchdog defect is a source finding rather than a confirmed attribution.
|
||||||
|
|
||||||
|
Framework SSH rejected the previously provided login on 2026-09-29. No password
|
||||||
|
was saved and no wallet changes were attempted. The human display-confirmation
|
||||||
|
question remains pending. Do not repeat a Framework reboot to reconfirm old work.
|
||||||
|
|
||||||
|
LND UI waiting-state, stale-balance, partial-failure/recovery and prompt-render
|
||||||
|
tests pass (4 Node tests). Waiting states avoid calls to LND endpoints that block
|
||||||
|
until sync, and prevent overlapping refreshes.
|
||||||
|
|
||||||
|
### Final source validation
|
||||||
|
|
||||||
|
The final backend suite passed: 1,548 passed, zero failed, four existing ignored
|
||||||
|
live/hardware tests. Includes saved pruning preference, rejecting an old catalog
|
||||||
|
that cannot honor explicit pruning, and all nine paid-Cashu protocol tests.
|
||||||
|
Unsigned candidate catalog passes strict drift and fleet registry trust checks.
|
||||||
|
The release gate caught a missing What's New entry; generated it from the curated
|
||||||
|
changelog and reran the frontend gate/build. No public release has been changed.
|
||||||
|
|
||||||
|
At 18:23 UTC dev Bitcoin exited with status 137 and restarted; current container
|
||||||
|
is not marked OOM-killed and no kernel/oomd record identified the cause. Bitcoin
|
||||||
|
is replaying blocks again (height 482071 at 18:31 UTC). Installed old LND continues
|
||||||
|
to time out while Bitcoin RPC warms up. Candidate is not deployed yet; verify its
|
||||||
|
readiness deferral live before declaring this fixed. Do not attribute the Bitcoin
|
||||||
|
exit to a specific actor without evidence.
|
||||||
|
|
||||||
|
### Doctor restart cause established and repaired
|
||||||
|
|
||||||
|
Full system journal identifies container-doctor at 18:23:21 UTC issuing raw
|
||||||
|
`podman restart bitcoin-core` for an allegedly missing 8333 listener. The same
|
||||||
|
script restarted LND at 17:57:48 and 18:23:35 UTC. The port was actually listening.
|
||||||
|
Reproduced the original `ss | awk | grep -q` pipeline returning `0 141 0`: grep
|
||||||
|
exits after its match, awk gets SIGPIPE, and pipefail falsely reports no listener.
|
||||||
|
The raw restart also enforces a short stop timeout and races Quadlet cleanup.
|
||||||
|
|
||||||
|
The repaired check consumes the entire socket snapshot, distinguishes inspection
|
||||||
|
failure from a missing port, and leaves containers running when inspection fails.
|
||||||
|
Necessary restarts use their managed systemd units and shutdown timeouts; unmanaged
|
||||||
|
Bitcoin/LND fallback receives 600/330-second grace respectively. Regression uses
|
||||||
|
20,000 socket rows plus mocked service/container commands and passes. Thirty
|
||||||
|
read-only checks of the actual Bitcoin listener pass. Script deployed to dev and
|
||||||
|
Shorty with root-only rollback copies. OTA runtime payload includes scripts/.
|
||||||
|
This evidence supersedes the earlier unknown-caller/unknown-exit attribution.
|
||||||
|
|
||||||
|
### Initial candidate live validation — 18:48 UTC
|
||||||
|
|
||||||
|
Source 0f85f588, optimized backend SHA256
|
||||||
|
84434c495c5f8472cf6bfcb6c65e762502c74718ad88271619373335c0054bb6,
|
||||||
|
deployed to dev and Shorty with matching hashes and rollback copies. Both
|
||||||
|
management services restarted; wallets/channels were not reset. Old embedded
|
||||||
|
runtime assets restored the old doctor on backend startup; updated the live
|
||||||
|
script AND embedded runtime copy on both nodes. Final OTA will contain the new
|
||||||
|
script directly.
|
||||||
|
|
||||||
|
Authenticated dev readiness transitioned from waiting_start to waiting_sync.
|
||||||
|
Real Chromium at 1440px and 390px showed Waiting for Bitcoin to sync, an unknown
|
||||||
|
balance, and no blocked native LND calls. Screenshot review also caught invented
|
||||||
|
zero capacity/channel counts during waiting: corrected them and the empty-channel
|
||||||
|
recommendation; five UI regression tests now pass.
|
||||||
|
|
||||||
|
Real Minibits Cashu purchase from dev to Shorty succeeded for one sat and returned
|
||||||
|
the expected 44 bytes. A rejected one-sat underpayment was refunded exactly, and
|
||||||
|
two cached downloads charged zero. Temporary seller files/catalog entries removed.
|
||||||
|
The first test runner expected data_base64 while the first-purchase API returns
|
||||||
|
data; cached responses use data_base64. Existing purchase clients only consume
|
||||||
|
data, so a follow-up normalizes both response variants to both fields.
|
||||||
|
|
||||||
|
The optional Files copy failed because FileBrowser owns host paths as mapped UID
|
||||||
|
100000. Follow-up uses its authenticated API with override=false and collision
|
||||||
|
suffixes. A live API probe succeeded, refused overwrite with HTTP409, preserved
|
||||||
|
original bytes, and cleaned up. New protocol tests cover folder creation, escaped
|
||||||
|
names, collisions, authentication failure, disk-full, and unavailable service.
|
||||||
|
Full backend suite for these follow-ups is running; do not package the earlier
|
||||||
|
backend as final.
|
||||||
|
|
||||||
|
### Follow-up validation and OTA delivery check
|
||||||
|
|
||||||
|
Paid-response and Files API regressions passed in the full backend run: 1,552
|
||||||
|
passed, zero failed, four existing ignored tests. Live browser waiting checks
|
||||||
|
passed again after removing invented zero capacity and channel counts.
|
||||||
|
|
||||||
|
OTA inspection found that companion image :local (created by old installers and
|
||||||
|
used on dev) bypassed both source-staleness detection and rebuilding. The earlier
|
||||||
|
assumption that build-context detection covered these nodes was incorrect.
|
||||||
|
Follow-up applies the existing source-mtime/stamp checks to both :local and
|
||||||
|
:latest, preserving the existing tag and rebuilding only stale source. Existing
|
||||||
|
image-ID comparison then restarts the UI companion onto the new image. This does
|
||||||
|
not restart LND itself. Regression covers every companion's two local tags; final
|
||||||
|
backend suite is running. Verify the resulting live rebuilt image before release.
|
||||||
|
|
||||||
|
### Test isolation finding — release remains blocked
|
||||||
|
|
||||||
|
The next full run passed 1,552 tests but one existing boot-loop timing test failed.
|
||||||
|
Its output and node logs exposed an independent test defect: MockRuntime tests
|
||||||
|
still invoked real Quadlet service operations and Podman socket recovery. These
|
||||||
|
caused further LND/companion restarts during unrestricted unit runs. They were not
|
||||||
|
a recurrence of the repaired doctor port check. Stopped unrestricted testing;
|
||||||
|
LND has remained running since 19:02:46 UTC during isolated test execution.
|
||||||
|
|
||||||
|
New isolated runner hides live wallets, service buses, container storage and host
|
||||||
|
process IDs, supplies a private network and temporary writable fixture paths,
|
||||||
|
and keeps host filesystems read-only. An independent boundary probe passed.
|
||||||
|
Test-only service helpers use a temporary Quadlet directory and simulated service
|
||||||
|
results; mocked runtimes skip real Podman socket/network provisioning. Host file
|
||||||
|
helpers require the isolated-runner marker and execute inside the namespace
|
||||||
|
instead of escaping through sudo/systemd-run. Release harness and AGENTS now
|
||||||
|
require this runner. Initial isolation trials correctly blocked host operations
|
||||||
|
and exposed fixture permission assumptions; final runner compiles and executes
|
||||||
|
the full suite with those fixture paths isolated. No final pass claimed yet.
|
||||||
|
|
||||||
|
Main dashboard candidate and AIUI build at b634f41a are now deployed on dev; served
|
||||||
|
index SHA matches the build. Live package.versions returns bitcoinPrune=false
|
||||||
|
for Core and Knots, preserving current automatic mode. Existing full chain stays
|
||||||
|
unpruned. Final backend (Files/cached response/legacy UI delivery follow-ups) is
|
||||||
|
not yet deployed; earlier 0f85f588 backend remains live on both nodes.
|
||||||
|
|
||||||
|
Final isolated backend run: **1,553 passed, zero failed, four existing ignored**
|
||||||
|
in 13 seconds after compilation. Boundary probe confirms no host service buses,
|
||||||
|
live wallet data, host process IDs, or external network. Bitcoin/LND start times
|
||||||
|
remained unchanged during isolated execution. Production helpers are unchanged;
|
||||||
|
the namespace-specific command behavior is compiled only into unit tests.
|
||||||
|
Release and ISO gates now use the isolated runner.
|
||||||
|
|
||||||
|
### Final backend deployment and App Store follow-up — 19:36 UTC
|
||||||
|
|
||||||
|
Full release harness passed: static/catalog checks, frontend type-check and
|
||||||
|
1,117 frontend tests, cargo-check, and isolated backend suite (1,553 passed,
|
||||||
|
four existing ignored). Final optimized backend built successfully; SHA256
|
||||||
|
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||||
|
Deployed to dev. Legacy :local LND companion automatically rebuilt at 19:35 UTC
|
||||||
|
and restarted onto image 702c0cd88fb5c8a561c76dabdb96c40648dd62d401c78f2e10d4318b06f02abe.
|
||||||
|
Served UI bytes match candidate source. Native Bitcoin/LND start times unchanged.
|
||||||
|
|
||||||
|
Actual desktop pruning screenshot exposed horizontal overflow; moved the
|
||||||
|
explanation below the app header. The App Store uses Marketplace.vue, a separate
|
||||||
|
install path from Discover.vue. Its first Install button bypassed the version
|
||||||
|
modal. The browser check therefore sent an unintended Knots install request at
|
||||||
|
19:28 UTC. Core remained running, no Knots container was created, and the full
|
||||||
|
chain was not pruned. Removed only the newly created Knots installed-app record
|
||||||
|
and newly created version config; preserved root-only rollback copies.
|
||||||
|
|
||||||
|
Marketplace now uses the shared version/pruning modal. Added integration tests
|
||||||
|
for both Core and Knots: no install request until confirmation, selected version
|
||||||
|
and pruning forwarded, cancellation sends no install request. Four Marketplace
|
||||||
|
tests pass (three new plus existing refresh check). Further browser checks block
|
||||||
|
package.install requests at their network boundary. Final frontend rebuild and
|
||||||
|
post-fix live checks remain pending. Final paid-file follow-up is still pending.
|
||||||
|
|
||||||
|
### Unsigned release candidate ready — 19:46 UTC
|
||||||
|
|
||||||
|
Final frontend source/build attribution: 3612458e. Production dashboard and AIUI
|
||||||
|
builds passed. Final frontend suite: 1,120 tests across 139 files passed.
|
||||||
|
Desktop 1280px and mobile 390px browser checks passed for the app detail pruning
|
||||||
|
choice and App Store version modal; no horizontal overflow and no installation
|
||||||
|
request. Screenshot review confirms readable controls and explanation. Browser
|
||||||
|
installation requests are blocked during these selection-only checks.
|
||||||
|
|
||||||
|
Final backend SHA above matches both dev and Shorty. A fresh one-sat purchase
|
||||||
|
passed on those exact binaries: correct file bytes, both response field aliases,
|
||||||
|
exact one-sat refund on underpayment, zero-charge cached repeat, and exact Files
|
||||||
|
copy. Temporary seller entries/files and Files test copy removed; transaction
|
||||||
|
audit and owned cache retained. Total net transfer during the two live purchase
|
||||||
|
rounds: two sats from dev to Shorty. Desktop/mobile LND waiting checks passed
|
||||||
|
again on the automatically rebuilt companion. Native Bitcoin and LND stayed up.
|
||||||
|
|
||||||
|
Prepared, unsigned files:
|
||||||
|
- releases/pending/v1.8.20-alpha/app-catalog.json
|
||||||
|
- releases/pending/v1.8.20-alpha/manifest.json
|
||||||
|
|
||||||
|
Staged OTA backend: 64,716,656 bytes, SHA256
|
||||||
|
16a173129672cbb40c250446ec52ba4a9bd1974cbb3a4988f90c6f3187b7a1f7.
|
||||||
|
Frontend archive: 97,152,297 bytes, SHA256
|
||||||
|
658b78fce0dfa20a627c987dd153b24cbac15adbde905cc6518744c637e12802.
|
||||||
|
Artifact sizes/hashes/release notes validate. Checked actual archive: flat
|
||||||
|
layout, readable root permissions, exact doctor/LND UI source bytes, and fresh
|
||||||
|
AIUI attribution. Catalog has zero metadata drift and passes fleet registry trust.
|
||||||
|
|
||||||
|
Remaining: user-local release-root signatures, Framework's final display
|
||||||
|
confirmation, signed publication to git/ngit, then raw ISO build/boot test/signing
|
||||||
|
and publication. No v1.8.20 public release or tag exists yet. Four pre-existing
|
||||||
|
hardware/live tests remain ignored. Bitcoin sync-to-ready recovery is covered
|
||||||
|
by deterministic tests; the live node remains in initial sync. Do not describe
|
||||||
|
these checks as proof against every possible network/payment failure.
|
||||||
|
|
||||||
|
### Signing and release authorization — 2026-09-30
|
||||||
|
|
||||||
|
Both catalog and OTA signatures verify against the pinned release root. Staged
|
||||||
|
artifact hash/size checks and catalog drift/trust checks pass. User accepted the
|
||||||
|
remaining Framework display check and explicitly authorized release. Publication
|
||||||
|
and ISO build may proceed; do not regenerate the signed manifest or artifacts.
|
||||||
|
|
||||||
|
### Published OTA; ISO withheld after live shutdown defect — 2026-09-30
|
||||||
|
|
||||||
|
Signed 1.8.20 OTA/catalog published to git and ngit, with public asset hashes
|
||||||
|
verified. Catalog rollout triggered a Bitcoin command update at 08:34 UTC.
|
||||||
|
Although the orchestrator allowed a long stop, Quadlet's generated Podman removal
|
||||||
|
still used its ten-second default and killed Bitcoin. Core replayed its block
|
||||||
|
index; LND later lost its connection to the previous Bitcoin container IP.
|
||||||
|
|
||||||
|
Stopped the ISO build and queued boot check; any partial 1.8.20 ISO is invalid
|
||||||
|
and must not be published. Preparing 1.8.21 to supersede the immutable signed OTA.
|
||||||
|
Installed explicit graceful-stop systemd overrides on dev and Shorty without
|
||||||
|
restarting native services. Candidate Quadlet fix adds per-app container, systemd,
|
||||||
|
and command-wait budgets, including existing containers and uninstall fallback.
|
||||||
|
Focused 43 tests pass, including actual Quadlet generator stop-before-remove order.
|
||||||
|
Full tests, disposable slow-stop verification, build and deployment remain pending.
|
||||||
|
|
||||||
|
Disposable live regression passed: started an Alpine container with its legacy
|
||||||
|
ten-second stop setting, rewrote and reloaded its Quadlet with explicit twenty-
|
||||||
|
second graceful stop, verified the same container ID and old internal timeout
|
||||||
|
remained running, then stopped it. Its twelve-second shutdown handler completed
|
||||||
|
in 12.6 seconds, emitted the completion marker, and exited without SIGKILL/137.
|
||||||
|
Fixture had no network or wallet mounts and was removed afterward.
|
||||||
|
|
||||||
|
Core finished index loading and resumed unpruned initial sync. LND automatically
|
||||||
|
unlocked at 08:47 UTC. The existing backend-address cascade then performed a
|
||||||
|
graceful LND restart at 08:57 UTC after Bitcoin reconciliation completed; LND
|
||||||
|
automatically unlocked again and reached chain-sync waiting. No manual wallet
|
||||||
|
unlock or restart was used for this recovery.
|
||||||
|
|
||||||
|
### False dependency restart exposed during monitoring — 09:08 UTC
|
||||||
|
|
||||||
|
The initial 1.8.21 candidate passed all 1,557 isolated backend tests and 1,120
|
||||||
|
frontend tests. Monitoring nevertheless found another managed LND restart at
|
||||||
|
09:08:32 while Bitcoin's container/start timestamp remained unchanged. Management
|
||||||
|
logs explicitly attribute it to the backend-address cascade. This also makes
|
||||||
|
the earlier 08:57 cascade suspect; it must not be described as a proven necessary
|
||||||
|
restart. These service restarts preceded the isolated test executable, whose
|
||||||
|
namespace boundaries remain intact.
|
||||||
|
|
||||||
|
The cascade trusted Started/Installed action reports. A failed runtime inspection
|
||||||
|
followed by successful systemctl start of an already active unit can produce
|
||||||
|
Started without changing Bitcoin. Dependency restarts now require observed
|
||||||
|
container-ID, running-state, or start-time changes. Failed observations remain
|
||||||
|
unknown, not absence; a known absent backend becoming running still qualifies.
|
||||||
|
Actual exec-drift restarts are recognized even when their outer report is NoOp.
|
||||||
|
Stopped/lifecycle-in-flight dependents remain excluded, and user stop markers
|
||||||
|
are re-read after the potentially slow pass. Added runtime-observation and
|
||||||
|
false-action/real-exec-drift regression cases; full isolated rerun pending.
|
||||||
|
Stopped the first optimized build and preparing new artifacts from this correction.
|
||||||
|
|
||||||
|
### Final 1.8.21 artifacts and live verification — 2026-09-30
|
||||||
|
|
||||||
|
Source and frontend/AIUI attribution: c993d9dd. Full isolated backend suite:
|
||||||
|
1,559 passed, zero failed, four existing hardware/live tests ignored. Frontend
|
||||||
|
suite: 1,120 passed; final production type-check/build passed after the last
|
||||||
|
release-note-only edit. Optimized backend built in 13m22s.
|
||||||
|
|
||||||
|
Staged unsigned 1.8.21 OTA manifest and artifacts:
|
||||||
|
- Backend: 64,748,176 bytes; SHA256
|
||||||
|
ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb.
|
||||||
|
- Frontend archive: 97,152,546 bytes; SHA256
|
||||||
|
6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620.
|
||||||
|
|
||||||
|
Artifact validator passed. Actual archive has flat paths, readable root index,
|
||||||
|
and exact fresh AIUI, doctor and LND UI payload bytes. Exact files deployed to
|
||||||
|
dev at 09:32 UTC and Shorty at 09:35 UTC; rollback binaries and dashboards under
|
||||||
|
root-only /var/lib/archipelago/support/release-1821 on each node. Only management
|
||||||
|
services restarted. Existing Bitcoin/Core-or-Knots and native LND container IDs
|
||||||
|
and start times were preserved. Correct generated graceful-stop commands are
|
||||||
|
present before forced removal on both nodes; temporary grace overrides removed.
|
||||||
|
Dev systemd deadlines are 615 seconds for Bitcoin and 345 seconds for LND.
|
||||||
|
|
||||||
|
Desktop/mobile Lightning UI checks passed again: waiting for Bitcoin sync,
|
||||||
|
unknown balance, no unavailable native RPC requests. Served dashboard and AIUI
|
||||||
|
attribution bytes match the release. Native LND states: dev RPC_ACTIVE while
|
||||||
|
Bitcoin syncs; Shorty SERVER_ACTIVE. Dev completed full reconciliation passes
|
||||||
|
at 09:34:21 and 09:36:40 with Bitcoin/LND NoOp, and no dependency restart.
|
||||||
|
Shorty's first full pass completed 09:36:55 with Knots/LND NoOp.
|
||||||
|
|
||||||
|
Final paid-file check on these exact binaries passed: fresh one-sat dev-to-Shorty
|
||||||
|
purchase, exact one-sat refund on underpayment, identical response aliases,
|
||||||
|
correct Files copy, and zero-charge cached repeat. Removed temporary seller
|
||||||
|
entries/files and Files copy; retained purchase audit and owned cache. Total net
|
||||||
|
transfer across all three live payment rounds in this repair session: three sats.
|
||||||
|
|
||||||
|
Remaining: finish Shorty observation and remove temporary diagnostic logging;
|
||||||
|
user-local 1.8.21 OTA signature (existing catalog signature remains valid),
|
||||||
|
publish git/ngit, build/boot-test/sign and publish the raw 1.8.21 ISO.
|
||||||
|
No 1.8.21 release tag or public OTA yet. Do not publish the quarantined partial
|
||||||
|
1.8.20 ISO. The existing 1.8.20 git/ngit release notes now explain the withheld ISO
|
||||||
|
and pending hotfix; signed 1.8.20 assets remain immutable.
|
||||||
|
|
||||||
|
Shorty's second clean full pass completed at 09:38:06 UTC. Removed temporary
|
||||||
|
diagnostic logging on both nodes and restarted only management again; native
|
||||||
|
Bitcoin and LND IDs/start times remained unchanged, with generated stop settings
|
||||||
|
still verified. No temporary graceful-stop overrides remain. Catalog signature
|
||||||
|
verifies against the pinned release root; final 1.8.21 artifact validator passes.
|
||||||
|
The candidate is ready for the user's local OTA signing ceremony.
|
||||||
|
|
||||||
|
### 1.8.21 publication completed — 2026-09-30
|
||||||
|
|
||||||
|
The operator signed the OTA manifest and subsequently the ISO checksum JSON.
|
||||||
|
Both signatures verified against the pinned release root. The signed OTA was
|
||||||
|
published on git/ngit, and the operator confirmed that Framework could see the
|
||||||
|
update. Source main and the annotated `v1.8.21-alpha` tag were published.
|
||||||
|
|
||||||
|
Raw ISO:
|
||||||
|
`archipelago-installer-1.8.21-alpha-unbundled-x86_64_RC1.iso`
|
||||||
|
|
||||||
|
- Size: 2,682,419,200 bytes.
|
||||||
|
- SHA256: `8667b5522c476a40e29abba19df4180086191527a194a88765aa70ed527f9406`.
|
||||||
|
- Build and ISO smoke checks passed. The mounted backend matched the staged
|
||||||
|
OTA backend hash, and the full dashboard/AIUI tree matched the fresh build.
|
||||||
|
- An isolated UEFI QEMU guest, with no network or host disks attached, booted to
|
||||||
|
the installer prompt. The VM was stopped and the ISO unmounted afterward.
|
||||||
|
This was an installer boot check, not a full installation onto hardware.
|
||||||
|
- Uploaded the raw ISO, plain SHA256 sidecar and signed checksum JSON to the
|
||||||
|
[1.8.21 release](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.21-alpha).
|
||||||
|
The stored server file hashes matched, the public ISO headers and first/last
|
||||||
|
byte samples matched, and both public checksum files matched byte-for-byte.
|
||||||
|
- The ngit downloader's full-ISO acquisition exceeded its fixed 30-minute
|
||||||
|
deadline on the available connection. Published Nostr asset records using
|
||||||
|
the already verified hashes, sizes and public URLs with the existing ngit
|
||||||
|
signer; both repository relays acknowledged them. Ngit then accepted those
|
||||||
|
records and final readback resolved all five release assets with the expected
|
||||||
|
hashes and sizes. No new release-root signing was performed by the assistant.
|
||||||
|
|
||||||
|
Final publication evidence: `/tmp/archy-1821-finish-events.log`,
|
||||||
|
`/tmp/archy-ngit-1821-complete-view.json`, and
|
||||||
|
`/tmp/archy-1821-verified-asset-events.log` on the development box.
|
||||||
|
|
||||||
|
Subsequent review of PRs #161/#162 found additional delivery and concurrent
|
||||||
|
file-save edge cases. Their repaired, tested branches are recorded in
|
||||||
|
[the next-release review](pr-review-20260930.md); those changes are not in the
|
||||||
|
signed 1.8.21 artifacts. The X250 kiosk selector report is also tracked for the
|
||||||
|
next release. No claim of exhaustive hardware or network-failure coverage is
|
||||||
|
made for this release.
|
||||||
@@ -2607,21 +2607,14 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
|
|||||||
echo " ✅ Bundled image-versions.sh"
|
echo " ✅ Bundled image-versions.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bundle docker UI source files for building custom UIs on first boot
|
# Build-source apps need their complete contexts even on unbundled ISOs.
|
||||||
# Always bundle — these are tiny HTML/CSS files, not container images
|
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
|
||||||
if true; then
|
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
|
||||||
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
|
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
|
||||||
if [ -d "$DOCKER_UI_DIR" ]; then
|
[ -d "$DOCKER_UI_DIR" ] || { echo "Missing docker build sources" >&2; exit 1; }
|
||||||
echo " Bundling docker UI source files..."
|
mkdir -p "$ARCH_DIR/docker"
|
||||||
mkdir -p "$ARCH_DIR/docker"
|
cp -a "$DOCKER_UI_DIR/." "$ARCH_DIR/docker/"
|
||||||
for ui_dir in bitcoin-ui lnd-ui electrs-ui; do
|
python3 "$SCRIPT_DIR/../../scripts/check-app-build-contexts.py" "$ARCH_DIR"
|
||||||
if [ -d "$DOCKER_UI_DIR/$ui_dir" ]; then
|
|
||||||
cp -r "$DOCKER_UI_DIR/$ui_dir" "$ARCH_DIR/docker/"
|
|
||||||
echo " ✅ Bundled $ui_dir source"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$UNBUNDLED" = "1" ]; then
|
if [ "$UNBUNDLED" = "1" ]; then
|
||||||
echo " ✅ Unbundled build ready (Tor setup included, no container images)"
|
echo " ✅ Unbundled build ready (Tor setup included, no container images)"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.17-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"version": "1.8.17-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scure/bip39": "^2.2.0",
|
"@scure/bip39": "^2.2.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "neode-ui",
|
"name": "neode-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.8.17-alpha",
|
"version": "1.8.21-alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "./start-dev.sh",
|
"start": "./start-dev.sh",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface PackageVersionsResponse {
|
|||||||
pinnedVersion: string | null
|
pinnedVersion: string | null
|
||||||
autoUpdate: boolean
|
autoUpdate: boolean
|
||||||
versions: CatalogVersionInfo[]
|
versions: CatalogVersionInfo[]
|
||||||
|
bitcoinPrune?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppGatePortStatus {
|
export interface AppGatePortStatus {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-5 space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-white/80">
|
||||||
|
<input v-model="model" type="checkbox" class="accent-orange-400" />
|
||||||
|
Prune Bitcoin to save disk space
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-white/50">
|
||||||
|
Keeps about 50 GB of recent blocks, using the same settings as automatic
|
||||||
|
pruning on smaller disks. All blocks are still downloaded and verified.
|
||||||
|
Mempool and other apps that need the full blockchain won’t be available.
|
||||||
|
Turning pruning off later requires downloading the blockchain again.
|
||||||
|
</p>
|
||||||
|
<p v-if="!model" class="text-xs text-white/50">
|
||||||
|
Automatic pruning still applies on disks smaller than 1 TB.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<boolean>({ default: false })
|
||||||
|
</script>
|
||||||
@@ -3,6 +3,9 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
|
||||||
|
|
||||||
|
defineProps<{ setupOnly?: boolean }>()
|
||||||
|
const emit = defineEmits<{ ready: [] }>()
|
||||||
|
|
||||||
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
// Ecash (Cashu) wallet backup card — the same shape as the node recovery
|
||||||
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
// phrase and the Lightning seed cards, deliberately: a third reveal pattern
|
||||||
// would be a third thing to learn.
|
// would be a third thing to learn.
|
||||||
@@ -102,12 +105,14 @@ async function submitReveal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeReveal() {
|
function closeReveal() {
|
||||||
|
const established = revealedWords.value.length > 0
|
||||||
showRevealModal.value = false
|
showRevealModal.value = false
|
||||||
revealedWords.value = []
|
revealedWords.value = []
|
||||||
revealPassword.value = ''
|
revealPassword.value = ''
|
||||||
revealCode.value = ''
|
revealCode.value = ''
|
||||||
revealPassphrase.value = ''
|
revealPassphrase.value = ''
|
||||||
showRevealPassphrase.value = false
|
showRevealPassphrase.value = false
|
||||||
|
if (established) emit('ready')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyRevealedWords() {
|
async function copyRevealedWords() {
|
||||||
@@ -221,61 +226,54 @@ async function restoreFromPhrase() {
|
|||||||
Your ecash has no backup yet
|
Your ecash has no backup yet
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
|
<h2 class="text-xl font-semibold text-white/96 mb-1">{{ setupOnly ? 'Set up your Cashu Lightning address' : 'Ecash backup phrase' }}</h2>
|
||||||
|
|
||||||
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
|
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm leading-relaxed text-white/60">
|
||||||
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
|
Your node's recovery phrase also recovers this ecash phrase. Reveal its 24 words
|
||||||
phrase — so the words you already wrote down cover your ecash too. Reveal it here
|
to restore in a compatible Cashu wallet without sharing your node's phrase.
|
||||||
if you want to restore your ecash into another wallet (Minibits, Nutstash,
|
|
||||||
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
|
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="status?.active" class="text-sm text-white/60">
|
<p v-else-if="status?.active" class="text-sm leading-relaxed text-white/60">
|
||||||
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
|
Save your 24-word ecash phrase to restore this wallet here or in another
|
||||||
restore your ecash into another wallet (Minibits, Nutstash,
|
compatible Cashu wallet.
|
||||||
<span class="font-mono">cdk-cli</span>).
|
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm text-white/60">
|
<p v-else class="text-sm leading-relaxed text-white/60">
|
||||||
Ecash is a bearer instrument: the coins live in a file on this node, and right now
|
If this node's coin file is lost, your ecash is lost. Set up a phrase to recover
|
||||||
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
|
future coins; existing coins aren't covered.
|
||||||
that for every coin minted from then on.
|
|
||||||
<template v-if="status?.derivable_from_node_seed">
|
<template v-if="status?.derivable_from_node_seed">
|
||||||
It's derived from this node's recovery phrase, so there's nothing new to write down.
|
Your node's recovery phrase will also recover this phrase.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
This node has no encrypted seed backup to derive from, so the phrase will be its
|
This node has no saved seed, so write down and keep the new phrase separately.
|
||||||
own — you'll need to write these words down and keep them.
|
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
|
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
|
||||||
This wallet's phrase was <strong>not</strong> derived from the node's recovery
|
{{ status?.source === 'imported' ? 'This imported phrase' : 'This phrase' }} is separate
|
||||||
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring
|
from your node's backup. <strong>Only these words recover this ecash wallet.</strong>
|
||||||
the node will not bring the ecash back. Only these words will.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
|
||||||
@click="openReveal"
|
@click="openReveal"
|
||||||
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
|
>{{ status?.active ? 'Reveal' : (setupOnly ? 'Set up address' : 'Set up backup') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="status?.active" class="mt-4 pt-4 border-t border-white/10">
|
<div v-if="status?.active && !setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<p class="text-sm text-white/60 min-w-0">
|
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||||
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
<span class="text-white/80 font-medium">Restore from this phrase.</span>
|
||||||
Asks your mint which coins it has signed for these words and puts back any that
|
Recover unspent coins from your mint. Safe to repeat; coins you already hold
|
||||||
are still unspent. Safe to run at any time — it never duplicates coins you already
|
won't be duplicated.
|
||||||
hold.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
:disabled="restoring"
|
:disabled="restoring"
|
||||||
@click="restoreFromPhrase"
|
@click="restoreFromPhrase"
|
||||||
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
|
>{{ restoring ? 'Scanning…' : 'Restore' }}</button>
|
||||||
@@ -284,16 +282,16 @@ async function restoreFromPhrase() {
|
|||||||
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 pt-4 border-t border-white/10">
|
<div v-if="!setupOnly" class="mt-4 pt-4 border-t border-white/10">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
<p class="text-sm text-white/60 min-w-0">
|
<p class="text-sm leading-relaxed text-white/60 min-w-0">
|
||||||
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
|
||||||
Point this wallet at a phrase you already have — from Minibits, Nutstash or
|
Import a phrase from Minibits, Nutstash or <span class="font-mono">cdk-cli</span>
|
||||||
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
|
to restore its coins here.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
@click="openImport"
|
@click="openImport"
|
||||||
>Import</button>
|
>Import</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,7 +317,7 @@ async function restoreFromPhrase() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-sm text-white/60 mb-4">
|
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||||
Paste the 24-word phrase from the other wallet. The coins already in this wallet
|
Paste the 24-word phrase from the other wallet. The coins already in this wallet
|
||||||
stay spendable either way.
|
stay spendable either way.
|
||||||
</p>
|
</p>
|
||||||
@@ -376,9 +374,8 @@ async function restoreFromPhrase() {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<template v-if="revealedWords.length === 0">
|
<template v-if="revealedWords.length === 0">
|
||||||
<p class="text-sm text-white/60 mb-4">
|
<p class="text-sm leading-relaxed text-white/60 mb-4">
|
||||||
Confirm your credentials to
|
Confirm your credentials to {{ status?.active ? 'reveal' : 'set up' }} your ecash phrase.
|
||||||
{{ status?.active ? 'display the 24-word ecash phrase' : 'derive and display your ecash backup phrase' }}.
|
|
||||||
</p>
|
</p>
|
||||||
<form @submit.prevent="submitReveal" class="space-y-3">
|
<form @submit.prevent="submitReveal" class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -407,12 +404,12 @@ async function restoreFromPhrase() {
|
|||||||
<SeedRevealPanel :words="revealedWords" />
|
<SeedRevealPanel :words="revealedWords" />
|
||||||
<p class="text-xs text-white/40 mt-3">
|
<p class="text-xs text-white/40 mt-3">
|
||||||
<template v-if="revealedSource === 'node-seed'">
|
<template v-if="revealedSource === 'node-seed'">
|
||||||
Derived from this node's recovery phrase — restoring the node restores this
|
Your node's recovery phrase recovers this ecash wallet too. Use these words
|
||||||
ecash wallet too. These words also restore it into any NUT-13 wallet.
|
separately in a compatible Cashu (NUT-13) wallet.
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
This phrase is independent of the node's recovery phrase. It is the
|
Write these words down. They are the <strong>only</strong> way to recover
|
||||||
<strong>only</strong> way to restore this ecash wallet — write it down.
|
this ecash wallet; your node's phrase won't recover it.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2 pt-4">
|
<div class="flex gap-2 pt-4">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
|
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm font-mono focus:outline-none focus:border-orange-400/60"
|
||||||
/>
|
/>
|
||||||
<p class="text-[11px] text-white/40 mt-1">
|
<p class="text-[11px] text-white/40 mt-1">
|
||||||
Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this
|
Defaults to mempool.space. Any Mempool-compatible instance works — you can change this
|
||||||
any time in Settings → System.
|
any time in Settings → System.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,16 +25,27 @@
|
|||||||
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
|
<div v-if="loading" class="py-6 text-center text-white/60 text-sm">{{ t('common.loading') }}</div>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
|
<fieldset class="space-y-2">
|
||||||
<select
|
<legend class="text-white/60 text-sm mb-2">{{ t('appDetails.selectVersion') }}</legend>
|
||||||
v-model="selected"
|
<!-- Inline options avoid native popup rendering in the kiosk WebView.
|
||||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
|
They stay in document flow above the pruning explanation. -->
|
||||||
>
|
<div class="max-h-40 overflow-y-auto space-y-2 rounded-lg">
|
||||||
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
|
<label
|
||||||
</select>
|
v-for="v in versions"
|
||||||
|
:key="v.version"
|
||||||
|
class="flex items-center gap-3 rounded-lg border px-3 py-2.5 text-sm text-white cursor-pointer"
|
||||||
|
:class="selected === v.version ? 'border-blue-400/60 bg-slate-800' : 'border-white/10 bg-slate-900'"
|
||||||
|
>
|
||||||
|
<input v-model="selected" type="radio" :name="`install-version-${appId}`" :value="v.version" class="shrink-0 accent-blue-400" />
|
||||||
|
<span>{{ optionLabel(v) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BitcoinPruningChoice v-if="isBitcoin && !loading" v-model="prune" />
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex gap-2 mt-6">
|
<div class="flex gap-2 mt-6">
|
||||||
<button
|
<button
|
||||||
@@ -58,9 +69,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import BaseModal from './BaseModal.vue'
|
import BaseModal from './BaseModal.vue'
|
||||||
|
import BitcoinPruningChoice from './BitcoinPruningChoice.vue'
|
||||||
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
|
||||||
import { displayVersion } from '@/utils/version'
|
import { displayVersion } from '@/utils/version'
|
||||||
|
|
||||||
@@ -73,13 +85,16 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
|
||||||
confirm: [version: string]
|
confirm: [version: string, prune?: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const versions = ref<CatalogVersionInfo[]>([])
|
const versions = ref<CatalogVersionInfo[]>([])
|
||||||
const selected = ref('')
|
const selected = ref('')
|
||||||
|
const prune = ref(false)
|
||||||
|
const pruneKnown = ref(false)
|
||||||
|
const isBitcoin = computed(() => ['bitcoin-core', 'bitcoin-knots'].includes(props.appId))
|
||||||
|
|
||||||
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
|
// Latest reads as a sentence (no "v" prefix); concrete versions are normalized.
|
||||||
function optionLabel(v: CatalogVersionInfo): string {
|
function optionLabel(v: CatalogVersionInfo): string {
|
||||||
@@ -92,17 +107,22 @@ function optionLabel(v: CatalogVersionInfo): string {
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
prune.value = false
|
||||||
|
pruneKnown.value = false
|
||||||
versions.value = []
|
versions.value = []
|
||||||
selected.value = ''
|
selected.value = ''
|
||||||
try {
|
try {
|
||||||
const info = await rpcClient.getPackageVersions(props.appId)
|
const info = await rpcClient.getPackageVersions(props.appId)
|
||||||
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
// catalog_versions() returns the list default(=latest)-first, so versions[0]
|
||||||
// is the latest — pre-select it.
|
// is the latest — pre-select it.
|
||||||
|
pruneKnown.value = typeof info.bitcoinPrune === 'boolean'
|
||||||
|
prune.value = info.bitcoinPrune === true
|
||||||
versions.value = info.versions || []
|
versions.value = info.versions || []
|
||||||
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
|
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
|
||||||
// Fall back to the floating "latest" so the install can still proceed.
|
// Fall back to the floating "latest" so the install can still proceed.
|
||||||
|
versions.value = [{ version: 'latest' } as CatalogVersionInfo]
|
||||||
selected.value = 'latest'
|
selected.value = 'latest'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -111,7 +131,7 @@ async function load() {
|
|||||||
|
|
||||||
function confirm() {
|
function confirm() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
emit('confirm', selected.value)
|
emit('confirm', selected.value, isBitcoin.value && (pruneKnown.value || prune.value) ? prune.value : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -77,8 +77,13 @@
|
|||||||
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
<div v-else-if="lnAddressLoading" class="mb-4 text-center text-white/50 text-sm py-4">
|
||||||
{{ t('receiveBitcoin.lnAddressLoading') }}
|
{{ t('receiveBitcoin.lnAddressLoading') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="lnAddressNeedsSetup" class="mb-3">
|
||||||
|
<p class="text-sm text-white/70 mb-3">Set up this wallet's recovery phrase once to enable its Lightning address.</p>
|
||||||
|
<EcashSeedBackup setup-only @ready="loadLnAddress" />
|
||||||
|
</div>
|
||||||
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
<div v-else-if="lnAddressError" class="mb-3 text-xs text-white/40">
|
||||||
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
{{ t('receiveBitcoin.lnAddressUnavailable') }}
|
||||||
|
<button type="button" class="glass-button rounded-lg px-3 py-2 ml-2" @click="loadLnAddress">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -132,6 +137,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import BaseModal from '@/components/BaseModal.vue'
|
import BaseModal from '@/components/BaseModal.vue'
|
||||||
import CopyButton from '@/components/CopyButton.vue'
|
import CopyButton from '@/components/CopyButton.vue'
|
||||||
|
import EcashSeedBackup from '@/components/EcashSeedBackup.vue'
|
||||||
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
import PaymentSuccessPane, { type SuccessRow } from '@/components/PaymentSuccessPane.vue'
|
||||||
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
|
||||||
import { useLightningRequired } from '@/composables/useLightningRequired'
|
import { useLightningRequired } from '@/composables/useLightningRequired'
|
||||||
@@ -214,6 +220,7 @@ const error = ref('')
|
|||||||
const lnAddress = ref('')
|
const lnAddress = ref('')
|
||||||
const lnAddressLoading = ref(false)
|
const lnAddressLoading = ref(false)
|
||||||
const lnAddressError = ref(false)
|
const lnAddressError = ref(false)
|
||||||
|
const lnAddressNeedsSetup = ref(false)
|
||||||
// A payment the backend fetched (and so already consumed at Minibits) but
|
// A payment the backend fetched (and so already consumed at Minibits) but
|
||||||
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
|
// couldn't redeem yet — it's queued for automatic retry, not lost, but the
|
||||||
// operator should see it rather than have it be a silent, unbounded wait.
|
// operator should see it rather than have it be a silent, unbounded wait.
|
||||||
@@ -230,6 +237,7 @@ async function loadLnAddress() {
|
|||||||
if (lnAddress.value || lnAddressLoading.value) return
|
if (lnAddress.value || lnAddressLoading.value) return
|
||||||
lnAddressLoading.value = true
|
lnAddressLoading.value = true
|
||||||
lnAddressError.value = false
|
lnAddressError.value = false
|
||||||
|
lnAddressNeedsSetup.value = false
|
||||||
try {
|
try {
|
||||||
const res = await rpcClient.call<{ address?: string }>({
|
const res = await rpcClient.call<{ address?: string }>({
|
||||||
method: 'wallet.ecash-lnaddress',
|
method: 'wallet.ecash-lnaddress',
|
||||||
@@ -245,6 +253,16 @@ async function loadLnAddress() {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
lnAddressError.value = true
|
lnAddressError.value = true
|
||||||
|
// A legacy wallet may hold valid proofs without having a recovery phrase.
|
||||||
|
// Use the existing authenticated setup flow; never silently create a new
|
||||||
|
// identity or send the user to an unexplained generic service error.
|
||||||
|
try {
|
||||||
|
const seedStatus = await rpcClient.call<{ active: boolean; can_activate: boolean }>({
|
||||||
|
method: 'wallet.ecash-seed-status',
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
lnAddressNeedsSetup.value = seedStatus.active === false && seedStatus.can_activate === true
|
||||||
|
} catch { /* Keep the retryable service error when status is unavailable. */ }
|
||||||
} finally {
|
} finally {
|
||||||
lnAddressLoading.value = false
|
lnAddressLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
@change="saveExplorer"
|
@change="saveExplorer"
|
||||||
/>
|
/>
|
||||||
<p class="text-[11px] text-white/40 mt-1">
|
<p class="text-[11px] text-white/40 mt-1">
|
||||||
Any Mempool-compatible instance works. Default: tx1138.com.
|
Any Mempool-compatible instance works. Default: mempool.space.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
|
<div class="mt-3 p-3 rounded-lg border border-amber-400/25 bg-amber-500/10 text-amber-200/80 text-xs leading-relaxed">
|
||||||
|
|||||||
@@ -22,6 +22,37 @@ describe('EcashSeedBackup reveal credentials (#127)', () => {
|
|||||||
document.body.innerHTML = ''
|
document.body.innerHTML = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('signals readiness only after authenticated setup is finished and clears the words', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'wallet.ecash-seed-status') {
|
||||||
|
return { active: false, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||||
|
}
|
||||||
|
if (method === 'wallet.ecash-seed-reveal') {
|
||||||
|
return { words: [...Array(23).fill('abandon'), 'art'], source: 'node-seed' } as never
|
||||||
|
}
|
||||||
|
throw new Error('unexpected request')
|
||||||
|
})
|
||||||
|
wrapper = mount(EcashSeedBackup, { props: { setupOnly: true }, attachTo: document.body })
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
const cancel = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Cancel')!
|
||||||
|
cancel.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
|
||||||
|
password.value = 'test-password'
|
||||||
|
password.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('ready')).toBeUndefined()
|
||||||
|
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Done')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.emitted('ready')).toEqual([[]])
|
||||||
|
expect(document.body.querySelector('[aria-labelledby="reveal-ecash-seed-title"]')).toBeNull()
|
||||||
|
expect(document.body.textContent).not.toContain('abandon')
|
||||||
|
})
|
||||||
|
|
||||||
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
it('asks for a separate backup passphrase only after password decryption fails', async () => {
|
||||||
vi.mocked(rpcClient.call)
|
vi.mocked(rpcClient.call)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import InstallVersionModal from '../InstallVersionModal.vue'
|
||||||
|
const versions = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('../../api/rpc-client', () => ({ rpcClient: { getPackageVersions: versions } }))
|
||||||
|
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false, messages: { en: { common: { install: 'Install', cancel: 'Cancel' } } } })
|
||||||
|
function modal(id = 'bitcoin-core') {
|
||||||
|
return mount(InstallVersionModal, {
|
||||||
|
props: { show: true, appId: id, app: { id, title: id } },
|
||||||
|
global: { plugins: [i18n], stubs: { BaseModal: { template: '<div><slot/><slot name="footer"/></div>' } } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
describe('Bitcoin install storage choice', () => {
|
||||||
|
it.each(['bitcoin-core', 'bitcoin-knots'])('sends chosen version and explicit pruning for %s', async id => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: false, default: 'latest', versions: [{ version: 'latest' }, { version: '28.4' }] })
|
||||||
|
const wrapper = modal(id)
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('input[type=radio][value="28.4"]').setValue(true)
|
||||||
|
await wrapper.get('input[type=checkbox]').setValue(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['28.4', true]])
|
||||||
|
expect(wrapper.text()).toContain('automatic pruning')
|
||||||
|
expect(wrapper.text()).toContain('Mempool')
|
||||||
|
expect(wrapper.find('select').exists()).toBe(false)
|
||||||
|
expect(wrapper.findAll('input[type=radio]')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
it('keeps automatic disk selection by default and resets on reopening', async () => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
|
||||||
|
const wrapper = modal()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', false]])
|
||||||
|
await wrapper.get('input[type=checkbox]').setValue(true)
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await flushPromises()
|
||||||
|
expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(false)
|
||||||
|
})
|
||||||
|
it('still allows choosing pruning when version lookup fails', async () => {
|
||||||
|
versions.mockRejectedValue(new Error('offline'))
|
||||||
|
const wrapper = modal()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('input[type=checkbox]').setValue(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||||
|
})
|
||||||
|
it('remembers the node pruning preference when reinstalling or switching Bitcoin variants', async () => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: true, versions: [{ version: 'latest' }] })
|
||||||
|
const wrapper = modal('bitcoin-knots')
|
||||||
|
await flushPromises()
|
||||||
|
expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(true)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', true]])
|
||||||
|
})
|
||||||
|
it('does not turn off a saved pruning preference when its lookup fails', async () => {
|
||||||
|
versions.mockRejectedValue(new Error('offline'))
|
||||||
|
const wrapper = modal()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('confirm')).toEqual([['latest', undefined]])
|
||||||
|
})
|
||||||
|
it('does not offer Bitcoin settings for other apps', async () => {
|
||||||
|
versions.mockResolvedValue({ bitcoinPrune: false, versions: [{ version: 'latest' }] })
|
||||||
|
const wrapper = modal('other')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('input[type=checkbox]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
|
||||||
|
import EcashSeedBackup from '../EcashSeedBackup.vue'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
@@ -39,6 +40,51 @@ beforeEach(() => {
|
|||||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||||
// path a future change could regress, so it's worth pinning down.
|
// path a future change could regress, so it's worth pinning down.
|
||||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||||
|
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||||
|
let active = false
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'wallet.ecash-lnaddress') {
|
||||||
|
if (!active) throw new Error('The ecash wallet has no seed yet')
|
||||||
|
return { address: 'someone@minibits.cash' } as never
|
||||||
|
}
|
||||||
|
if (method === 'wallet.ecash-seed-status') {
|
||||||
|
return { active, can_activate: true, derivable_from_node_seed: true, source: null } as never
|
||||||
|
}
|
||||||
|
return {} as never
|
||||||
|
})
|
||||||
|
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||||
|
const tab = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!
|
||||||
|
tab.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('Set up your Cashu Lightning address')
|
||||||
|
expect(document.body.textContent).not.toContain('receiveBitcoin.lnAddressUnavailable')
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.some(([r]) => r.method === 'wallet.ecash-seed-reveal')).toBe(false)
|
||||||
|
active = true
|
||||||
|
wrapper.findComponent(EcashSeedBackup).vm.$emit('ready')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('someone@minibits.cash')
|
||||||
|
expect(wrapper.emitted('close')).toBeFalsy()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a seeded wallet on the retry path during a service outage', async () => {
|
||||||
|
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||||
|
if (method === 'wallet.ecash-seed-status') return { active: true, can_activate: true } as never
|
||||||
|
throw new Error('service unavailable')
|
||||||
|
})
|
||||||
|
const wrapper = mount(ReceiveBitcoinModal, { props: { show: true }, attachTo: document.body })
|
||||||
|
Array.from(document.body.querySelectorAll('button')).find(b => b.textContent?.toLowerCase().includes('ecash'))!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findComponent(EcashSeedBackup).exists()).toBe(false)
|
||||||
|
expect(document.body.textContent).toContain('receiveBitcoin.lnAddressUnavailable')
|
||||||
|
const retry = Array.from(document.body.querySelectorAll('button')).find(b => b.textContent === 'Retry')!
|
||||||
|
expect(retry).toBeTruthy()
|
||||||
|
retry.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
it('does not close/emit when the ecash tab is clicked and the RPC succeeds', async () => {
|
||||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'someone@minibits.cash' } as never)
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ describe('useTxExplorer.openTx', () => {
|
|||||||
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not open an external explorer while container discovery is pending', async () => {
|
||||||
|
const external = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||||
|
let finish!: () => void
|
||||||
|
ensureFetched.mockImplementationOnce(() => new Promise<void>(resolve => {
|
||||||
|
finish = () => { fetched = true; resolve() }
|
||||||
|
}))
|
||||||
|
const { openTx, setExplorer } = useTxExplorer()
|
||||||
|
setExplorer(DEFAULT_TX_EXPLORER, true)
|
||||||
|
const opening = openTx(TX)
|
||||||
|
expect(external).not.toHaveBeenCalled()
|
||||||
|
expect(openSession).not.toHaveBeenCalled()
|
||||||
|
finish()
|
||||||
|
await opening
|
||||||
|
expect(openSession).toHaveBeenCalledWith('mempool', { path: `/tx/${TX}` })
|
||||||
|
expect(external).not.toHaveBeenCalled()
|
||||||
|
external.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
it('asks for consent only when Mempool genuinely is not installed', async () => {
|
||||||
containerState = 'not-installed'
|
containerState = 'not-installed'
|
||||||
const { openTx, pendingTx } = useTxExplorer()
|
const { openTx, pendingTx } = useTxExplorer()
|
||||||
@@ -84,3 +102,23 @@ describe('useTxExplorer.openTx', () => {
|
|||||||
expect(pendingTx.value).toBe(TX)
|
expect(pendingTx.value).toBe(TX)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('explorer default migration', () => {
|
||||||
|
beforeEach(() => { localStorage.clear(); vi.resetModules() })
|
||||||
|
it('uses mempool.space with consent for a new browser', async () => {
|
||||||
|
const { useTxExplorer } = await import('../useTxExplorer')
|
||||||
|
expect(useTxExplorer().prefs.value).toEqual({ url: 'https://mempool.space', acknowledged: false })
|
||||||
|
})
|
||||||
|
it.each(['https://tx1138.com', 'https://tx1138.com/', 'http://tx1138.com'])('migrates %s and resets consent', async url => {
|
||||||
|
localStorage.setItem('archipelago.tx-explorer.v1', JSON.stringify({ url, acknowledged: true }))
|
||||||
|
const { useTxExplorer } = await import('../useTxExplorer')
|
||||||
|
expect(useTxExplorer().prefs.value).toEqual({ url: 'https://mempool.space', acknowledged: false })
|
||||||
|
expect(JSON.parse(localStorage.getItem('archipelago.tx-explorer.v1')!)).toEqual(useTxExplorer().prefs.value)
|
||||||
|
})
|
||||||
|
it('preserves a custom explorer and its consent', async () => {
|
||||||
|
const prefs = { url: 'https://my-explorer.example', acknowledged: true }
|
||||||
|
localStorage.setItem('archipelago.tx-explorer.v1', JSON.stringify(prefs))
|
||||||
|
const { useTxExplorer } = await import('../useTxExplorer')
|
||||||
|
expect(useTxExplorer().prefs.value).toEqual(prefs)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import { ref } from 'vue'
|
|||||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||||
import { useContainerStore } from '@/stores/container'
|
import { useContainerStore } from '@/stores/container'
|
||||||
|
|
||||||
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
|
export const DEFAULT_TX_EXPLORER = 'https://mempool.space'
|
||||||
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
|
export const EXPLORER_PLACEHOLDER = DEFAULT_TX_EXPLORER
|
||||||
|
|
||||||
const KEY = 'archipelago.tx-explorer.v1'
|
const KEY = 'archipelago.tx-explorer.v1'
|
||||||
|
|
||||||
@@ -30,7 +30,13 @@ interface TxExplorerPrefs {
|
|||||||
function loadPrefs(): TxExplorerPrefs {
|
function loadPrefs(): TxExplorerPrefs {
|
||||||
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
|
||||||
try {
|
try {
|
||||||
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
const stored = { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
|
||||||
|
// Changing operators requires fresh consent, even if the old one was trusted.
|
||||||
|
if (typeof stored.url === 'string' && /^https?:\/\/tx1138\.com\/*$/i.test(stored.url.trim())) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(defaults))
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
return stored
|
||||||
} catch {
|
} catch {
|
||||||
return defaults
|
return defaults
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ vi.mock('@/api/rpc-client', () => ({
|
|||||||
vi.stubGlobal('open', mockWindowOpen)
|
vi.stubGlobal('open', mockWindowOpen)
|
||||||
|
|
||||||
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
|
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
|
||||||
|
import { useAppStore } from '../app'
|
||||||
|
|
||||||
describe('useAppLauncherStore', () => {
|
describe('useAppLauncherStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -54,6 +55,25 @@ describe('useAppLauncherStore', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('blocks both browser and embedded launch while HTTP is unready', () => {
|
||||||
|
const app = useAppStore()
|
||||||
|
app.data = { 'package-data': { gitea: { state: 'running', 'ui-ready': false, health: 'healthy', manifest: { id: 'gitea', title: 'Gitea' } } } } as never
|
||||||
|
const launcher = useAppLauncherStore()
|
||||||
|
launcher.openSession('gitea')
|
||||||
|
expect(launcher.panelAppId).toBeNull()
|
||||||
|
launcher.open({ url: 'http://192.0.2.10:3001/', title: 'Gitea', openInNewTab: true })
|
||||||
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
|
expect(launcher.isOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('also gates a dynamic app resolved through its runtime URL', () => {
|
||||||
|
useAppStore().data = { 'package-data': { custom: { state: 'running', 'ui-ready': false, manifest: { id: 'custom', title: 'Custom' }, installed: { 'interface-addresses': { main: { 'lan-address': 'http://localhost:18993/' } } } } } } as never
|
||||||
|
const launcher = useAppLauncherStore()
|
||||||
|
launcher.open({ url: 'http://192.0.2.10:18993/', title: 'Custom', openInNewTab: true })
|
||||||
|
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||||
|
expect(launcher.isOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('starts closed with empty state', () => {
|
it('starts closed with empty state', () => {
|
||||||
const store = useAppLauncherStore()
|
const store = useAppLauncherStore()
|
||||||
expect(store.isOpen).toBe(false)
|
expect(store.isOpen).toBe(false)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { reactive, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const fake = reactive<{ packages: Record<string, unknown> }>({ packages: {} })
|
||||||
|
vi.mock('../sync', () => ({ useSyncStore: () => fake }))
|
||||||
|
vi.mock('../../api/rpc-client', () => ({ rpcClient: {} }))
|
||||||
|
import { useServerStore } from '../server'
|
||||||
|
|
||||||
|
function installing(phase = 'preparing-app') {
|
||||||
|
return { state: 'installing', manifest: { title: 'Git Workshop' }, 'install-progress': { phase, size: 0, downloaded: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('installation state after hard refresh', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
fake.packages = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores an in-flight install from an already-loaded server snapshot', () => {
|
||||||
|
fake.packages = { 'archipelago-source': installing() }
|
||||||
|
const store = useServerStore()
|
||||||
|
expect(store.isInstalling('archipelago-source')).toBe(true)
|
||||||
|
expect(store.installingApps.get('archipelago-source')).toMatchObject({
|
||||||
|
progress: 20,
|
||||||
|
message: 'Downloading, building and starting app…',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a long download visible and clears it on terminal success', async () => {
|
||||||
|
const store = useServerStore()
|
||||||
|
fake.packages = { 'nginx-proxy-manager': installing() }
|
||||||
|
await nextTick()
|
||||||
|
expect(store.isInstalling('nginx-proxy-manager')).toBe(true)
|
||||||
|
fake.packages = { 'nginx-proxy-manager': installing() }
|
||||||
|
await nextTick()
|
||||||
|
expect(store.installingApps.get('nginx-proxy-manager')?.progress).toBe(20)
|
||||||
|
fake.packages = { 'nginx-proxy-manager': { state: 'running' } }
|
||||||
|
await nextTick()
|
||||||
|
expect(store.isInstalling('nginx-proxy-manager')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -239,6 +239,11 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
const panelPath = ref<string | null>(null)
|
const panelPath = ref<string | null>(null)
|
||||||
|
|
||||||
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
|
function openSessionNow(appId: string, opts: LaunchOptions = {}) {
|
||||||
|
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||||
|
if (pkg?.['ui-ready'] === false) {
|
||||||
|
useToast().info(`${pkg.manifest?.title || appId} is not ready to open yet`)
|
||||||
|
return
|
||||||
|
}
|
||||||
recordAppLaunch(appId)
|
recordAppLaunch(appId)
|
||||||
const mobile = isMobileViewport()
|
const mobile = isMobileViewport()
|
||||||
|
|
||||||
@@ -295,7 +300,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// Apply the same readiness gate here so a container that has just entered
|
// Apply the same readiness gate here so a container that has just entered
|
||||||
// `running` cannot race nginx and show a transient 502 to the user.
|
// `running` cannot race nginx and show a transient 502 to the user.
|
||||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||||
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
|
if (pkg && (pkg['ui-ready'] === false || (pkg.state === 'running' && !isAppReadyForLaunch(pkg)))) {
|
||||||
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
|
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -394,6 +399,12 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
let launchUrl = normalizeLaunchUrl(payload.url, titleHintId)
|
||||||
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId
|
||||||
|
|
||||||
|
const pkg = resolvedId ? useAppStore().data?.['package-data']?.[resolvedId] : undefined
|
||||||
|
if (pkg?.['ui-ready'] === false) {
|
||||||
|
useToast().info(`${pkg.manifest?.title || resolvedId} is not ready to open yet`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Scheme discipline for everything launched on this host. Ports fronted
|
// Scheme discipline for everything launched on this host. Ports fronted
|
||||||
// by the node's app gate (manifest auth gated/open) serve TLS on the same
|
// by the node's app gate (manifest auth gated/open) serve TLS on the same
|
||||||
// port — on an HTTPS connection those must open over https. Ports that
|
// port — on an HTTPS connection those must open over https. Ports that
|
||||||
@@ -472,6 +483,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
|||||||
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
|
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
|
||||||
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
|
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
|
||||||
if (m?.[1]) return m[1].toLowerCase()
|
if (m?.[1]) return m[1].toLowerCase()
|
||||||
|
// Dynamic/sideloaded apps have no entry in the static port map.
|
||||||
|
if (u.hostname === window.location.hostname && u.port) {
|
||||||
|
for (const [id, pkg] of Object.entries(useAppStore().data?.['package-data'] || {})) {
|
||||||
|
const address = pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||||
|
if (!address) continue
|
||||||
|
try {
|
||||||
|
const runtime = new URL(address)
|
||||||
|
if (runtime.port === u.port && ['localhost', '127.0.0.1', window.location.hostname].includes(runtime.hostname)) return id
|
||||||
|
} catch { /* malformed runtime address is not a launch target */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
// Check port-based apps
|
// Check port-based apps
|
||||||
const appId = PORT_TO_APP_ID[u.port]
|
const appId = PORT_TO_APP_ID[u.port]
|
||||||
if (appId) return appId
|
if (appId) return appId
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { InstallPhase } from '../types/api'
|
|||||||
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
|
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
|
||||||
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
|
'preparing': { progress: 5, message: 'Preparing…', status: 'downloading' },
|
||||||
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
'pulling-image': { progress: 20, message: 'Downloading image…', status: 'downloading' },
|
||||||
|
'preparing-app': { progress: 20, message: 'Downloading, building and starting app…', status: 'downloading' },
|
||||||
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
'creating-container': { progress: 70, message: 'Creating container…', status: 'installing' },
|
||||||
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
|
||||||
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
|
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
|
||||||
@@ -149,7 +150,7 @@ export const useServerStore = defineStore('server', () => {
|
|||||||
uninstallingApps.value.delete(appId)
|
uninstallingApps.value.delete(appId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
|
||||||
const existing = installingApps.value.get(appId)
|
const existing = installingApps.value.get(appId)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const PackageState = {
|
|||||||
export type PackageState = typeof PackageState[keyof typeof PackageState]
|
export type PackageState = typeof PackageState[keyof typeof PackageState]
|
||||||
|
|
||||||
export interface PackageDataEntry {
|
export interface PackageDataEntry {
|
||||||
|
'ui-ready'?: boolean // HTTP upstream readiness, separate from container health
|
||||||
state: PackageState
|
state: PackageState
|
||||||
health?: string | null // "healthy", "unhealthy", "starting", or null
|
health?: string | null // "healthy", "unhealthy", "starting", or null
|
||||||
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
|
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
|
||||||
@@ -180,6 +181,7 @@ export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
|
|||||||
export type InstallPhase =
|
export type InstallPhase =
|
||||||
| 'preparing'
|
| 'preparing'
|
||||||
| 'pulling-image'
|
| 'pulling-image'
|
||||||
|
| 'preparing-app'
|
||||||
| 'creating-container'
|
| 'creating-container'
|
||||||
| 'starting-container'
|
| 'starting-container'
|
||||||
| 'waiting-healthy'
|
| 'waiting-healthy'
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ const canLaunch = computed(() => {
|
|||||||
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
|
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
|
||||||
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
|
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
|
||||||
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
|
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
|
||||||
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
|
||||||
})
|
})
|
||||||
|
|
||||||
const features = computed(() => {
|
const features = computed(() => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
:must-open-new-tab="mustOpenNewTab"
|
:must-open-new-tab="mustOpenNewTab"
|
||||||
:auto-retry-count="autoRetryCount"
|
:auto-retry-count="autoRetryCount"
|
||||||
:refresh-key="refreshKey"
|
:refresh-key="refreshKey"
|
||||||
|
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
|
||||||
:blocked-reason="blockedReason"
|
:blocked-reason="blockedReason"
|
||||||
:blocked-title="blockedTitle"
|
:blocked-title="blockedTitle"
|
||||||
:warming-up="warmingUp"
|
:warming-up="warmingUp"
|
||||||
@@ -375,6 +376,20 @@ const panelClasses = computed(() => {
|
|||||||
return `${base} app-session-overlay`
|
return `${base} app-session-overlay`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A cold/restarting upstream is held outside the iframe. Start one fresh
|
||||||
|
// load when the scanner observes HTTP readiness; no manual refresh required.
|
||||||
|
watch(() => packageEntry.value?.['ui-ready'], (ready, previous) => {
|
||||||
|
if (ready === false) {
|
||||||
|
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||||
|
if (autoRetryId) clearTimeout(autoRetryId)
|
||||||
|
if (iframeCheckId) clearTimeout(iframeCheckId)
|
||||||
|
loading.value = false
|
||||||
|
} else if (previous === false && ready === true) {
|
||||||
|
autoRetryCount.value = 0
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// --- Lifecycle handlers ---
|
// --- Lifecycle handlers ---
|
||||||
|
|
||||||
function onLoad() {
|
function onLoad() {
|
||||||
@@ -433,6 +448,7 @@ function refresh() {
|
|||||||
|
|
||||||
function startLoadTimeout() {
|
function startLoadTimeout() {
|
||||||
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
if (loadTimeoutId) clearTimeout(loadTimeoutId)
|
||||||
|
if (packageEntry.value?.['ui-ready'] === false) return
|
||||||
loadTimeoutId = setTimeout(() => {
|
loadTimeoutId = setTimeout(() => {
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -442,11 +458,13 @@ function startLoadTimeout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openNewTabAndBack() {
|
function openNewTabAndBack() {
|
||||||
|
if (packageEntry.value?.['ui-ready'] === false) return
|
||||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||||
closeSession()
|
closeSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNewTab() {
|
function openNewTab() {
|
||||||
|
if (packageEntry.value?.['ui-ready'] === false) return
|
||||||
if (appUrl.value) openExternalUrl(appUrl.value)
|
if (appUrl.value) openExternalUrl(appUrl.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -672,6 +672,11 @@ async function handleInstall(app: MarketplaceApp) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
|
if (['bitcoin-core', 'bitcoin-knots'].includes(app.id)) {
|
||||||
|
installModalApp.value = app
|
||||||
|
showInstallModal.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
// Multi-version apps (Bitcoin Knots / Core): let the runner pick a version up
|
||||||
// front via a full-screen modal (latest pre-selected) instead of silently
|
// front via a full-screen modal (latest pre-selected) instead of silently
|
||||||
// installing the default. Best-effort — if the lookup fails we install directly.
|
// installing the default. Best-effort — if the lookup fails we install directly.
|
||||||
@@ -686,19 +691,19 @@ async function handleInstall(app: MarketplaceApp) {
|
|||||||
startInstall(app)
|
startInstall(app)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startInstall(app: MarketplaceApp, versionOverride?: string) {
|
function startInstall(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (app.source === 'local') {
|
if (app.source === 'local') {
|
||||||
installApp(app, versionOverride)
|
installApp(app, versionOverride, prune)
|
||||||
} else {
|
} else {
|
||||||
installCommunityApp(app, versionOverride)
|
installCommunityApp(app, versionOverride, prune)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onInstallModalConfirm(version: string) {
|
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||||
const app = installModalApp.value
|
const app = installModalApp.value
|
||||||
showInstallModal.value = false
|
showInstallModal.value = false
|
||||||
installModalApp.value = null
|
installModalApp.value = null
|
||||||
if (app) startInstall(app, version)
|
if (app) startInstall(app, version, prune)
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewAppDetails(app: MarketplaceApp) {
|
function viewAppDetails(app: MarketplaceApp) {
|
||||||
@@ -774,25 +779,25 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
|||||||
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp, versionOverride?: string) {
|
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
queueInstall(app)
|
queueInstall(app)
|
||||||
installToast(app)
|
installToast(app)
|
||||||
try {
|
try {
|
||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||||
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
|
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }, timeout: 600000 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
if (import.meta.env.DEV) console.error('Installation failed:', err)
|
||||||
failInstall(app, err)
|
failInstall(app, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
|
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||||
queueInstall(app)
|
queueInstall(app)
|
||||||
installToast(app)
|
installToast(app)
|
||||||
try {
|
try {
|
||||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
|
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||||
if ((app as Record<string, unknown>).containerConfig) {
|
if ((app as Record<string, unknown>).containerConfig) {
|
||||||
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@
|
|||||||
class="order-2 lg:order-none"
|
class="order-2 lg:order-none"
|
||||||
:animate="animateCards"
|
:animate="animateCards"
|
||||||
:wallet-connected="walletConnected"
|
:wallet-connected="walletConnected"
|
||||||
|
:wallet-balance-unavailable="walletBalanceUnavailable"
|
||||||
:wallet-onchain="walletOnchain"
|
:wallet-onchain="walletOnchain"
|
||||||
:wallet-lightning="walletLightning"
|
:wallet-lightning="walletLightning"
|
||||||
:wallet-ecash="walletEcash"
|
:wallet-ecash="walletEcash"
|
||||||
@@ -685,6 +686,7 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
|
|||||||
// readout instead; a rail only becomes a number when a call actually
|
// readout instead; a rail only becomes a number when a call actually
|
||||||
// succeeds, so a real 0 is still a real 0.
|
// succeeds, so a real 0 is still a real 0.
|
||||||
const walletConnected = ref(false)
|
const walletConnected = ref(false)
|
||||||
|
const walletBalanceUnavailable = ref(false)
|
||||||
const walletOnchain = ref<number | null>(null)
|
const walletOnchain = ref<number | null>(null)
|
||||||
const walletLightning = ref<number | null>(null)
|
const walletLightning = ref<number | null>(null)
|
||||||
const walletEcash = ref<number | null>(null)
|
const walletEcash = ref<number | null>(null)
|
||||||
@@ -775,13 +777,24 @@ async function loadWeb5Status() {
|
|||||||
// call, which is what makes the card feel like an app launch.
|
// call, which is what makes the card feel like an app launch.
|
||||||
const balances = Promise.allSettled([
|
const balances = Promise.allSettled([
|
||||||
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
|
||||||
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
|
.then(res => {
|
||||||
|
if (!Number.isSafeInteger(res.balance_sats) || res.balance_sats < 0 ||
|
||||||
|
!Number.isSafeInteger(res.channel_balance_sats) || res.channel_balance_sats < 0) {
|
||||||
|
throw new Error('LND balance is unavailable')
|
||||||
|
}
|
||||||
|
walletOnchain.value = res.balance_sats
|
||||||
|
walletLightning.value = res.channel_balance_sats
|
||||||
|
walletConnected.value = true
|
||||||
|
walletBalanceUnavailable.value = false
|
||||||
|
walletInfoFailures = 0
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// A single slow poll must NOT flip the card to "disconnected" and
|
// A single slow poll must NOT flip the card to "disconnected" and
|
||||||
// hide balances the user already knows — busy nodes routinely blow
|
// hide balances the user already knows — busy nodes routinely blow
|
||||||
// the 5s budget mid-payment or during IO storms (a test node user
|
// the 5s budget mid-payment or during IO storms (a test node user
|
||||||
// report: balances vanished while a payment settled). Only call it
|
// report: balances vanished while a payment settled). Only call it
|
||||||
// disconnected after three consecutive failures (~30s of silence).
|
// disconnected after three consecutive failures (~30s of silence).
|
||||||
|
walletBalanceUnavailable.value = true
|
||||||
walletInfoFailures += 1
|
walletInfoFailures += 1
|
||||||
if (walletInfoFailures >= 3) walletConnected.value = false
|
if (walletInfoFailures >= 3) walletConnected.value = false
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
:tier-label="getAppTier(app.id)"
|
:tier-label="getAppTier(app.id)"
|
||||||
:install-blocked-reason="installBlockedReason(app.id)"
|
:install-blocked-reason="installBlockedReason(app.id)"
|
||||||
@view="viewAppDetails"
|
@view="viewAppDetails"
|
||||||
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
|
@install="handleInstall(app)"
|
||||||
@launch="launchInstalledApp"
|
@launch="launchInstalledApp"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +153,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- End Scrollable Apps Section -->
|
<!-- End Scrollable Apps Section -->
|
||||||
|
<InstallVersionModal
|
||||||
|
:show="showInstallModal"
|
||||||
|
:app-id="installModalApp?.id || ''"
|
||||||
|
:app="installModalApp"
|
||||||
|
@close="showInstallModal = false; installModalApp = null"
|
||||||
|
@confirm="onInstallModalConfirm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -175,11 +181,13 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
|
|||||||
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
|
||||||
import { useCachedResource } from '@/composables/useCachedResource'
|
import { useCachedResource } from '@/composables/useCachedResource'
|
||||||
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
import RefreshIndicator from '@/components/RefreshIndicator.vue'
|
||||||
|
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||||
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
|
||||||
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
|
||||||
import {
|
import {
|
||||||
type MarketplaceApp,
|
type MarketplaceApp,
|
||||||
INSTALLED_ALIASES,
|
INSTALLED_ALIASES,
|
||||||
|
MULTI_VERSION_APP_IDS,
|
||||||
getAppTier,
|
getAppTier,
|
||||||
categorizeCommunityApp,
|
categorizeCommunityApp,
|
||||||
getCuratedAppList,
|
getCuratedAppList,
|
||||||
@@ -206,6 +214,8 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
|
|||||||
|
|
||||||
// Installation state — uses global store so it persists across navigation
|
// Installation state — uses global store so it persists across navigation
|
||||||
const installingApps = server.installingApps
|
const installingApps = server.installingApps
|
||||||
|
const showInstallModal = ref(false)
|
||||||
|
const installModalApp = ref<MarketplaceApp | null>(null)
|
||||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||||
|
|
||||||
function installToast(app: MarketplaceApp) {
|
function installToast(app: MarketplaceApp) {
|
||||||
@@ -518,7 +528,34 @@ function failInstall(app: MarketplaceApp, err: unknown) {
|
|||||||
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
|
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installApp(app: MarketplaceApp) {
|
function handleInstall(app: MarketplaceApp) {
|
||||||
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
|
const blocked = installBlockedReason(app.id)
|
||||||
|
if (blocked) {
|
||||||
|
toast.error(blocked)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (MULTI_VERSION_APP_IDS.has(app.id)) {
|
||||||
|
installModalApp.value = app
|
||||||
|
showInstallModal.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startInstall(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInstall(app: MarketplaceApp, version?: string, prune?: boolean) {
|
||||||
|
if (app.source === 'local') void installApp(app, version, prune)
|
||||||
|
else void installCommunityApp(app, version, prune)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInstallModalConfirm(version: string, prune?: boolean) {
|
||||||
|
const app = installModalApp.value
|
||||||
|
showInstallModal.value = false
|
||||||
|
installModalApp.value = null
|
||||||
|
if (app) startInstall(app, version, prune)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
if (installingApps.has(app.id) || isInstalled(app.id)) return
|
||||||
const blocked = installBlockedReason(app.id)
|
const blocked = installBlockedReason(app.id)
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
@@ -536,7 +573,7 @@ async function installApp(app: MarketplaceApp) {
|
|||||||
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
method: 'package.install',
|
method: 'package.install',
|
||||||
params: { id: app.id, url: installUrl, version: app.version },
|
params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) },
|
||||||
timeout: 600000,
|
timeout: 600000,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -545,7 +582,7 @@ async function installApp(app: MarketplaceApp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installCommunityApp(app: MarketplaceApp) {
|
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
|
||||||
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
|
||||||
const blocked = installBlockedReason(app.id)
|
const blocked = installBlockedReason(app.id)
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
@@ -558,7 +595,7 @@ async function installCommunityApp(app: MarketplaceApp) {
|
|||||||
installToast(app)
|
installToast(app)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
|
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
|
||||||
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
if (app.containerConfig) installParams.containerConfig = app.containerConfig
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
method: 'package.install',
|
method: 'package.install',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="!isInstalled"
|
v-if="!isInstalled"
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||||
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="glass-button glass-button-sm px-6 py-2.5 rounded-lg text-sm font-semibold flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
@@ -90,6 +90,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BitcoinPruningChoice
|
||||||
|
v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded"
|
||||||
|
v-model="pruneOnInstall"
|
||||||
|
class="hidden md:block"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Mobile: Two Column Grid Layout -->
|
<!-- Mobile: Two Column Grid Layout -->
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<!-- Top: Icon + Info -->
|
<!-- Top: Icon + Info -->
|
||||||
@@ -137,6 +143,7 @@
|
|||||||
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" class="mb-4" />
|
||||||
|
|
||||||
<!-- Bottom: Action Buttons -->
|
<!-- Bottom: Action Buttons -->
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
@@ -153,7 +160,7 @@
|
|||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@click="installApp"
|
@click="installApp"
|
||||||
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
|
||||||
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
:title="demoNoInstall ? 'Not available in the demo' : (installBlockedReason || undefined)"
|
||||||
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
class="glass-button glass-button-sm px-4 py-2.5 rounded-lg text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed col-span-2"
|
||||||
>
|
>
|
||||||
@@ -374,6 +381,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import BitcoinPruningChoice from '@/components/BitcoinPruningChoice.vue'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
@@ -408,6 +416,10 @@ const bitcoinPruned = ref(false)
|
|||||||
// Hidden when an app offers only one version — install stays one-click.
|
// Hidden when an app offers only one version — install stays one-click.
|
||||||
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
const installVersions = ref<{ version: string; default: boolean; deprecated: boolean; eol: string | null }[]>([])
|
||||||
const selectedInstallVersion = ref('')
|
const selectedInstallVersion = ref('')
|
||||||
|
const pruneOnInstall = ref(false)
|
||||||
|
const pruneSettingKnown = ref(false)
|
||||||
|
const prunePrefsLoaded = ref(false)
|
||||||
|
const isBitcoinInstall = computed(() => ['bitcoin-core', 'bitcoin-knots'].includes(app.value?.id || ''))
|
||||||
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
const backButtonLabel = computed(() => route.query.from === 'home' ? t('marketplaceDetails.backToHome') : t('marketplaceDetails.backToStore'))
|
||||||
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
const electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
|
||||||
|
|
||||||
@@ -587,10 +599,13 @@ onMounted(() => {
|
|||||||
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
// cached entry is missing or past its TTL, so a repeat open inside the TTL
|
||||||
// paints from cache with no new RPC.
|
// paints from cache with no new RPC.
|
||||||
async function loadInstallVersions() {
|
async function loadInstallVersions() {
|
||||||
if (versionsResource.data.value === null || versionsResource.isStale.value) {
|
if (isBitcoinInstall.value || versionsResource.data.value === null || versionsResource.isStale.value) {
|
||||||
await versionsResource.refresh()
|
await versionsResource.refresh()
|
||||||
}
|
}
|
||||||
const info = versionsResource.data.value
|
const info = versionsResource.data.value
|
||||||
|
pruneSettingKnown.value = !versionsResource.error.value && typeof info?.bitcoinPrune === 'boolean'
|
||||||
|
pruneOnInstall.value = pruneSettingKnown.value && info?.bitcoinPrune === true
|
||||||
|
prunePrefsLoaded.value = true
|
||||||
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
if (!info || !info.supportsVersions || info.versions.length < 2) {
|
||||||
installVersions.value = []
|
installVersions.value = []
|
||||||
return
|
return
|
||||||
@@ -701,6 +716,7 @@ async function installApp() {
|
|||||||
id: app.value.id,
|
id: app.value.id,
|
||||||
dockerImage: app.value.dockerImage,
|
dockerImage: app.value.dockerImage,
|
||||||
version: chosenVersion,
|
version: chosenVersion,
|
||||||
|
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||||
}
|
}
|
||||||
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
|
||||||
await rpcClient.call({
|
await rpcClient.call({
|
||||||
@@ -717,6 +733,7 @@ async function installApp() {
|
|||||||
id: app.value.id,
|
id: app.value.id,
|
||||||
url: installUrl,
|
url: installUrl,
|
||||||
version: chosenVersion,
|
version: chosenVersion,
|
||||||
|
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
|
||||||
},
|
},
|
||||||
timeout: 600000,
|
timeout: 600000,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { flushPromises, mount } from '@vue/test-utils'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import Marketplace from '../Marketplace.vue'
|
import Marketplace from '../Marketplace.vue'
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import InstallVersionModal from '@/components/InstallVersionModal.vue'
|
||||||
|
import MarketplaceAppCard from '../marketplace/MarketplaceAppCard.vue'
|
||||||
|
|
||||||
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
|
// Mirrors the CloudPeersRefresh.test.ts pattern (in-repo convention for
|
||||||
// mounting a view directly with its heavier deps mocked at the module
|
// mounting a view directly with its heavier deps mocked at the module
|
||||||
@@ -44,22 +47,37 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useToast', () => ({
|
vi.mock('@/composables/useToast', () => ({
|
||||||
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
|
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api/rpc-client', () => ({
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
rpcClient: {
|
rpcClient: {
|
||||||
call: vi.fn(),
|
call: vi.fn(),
|
||||||
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
|
marketplaceDiscover: vi.fn().mockResolvedValue({ apps: [] }),
|
||||||
|
getPackageVersions: vi.fn(),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('../discover/curatedApps', () => ({
|
||||||
|
fetchAppCatalog: vi.fn().mockResolvedValue({
|
||||||
|
apps: ['bitcoin-core', 'bitcoin-knots'].map(id => ({
|
||||||
|
id, title: id, version: '29.0', description: 'Bitcoin node',
|
||||||
|
dockerImage: `registry.example/${id}:29.0`, source: 'community',
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
|
||||||
routerPushMock.mockClear()
|
routerPushMock.mockClear()
|
||||||
toastErrorMock.mockClear()
|
toastErrorMock.mockClear()
|
||||||
toastInfoMock.mockClear()
|
toastInfoMock.mockClear()
|
||||||
|
vi.mocked(rpcClient.call).mockReset()
|
||||||
|
vi.mocked(rpcClient.getPackageVersions).mockResolvedValue({
|
||||||
|
supportsVersions: true, default: '29.0', bitcoinPrune: false,
|
||||||
|
versions: [{ version: '29.0', default: true, deprecated: false, eol: null }],
|
||||||
|
} as Awaited<ReturnType<typeof rpcClient.getPackageVersions>>)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -89,4 +107,38 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
|
|||||||
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['bitcoin-core', 'bitcoin-knots'])('requires the version modal before installing %s and forwards pruning', async (id) => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
|
||||||
|
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||||
|
await flushPromises()
|
||||||
|
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === id)!
|
||||||
|
expect(card.exists()).toBe(true)
|
||||||
|
card.vm.$emit('install', card.props('app'))
|
||||||
|
await flushPromises()
|
||||||
|
const installs = () => vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')
|
||||||
|
expect(installs()).toHaveLength(0)
|
||||||
|
const modal = wrapper.findComponent(InstallVersionModal)
|
||||||
|
expect(modal.props('show')).toBe(true)
|
||||||
|
await modal.get('input[type="checkbox"]').setValue(true)
|
||||||
|
await modal.get('button.glass-button-warning').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(installs()).toHaveLength(1)
|
||||||
|
expect(installs()[0]?.[0].params).toMatchObject({ id, version: '29.0', prune: true })
|
||||||
|
expect(modal.props('show')).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels Bitcoin selection without sending an installation request', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ blockchain_info: { pruned: false } }) }))
|
||||||
|
const wrapper = mount(Marketplace, { global: { plugins: [createPinia()], stubs: { Teleport: true } } })
|
||||||
|
await flushPromises()
|
||||||
|
const card = wrapper.findAllComponents(MarketplaceAppCard).find(c => c.props('app').id === 'bitcoin-knots')!
|
||||||
|
card.vm.$emit('install', card.props('app'))
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.findComponent(InstallVersionModal).vm.$emit('close')
|
||||||
|
await flushPromises()
|
||||||
|
expect(vi.mocked(rpcClient.call).mock.calls.filter(([request]) => request.method === 'package.install')).toHaveLength(0)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -238,6 +238,47 @@ describe('Home tab cache (Task 2): system/update/storage groups + wallet freshne
|
|||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each(['failure', 'missing', 'partial'])('preserves known balances during %s and clears the warning on recovery', async (failure) => {
|
||||||
|
const wrapper = mountHomeHost()
|
||||||
|
await settle()
|
||||||
|
const home = wrapper.findComponent(Home)
|
||||||
|
const refresh = () => (home.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
|
||||||
|
rpcCallMock.mockImplementationOnce(async () => {
|
||||||
|
if (failure === 'failure') throw new Error('wallet locked')
|
||||||
|
return failure === 'partial' ? { balance_sats: 0 } : {}
|
||||||
|
})
|
||||||
|
await refresh()
|
||||||
|
await settle()
|
||||||
|
const card = wrapper.findComponent(HomeWalletCard)
|
||||||
|
expect(card.props('walletOnchain')).toBe(5000)
|
||||||
|
expect(card.props('walletLightning')).toBe(2500)
|
||||||
|
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('last known')
|
||||||
|
const snapshot = JSON.parse(localStorage.getItem('archy-wallet-snapshot-v1')!)
|
||||||
|
expect(snapshot.onchain).toBe(5000)
|
||||||
|
expect(snapshot.lightning).toBe(2500)
|
||||||
|
rpcCallMock.mockImplementationOnce(async () => ({ balance_sats: 0, channel_balance_sats: 0, synced_to_chain: true }))
|
||||||
|
await refresh()
|
||||||
|
await settle()
|
||||||
|
expect(card.props('walletOnchain')).toBe(0)
|
||||||
|
expect(card.props('walletLightning')).toBe(0)
|
||||||
|
expect(card.find('[data-testid="wallet-balance-unavailable"]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows unknown rather than zero when the first LND request fails', async () => {
|
||||||
|
rpcCallMock.mockImplementation(async (request) => {
|
||||||
|
if (request.method === 'lnd.getinfo') throw new Error('wallet locked')
|
||||||
|
return defaultRpcCallImpl(request)
|
||||||
|
})
|
||||||
|
const wrapper = mountHomeHost()
|
||||||
|
await settle()
|
||||||
|
const card = wrapper.findComponent(HomeWalletCard)
|
||||||
|
expect(card.props('walletOnchain')).toBeNull()
|
||||||
|
expect(card.props('walletLightning')).toBeNull()
|
||||||
|
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('unavailable')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
|
||||||
const wrapper = mountHomeHost()
|
const wrapper = mountHomeHost()
|
||||||
await settle()
|
await settle()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
first, then sync status arrives), and the sync screen is strictly
|
first, then sync status arrives), and the sync screen is strictly
|
||||||
more informative, so it takes precedence instead of the two
|
more informative, so it takes precedence instead of the two
|
||||||
rendering on top of each other. -->
|
rendering on top of each other. -->
|
||||||
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
<AppLoadingScreen v-if="loading && !uiReadyBlocked && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
|
v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!electrsSync || electrsSync.stale)"
|
||||||
class="absolute inset-0 app-session-frame-scroll-host"
|
class="absolute inset-0 app-session-frame-scroll-host"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
@pointerdown="focusIframe"
|
@pointerdown="focusIframe"
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
reachable yet, so the "App not reachable / retry" overlay would just
|
reachable yet, so the "App not reachable / retry" overlay would just
|
||||||
paint over the sync progress and read as a hard error. -->
|
paint over the sync progress and read as a hard error. -->
|
||||||
<Transition name="content-fade">
|
<Transition name="content-fade">
|
||||||
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
<div v-if="(iframeBlocked || uiReadyBlocked) && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
|
||||||
<div class="text-center px-8">
|
<div class="text-center px-8">
|
||||||
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
|
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
|
||||||
the padlock reads as "blocked/denied" and this state is neither. -->
|
the padlock reads as "blocked/denied" and this state is neither. -->
|
||||||
@@ -78,7 +78,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
<h3 class="text-lg font-semibold text-white mb-2">{{ warmingUp ? `${appTitle} is starting…` : blockedReason ? blockedTitle : (mustOpenNewTab ? 'This app opens in a new tab' : 'App not reachable') }}</h3>
|
||||||
<p class="text-white/50 text-sm mb-6">
|
<p class="text-white/50 text-sm mb-6">
|
||||||
<template v-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
<template v-if="uiReadyBlocked">{{ blockedReason }} This screen opens automatically when it is ready.</template>
|
||||||
|
<template v-else-if="mustOpenNewTab">{{ appTitle }} sets security headers that prevent iframe embedding.<br>Open it in a new browser tab instead.</template>
|
||||||
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
<template v-else-if="warmingUp">The container is running but hasn't finished warming up yet.<br>This screen opens on its own as soon as it answers.<span v-if="autoRetryCount > 0" class="block text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||||
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
<template v-else-if="blockedReason">{{ blockedReason }}<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Checking again automatically ({{ autoRetryCount }})...</span></template>
|
||||||
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
<template v-else>{{ appTitle }} may still be starting up or the container is stopped.<br><span v-if="autoRetryCount > 0" class="text-yellow-400/70">Retrying automatically ({{ autoRetryCount }})...</span></template>
|
||||||
@@ -95,6 +96,7 @@
|
|||||||
Retry now
|
Retry now
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!uiReadyBlocked"
|
||||||
@click="$emit('openNewTabAndBack')"
|
@click="$emit('openNewTabAndBack')"
|
||||||
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
@@ -108,7 +110,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
|
<div v-if="!appUrl && !uiReadyBlocked" class="absolute inset-0 flex items-center justify-center">
|
||||||
<div class="text-center px-8">
|
<div class="text-center px-8">
|
||||||
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
|
<h3 class="text-lg font-semibold text-white mb-2">App not configured</h3>
|
||||||
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
|
<p class="text-white/50 text-sm">No URL found for {{ appId }}</p>
|
||||||
@@ -133,6 +135,7 @@ const props = defineProps<{
|
|||||||
mustOpenNewTab: boolean
|
mustOpenNewTab: boolean
|
||||||
autoRetryCount: number
|
autoRetryCount: number
|
||||||
refreshKey: number
|
refreshKey: number
|
||||||
|
uiReadyBlocked?: boolean
|
||||||
blockedReason?: string
|
blockedReason?: string
|
||||||
blockedTitle?: string
|
blockedTitle?: string
|
||||||
// True while the container is up but its probe hasn't answered yet and the
|
// True while the container is up but its probe hasn't answered yet and the
|
||||||
|
|||||||
@@ -66,3 +66,19 @@ describe('AppSessionFrame warm-up state', () => {
|
|||||||
expect(text).toContain('This app opens in a new tab')
|
expect(text).toContain('This app opens in a new tab')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('HTTP readiness gate', () => {
|
||||||
|
it('does not show a missing-configuration error during initial installation', () => {
|
||||||
|
const frame = mountFrame({ appUrl: '', uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…' })
|
||||||
|
expect(frame.text()).not.toContain('App not configured')
|
||||||
|
expect(frame.find('iframe').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
it('does not mount an iframe before readiness, then opens automatically', async () => {
|
||||||
|
const frame = mountFrame({ iframeBlocked: false, uiReadyBlocked: true, blockedReason: 'Waiting for the app to be ready…', blockedTitle: 'App not ready' })
|
||||||
|
expect(frame.find('iframe').exists()).toBe(false)
|
||||||
|
expect(frame.text()).toContain('opens automatically')
|
||||||
|
expect(frame.text()).not.toContain('Open in new tab')
|
||||||
|
await frame.setProps({ uiReadyBlocked: false, blockedReason: '' })
|
||||||
|
expect(frame.find('iframe').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -184,3 +184,24 @@ describe('appsConfig service filtering', () => {
|
|||||||
expect(canLaunch(pkg)).toBe(true)
|
expect(canLaunch(pkg)).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('HTTP readiness independent of container health', () => {
|
||||||
|
it('blocks fixed launch URLs while the HTTP upstream is unavailable', () => {
|
||||||
|
for (const id of ['gitea', 'filebrowser', 'fedimint', 'lnd']) {
|
||||||
|
const pkg = makePkg(id, id, 'other')
|
||||||
|
pkg['ui-ready'] = false
|
||||||
|
pkg.health = 'healthy'
|
||||||
|
expect(canLaunch(pkg)).toBe(false)
|
||||||
|
expect(isAppReadyForLaunch(pkg)).toBe(false)
|
||||||
|
expect(launchBlockedReason(id, pkg)).toContain('Waiting')
|
||||||
|
pkg['ui-ready'] = true
|
||||||
|
expect(isAppReadyForLaunch(pkg)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('allows a ready companion while its backend is syncing', () => {
|
||||||
|
const pkg = makePkg('lnd', 'Lightning', 'bitcoin')
|
||||||
|
pkg.health = 'starting'
|
||||||
|
pkg['ui-ready'] = true
|
||||||
|
expect(isAppReadyForLaunch(pkg)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
|
|||||||
|
|
||||||
export function canLaunch(pkg: PackageDataEntry): boolean {
|
export function canLaunch(pkg: PackageDataEntry): boolean {
|
||||||
if (isWebOnlyApp(pkg.manifest.id)) return true
|
if (isWebOnlyApp(pkg.manifest.id)) return true
|
||||||
|
if (pkg['ui-ready'] === false) return false
|
||||||
// Headless backends never get a Launch button, even with a published port.
|
// Headless backends never get a Launch button, even with a published port.
|
||||||
if (isServicePackage(pkg.manifest.id, pkg)) return false
|
if (isServicePackage(pkg.manifest.id, pkg)) return false
|
||||||
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
|
||||||
@@ -277,6 +278,7 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
|
|||||||
* health check retain the legacy state/port behaviour.
|
* health check retain the legacy state/port behaviour.
|
||||||
*/
|
*/
|
||||||
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
||||||
|
if (pkg['ui-ready'] !== undefined) return pkg['ui-ready']
|
||||||
const manifest = pkg.manifest as unknown as Record<string, unknown>
|
const manifest = pkg.manifest as unknown as Record<string, unknown>
|
||||||
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
|
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
|
||||||
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
|
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
|
||||||
@@ -285,6 +287,10 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
|
|||||||
|
|
||||||
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
|
||||||
const appId = pkg?.manifest?.id || id
|
const appId = pkg?.manifest?.id || id
|
||||||
|
if (pkg?.['ui-ready'] === false && !isServicePackage(appId, pkg)) {
|
||||||
|
if (pkg.state === PackageState.Stopped || pkg.state === PackageState.Exited) return 'App is stopped. Start it to open it.'
|
||||||
|
return 'Waiting for the app to be ready…'
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(appId === 'fedimint' || appId === 'fedimintd') &&
|
(appId === 'fedimint' || appId === 'fedimintd') &&
|
||||||
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
|
||||||
|
|||||||
@@ -54,6 +54,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="walletBalanceUnavailable" data-testid="wallet-balance-unavailable" class="text-sm text-amber-200 mb-3" role="status">
|
||||||
|
{{ walletOnchain != null || walletLightning != null
|
||||||
|
? 'Bitcoin and Lightning balances could not be refreshed. Showing last known amounts.'
|
||||||
|
: 'Bitcoin and Lightning balances are unavailable while the wallet starts or reconnects.' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Incoming Transactions Panel -->
|
<!-- Incoming Transactions Panel -->
|
||||||
<transition name="incoming-tx-slide">
|
<transition name="incoming-tx-slide">
|
||||||
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
|
||||||
@@ -221,6 +227,7 @@ export interface WalletTransaction {
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
animate: boolean
|
animate: boolean
|
||||||
walletConnected: boolean
|
walletConnected: boolean
|
||||||
|
walletBalanceUnavailable?: boolean
|
||||||
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
|
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
|
||||||
// what lets the card show a pixel readout instead of claiming a figure.
|
// what lets the card show a pixel readout instead of claiming a figure.
|
||||||
walletOnchain: number | null
|
walletOnchain: number | null
|
||||||
|
|||||||
@@ -362,6 +362,60 @@ init()
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||||
|
<!-- v1.8.21-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.21-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 30, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.</p>
|
||||||
|
<p>Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.</p>
|
||||||
|
<p>Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.</p>
|
||||||
|
<p>Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- v1.8.20-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.20-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 29, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.</p>
|
||||||
|
<p>Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.</p>
|
||||||
|
<p>Improved saving paid files into Files and reopening purchases without paying again.</p>
|
||||||
|
<p>Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.</p>
|
||||||
|
<p>Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.</p>
|
||||||
|
<p>LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.</p>
|
||||||
|
<p>Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.</p>
|
||||||
|
<p>Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- v1.8.19-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.19-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 28, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.</p>
|
||||||
|
<p>Embedded AIUI now stays transparent so the dashboard background appears once.</p>
|
||||||
|
<p>AIUI background fixes are now included reliably in OTA updates and fresh installations.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- v1.8.18-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.18-alpha</span>
|
||||||
|
<span class="text-xs text-white/40">September 18, 2026</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||||
|
<p>Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.</p>
|
||||||
|
<p>Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.</p>
|
||||||
|
<p>Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- v1.8.17-alpha -->
|
<!-- v1.8.17-alpha -->
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
+18
-18
@@ -1,30 +1,30 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
|
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||||
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
|
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||||
"size_bytes": 64953344
|
"size_bytes": 64748176
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||||
"size_bytes": 98801608
|
"size_bytes": 97152546
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-15",
|
"release_date": "2026-09-30",
|
||||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.17-alpha"
|
"version": "1.8.21-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -618,7 +618,7 @@
|
|||||||
},
|
},
|
||||||
"container": {
|
"container": {
|
||||||
"custom_args": [
|
"custom_args": [
|
||||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${BITCOIN_PRUNE:-0}\" = \"1\" ] || [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||||
],
|
],
|
||||||
"data_uid": "100101:100101",
|
"data_uid": "100101:100101",
|
||||||
"derived_env": [
|
"derived_env": [
|
||||||
@@ -768,7 +768,7 @@
|
|||||||
},
|
},
|
||||||
"container": {
|
"container": {
|
||||||
"custom_args": [
|
"custom_args": [
|
||||||
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${BITCOIN_PRUNE:-0}\" = \"1\" ] || [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
|
||||||
],
|
],
|
||||||
"data_uid": "100101:100101",
|
"data_uid": "100101:100101",
|
||||||
"derived_env": [
|
"derived_env": [
|
||||||
@@ -1378,6 +1378,67 @@
|
|||||||
},
|
},
|
||||||
"version": "0.1.0-preview"
|
"version": "0.1.0-preview"
|
||||||
},
|
},
|
||||||
|
"cuprate-ui": {
|
||||||
|
"image": "source.archipelago-foundation.org/lfg2025/cuprate-ui:1.7.123-alpha",
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"container": {
|
||||||
|
"build": {
|
||||||
|
"context": "/opt/archipelago/docker/cuprate-ui",
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"tag": "localhost/cuprate-ui:local"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"app_id": "cuprate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx\ninside a container, serves a static status dashboard, and proxies\n/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the\npublished host port for the container's 18089). No credentials are\ninjected — the restricted RPC is Monero's own safe-for-public subset — so\nthe nginx.conf is baked into the image and there is no rendered-config\nbind-mount like bitcoin-ui's.\n",
|
||||||
|
"environment": [],
|
||||||
|
"health_check": {
|
||||||
|
"endpoint": "http://127.0.0.1:18091",
|
||||||
|
"interval": "30s",
|
||||||
|
"path": "/",
|
||||||
|
"retries": 3,
|
||||||
|
"timeout": "5s",
|
||||||
|
"type": "http"
|
||||||
|
},
|
||||||
|
"id": "cuprate-ui",
|
||||||
|
"metadata": {
|
||||||
|
"author": "Archipelago",
|
||||||
|
"category": "money",
|
||||||
|
"icon": "/assets/img/app-icons/cuprate.svg",
|
||||||
|
"repo": "https://github.com/Cuprate/cuprate",
|
||||||
|
"tier": "optional"
|
||||||
|
},
|
||||||
|
"name": "Cuprate UI",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"auth": "gated",
|
||||||
|
"bind": "127.0.0.1",
|
||||||
|
"container": 18091,
|
||||||
|
"host": 18091,
|
||||||
|
"protocol": "tcp",
|
||||||
|
"session_passthrough": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"memory_limit": "64Mi"
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"network_policy": "host",
|
||||||
|
"readonly_root": false
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"kind": "internal"
|
||||||
|
},
|
||||||
|
"version": "1.0.0",
|
||||||
|
"volumes": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.7.123-alpha"
|
||||||
|
},
|
||||||
"electrs-ui": {
|
"electrs-ui": {
|
||||||
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
|
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
|
||||||
"manifest": {
|
"manifest": {
|
||||||
@@ -5464,7 +5525,7 @@
|
|||||||
"tag": "NOSTR IDENTITY // YOUR NODE"
|
"tag": "NOSTR IDENTITY // YOUR NODE"
|
||||||
},
|
},
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"signature": "e716a9069021af87a2252d7561c01153f17c5630d7c36d8fdc1be1c7aa40560d09557513c0e09835a6b76b52b4f7edf0619cbaff02ac1d9d818066f67046e401",
|
"signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"storefront": {
|
"storefront": {
|
||||||
"popular": [
|
"popular": [
|
||||||
@@ -5485,10 +5546,10 @@
|
|||||||
"id": "archipelago-source",
|
"id": "archipelago-source",
|
||||||
"installLabel": "Install GitWorkshop",
|
"installLabel": "Install GitWorkshop",
|
||||||
"launchLabel": "Open GitWorkshop",
|
"launchLabel": "Open GitWorkshop",
|
||||||
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
|
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
|
||||||
"tag": "NGIT // NOSTR // NO SILO"
|
"tag": "NGIT // NOSTR // NO SILO"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"updated": "2026-09-15"
|
"updated": "2026-09-29"
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -1,30 +1,30 @@
|
|||||||
{
|
{
|
||||||
"changelog": [
|
"changelog": [
|
||||||
"Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.",
|
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.",
|
||||||
"Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
|
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.",
|
||||||
"Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.",
|
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.",
|
||||||
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
|
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20."
|
||||||
],
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
|
||||||
"name": "archipelago",
|
"name": "archipelago",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
|
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
|
||||||
"size_bytes": 64953344
|
"size_bytes": 64748176
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"current_version": "1.8.17-alpha",
|
"current_version": "1.8.21-alpha",
|
||||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
|
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
|
"name": "archipelago-frontend-1.8.21-alpha.tar.gz",
|
||||||
"new_version": "1.8.17-alpha",
|
"new_version": "1.8.21-alpha",
|
||||||
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
|
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
|
||||||
"size_bytes": 98801608
|
"size_bytes": 97152546
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"release_date": "2026-09-15",
|
"release_date": "2026-09-30",
|
||||||
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
|
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
|
||||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||||
"version": "1.8.17-alpha"
|
"version": "1.8.21-alpha"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,10 +112,9 @@ VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*
|
|||||||
if [ "$SKIP_GATES" = "0" ]; then
|
if [ "$SKIP_GATES" = "0" ]; then
|
||||||
stage "release-gate-harness" bash tests/release/run.sh
|
stage "release-gate-harness" bash tests/release/run.sh
|
||||||
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||||
# Full Rust suite — the release harness only runs a 6-module slice;
|
# The release harness runs the full backend suite inside namespaces.
|
||||||
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
|
# Never execute unrestricted tests on a node with live wallets/services.
|
||||||
stage "cargo-test-full" timeout 5400 env CARGO_INCREMENTAL=0 \
|
|
||||||
nice -n 10 cargo test --manifest-path core/Cargo.toml -p archipelago --bin archipelago
|
|
||||||
else
|
else
|
||||||
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate build-source apps against an OTA/ISO runtime payload before shipping."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def check(root: Path) -> int:
|
||||||
|
root = root.resolve()
|
||||||
|
manifests = sorted((root / 'apps').glob('*/manifest.y*ml'))
|
||||||
|
if not manifests:
|
||||||
|
raise ValueError(f'No app manifests in {root / "apps"}')
|
||||||
|
count = 0
|
||||||
|
for manifest in manifests:
|
||||||
|
app = yaml.safe_load(manifest.read_text())['app']
|
||||||
|
build = app.get('container', {}).get('build')
|
||||||
|
if not build:
|
||||||
|
continue
|
||||||
|
context = Path(build['context'])
|
||||||
|
if context.is_absolute():
|
||||||
|
context = root / context.relative_to('/opt/archipelago')
|
||||||
|
else:
|
||||||
|
context = manifest.parent / context
|
||||||
|
context = context.resolve()
|
||||||
|
if not context.is_relative_to(root) or not context.is_dir():
|
||||||
|
raise ValueError(f'{app["id"]}: missing or out-of-payload build context: {context}')
|
||||||
|
dockerfile = (context / build.get('dockerfile', 'Dockerfile')).resolve()
|
||||||
|
if not dockerfile.is_relative_to(context) or not dockerfile.is_file():
|
||||||
|
raise ValueError(f'{app["id"]}: missing or out-of-context Dockerfile: {dockerfile}')
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
count = check(Path(sys.argv[1] if len(sys.argv) > 1 else '.'))
|
||||||
|
except (ValueError, KeyError, OSError, yaml.YAMLError) as error:
|
||||||
|
sys.exit(f'Invalid app build payload: {error}')
|
||||||
|
print(f'Validated {count} app build contexts and Dockerfiles.')
|
||||||
+42
-11
@@ -47,13 +47,33 @@ podman_rootless() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
port_is_listening() {
|
port_is_listening() {
|
||||||
local port="$1"
|
local port="$1" protocol="${2:-tcp}" listeners
|
||||||
local protocol="${2:-tcp}"
|
|
||||||
case "$protocol" in
|
case "$protocol" in
|
||||||
tcp) ss -ltn 2>/dev/null ;;
|
tcp) listeners=$(ss -ltn 2>/dev/null) || return 2 ;;
|
||||||
udp) ss -lun 2>/dev/null ;;
|
udp) listeners=$(ss -lun 2>/dev/null) || return 2 ;;
|
||||||
*) return 1 ;;
|
*) return 2 ;;
|
||||||
esac | awk '{print $4}' | grep -Eq "(^|:)$port$"
|
esac
|
||||||
|
# Consume the whole snapshot. grep -q closed the old pipe early, so awk
|
||||||
|
# received SIGPIPE and pipefail turned a FOUND port into a failed check.
|
||||||
|
awk -v port="$port" '$4 ~ ("(^|:)" port "$") { found=1 } END { exit !found }' <<< "$listeners"
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_rootless_container() {
|
||||||
|
local name="$1" unit
|
||||||
|
unit=$(podman_rootless inspect "$name" --format '{{index .Config.Labels "PODMAN_SYSTEMD_UNIT"}}' 2>/dev/null) || return 1
|
||||||
|
if [[ "$unit" =~ ^[a-zA-Z0-9_.@-]+\.service$ ]]; then
|
||||||
|
# Respect the managed service's shutdown timeout and --rm lifecycle.
|
||||||
|
# Raw podman restart uses a short timeout and races Quadlet cleanup.
|
||||||
|
if [ "$(id -u)" = 0 ]; then
|
||||||
|
sudo -u archipelago env XDG_RUNTIME_DIR="/run/user/$(id -u archipelago)" systemctl --user restart "$unit"
|
||||||
|
else
|
||||||
|
systemctl --user restart "$unit"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
local grace=30
|
||||||
|
case "$name" in bitcoin|bitcoin-core|bitcoin-knots) grace=600 ;; lnd) grace=330 ;; esac
|
||||||
|
podman_rootless restart --time "$grace" "$name"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
run_fix() {
|
run_fix() {
|
||||||
@@ -573,19 +593,27 @@ fix_missing_rootless_ports() {
|
|||||||
bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
bindings=$(podman_rootless inspect "$name" --format '{{range $p,$bindings := .NetworkSettings.Ports}}{{if $bindings}}{{range $bindings}}{{printf "%s %s\n" $p .HostPort}}{{end}}{{end}}{{end}}' 2>/dev/null | sort -u)
|
||||||
[ -n "$bindings" ] || continue
|
[ -n "$bindings" ] || continue
|
||||||
|
|
||||||
local missing=()
|
local missing=() inspection_failed=false status
|
||||||
local container_binding host_port protocol
|
local container_binding host_port protocol
|
||||||
while read -r container_binding host_port; do
|
while read -r container_binding host_port; do
|
||||||
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
|
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
|
||||||
protocol="${container_binding##*/}"
|
protocol="${container_binding##*/}"
|
||||||
if ! port_is_listening "$host_port" "$protocol"; then
|
status=0
|
||||||
missing+=("$host_port/$protocol")
|
port_is_listening "$host_port" "$protocol" || status=$?
|
||||||
fi
|
case "$status" in
|
||||||
|
0) ;;
|
||||||
|
1) missing+=("$host_port/$protocol") ;;
|
||||||
|
*) inspection_failed=true ;;
|
||||||
|
esac
|
||||||
done <<< "$bindings"
|
done <<< "$bindings"
|
||||||
|
|
||||||
|
if $inspection_failed; then
|
||||||
|
log "WARN: cannot inspect listeners for $name; leaving it running"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
if [ ${#missing[@]} -gt 0 ]; then
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
|
||||||
if podman_rootless restart "$name" >/dev/null 2>&1; then
|
if restart_rootless_container "$name" >/dev/null 2>&1; then
|
||||||
fixed=true
|
fixed=true
|
||||||
else
|
else
|
||||||
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
log "WARN: failed to restart $name for missing rootlessport listener(s)"
|
||||||
@@ -676,6 +704,9 @@ fix_archipelago_dialout() {
|
|||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Allow regression tests to source helpers without running repairs.
|
||||||
|
[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0
|
||||||
|
|
||||||
# If remote host provided, run via SSH
|
# If remote host provided, run via SSH
|
||||||
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
if [ -n "$1" ] && [ "$1" != "--local" ]; then
|
||||||
REMOTE_HOST="$1"
|
REMOTE_HOST="$1"
|
||||||
|
|||||||
@@ -78,15 +78,17 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
|||||||
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
|
||||||
echo "Staging frontend archive in $STAGING_DIR..."
|
echo "Staging frontend archive in $STAGING_DIR..."
|
||||||
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
|
||||||
# Bake AIUI in so fresh installs pick it up. OTA already
|
# create-release.sh folds the freshly built AIUI into FRONTEND_DIST.
|
||||||
# carries-forward the existing aiui/ if the tarball lacks one
|
# Never overlay it with the older demo bundle (or nest aiui/aiui/).
|
||||||
# (update.rs:922), but including it here makes the tarball
|
if [ ! -f "$STAGING_DIR/aiui/index.html" ] || \
|
||||||
# the single source of truth instead of relying on a side-
|
[ ! -f "$STAGING_DIR/aiui/BUILD-INFO" ]; then
|
||||||
# effect of the in-place swap.
|
echo "Error: fresh AIUI payload missing from frontend dist" >&2
|
||||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
exit 1
|
||||||
echo " Including AIUI from demo/aiui/"
|
|
||||||
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
|
|
||||||
fi
|
fi
|
||||||
|
grep -Fxq "commit=$(git -C "$PROJECT_ROOT" rev-parse HEAD)" "$STAGING_DIR/aiui/BUILD-INFO" || {
|
||||||
|
echo "Error: AIUI payload was not built from the current commit" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
# OTA bridge for nodes running older updaters: they only know how to
|
# OTA bridge for nodes running older updaters: they only know how to
|
||||||
# apply the backend binary and frontend archive. Carry host runtime
|
# apply the backend binary and frontend archive. Carry host runtime
|
||||||
# assets inside the frontend tarball; the new backend promotes them
|
# assets inside the frontend tarball; the new backend promotes them
|
||||||
@@ -99,6 +101,7 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
|
|||||||
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
|
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
python3 "$PROJECT_ROOT/scripts/check-app-build-contexts.py" "$RUNTIME_DIR"
|
||||||
# KEEP IN SYNC with the `for unit in [...]` array in
|
# KEEP IN SYNC with the `for unit in [...]` array in
|
||||||
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
|
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
|
||||||
# bootstrap installs but this list does not ship simply never reaches a
|
# bootstrap installs but this list does not ship simply never reaches a
|
||||||
|
|||||||
@@ -169,15 +169,11 @@ else
|
|||||||
fi
|
fi
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
|
# Build AIUI from the same source as the release. The checked-in demo bundle
|
||||||
# bakes it from demo/aiui independently, but build-iso-release.sh's
|
# can predate source fixes and must never overwrite the production payload.
|
||||||
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
|
bash "$SCRIPT_DIR/build-aiui.sh"
|
||||||
# consecutive releases (.127, .129) because this fold-in was manual.
|
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
cp -r "$PROJECT_ROOT/aiui/packages/app/dist" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
|
||||||
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
|
||||||
echo " AIUI folded into web/dist from demo/aiui"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
||||||
# dist would ship with a perfectly valid sha256. Require the freshly built
|
# dist would ship with a perfectly valid sha256. Require the freshly built
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Verify the mounted artifact carries every manifest-declared build source.
|
||||||
|
if python3 "$REPO/scripts/check-app-build-contexts.py" "$MNT/archipelago"; then
|
||||||
|
ok "app build contexts and Dockerfiles"
|
||||||
|
else
|
||||||
|
bad "incomplete app build payload"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── GRUB must boot the live system ───────────────────────────────────
|
# ── GRUB must boot the live system ───────────────────────────────────
|
||||||
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
|
||||||
ok "grub.cfg has boot=live"
|
ok "grub.cfg has boot=live"
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Compile normally; execute unit tests away from real wallets, service buses,
|
||||||
|
# container storage, processes and networking. Never silently fall back to host.
|
||||||
|
set -euo pipefail
|
||||||
|
REPO=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
command -v systemd-run >/dev/null
|
||||||
|
command -v unshare >/dev/null
|
||||||
|
command -v setpriv >/dev/null
|
||||||
|
sudo -n true || { echo 'Isolated backend tests require noninteractive sudo for systemd namespaces.' >&2; exit 1; }
|
||||||
|
metadata=$(mktemp)
|
||||||
|
trap 'rm -f "$metadata"' EXIT
|
||||||
|
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" -p archipelago --bin archipelago \
|
||||||
|
--locked --no-run --message-format=json --config 'profile.test.package.archipelago.opt-level=0' > "$metadata"; then
|
||||||
|
python3 - "$metadata" <<'PYDIAG'
|
||||||
|
import json,sys
|
||||||
|
for line in open(sys.argv[1]):
|
||||||
|
try: item=json.loads(line)
|
||||||
|
except json.JSONDecodeError: continue
|
||||||
|
rendered=item.get('message',{}).get('rendered') if item.get('reason')=='compiler-message' else None
|
||||||
|
if rendered: print(rendered,file=sys.stderr,end='')
|
||||||
|
PYDIAG
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
executable=$(python3 - "$metadata" <<'PY'
|
||||||
|
import json,sys
|
||||||
|
found=[]
|
||||||
|
for line in open(sys.argv[1]):
|
||||||
|
try: item=json.loads(line)
|
||||||
|
except json.JSONDecodeError: continue
|
||||||
|
if item.get('reason')=='compiler-artifact' and item.get('profile',{}).get('test') and item.get('executable'):
|
||||||
|
found.append(item['executable'])
|
||||||
|
assert len(found)==1, f'Expected one unit test executable, got {len(found)}'
|
||||||
|
print(found[0])
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
[[ -x "$executable" ]]
|
||||||
|
unit="archy-isolated-tests-$(date +%s)-$$"
|
||||||
|
sudo -n systemd-run --unit="$unit" --wait --pipe --collect \
|
||||||
|
--property="WorkingDirectory=$REPO/core" \
|
||||||
|
--property=PrivateNetwork=yes --property=PrivateTmp=yes --property=PrivateDevices=yes \
|
||||||
|
--property=ProtectSystem=strict --property=ProtectHome=read-only \
|
||||||
|
--property=NoNewPrivileges=yes \
|
||||||
|
--property='TemporaryFileSystem=/run:rw /var/lib/archipelago:rw /var/lib/containers:rw /root:rw' \
|
||||||
|
--setenv=ARCHY_TEST_ISOLATED=1 \
|
||||||
|
/usr/bin/unshare --pid --fork --mount-proc --kill-child \
|
||||||
|
/usr/bin/setpriv --bounding-set=-all,+chown,+dac_override,+fowner,+setuid,+setgid,+kill \
|
||||||
|
"$executable" --test-threads=4 "$@"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise release payload checks with complete, incomplete and escaping contexts."""
|
||||||
|
import importlib.util
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
spec = importlib.util.spec_from_file_location('contexts', REPO / 'scripts/check-app-build-contexts.py')
|
||||||
|
contexts = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(contexts)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildPayloadTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
shutil.copytree(REPO / 'apps', self.root / 'apps')
|
||||||
|
shutil.copytree(REPO / 'docker', self.root / 'docker')
|
||||||
|
|
||||||
|
def test_complete_payload(self):
|
||||||
|
self.assertGreaterEqual(contexts.check(self.root), 6)
|
||||||
|
|
||||||
|
def test_iso_old_allowlist_rejected(self):
|
||||||
|
shutil.rmtree(self.root / 'docker/archipelago-source')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'archipelago-source.*missing'):
|
||||||
|
contexts.check(self.root)
|
||||||
|
|
||||||
|
def test_missing_dockerfile_rejected(self):
|
||||||
|
(self.root / 'docker/archipelago-source/Dockerfile').unlink()
|
||||||
|
with self.assertRaisesRegex(ValueError, 'archipelago-source.*Dockerfile'):
|
||||||
|
contexts.check(self.root)
|
||||||
|
|
||||||
|
def test_context_symlink_cannot_escape_payload(self):
|
||||||
|
target = self.root / 'docker/archipelago-source'
|
||||||
|
shutil.rmtree(target)
|
||||||
|
target.symlink_to(REPO / 'docker/archipelago-source', target_is_directory=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'out-of-payload'):
|
||||||
|
contexts.check(self.root)
|
||||||
|
|
||||||
|
def test_empty_payload_rejected(self):
|
||||||
|
shutil.rmtree(self.root / 'apps')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'No app manifests'):
|
||||||
|
contexts.check(self.root)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Exercise actual manifest entrypoints with a fake bitcoind; no node data touched."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
class PruningEntrypoint(unittest.TestCase):
|
||||||
|
def test_auto_and_user_choice_for_both_bitcoin_implementations(self):
|
||||||
|
for app in ('bitcoin-core', 'bitcoin-knots'):
|
||||||
|
manifest = yaml.safe_load((ROOT / 'apps' / app / 'manifest.yml').read_text())
|
||||||
|
command = manifest['app']['container']['custom_args'][0]
|
||||||
|
for disk, choice, pruned in [(500,'0',True),(999,'0',True),(1000,'0',False),(2000,'0',False),(2000,'1',True),(500,'1',True)]:
|
||||||
|
with self.subTest(app=app,disk=disk,choice=choice), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
binary = root / 'bitcoind'
|
||||||
|
binary.write_text('#!/usr/bin/env python3\nimport json,sys\nprint(json.dumps(sys.argv[1:]))\n')
|
||||||
|
binary.chmod(0o755)
|
||||||
|
env = dict(os.environ, PATH=directory+':'+os.environ['PATH'], DISK_GB=str(disk), BITCOIN_PRUNE=choice,
|
||||||
|
BITCOIN_RPC_USER='test',BITCOIN_RPC_PASS='test')
|
||||||
|
# Isolate the ephemeral RPC config too.
|
||||||
|
script = command.replace('/tmp/rpc.conf',str(root/'rpc.conf'))
|
||||||
|
args = json.loads(subprocess.check_output(['sh','-c',script],env=env,text=True))
|
||||||
|
self.assertEqual('-prune=50000' in args,pruned)
|
||||||
|
self.assertEqual('-txindex=1' in args,not pruned)
|
||||||
|
self.assertIn('-server=1',args)
|
||||||
|
|
||||||
|
if __name__ == '__main__': unittest.main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# No real service/container operations: all external operations are replaced.
|
||||||
|
set -euo pipefail
|
||||||
|
source "$(dirname "$0")/../../scripts/container-doctor.sh"
|
||||||
|
ss() {
|
||||||
|
[[ "${SS_FAIL:-0}" == 0 ]] || return 1
|
||||||
|
printf 'LISTEN 0 4096 *:8333 *:*\n'
|
||||||
|
# More than a pipe buffer, reliably reproducing grep -q / pipefail SIGPIPE.
|
||||||
|
awk 'BEGIN { for(i=0;i<20000;i++) print "LISTEN 0 4096 127.0.0.1:1234 *:*" }'
|
||||||
|
}
|
||||||
|
port_is_listening 8333
|
||||||
|
port_is_listening 1234 udp
|
||||||
|
if port_is_listening 833; then exit 1; else [[ $? == 1 ]]; fi
|
||||||
|
if SS_FAIL=1 port_is_listening 8333; then exit 1; else [[ $? == 2 ]]; fi
|
||||||
|
calls=$(mktemp)
|
||||||
|
trap 'rm -f "$calls"' EXIT
|
||||||
|
podman_rootless() {
|
||||||
|
case "$1" in
|
||||||
|
ps) echo bitcoin-core ;;
|
||||||
|
inspect)
|
||||||
|
if [[ "$*" == *PODMAN_SYSTEMD_UNIT* ]]; then echo "${TEST_UNIT:-bitcoin-core.service}";
|
||||||
|
else echo '8333/tcp 8333'; fi ;;
|
||||||
|
restart) echo "podman $*" >> "$calls" ;;
|
||||||
|
*) exit 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
id() { echo 1000; }
|
||||||
|
systemctl() { echo "systemctl $*" >> "$calls"; }
|
||||||
|
# Healthy listener and failed ss inspection must not restart anything.
|
||||||
|
fix_missing_rootless_ports && exit 1
|
||||||
|
SS_FAIL=1 fix_missing_rootless_ports && exit 1
|
||||||
|
[[ ! -s "$calls" ]]
|
||||||
|
# A real missing listener restarts its managed unit, preserving stop timeout.
|
||||||
|
ss() { echo 'LISTEN 0 4096 *:1234 *:*'; }
|
||||||
|
fix_missing_rootless_ports
|
||||||
|
grep -Fx 'systemctl --user restart bitcoin-core.service' "$calls"
|
||||||
|
: > "$calls"
|
||||||
|
TEST_UNIT='<no value>' restart_rootless_container bitcoin-core
|
||||||
|
grep -Fx 'podman restart --time 600 bitcoin-core' "$calls"
|
||||||
|
echo 'PASS: healthy/missing/failed listener checks and safe managed/unmanaged restart'
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
const html = fs.readFileSync('docker/lnd-ui/index.html', 'utf8');
|
||||||
|
function extract(name) {
|
||||||
|
const start = html.indexOf(' function '+name+'(');
|
||||||
|
const end = html.indexOf('\n }', start)+10;
|
||||||
|
assert.ok(start >= 0 && end > start);
|
||||||
|
return html.slice(start, end);
|
||||||
|
}
|
||||||
|
function fixture() {
|
||||||
|
const elements = new Map();
|
||||||
|
const el = id => { if (!elements.has(id)) elements.set(id,{style:{},textContent:'',className:''}); return elements.get(id) };
|
||||||
|
const ctx = {state:{info:null,readiness:null}, document:{getElementById:el}, setText:(id,v)=>el(id).textContent=v,fmtCount:String};
|
||||||
|
vm.createContext(ctx);
|
||||||
|
vm.runInContext(extract('renderHeader')+'\n'+extract('validBalance'),ctx);
|
||||||
|
return {ctx,el};
|
||||||
|
}
|
||||||
|
test('missing, starting and syncing Bitcoin each show waiting and recover',()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
for (const [state,message] of [['waiting_install','Waiting for Bitcoin to be installed'],['waiting_start','Waiting for Bitcoin to start'],['waiting_sync','Waiting for Bitcoin to sync']]) {
|
||||||
|
ctx.state.readiness={state,message}; ctx.renderHeader();
|
||||||
|
assert.equal(el('headerStatusText').textContent,message);
|
||||||
|
assert.equal(el('syncCard').style.display,'');
|
||||||
|
assert.match(el('syncSubtitle').textContent,/automatically/);
|
||||||
|
assert.equal(el('syncPercent').textContent,'');
|
||||||
|
}
|
||||||
|
ctx.state.readiness={state:'bitcoin_ready'};
|
||||||
|
ctx.state.info={synced_to_chain:true,synced_to_graph:true};
|
||||||
|
ctx.renderHeader();
|
||||||
|
assert.equal(el('headerStatusText').textContent,'Running');
|
||||||
|
assert.equal(el('syncCard').style.display,'none');
|
||||||
|
ctx.state.info=null;ctx.state.readiness=null;ctx.renderHeader();
|
||||||
|
assert.equal(el('headerStatusText').textContent,'Connecting to LND');
|
||||||
|
});
|
||||||
|
test('balances reject missing, malformed, fractional and negative values; real zero remains valid',()=>{
|
||||||
|
const {ctx}=fixture();
|
||||||
|
for (const v of [null,undefined,'',{},false,-1,'-1','garbage',1.5,'1.5',Infinity,Number.MAX_SAFE_INTEGER+1]) assert.equal(ctx.validBalance(v),false,String(v));
|
||||||
|
for(const v of [0,'0',123,'123']) assert.equal(ctx.validBalance(v),true,String(v));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed and partial balance polls retain known balances and label them stale; recovery clears flags',async()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
el('refreshIcon').classList={add(){},remove(){}};
|
||||||
|
const start=html.indexOf(' async function refreshAll()');
|
||||||
|
const end=html.indexOf('\n }',start)+10;
|
||||||
|
vm.runInContext(html.slice(start,end),ctx);
|
||||||
|
let responses={
|
||||||
|
'/v1/getinfo':{synced_to_chain:true},
|
||||||
|
'/v1/balance/blockchain':{confirmed_balance:'500',unconfirmed_balance:'0'},
|
||||||
|
'/v1/balance/channels':{local_balance:{sat:'250'}},
|
||||||
|
};
|
||||||
|
ctx.lndSafe=async(path,fallback)=>responses[path]??fallback;
|
||||||
|
ctx.renderAll=()=>{};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'500');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
|
||||||
|
responses={};await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'500');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
|
||||||
|
assert.equal(ctx.state.onchainStale,true);assert.equal(ctx.state.chanbalStale,true);
|
||||||
|
responses={'/v1/balance/blockchain':{confirmed_balance:'0'},'/v1/balance/channels':{error:'locked'}};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'0');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'250');
|
||||||
|
assert.equal(ctx.state.onchainStale,false);assert.equal(ctx.state.chanbalStale,true);
|
||||||
|
responses={'/v1/balance/blockchain':{confirmed_balance:'600'},'/v1/balance/channels':{local_balance:{sat:'300'}}};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.equal(ctx.state.onchain.confirmed_balance,'600');
|
||||||
|
assert.equal(ctx.state.chanbal.local_balance.sat,'300');
|
||||||
|
assert.equal(ctx.state.onchainStale,false);assert.equal(ctx.state.chanbalStale,false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waiting renders promptly without querying unavailable LND endpoints, then resumes after Bitcoin sync',async()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
el('refreshIcon').classList={add(){},remove(){}};
|
||||||
|
const start=html.indexOf(' async function refreshAll()');
|
||||||
|
vm.runInContext(html.slice(start,html.indexOf('\n }',start)+10),ctx);
|
||||||
|
let readiness={state:'waiting_sync',message:'Waiting for Bitcoin to sync'};
|
||||||
|
const calls=[];let renders=0;
|
||||||
|
ctx.lndSafe=async(path,fallback)=>{calls.push(path);return path==='/archy-status'?readiness:fallback};
|
||||||
|
ctx.renderAll=()=>{renders++;ctx.renderHeader()};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.deepEqual(calls,['/archy-status']);
|
||||||
|
assert.equal(renders,1);
|
||||||
|
assert.equal(el('headerStatusText').textContent,'Waiting for Bitcoin to sync');
|
||||||
|
assert.equal(ctx.state.onchain,undefined);
|
||||||
|
assert.equal(ctx.state.refreshing,false);
|
||||||
|
readiness={state:'bitcoin_ready',message:'Bitcoin is ready'};
|
||||||
|
await ctx.refreshAll();
|
||||||
|
assert.ok(calls.includes('/v1/getinfo'));
|
||||||
|
assert.ok(calls.includes('/v1/balance/blockchain'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cold waiting never invents zero channel capacity or an empty wallet recommendation',()=>{
|
||||||
|
const {ctx,el}=fixture();
|
||||||
|
Object.assign(ctx,{num:v=>Number(v)||0,fmtAmount:String,fmtAmountShort:String,setBalance:(id,v)=>el(id).value=v});
|
||||||
|
vm.runInContext(extract('renderBalances')+'\n'+extract('renderSummary')+'\n'+extract('renderChannels'),ctx);
|
||||||
|
ctx.state.channels=[];
|
||||||
|
ctx.renderBalances();ctx.renderSummary();ctx.renderChannels();
|
||||||
|
for(const id of ['liqLocal','liqRemote','statActiveChannels','healthPending','chActive']) assert.equal(el(id).textContent,'—');
|
||||||
|
assert.equal(el('balTotal').value,null);
|
||||||
|
assert.match(el('liqHint').textContent,/waiting for LND/);
|
||||||
|
assert.doesNotMatch(el('channelList').innerHTML,/No payment channels yet/);
|
||||||
|
ctx.state.info={};ctx.state.chanbal={local_balance:{sat:'0'}};
|
||||||
|
ctx.renderBalances();
|
||||||
|
assert.equal(el('liqLocal').textContent,'0');
|
||||||
|
});
|
||||||
@@ -71,7 +71,11 @@ summary() {
|
|||||||
# ── Stage 1: static ──────────────────────────────────────────────────
|
# ── Stage 1: static ──────────────────────────────────────────────────
|
||||||
stage "git-diff-check" git diff --check
|
stage "git-diff-check" git diff --check
|
||||||
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
stage "cargo-fmt" timeout 240 cargo fmt --manifest-path core/Cargo.toml --all --check
|
||||||
|
stage "app-build-contexts" python3 tests/regression/app-build-contexts.py
|
||||||
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
stage "manifest-shell" python3 scripts/check-manifest-shell.py
|
||||||
|
stage "doctor-ports" bash tests/regression/container-doctor-ports.sh
|
||||||
|
stage "bitcoin-pruning" python3 tests/regression/bitcoin-prune-entrypoint.py
|
||||||
|
stage "lnd-ui-readiness" node --test tests/regression/lnd-ui-readiness.cjs
|
||||||
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
stage "catalog-drift" python3 scripts/check-app-catalog-drift.py --release --strict
|
||||||
|
|
||||||
# Validate the artifact that will actually be signed and published, not only
|
# Validate the artifact that will actually be signed and published, not only
|
||||||
@@ -166,9 +170,7 @@ stage "cargo-check" timeout 580 cargo check --manifest-path core/Cargo.toml
|
|||||||
# 2026-08-20 1500s died at unit 427/429 (the archipelago bin test, the biggest
|
# 2026-08-20 1500s died at unit 427/429 (the archipelago bin test, the biggest
|
||||||
# link) on a loaded, swapping dev box, again without running a single test.
|
# link) on a loaded, swapping dev box, again without running a single test.
|
||||||
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
# 3600s leaves headroom; a warm target/ finishes in a fraction of it.
|
||||||
stage "cargo-test-weekly" timeout 3600 env CARGO_INCREMENTAL=0 \
|
stage "cargo-test-isolated" timeout 3600 bash scripts/test-backend-isolated.sh
|
||||||
cargo test --manifest-path core/Cargo.toml -p archipelago -- \
|
|
||||||
update:: lnd container::image_versions upgrade_preserves_container scanner drift missing_secret collision
|
|
||||||
|
|
||||||
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
# ── Stage 4: live node smoke ─────────────────────────────────────────
|
||||||
if [[ $LIVE -eq 1 ]]; then
|
if [[ $LIVE -eq 1 ]]; then
|
||||||
@@ -215,7 +217,7 @@ if [[ $LIVE -eq 1 ]]; then
|
|||||||
[ -z "$st" ] && continue
|
[ -z "$st" ] && continue
|
||||||
seen=1
|
seen=1
|
||||||
echo "LND($port) state: $st"
|
echo "LND($port) state: $st"
|
||||||
echo "$st" | grep -q "RPC_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
|
echo "$st" | grep -qE "UNLOCKED|RPC_ACTIVE|SERVER_ACTIVE" && { echo "OK: LND wallet is unlocked"; exit 0; }
|
||||||
echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; }
|
echo "$st" | grep -qE "NON_EXISTING|WAITING_TO_START" && { echo "OK: LND wallet not initialized yet — not a lock regression"; exit 0; }
|
||||||
done
|
done
|
||||||
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }
|
[ -z "$seen" ] && { echo "SKIP: LND /v1/state not reachable on 18080/8080"; exit 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user