Compare commits

..
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 19e01cd5de fix(content): never take a paid buyer's ecash and then fail to deliver
2026-09-18: a peer purchase paid 10 sats, the seller redeemed them, and the
buyer got no file plus a "seller doesn't accept your Cashu mint" error.
Three defects lined up:

1. The seller checked file existence with stat() but only read the file
   AFTER redeeming the payment. Filebrowser-owned 0640 files (uid 100999)
   passed stat but failed fs::read for the archipelago service user.
   serve_content now checks existence and readability BEFORE the payment
   gate, so an unservable file costs the buyer nothing.
2. The HTTP handler mapped every serve_content error to a bare, unlogged
   404. A server-side failure is now a logged 500. (A 404 also makes the
   buyer's Auto transport re-send the request over Tor.)
3. That re-send carried the same single-use token, which the mint had
   already spent, so the seller answered 402. Redemption is now
   idempotent: a token that verified for an item keeps authorising that
   item for 10 minutes (per token, per item; SHA-256 keyed, in-memory,
   concurrent requests serialised, failures never cached).

Buyer side: reclaim_spent_ecash now reports whether the refund worked, and
the error text no longer claims "refunded" when it wasn't, or asserts the
seller rejects the mint when the cause is unknown.

Adds tests for replay, concurrency, failure-not-cached, cross-item, and
unreadable-file-before-payment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:28:19 +00:00
104 changed files with 1339 additions and 5052 deletions
-8
View File
@@ -21,11 +21,3 @@ While its status is OPEN:
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.
-30
View File
@@ -2,36 +2,6 @@
## 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)
- 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.
+2 -3
View File
@@ -46,14 +46,13 @@ interface RateBucket {
const rateBuckets = new Map<string, RateBucket>()
// Vite imports this module during builds too; cleanup must not keep the
// process alive once compilation has finished.
// Clean up stale buckets every 5 minutes
setInterval(() => {
const now = Date.now()
for (const [key, bucket] of rateBuckets) {
if (now > bucket.resetAt) rateBuckets.delete(key)
}
}, 5 * 60_000).unref()
}, 5 * 60_000)
function getClientIp(req: IncomingMessage): string {
return req.socket.remoteAddress ?? 'unknown'
-1
View File
@@ -33,7 +33,6 @@ const PWA_CACHE_VERSION = '2'
// Only embedded when explicitly requested via ?embedded param
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
+5 -5
View File
@@ -2,13 +2,13 @@
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]"
:style="isEmbedded
? { background: 'transparent' }
: isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
:style="isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: isEmbedded
? { background: 'transparent' }
: { backgroundColor: '#f5f4f1' }"
>
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout -->
<div
+6 -15
View File
@@ -57,8 +57,12 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
/* Standalone canvas fallback. Embedded mode overrides this below so
Archy's wallpaper remains visible through the iframe. */
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
EXCEPT the embedded Chat page, which intentionally goes transparent so
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;
}
@@ -66,19 +70,6 @@ html.light body {
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 ===== */
@layer components {
+1 -1
View File
@@ -54,7 +54,7 @@ app:
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";
fi;
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
if [ "${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;
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;
+1 -1
View File
@@ -60,7 +60,7 @@ app:
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";
fi;
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
if [ "${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;
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;
+7
View File
@@ -106,3 +106,10 @@ app:
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
-8
View File
@@ -14,16 +14,8 @@ app:
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
pull_policy: if-not-present
# Portainer fetches Git sources and images from services on this same node.
# Rootless pasta copies the host LAN address into its namespace, so a LAN
# URL points back at Portainer itself. Give it a private address with the
# supported rootless slirp backend; public app URLs still traverse the gate.
network: slirp4netns
data_uid: "1000:1000"
# Snapshot state before an upgrade recreates this app with new networking.
backup_on_network_change: true
dependencies:
- storage: 1Gi
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.21-alpha"
version = "1.8.17-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.21-alpha"
version = "1.8.17-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+15 -1
View File
@@ -162,11 +162,25 @@ impl ApiHandler {
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
),
)),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
)),
// A server-side failure is NOT "not found": reporting it as a 404
// hid an unreadable file behind a silent, unlogged response, and a
// buyer's client re-sends a 404 over another transport. 5xx it, and
// say why in the journal.
Err(e) => {
tracing::warn!(content_id = %content_id, "content request failed: {e:#}");
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"application/json",
hyper::Body::from(
r#"{"error":"The seller could not read this file right now. You have not been charged."}"#,
),
))
}
}
}
-13
View File
@@ -138,19 +138,6 @@ impl ApiHandler {
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
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}");
// LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the
+131 -154
View File
@@ -22,9 +22,11 @@ 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
/// 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
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
/// 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) -> String {
/// un-redeemed notes; for Cashu the proofs are received back. Returns whether
/// the value came back: false if the seller already claimed the token (then
/// the value is genuinely gone), so callers never tell the buyer they were
/// refunded when they weren't.
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> bool {
let res = match backend {
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
.await
@@ -33,98 +35,29 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
};
match res {
Ok(sats) => {
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
format!("Refunded {sats} sats to your wallet.")
tracing::info!(
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
);
true
}
Err(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()
tracing::warn!(
"paid download: could not reclaim {backend} ecash (the peer may have already \
claimed it): {e:#}"
);
false
}
}
}
/// 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"
/// What to tell the buyer about their payment after a failed sale.
fn refund_note(reclaimed: bool) -> &'static str {
if reclaimed {
"Your ecash was refunded to your wallet."
} 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()?;
"The seller had already claimed the payment, so it could not be refunded \
automatically — contact the seller."
}
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 {
@@ -548,10 +481,17 @@ impl RpcHandler {
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
.await
{
let mut result = paid_content_response(&bytes, &mime, 0);
result["already_owned"] = serde_json::json!(true);
result["filename"] = serde_json::json!(o.filename);
return Ok(result);
use base64::Engine;
return Ok(serde_json::json!({
"owned": true,
"already_owned": true,
"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
// repurchase rather than stranding the user.
@@ -625,27 +565,33 @@ impl RpcHandler {
// 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
// here means the peer is genuinely unreachable on both transports.
let (response, transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
// The token was already minted/spent — reclaim it so the buyer
// doesn't lose the value when the seller was simply unreachable.
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("Could not reach the peer over mesh or Tor. {refund}")
}));
}
};
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
// The token was already minted/spent — reclaim it so the buyer
// doesn't lose the value when the seller was simply unreachable.
let reclaimed =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!(
"Could not reach the peer over mesh or Tor — it may be offline. {} Please try again.",
refund_note(reclaimed)
)
}));
}
};
// Record which transport actually reached the peer (B14).
if let Err(e) = crate::federation::record_peer_transport(
&self.config.data_dir,
@@ -659,17 +605,29 @@ impl RpcHandler {
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// A 402 can mean mint validation, network failure, underpayment,
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
// Payment was rejected by the seller. Surface the most likely cause
// per backend — for ecash both sides must share a redemption network
// (a Cashu mint, or a Fedimint federation).
let body = response.text().await.unwrap_or_default();
tracing::warn!(
"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
// their funds (the spent-but-unredeemed-notes case the user hit).
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
let reclaimed =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
// The 402 body is generic, so don't assert a cause — a seller that
// redeemed the token and then failed to deliver also lands here.
let hint = match used_backend {
"fedimint" => "the seller may not be in the same Fedimint federation as you",
_ => "the seller may not accept your Cashu mint",
};
return Ok(serde_json::json!({
"error": format!("The seller could not verify the payment. {refund}")
"error": format!(
"Payment not accepted by the seller — {hint}. {} Try the other ecash \
type, or use a shared mint/federation.",
refund_note(reclaimed)
)
}));
}
@@ -677,9 +635,10 @@ impl RpcHandler {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
let reclaimed =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({status}). {refund}")
"error": format!("Peer returned an error ({status}). {}", refund_note(reclaimed))
}));
}
@@ -726,41 +685,63 @@ impl RpcHandler {
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
}
// The durable purchased-content cache above is primary. A Files copy
// remains optional: a stopped FileBrowser must not undo a paid download.
let filed = async {
let auth = self.handle_filebrowser_token().await?;
let token = auth
.get("token")
.and_then(|v| v.as_str())
.context("FileBrowser omitted its authentication token")?;
let client = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(30))
.build()?;
file_purchase_in_files(
&client,
"http://127.0.0.1:8083",
token,
&filename,
&mime_type,
&bytes,
)
.await
}
.await;
match filed {
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
Err(error) => tracing::warn!(
"paid download: optional Files copy failed; purchase cache retained: {error}"
),
// Auto-file the purchase into the user's Files area (2026-07-22):
// Photos for images/video, Music for audio, Documents otherwise —
// same buckets the Cloud view uses. The in-app viewer still plays
// from the purchase cache; this makes the file ALSO show up where
// files live, on every device, without relying on a browser
// download. Best-effort: never fail a paid download over it.
{
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
"Photos"
} else if mime_type.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let base = std::path::Path::new(&filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let dir = self.config.data_dir.join("filebrowser").join(folder);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (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());
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
result["ecash_backend"] = serde_json::json!(used_backend);
Ok(result)
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
"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
@@ -1433,7 +1414,3 @@ impl RpcHandler {
}
}
}
#[cfg(test)]
#[path = "content_tests.rs"]
mod tests;
@@ -1,164 +0,0 @@
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);
}
}
-84
View File
@@ -109,50 +109,7 @@ fn checked_balances(
))
}
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 {
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> {
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
@@ -462,44 +419,3 @@ mod tests {
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"
);
}
}
+1 -62
View File
@@ -133,36 +133,12 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the
/// 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() {
tokio::spawn(async move {
let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None;
let mut last_height: Option<u64> = None;
loop {
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 {
bad_minutes = 0; // no LND on this node (or not set up yet)
continue;
@@ -185,10 +161,6 @@ pub(crate) fn spawn_lnd_health_watchdog() {
bad_minutes = 0; // down/locked — not the wedge signature
continue;
};
if !resp.status().is_success() {
bad_minutes = 0;
continue;
}
let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0;
continue;
@@ -210,12 +182,7 @@ pub(crate) fn spawn_lnd_health_watchdog() {
.get("num_pending_channels")
.and_then(|v| v.as_u64())
.unwrap_or(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));
let wedged = !synced || (channels > 0 && peers == 0);
if !wedged {
bad_minutes = 0;
continue;
@@ -272,31 +239,3 @@ impl RpcHandler {
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,15 +89,6 @@ impl RpcHandler {
match handler.handle_package_install(params).await {
Ok(_) => {
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
// and healthy (see install.rs post-start exit check).
// Kick the scanner first so the fresh manifest (with
@@ -193,20 +184,17 @@ impl RpcHandler {
// phase is cleared (None) so no stale InstallPhase
// lingers on the card.
let err_msg = format!("Install failed: {:#}", e);
handler
.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
}
})
.await;
let (mut data, _) = handler.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(&package_id_spawn) {
entry.state = PackageState::Stopped;
entry.install_progress = Some(crate::data_model::InstallProgress {
size: 0,
downloaded: 0,
phase: None,
message: Some(err_msg),
});
handler.state_manager.update_data(data).await;
}
}
}
});
@@ -264,11 +252,6 @@ impl RpcHandler {
match handler.handle_package_uninstall(params).await {
Ok(_) => {
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
// success. Nothing more to do here.
}
@@ -399,56 +382,52 @@ impl RpcHandler {
/// call, but fires before the spawn so the UI sees it immediately.
async fn flip_to_installing(state_manager: &StateManager, package_id: &str) {
use crate::data_model::{Description, Manifest, PackageDataEntry, StaticFiles};
state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
ui_ready: None,
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
})
.await;
let (mut data, _) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
state: PackageState::Installing,
health: None,
exit_code: None,
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
// Leave icon empty during the transient Installing window:
// hardcoding `<id>.png` is wrong for ~half our apps (many use
// `.svg` / `.webp`), producing a broken-image flicker until
// the scanner refreshes the entry. The frontend's `icon`
// computed falls through to `curatedMap.get(id)?.icon` which
// has the correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
title: package_id.to_string(),
version: String::new(),
description: Description {
short: "Installing...".to_string(),
long: String::new(),
},
release_notes: String::new(),
license: String::new(),
wrapper_repo: String::new(),
upstream_repo: String::new(),
support_site: String::new(),
marketing_site: String::new(),
donation_url: None,
author: None,
website: None,
interfaces: None,
tier: None,
},
installed: None,
install_progress: None,
uninstall_stage: None,
available_update: None,
});
entry.state = PackageState::Installing;
state_manager.update_data(data).await;
}
/// True when the failed install still has a real footprint: any container
@@ -506,23 +485,20 @@ async fn remove_entry_with_notification(
id_prefix: &str,
message: &str,
) {
handler
.state_manager
.mutate_data(|data| {
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
})
.await;
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
handler.state_manager.update_data(data).await;
}
/// Flip an existing entry's state and return the pre-flip value (or None if
@@ -532,23 +508,18 @@ async fn flip_package_state(
package_id: &str,
new_state: PackageState,
) -> Option<PackageState> {
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
state_manager.update_data(data).await;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
}
/// Set state unconditionally (no-op if entry no longer exists).
@@ -557,18 +528,13 @@ async fn set_package_state(
package_id: &str,
new_state: PackageState,
) {
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
}
/// Set state and clear the uninstall_stage label. Used when an uninstall
@@ -579,17 +545,12 @@ async fn set_package_state_and_clear_uninstall_stage(
package_id: &str,
new_state: PackageState,
) {
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
entry.uninstall_stage = None;
}
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = new_state;
entry.uninstall_stage = None;
state_manager.update_data(data).await;
}
}
/// Kick the container scanner to run immediately and wait for it to finish
+132 -15
View File
@@ -326,10 +326,6 @@ impl RpcHandler {
// an older version pins it so install_fresh resolves that image and the
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
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()) {
persist_install_version_selection(package_id, version).await;
}
@@ -545,7 +541,7 @@ impl RpcHandler {
// Keep legacy install flow as default while migration is in progress.
if orchestrator_managed {
let orchestrator_app_id = orchestrator_install_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::PreparingApp)
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
install_log(&format!(
"INSTALL ORCH: {} — attempting orchestrator install as {}",
@@ -1699,10 +1695,32 @@ autopilot.active=false\n",
patch_indeedhub_nostr_provider().await;
}
// Gitea owns its public URL and security settings in app.ini, including
// values chosen in its first-run setup. Do not rewrite operator values
// or claim success from best-effort grep/sed commands. The app gate
// fronts its declared HTTP port and handles frame headers separately.
// Gitea: keep it on its native host port (3001). The UI opens Gitea
// in a new tab on that direct port so absolute asset URLs must be
// rooted at the host port rather than Archipelago's /app/gitea/ path.
if package_id == "gitea" {
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
// Set ROOT_URL to the direct launch route so links/assets stay
// anchored under the same origin Gitea is launched from.
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
.output()
.await;
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
.output()
.await;
info!(
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
host_ip
);
}
if package_id == "nextcloud" {
let host_ip = &self.config.host_ip;
@@ -2031,8 +2049,25 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
}
async fn cleanup_stale_package_ports(package_id: &str) {
// Never kill by port: another app or the management gate may own it.
crate::container::ghost_reaper::reap_for_app(package_id).await;
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").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(
@@ -2157,11 +2192,93 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
return true;
}
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
crate::container::ghost_reaper::reap_for_app(package_id).await;
return true;
match package_id {
"grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
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() {
@@ -14,23 +14,20 @@ impl RpcHandler {
/// the rare case where the pull stream actually parses, but podman
/// almost never emits parseable progress on a piped stderr.
pub(super) async fn set_install_progress(&self, package_id: &str, downloaded: u64, size: u64) {
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: existing_phase,
message: None,
});
})
.await;
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing;
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: existing_phase,
message: None,
});
self.state_manager.update_data(data).await;
}
/// Set the install pipeline phase and broadcast. This is the
@@ -38,86 +35,76 @@ impl RpcHandler {
/// percentage and a user-facing label. Byte counters are retained
/// for the rare case podman emits parseable progress.
pub(super) async fn set_install_phase(&self, package_id: &str, phase: InstallPhase) {
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
// Preparing / PullingImage / CreatingContainer / StartingContainer /
// WaitingHealthy / PostInstall all map to the Installing state.
// Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it.
if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
}
let (size, downloaded) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded))
.unwrap_or((0, 0));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: Some(phase),
message: None,
});
})
.await;
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
// Preparing / PullingImage / CreatingContainer / StartingContainer /
// WaitingHealthy / PostInstall all map to the Installing state.
// Updates use Updating state — the wrapper has already flipped
// state to Updating, so don't clobber it.
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded))
.unwrap_or((0, 0));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase: Some(phase),
message: None,
});
self.state_manager.update_data(data).await;
}
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
})
.await;
let (mut data, _rev) = self.state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
self.state_manager.update_data(data).await;
}
/// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) {
self.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
})
.await;
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
self.state_manager.update_data(data).await;
}
/// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
self.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
})
.await;
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
self.state_manager.update_data(data).await;
}
/// Update install progress (static method for use in async closures).
@@ -127,28 +114,25 @@ impl RpcHandler {
downloaded: u64,
total: u64,
) {
state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
})
.await;
let (mut data, _rev) = state_manager.get_snapshot().await;
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
state_manager.update_data(data).await;
}
}
/// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry {
ui_ready: None,
state: PackageState::Installing,
health: None,
exit_code: None,
+64 -46
View File
@@ -1431,9 +1431,10 @@ async fn repair_before_package_start(container_name: &str) {
// 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
// lock — and `Restart=always` loops it there forever. Ordered before
// starting the replacement. A port sweep cannot distinguish a ghost
// from the dashboard gate or another live app and must never kill it.
// the port cleanup below: killing the owner is what actually frees the
// port, and the port sweep alone cannot tell a ghost from a live app.
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) {
@@ -1578,6 +1579,7 @@ async fn repair_netbird_network() {
async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return;
}
@@ -1586,7 +1588,7 @@ async fn repair_nginx_proxy_manager_container() {
)
.await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
cleanup_nginx_proxy_manager_ports().await;
if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
}
@@ -1810,9 +1812,6 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
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") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
@@ -2033,10 +2032,51 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return;
}
// Only reap processes proven to belong to an absent container. The app
// gate shares the app's port on other addresses and lives in this daemon;
// killing port owners (or matching argv with pkill) kills the dashboard.
crate::container::ghost_reaper::reap_for_app(container_name).await;
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
return;
}
}
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 {
@@ -2055,16 +2095,13 @@ async fn flip_package_state(
package_id: &str,
transitional: PackageState,
) -> Option<PackageState> {
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.ui_ready = Some(false);
entry.state = transitional;
}
prev
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.state = transitional;
state_manager.update_data(data).await;
}
prev
}
/// Write the package entry's final state. No-op if the entry has since
@@ -2074,18 +2111,13 @@ async fn set_package_state(
package_id: &str,
new_state: PackageState,
) {
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
}
pub(super) async fn reconcile_companions_for(package_id: &str) {
@@ -2153,20 +2185,6 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
mod tests {
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]
fn missing_container_classifier_covers_podman5_phrasings() {
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
@@ -153,18 +153,8 @@ impl RpcHandler {
let default = app_catalog::catalog_default_version(app_id);
let cfg = version_config::read(app_id);
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!({
"bitcoinPrune": bitcoin_prune,
"id": app_id,
"supportsVersions": supports_versions(app_id),
"default": default,
+14 -22
View File
@@ -150,31 +150,23 @@ async fn flip_to_transitional(
app_id: &str,
transitional: PackageState,
) -> Option<PackageState> {
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(app_id) {
entry.ui_ready = Some(false);
entry.state = transitional;
}
prev
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(app_id) {
entry.state = transitional;
state_manager.update_data(data).await;
}
prev
}
/// Set the entry's state to `new_state`. No-ops if the entry has since been
/// removed (e.g. uninstall ran concurrently).
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
}
-3
View File
@@ -114,9 +114,6 @@ impl PortMap {
/// there.
fn apps_dirs() -> Vec<PathBuf> {
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") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
}
+3 -48
View File
@@ -144,34 +144,6 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
.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.
pub async fn run(
gate: Arc<AppGate>,
@@ -190,12 +162,11 @@ pub async fn run(
loop {
tokio::select! {
_ = interval.tick() => {}
_ = REFRESH_KICK.notified() => {}
_ = interval.tick() => {
sweep(&gate, &status, &mut held, &shutdown_rx).await;
}
_ = shutdown_rx.changed() => return,
}
sweep(&gate, &status, &mut held, &shutdown_rx).await;
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
}
}
@@ -490,19 +461,3 @@ mod tests {
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));
}
}
-1
View File
@@ -322,7 +322,6 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
PackageDataEntry {
ui_ready: None,
state: PackageState::Running,
health: None,
exit_code: None,
-1
View File
@@ -1069,7 +1069,6 @@ mod tests {
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
};
PackageDataEntry {
ui_ready: None,
state: PackageState::Running,
health: None,
exit_code: None,
+1 -23
View File
@@ -100,11 +100,7 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
.trim()
.trim_end_matches('.');
let lower = detail.to_lowercase();
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") {
let state = if lower.contains("verifying blocks") {
Some("verifying blocks after restart")
} else if lower.contains("connection reset") {
Some("starting up and not yet accepting RPC connections")
@@ -344,21 +340,3 @@ mod tests {
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);
}
}
}
+6 -31
View File
@@ -313,7 +313,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
/// should reference (`localhost/<base>:latest` for build, registry
/// URL for pull).
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
let mut local_image = format!("localhost/{}:latest", spec.image_base);
let local_image = format!("localhost/{}:latest", spec.image_base);
let local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
@@ -322,13 +322,11 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// 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.
// `:local` is a deliberate manual override — never auto-rebuild it.
if image_exists(&local_image_compat).await {
local_image = local_image_compat.clone();
return Ok(local_image_compat);
}
// Reuse either local tag only when the build context has NOT
// Reuse the auto-built `:latest` only when the build context has NOT
// changed since it was built. Without this staleness check an
// already-present image is reused forever, so edits to the baked-in
// context (Dockerfile, nginx.conf, …) never reach the node — this is
@@ -851,43 +849,20 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
if !matches_known_shape {
return Ok(true);
}
if let Some(image) = managed_local_image(spec, &on_disk) {
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// Conservative on any timeout/error inside: reuse the cache.
return Ok(context_is_newer_than_image(dir, &image).await);
return Ok(context_is_newer_than_image(dir, &local_image).await);
}
}
}
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)]
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, &registry).render()),
None
);
}
}
use super::*;
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
@@ -3,9 +3,8 @@
use anyhow::Result;
use archipelago_container::{
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
@@ -26,15 +25,8 @@ impl DockerPackageScanner {
}
/// Scan Docker containers and convert to package data
pub async fn scan_containers(
&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);
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
let containers = self.runtime.list_containers().await?;
debug!("Found {} containers", containers.len());
@@ -147,18 +139,6 @@ impl DockerPackageScanner {
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
let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the
@@ -199,22 +179,14 @@ impl DockerPackageScanner {
let tor_address = read_tor_address(&app_id).await;
// Extract actual version from container image tag
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)
};
let running_version = image_versions::extract_version_from_image(&container.image);
// Decoupled from the binary OTA: prefer the remote app catalog,
// falling back to the image-versions.sh pin when uncovered/offline.
let available_update = if container.id.is_empty() {
None
} else {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
};
let available_update =
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
let package = PackageDataEntry {
ui_ready: Some(false),
state: package_state.clone(),
health: container.health.clone(),
exit_code: if package_state == PackageState::Exited {
@@ -311,215 +283,10 @@ 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(&current, *port))
};
if needs_refresh {
refresh_now().await;
}
let current = status.read().await;
for (id, port) in gated {
if !port_claimed(&current, port) {
packages.get_mut(&id).unwrap().ui_ready = Some(false);
}
}
}
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 {
title: String,
description: String,
-104
View File
@@ -89,74 +89,18 @@ bitcoind.estimatemode=ECONOMICAL\n"
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<()> {
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";
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 {
return Ok(());
}
if !bitcoin_rpc_ready().await {
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
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?;
wait_for_admin_macaroon(admin_macaroon).await
}
@@ -314,9 +258,6 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
// exactly the nodes least able to afford it. Waiting longer costs nothing —
// a wrong password still exits on the first pass via `all_rejected`.
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;
for pw in &candidates {
match try_unlock_once(&client, pw).await {
@@ -353,10 +294,6 @@ 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
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
@@ -1152,44 +1089,3 @@ mod tests {
.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));
}
}
}
@@ -1,251 +0,0 @@
//! Consistent, private snapshots for declaratively opted-in network migrations.
use anyhow::{bail, Context, Result};
use archipelago_container::AppManifest;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
match manifest.app.extensions.get("backup_on_network_change") {
None => Ok(false),
Some(value) => value
.as_bool()
.context("backup_on_network_change must be boolean"),
}
}
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
let mut sources = Vec::new();
for volume in &manifest.app.volumes {
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
continue;
}
// A runtime socket is a connection, not application state.
if volume.source == "/run/user/1000/podman/podman.sock" {
continue;
}
if volume.volume_type != "bind" {
bail!("network migration backup requires bind-mounted persistent state");
}
let path = Path::new(&volume.source);
let relative = path
.strip_prefix(data_dir)
.context("network migration state must be inside the node data directory")?;
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
bail!("invalid network migration state path");
}
sources.push(relative.to_path_buf());
}
sources.sort();
sources.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for source in sources {
if !roots.iter().any(|root| source.starts_with(root)) {
roots.push(source);
}
}
if roots.is_empty() {
bail!("network migration backup has no persistent state mounts");
}
Ok(roots)
}
/// Caller must gracefully stop the app before this function, and resume the old
/// service if it fails. No source files are changed or deleted by this operation.
pub async fn snapshot(
manifest: &AppManifest,
data_dir: &Path,
previous_unit: Option<&[u8]>,
) -> Result<PathBuf> {
let mut command = tokio::process::Command::new("podman");
command.args(["unshare", "tar"]);
snapshot_with_command(manifest, data_dir, previous_unit, command).await
}
async fn snapshot_with_command(
manifest: &AppManifest,
data_dir: &Path,
previous_unit: Option<&[u8]>,
mut command: tokio::process::Command,
) -> Result<PathBuf> {
let sources = relative_sources(manifest, data_dir)?;
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
for source in &sources {
let path = data_dir.join(source);
if tokio::fs::symlink_metadata(&path)
.await?
.file_type()
.is_symlink()
{
bail!("network migration state mount is a symlink; explicit backup required");
}
let canonical = tokio::fs::canonicalize(&path).await?;
if !canonical.starts_with(&canonical_root) {
bail!("network migration state path resolves outside node data directory");
}
}
let root = data_dir.join("migration-backups");
tokio::fs::create_dir_all(&root).await?;
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
let dir = root.join(uuid::Uuid::new_v4().to_string());
tokio::fs::create_dir(&dir).await?;
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
if let Some(unit) = previous_unit {
let path = dir.join("previous.container");
tokio::fs::write(&path, unit).await?;
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::File::open(&path).await?.sync_all().await?;
}
let partial = dir.join("state.tar.partial");
let archive = dir.join("state.tar");
let output = command
.args([
"--create",
"--numeric-owner",
"--acls",
"--xattrs",
"--file",
])
.arg(&partial)
.arg("--directory")
.arg(data_dir)
.arg("--")
.args(&sources)
.output()
.await
.context("start rootless migration snapshot")?;
if !output.status.success() {
// No tar stderr in public logs: it can contain private filenames.
let _ = tokio::fs::remove_file(&partial).await;
bail!("persistent-state snapshot failed; original state was left intact");
}
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::File::open(&partial).await?.sync_all().await?;
tokio::fs::rename(&partial, &archive).await?;
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
"network": manifest.app.container.network, "sources": sources});
tokio::fs::write(
dir.join("metadata.json"),
serde_json::to_vec_pretty(&metadata)?,
)
.await?;
tokio::fs::File::open(&dir).await?.sync_all().await?;
Ok(archive)
}
#[cfg(test)]
mod tests {
use super::*;
fn portainer() -> AppManifest {
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
}
#[tokio::test]
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("portainer");
tokio::fs::create_dir_all(state.join("compose"))
.await
.unwrap();
tokio::fs::write(state.join("portainer.db"), b"fixture database")
.await
.unwrap();
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
.await
.unwrap();
let mut m = portainer();
m.app.volumes[0].source = state.display().to_string();
m.app.volumes[1].source = state.join("compose").display().to_string();
let archive = snapshot_with_command(
&m,
dir.path(),
Some(b"old unit"),
tokio::process::Command::new("tar"),
)
.await
.unwrap();
assert_eq!(
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
.await
.unwrap(),
b"old unit"
);
let restored = tempfile::tempdir().unwrap();
assert!(tokio::process::Command::new("tar")
.arg("-xf")
.arg(archive)
.arg("-C")
.arg(restored.path())
.status()
.await
.unwrap()
.success());
assert_eq!(
tokio::fs::read(restored.path().join("portainer/portainer.db"))
.await
.unwrap(),
b"fixture database"
);
assert_eq!(
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
.await
.unwrap(),
b"services: {}\n"
);
assert_eq!(
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
b"fixture database"
);
}
#[tokio::test]
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("portainer");
tokio::fs::create_dir_all(state.join("compose"))
.await
.unwrap();
tokio::fs::write(state.join("portainer.db"), b"unchanged")
.await
.unwrap();
let mut m = portainer();
m.app.volumes[0].source = state.display().to_string();
m.app.volumes[1].source = state.join("compose").display().to_string();
assert!(
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
.await
.is_err()
);
assert_eq!(
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
b"unchanged"
);
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
assert!(!entry.unwrap().path().join("state.tar").exists());
}
}
#[test]
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
let m = portainer();
assert!(enabled(&m).unwrap());
assert_eq!(
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
vec![PathBuf::from("portainer")]
);
}
#[test]
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
let mut m = portainer();
m.app.volumes[0].source = "/other/operator/state".into();
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
}
}
-1
View File
@@ -12,7 +12,6 @@ pub mod hooks;
pub mod image_policy;
pub mod image_versions;
pub mod lnd;
pub mod migration_backup;
pub mod prod_orchestrator;
pub mod quadlet;
pub mod registry;
@@ -91,14 +91,6 @@ fn is_builtin_network_mode(network: &str) -> bool {
)
}
// Only an explicitly selected rootless mode establishes drift. An omitted
// network delegates to Podman and must not recreate unrelated installed apps.
fn rootless_network_mode_drifted(expected: Option<&str>, actual: &str) -> bool {
matches!(expected, Some("slirp4netns" | "pasta"))
&& !actual.trim().is_empty()
&& actual.trim().split(':').next() != expected
}
fn uses_pasta_network(manifest: &AppManifest) -> bool {
manifest.app.container.network.as_deref() == Some("pasta")
}
@@ -806,10 +798,6 @@ fn host_port_bindings_drifted(
}
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";
if podman_socket_accepts_connections(socket_path).await {
return Ok(());
@@ -1182,21 +1170,15 @@ impl ReconcileReport {
fn cascade_pairs_for_report<'r>(
report: &'r ReconcileReport,
user_stopped: &std::collections::HashSet<String>,
changed_backends: &HashSet<String>,
) -> Vec<(&'r str, &'static str)> {
let mut pairs = Vec::new();
for (backend, action) in &report.actions {
if !matches!(
action,
ReconcileAction::NoOp | ReconcileAction::Started | ReconcileAction::Installed
ReconcileAction::Installed | ReconcileAction::Started
) {
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) {
let dep_untouched = report
.actions
@@ -1210,25 +1192,6 @@ fn cascade_pairs_for_report<'r>(
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)]
pub struct AdoptionReport {
pub adopted: Vec<String>,
@@ -1942,40 +1905,14 @@ impl ProdContainerOrchestrator {
_ => 2,
});
// Live container names (any state), for the same recovery check.
let listed_containers = self.runtime.list_containers().await.ok();
let present_containers: HashSet<String> = listed_containers
.as_ref()
.map(|cs| cs.iter().map(|c| c.name.clone()).collect())
let present_containers: std::collections::HashSet<String> = self
.runtime
.list_containers()
.await
.map(|cs| cs.into_iter().map(|c| c.name).collect())
.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 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)
// pass so the scanner overlays queued-but-down apps as Restarting
// instead of Stopped. Each app is deregistered as its turn finishes,
@@ -2015,7 +1952,7 @@ impl ProdContainerOrchestrator {
}
if mode == ReconcileMode::ExistingOnly
&& requires_archival_bitcoin(&app_id)
&& bitcoin_pruned
&& disk_gb < ARCHIVAL_BITCOIN_DISK_GB
{
report.record(
&app_id,
@@ -2150,20 +2087,7 @@ impl ProdContainerOrchestrator {
// state recovery, repair recreate, boot InstallMissing) moves the
// address behind a running dependent's back — §C "restart lnd after
// ANY bitcoin recreate".
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) {
for (backend, dep) in cascade_pairs_for_report(&report, &user_stopped) {
// Same rule as the RPC cascade: hold the dependent's op lock
// across the restart; skip when a worker is mid-sequence.
let lock = crate::app_ops::op_lock(dep);
@@ -2507,7 +2431,6 @@ impl ProdContainerOrchestrator {
return Ok(ReconcileAction::NoOp);
}
tracing::info!(app_id = %app_id, container = %name, "container env drift detected — recreating");
self.backup_network_change(&name, &resolved_manifest).await?;
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
@@ -2564,7 +2487,6 @@ impl ProdContainerOrchestrator {
.await
{
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
self.backup_network_change(&name, &resolved_manifest).await?;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -3090,9 +3012,13 @@ impl ProdContainerOrchestrator {
/// app is a companion (companion.rs owns those units), or when no
/// unit file exists yet (install_via_quadlet handles first-write).
///
/// Ordinary metadata changes wait for an operator restart. Runtime-affecting
/// changes restart the service and retain a durable pending marker until
/// that succeeds, including across daemon restarts and failed reloads.
/// We DON'T restart the .service when content changes — running
/// containers keep their current config until an operator-initiated
/// restart picks up the new file. That's the right tradeoff: file
/// updates are cheap and non-destructive; service restarts are
/// destructive (the SIGKILL cascade we're trying to eliminate).
/// systemctl --user daemon-reload runs only when content actually
/// changed, so steady-state reconcile ticks pay just one fs read.
async fn sync_quadlet_unit(&self, lm: &LoadedManifest, name: &str) -> Result<()> {
// Companions: same reasoning as migrate_to_quadlet_if_needed —
// companion.rs renders these units with a different shape, syncing
@@ -3112,7 +3038,7 @@ impl ProdContainerOrchestrator {
}
let old_body = tokio::fs::read_to_string(&unit_path)
.await
.with_context(|| format!("read existing quadlet for {name}"))?;
.unwrap_or_default();
let restart_required = quadlet::contains_stale_health_gate(&old_body);
let mut resolved = lm.manifest.clone();
@@ -3128,47 +3054,49 @@ impl ProdContainerOrchestrator {
quadlet::network_aliases_changed(&old_body, &new_body);
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
let needs_restart = restart_required
|| restart_for_port_change
|| restart_for_network_alias_change
|| restart_for_exec_change
|| restart_for_health_change;
// Record the obligation BEFORE replacing the unit. A failed reload or
// restart must not become a no-op on the next tick just because the
// generated file already matches the manifest.
let pending = quadlet::RestartObligation::prepare(&unit_path, needs_restart).await?;
if pending.is_pending() {
self.ensure_resolved_source_available(lm).await?;
}
if restart_for_network_alias_change {
self.backup_network_change(name, &resolved).await?;
}
let changed = quadlet::write_if_changed(&unit, &unit_dir)
.await
.with_context(|| format!("drift-sync quadlet unit for {name}"))?;
if changed || pending.is_pending() {
if changed {
quadlet::daemon_reload_user()
.await
.context("systemctl --user daemon-reload after drift-syncing quadlet unit")?;
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
"Quadlet unit drift-synced — file rewritten, .service NOT restarted (operator restart picks up new config)"
);
}
if pending.is_pending() {
if changed
&& (restart_required
|| restart_for_port_change
|| restart_for_network_alias_change
|| restart_for_exec_change
|| restart_for_health_change)
{
self.ensure_resolved_source_available(lm).await?;
let service = unit.service_name();
let reason = if restart_required {
"stale health gate"
} else if restart_for_port_change {
"port binding drift"
} else if restart_for_network_alias_change {
"network alias drift"
} else if restart_for_health_change {
"health command drift"
} else {
"exec drift"
};
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
service = %service,
"Applying pending Quadlet runtime change"
reason = reason,
"Quadlet unit rewrite requires service restart"
);
quadlet::restart_service(&service)
.await
.with_context(|| format!("restart drifted quadlet service {service}"))?;
pending.complete().await?;
} else if changed {
tracing::info!(
app_id = %lm.manifest.app.id,
container = %name,
"Quadlet metadata updated; operator restart will apply it"
);
}
Ok(())
}
@@ -3298,9 +3226,6 @@ impl ProdContainerOrchestrator {
}
async fn ensure_container_network(&self, manifest: &AppManifest) -> Result<()> {
if cfg!(test) {
return Ok(());
}
let Some(network) = manifest.app.container.network.as_deref() else {
return Ok(());
};
@@ -3487,9 +3412,11 @@ impl ProdContainerOrchestrator {
}
async fn cleanup_stale_grafana_port(&self) {
// Port 3001 can belong to Gitea or the daemon's gate. Reap only a
// Grafana container proven absent from Podman's inventory.
crate::container::ghost_reaper::reap_for_app("grafana").await;
let _ = tokio::process::Command::new("pkill")
.args(["-f", "pasta.*3001"])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
async fn detect_host_facts(&self) -> HostFacts {
@@ -3793,17 +3720,6 @@ impl ProdContainerOrchestrator {
}
let mut env = manifest.app.environment.clone();
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}}
// derived_env (works on Knots/Core/any distro). The old hardcoded
@@ -3870,61 +3786,6 @@ impl ProdContainerOrchestrator {
Ok(())
}
async fn backup_network_change(&self, name: &str, manifest: &AppManifest) -> Result<()> {
if !crate::container::migration_backup::enabled(manifest)? {
return Ok(());
}
// Only back up an actual network migration, not ordinary env drift.
let output = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output().await.context("inspect network before migration backup")?;
let present = if output.status.success() {
if !rootless_network_mode_drifted(manifest.app.container.network.as_deref(), &String::from_utf8_lossy(&output.stdout)) {
return Ok(());
}
true
} else {
// A crash after gracefully stopping a --rm Quadlet container can
// leave only its data and old unit. Prove absence before snapshotting
// stopped state; an inspect/Podman failure is not proof of absence.
let exists = tokio::process::Command::new("podman")
.args(["container", "exists", name]).status().await?;
if exists.code() != Some(1) {
anyhow::bail!("cannot verify existing container before network migration backup");
}
false
};
let service = format!("{name}.service");
let managed = quadlet::unit_exists(name).await;
let previous_unit = if managed {
Some(tokio::fs::read(quadlet::unit_dir().await?.join(format!("{name}.container"))).await?)
} else {
None
};
if managed {
quadlet::stop_service(&service).await?;
} else if present {
self.runtime.stop_container(name).await?;
}
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await {
Ok(archive) => {
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before network migration");
Ok(())
}
Err(error) => {
// The unit has not been rewritten yet. Restore its previous
// service on backup failure and report the migration failure.
let restored = if managed {
quadlet::enable_now(&service).await
} else {
self.runtime.start_container(name).await
};
restored.context("restore original app after failed migration snapshot")?;
Err(error)
}
}
}
async fn container_env_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if cfg!(test) {
return false;
@@ -3934,23 +3795,6 @@ impl ProdContainerOrchestrator {
return true;
}
// Quadlet handles declarative Network= drift above. Legacy rootless
// Podman containers need the same convergence when no unit owns them.
if matches!(manifest.app.container.network.as_deref(), Some("slirp4netns" | "pasta")) {
if let Ok(output) = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.HostConfig.NetworkMode}}"])
.output()
.await
{
if output.status.success() && rootless_network_mode_drifted(
manifest.app.container.network.as_deref(),
&String::from_utf8_lossy(&output.stdout),
) {
return true;
}
}
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
@@ -4993,17 +4837,6 @@ mod tests {
/// recovered when its siblings have live containers (the stack is
/// installed), and left alone when the whole stack is gone or the app
/// is not a stack member at all.
#[test]
fn explicit_rootless_network_change_converges_without_guessing_defaults() {
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "pasta"));
assert!(rootless_network_mode_drifted(Some("slirp4netns"), "bridge"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), "slirp4netns:allow_host_loopback=true"));
assert!(!rootless_network_mode_drifted(None, "pasta"));
assert!(!rootless_network_mode_drifted(Some("slirp4netns"), ""));
assert!(!rootless_network_mode_drifted(Some("archy-net"), "bridge"));
}
#[test]
fn absent_stack_member_recovery_requires_a_live_sibling() {
let present: HashSet<String> = ["indeedhub-redis", "indeedhub-relay", "indeedhub"]
@@ -6240,48 +6073,6 @@ 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]
async fn install_resolves_derived_and_secret_env_before_create() {
let rt = Arc::new(MockRuntime::default());
@@ -6553,67 +6344,6 @@ 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]
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
use std::collections::HashSet;
@@ -6625,7 +6355,6 @@ app:
failures: vec![],
};
let none = HashSet::new();
let changed: HashSet<String> = ["bitcoin-core".into(), "bitcoin-knots".into()].into();
// Backend recreated while lnd sat running (NoOp) → cascade.
let r = report(vec![
@@ -6633,7 +6362,7 @@ app:
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(
cascade_pairs_for_report(&r, &none, &changed),
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-knots", "lnd")]
);
@@ -6643,7 +6372,7 @@ app:
("lnd", ReconcileAction::NoOp),
]);
assert_eq!(
cascade_pairs_for_report(&r, &none, &changed),
cascade_pairs_for_report(&r, &none),
vec![("bitcoin-core", "lnd")]
);
@@ -6652,7 +6381,7 @@ app:
("bitcoin-knots", ReconcileAction::NoOp),
("lnd", ReconcileAction::NoOp),
]);
assert!(cascade_pairs_for_report(&r, &none, &none).is_empty());
assert!(cascade_pairs_for_report(&r, &none).is_empty());
// Dependent itself (re)started this pass → it already resolved the
// fresh address; no cascade.
@@ -6660,7 +6389,7 @@ app:
("bitcoin-knots", ReconcileAction::Installed),
("lnd", ReconcileAction::Started),
]);
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
assert!(cascade_pairs_for_report(&r, &none).is_empty());
// User-stopped dependent is never bounced.
let r = report(vec![
@@ -6668,14 +6397,14 @@ app:
("lnd", ReconcileAction::NoOp),
]);
let stopped: HashSet<String> = ["lnd".to_string()].into();
assert!(cascade_pairs_for_report(&r, &stopped, &changed).is_empty());
assert!(cascade_pairs_for_report(&r, &stopped).is_empty());
// Non-backend recreates don't cascade anything.
let r = report(vec![
("grafana", ReconcileAction::Installed),
("lnd", ReconcileAction::NoOp),
]);
assert!(cascade_pairs_for_report(&r, &none, &changed).is_empty());
assert!(cascade_pairs_for_report(&r, &none).is_empty());
}
#[tokio::test]
+5 -272
View File
@@ -184,7 +184,6 @@ pub struct QuadletUnit {
pub no_new_privileges: bool,
pub cpu_quota: Option<u32>,
pub restart_policy: RestartPolicy,
pub stop_grace_secs: Option<u64>,
}
impl QuadletUnit {
@@ -217,10 +216,6 @@ impl QuadletUnit {
let _ = writeln!(s, "[Container]");
let _ = writeln!(s, "ContainerName={}", self.name);
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
// must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never");
@@ -355,15 +350,6 @@ impl QuadletUnit {
// the unit stuck in deactivating. Health/status remains app-level state,
// not a systemd start gate.
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
// from saturating the journal. Companions: Always. Backends:
// OnFailure (clean stops stay stopped).
@@ -539,9 +525,6 @@ impl QuadletUnit {
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
restart_policy: RestartPolicy::Always,
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
manifest, name,
)),
}
}
}
@@ -693,13 +676,6 @@ pub async fn unit_exists(name: &str) -> bool {
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
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")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
@@ -809,11 +785,7 @@ pub async fn stop_service(service: &str) -> Result<()> {
/// corruption — so the orchestrator passes the per-app grace here. Never waits
/// less than `QUADLET_STOP_TIMEOUT`.
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
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));
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
match systemctl_user_status(&["stop", service], timeout).await {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
@@ -834,29 +806,10 @@ 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(
args: &[&str],
timeout: Duration,
) -> 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");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
@@ -903,10 +856,6 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
}
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");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
@@ -938,53 +887,6 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
!= directive_values(new_body, "HealthRetries=")
}
/// A unit rewrite and a successful systemd restart are separate operations.
/// Keep the restart obligation across errors or a management-daemon restart.
pub struct RestartObligation {
marker: PathBuf,
pending: bool,
}
impl RestartObligation {
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
let marker = unit_path.with_extension("restart-pending");
if newly_required {
// Contents contain no manifest environment or credentials. sync_all
// makes the obligation durable before the subsequent unit rename.
let file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&marker)
.await
.context("record pending Quadlet restart")?;
file.sync_all().await?;
if let Some(parent) = marker.parent() {
tokio::fs::File::open(parent).await?.sync_all().await?;
}
}
let pending = tokio::fs::try_exists(&marker).await?;
Ok(Self { marker, pending })
}
pub fn is_pending(&self) -> bool {
self.pending
}
/// Call only after systemd accepted the replacement service successfully.
pub async fn complete(self) -> Result<()> {
if self.pending {
tokio::fs::remove_file(&self.marker)
.await
.context("clear completed Quadlet restart")?;
if let Some(parent) = self.marker.parent() {
tokio::fs::File::open(parent).await?.sync_all().await?;
}
}
Ok(())
}
}
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
let old_ports = directive_values(old_body, "PublishPort=");
let new_ports = directive_values(new_body, "PublishPort=");
@@ -1021,10 +923,6 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
/// that systemd no longer knows about.
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
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
// rootless podman a generated unit can wedge in "deactivating" while
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
@@ -1032,12 +930,13 @@ 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
// blocks reinstall). If the graceful stop times out, escalate to
// SIGKILL + reset-failed so teardown always proceeds.
if systemctl_user_status(&["stop", &svc], timeout)
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
.await
.is_err()
{
let _ = kill_and_reset_service(&svc).await;
}
let path = dir.join(format!("{unit_name}.container"));
if fs::try_exists(&path).await.unwrap_or(false) {
match fs::remove_file(&path).await {
Ok(()) => {}
@@ -1050,9 +949,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
// exists to avoid.
let _ = tokio::time::timeout(
timeout,
QUADLET_STOP_TIMEOUT,
Command::new("podman")
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
.args(["rm", "-f", unit_name])
.status(),
)
.await;
@@ -1061,9 +960,6 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
/// Is the quadlet-generated service currently active?
pub async fn is_active(service: &str) -> bool {
if cfg!(test) {
return false;
}
Command::new("systemctl")
.args(["--user", "is-active", "--quiet", service])
.status()
@@ -1077,118 +973,6 @@ mod tests {
use super::*;
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]
fn render_emits_secret_env_by_reference_never_value() {
let u = QuadletUnit {
@@ -1588,28 +1372,6 @@ app:
assert!(!s.contains("Network=host"));
}
#[test]
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
let manifest = AppManifest::parse(include_str!(
"../../../../apps/portainer/manifest.yml"
))
.expect("shipped Portainer manifest must parse");
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
assert!(new.contains("Network=slirp4netns\n"));
assert!(!new.contains("NetworkAlias="));
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
assert!(!new.contains("PublishPort=0.0.0.0"));
// The upgrade changes networking only: retain both state mounts and the
// existing rootless socket, without an app.ini or repository rewrite.
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
let old = new.replace("Network=slirp4netns\n", "");
assert!(network_aliases_changed(&old, &new));
assert!(!network_aliases_changed(&new, &new));
assert!(!publish_ports_changed(&old, &new));
}
#[test]
fn from_manifest_slirp4netns_omits_network_alias() {
let yaml = r#"
@@ -1960,35 +1722,6 @@ app:
assert!(!network_aliases_changed(new, new));
}
#[tokio::test]
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
let dir = tempfile::tempdir().unwrap();
let unit = dir.path().join("portainer.container");
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
assert!(pending.is_pending());
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
.await
.unwrap();
// Simulate systemctl failure or daemon interruption after unit rewrite.
drop(pending);
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
assert!(retry.is_pending(), "matching unit must not discard failed restart");
retry.complete().await.unwrap();
assert!(!RestartObligation::prepare(&unit, false).await.unwrap().is_pending());
}
#[tokio::test]
async fn pending_runtime_change_errors_are_not_reported_as_success() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing/app.container");
assert!(RestartObligation::prepare(&missing, true).await.is_err());
let unit = dir.path().join("app.container");
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
tokio::fs::remove_file(unit.with_extension("restart-pending")).await.unwrap();
assert!(pending.complete().await.is_err());
}
#[test]
fn network_aliases_changed_detects_network_mode_drift() {
let old = "[Container]\nNetwork=slirp4netns\n";
+290 -8
View File
@@ -5,13 +5,110 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::fs;
use tokio::sync::Mutex;
use tracing::{debug, warn};
const CATALOG_FILE: &str = "content/catalog.json";
const CONTENT_DIR: &str = "content/files";
/// How long a redeemed payment token keeps entitling its buyer to re-fetch the
/// item it paid for. Long enough to cover a buyer's transport fallback (FIPS →
/// Tor re-sends the same request, token included) and a manual retry; short
/// enough that the ledger stays tiny and a leaked token isn't a standing pass.
const REDEMPTION_TTL: Duration = Duration::from_secs(600);
/// One ledger slot per payment token (keyed by its SHA-256 — the raw bearer
/// token is never held here). The inner mutex serialises verification of the
/// same token; its value is the content id the token was redeemed for.
struct RedemptionSlot {
created_at: Instant,
redeemed_for: Arc<Mutex<Option<String>>>,
}
static REDEMPTIONS: LazyLock<Mutex<HashMap<String, RedemptionSlot>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Decide whether `token` pays for `content_id`, redeeming it at most once.
///
/// Payment tokens are single-use: verifying one swaps its proofs at the mint,
/// so a second verification of the same token always fails "already spent".
/// A buyer's HTTP client can legitimately send the same request twice — its
/// FIPS attempt gets a 404/5xx and it re-sends over Tor — and without this
/// the seller redeemed the token on the first request, then answered the
/// retry `402 Payment required`: money taken, file never delivered.
///
/// So the first verification that succeeds is remembered (per token, per
/// item, for [`REDEMPTION_TTL`]) and later requests for the same item present
/// the same token are authorised without touching the mint again. Concurrent
/// requests with one token queue on the slot so only one runs `verify`.
/// A failed verification is not remembered — the slot is dropped so garbage
/// tokens can't accumulate and a legitimate retry gets a fresh attempt.
async fn authorize_payment<F, Fut>(token: &str, content_id: &str, verify: F) -> bool
where
F: FnOnce() -> Fut,
Fut: Future<Output = bool>,
{
let key = hex::encode(Sha256::digest(token.as_bytes()));
let redeemed_for = {
let mut ledger = REDEMPTIONS.lock().await;
ledger.retain(|_, s| s.created_at.elapsed() < REDEMPTION_TTL);
ledger
.entry(key.clone())
.or_insert_with(|| RedemptionSlot {
created_at: Instant::now(),
redeemed_for: Arc::new(Mutex::new(None)),
})
.redeemed_for
.clone()
};
let mut state = redeemed_for.lock().await;
if state.as_deref() == Some(content_id) {
debug!(
"Payment token already redeemed for '{}' — serving without re-verifying",
content_id
);
return true;
}
if verify().await {
*state = Some(content_id.to_string());
return true;
}
// Keep a slot that already holds a redemption (this token paid for a
// different item); drop one that never verified anything.
let never_redeemed = state.is_none();
drop(state);
if never_redeemed {
REDEMPTIONS.lock().await.remove(&key);
}
false
}
/// Confirm the node can actually hand the file over: it exists and this
/// process may read it. Must run BEFORE a payment is redeemed — a paid buyer
/// who then hits a read error has lost their token for nothing (2026-09-18:
/// filebrowser-owned `0640` files the node's service user couldn't open; the
/// stat calls passed, `fs::read` failed after the swap, the buyer got a 404).
/// Reading a byte (not just opening) also rejects a directory.
async fn ensure_servable(file_path: &Path) -> Result<()> {
use tokio::io::AsyncReadExt;
let mut file = fs::File::open(file_path)
.await
.with_context(|| format!("content file {} is not readable", file_path.display()))?;
let mut probe = [0u8; 1];
file.read(&mut probe)
.await
.with_context(|| format!("content file {} cannot be read", file_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentItem {
pub id: String,
@@ -296,6 +393,9 @@ pub async fn serve_content(
}
}
// Verify the file can be served BEFORE any payment is redeemed. The gate
// below swaps the buyer's token at the mint; failing to hand over the file
// after that takes their money and delivers nothing.
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
@@ -313,6 +413,10 @@ pub async fn serve_content(
prune_missing_content_entry(data_dir, id).await;
return Ok(ServeResult::NotFound);
}
if let Err(e) = ensure_servable(&file_path).await {
warn!(content_id = %id, "cannot serve content (payment not taken): {e:#}");
return Err(e);
}
// Check access control
if !owner_session {
@@ -325,13 +429,12 @@ pub async fn serve_content(
// Each path only counts when the sharer accepts that method.
let mut authorized = false;
if let Some(token) = payment_token {
let method = if token.trim().starts_with("cashu") {
"ecash"
} else {
"fedimint"
};
if method_accepted(&item.access, method)
&& verify_payment_token(data_dir, token, *price_sats).await
if (method_accepted(&item.access, "ecash")
|| method_accepted(&item.access, "fedimint"))
&& authorize_payment(token, id, || {
verify_payment_token(data_dir, token, *price_sats)
})
.await
{
authorized = true;
}
@@ -577,7 +680,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
}
/// Verify a payment token covers the required amount.
/// Accepts real Cashu tokens and Fedimint notes.
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
/// 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 {
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
@@ -729,3 +832,182 @@ mod prune_missing_content_tests {
assert_eq!(reloaded.items[0].id, "present-item");
}
}
#[cfg(test)]
mod paid_delivery_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A verifier that counts how often it actually runs.
fn counting(
calls: &Arc<AtomicUsize>,
result: bool,
) -> impl FnOnce() -> std::future::Ready<bool> {
let calls = calls.clone();
move || {
calls.fetch_add(1, Ordering::SeqCst);
std::future::ready(result)
}
}
#[tokio::test]
async fn replayed_token_is_served_without_redeeming_twice() {
// The 2026-09-18 incident: the buyer's client re-sent the same request
// over Tor after the seller had already redeemed the token, and the
// second verification ("already spent") turned into a 402.
let calls = Arc::new(AtomicUsize::new(0));
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
assert!(authorize_payment("tok-replay", "item-a", counting(&calls, true)).await);
assert_eq!(calls.load(Ordering::SeqCst), 1, "mint must be hit once");
}
#[tokio::test]
async fn concurrent_requests_with_one_token_redeem_once() {
// FIPS attempt still in flight when the Tor fallback arrives.
let calls = Arc::new(AtomicUsize::new(0));
let slow = |calls: Arc<AtomicUsize>| {
move || async move {
calls.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(100)).await;
true
}
};
let (a, b) = tokio::join!(
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
authorize_payment("tok-concurrent", "item-a", slow(calls.clone())),
);
assert!(a && b, "both requests must be served");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn failed_verification_is_not_remembered() {
let calls = Arc::new(AtomicUsize::new(0));
assert!(!authorize_payment("tok-bad", "item-a", counting(&calls, false)).await);
// A retry gets a fresh attempt — and can succeed (e.g. mint was down).
assert!(authorize_payment("tok-bad", "item-a", counting(&calls, true)).await);
assert_eq!(calls.load(Ordering::SeqCst), 2);
let ledger = REDEMPTIONS.lock().await;
let key = hex::encode(Sha256::digest(b"tok-bad"));
assert!(ledger.contains_key(&key), "successful redemption is kept");
}
#[tokio::test]
async fn failed_verification_leaves_no_ledger_entry() {
let calls = Arc::new(AtomicUsize::new(0));
assert!(!authorize_payment("tok-garbage", "item-a", counting(&calls, false)).await);
let key = hex::encode(Sha256::digest(b"tok-garbage"));
assert!(
!REDEMPTIONS.lock().await.contains_key(&key),
"garbage tokens must not accumulate"
);
}
#[tokio::test]
async fn token_redeemed_for_one_item_does_not_unlock_another() {
let calls = Arc::new(AtomicUsize::new(0));
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
// Item B is verified on its own merits (the real mint would say
// "already spent"); it must not ride on item A's redemption…
assert!(!authorize_payment("tok-cross", "item-b", counting(&calls, false)).await);
assert_eq!(calls.load(Ordering::SeqCst), 2);
// …and failing there must not revoke what the token already paid for.
assert!(authorize_payment("tok-cross", "item-a", counting(&calls, true)).await);
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
fn paid_item(id: &str, filename: &str) -> ContentItem {
ContentItem {
id: id.to_string(),
filename: filename.to_string(),
mime_type: "audio/mpeg".to_string(),
size_bytes: 4,
description: String::new(),
access: AccessControl::Paid {
price_sats: 10,
accepted: vec!["ecash".to_string()],
},
availability: Availability::AllPeers,
added_at: "2026-01-01T00:00:00Z".to_string(),
}
}
#[cfg(unix)]
#[tokio::test]
async fn unreadable_paid_file_errors_before_any_payment_is_redeemed() {
// Filebrowser-owned 0640 files the node's service user can't read:
// stat() succeeds, read() fails. That must surface as an error BEFORE
// the token is verified — never after the swap has taken the money.
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path();
save_catalog(
data_dir,
&ContentCatalog {
items: vec![paid_item("locked", "locked.mp3")],
},
)
.await
.unwrap();
let files = data_dir.join("content").join("files");
tokio::fs::create_dir_all(&files).await.unwrap();
let file = files.join("locked.mp3");
tokio::fs::write(&file, b"data").await.unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::File::open(&file).is_ok() {
return; // running as root: permissions can't be enforced here
}
// A token that would fail verification if it were reached: getting
// PaymentRequired here would mean the gate ran before the file check.
let result = serve_content(
data_dir,
"locked",
Some("cashuBnot-a-real-token"),
None,
None,
None,
false,
)
.await;
assert!(
result.is_err(),
"unreadable file must be a server error, not 402/404"
);
let key = hex::encode(Sha256::digest(b"cashuBnot-a-real-token"));
assert!(
!REDEMPTIONS.lock().await.contains_key(&key),
"no redemption may be attempted for an unservable file"
);
}
#[tokio::test]
async fn readable_paid_file_with_bad_token_still_requires_payment() {
let dir = tempfile::tempdir().unwrap();
let data_dir = dir.path();
save_catalog(
data_dir,
&ContentCatalog {
items: vec![paid_item("ok", "ok.mp3")],
},
)
.await
.unwrap();
let files = data_dir.join("content").join("files");
tokio::fs::create_dir_all(&files).await.unwrap();
tokio::fs::write(files.join("ok.mp3"), b"data").await.unwrap();
let result = serve_content(
data_dir,
"ok",
Some("cashuBnot-a-real-token-2"),
None,
None,
None,
false,
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
}
}
+1 -38
View File
@@ -194,7 +194,6 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
// Installation is a decision, not a runtime observation, so it gets a record
// of its own that no amount of downtime erodes.
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.
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
@@ -221,23 +220,12 @@ pub async fn load_installed_apps_if_recorded(
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) {
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");
}
let _ = fs::write(&path, json).await;
}
}
/// Record that an app is installed. Called when an install succeeds.
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;
if installed.insert(name.to_string()) {
save_installed_apps(data_dir, &installed).await;
@@ -247,7 +235,6 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
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;
if installed.remove(name) {
save_installed_apps(data_dir, &installed).await;
@@ -265,7 +252,6 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
/// need it. Runs on every boot, so an app installed before the upgrade is
/// still picked up whenever it is next seen alive.
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() {
return;
}
@@ -1511,26 +1497,3 @@ 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());
}
}
-6
View File
@@ -146,10 +146,6 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
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,
/// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")]
@@ -301,8 +297,6 @@ pub enum InstallPhase {
/// `podman pull` in progress (the longest phase — up to several
/// minutes for large images on slow networks).
PullingImage,
/// Orchestrator owns download/build and startup as one operation.
PreparingApp,
/// Creating data directories, writing app-specific configs
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
CreatingContainer,
+18 -86
View File
@@ -1765,17 +1765,12 @@ fn merge_preserving_transitional(
};
crate::data_model::PackageDataEntry {
state: state.clone(),
state,
// install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(),
uninstall_stage: existing.uninstall_stage.clone(),
// 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(),
exit_code: fresh.exit_code,
static_files: fresh.static_files.clone(),
@@ -1814,10 +1809,7 @@ async fn scan_and_update_packages(
absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> {
let (before_scan, _) = state.get_snapshot().await;
let mut packages = scanner
.scan_containers(data_dir, &before_scan.package_data)
.await?;
let mut packages = scanner.scan_containers().await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
@@ -1878,14 +1870,11 @@ async fn scan_and_update_packages(
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
if tor_changed || update_changed {
state
.mutate_data(|data| {
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address =
tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
})
.await;
let mut data = current_data;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
state.update_data(data).await;
}
return Ok(());
}
@@ -1910,13 +1899,6 @@ async fn scan_and_update_packages(
// died without cleanup and let the scan override it.
let now = Instant::now();
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);
let existing = merged.get(id);
let overwrite = match existing {
@@ -2072,40 +2054,22 @@ async fn scan_and_update_packages(
}
if changed || tor_changed || first_scan || update_changed {
state
.mutate_data(|data| {
// A lifecycle operation may have started/finished while this scan
// awaited probes or disk I/O. Never overwrite that newer entry or
// resurrect one that an uninstall removed in the meantime.
apply_scanned_packages(&mut data.package_data, &current_data.package_data, &merged);
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available;
})
.await;
let mut data = current_data;
data.package_data = merged;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available;
state.update_data(data).await;
debug!(
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
changed, tor_changed, first_scan, update_changed
);
}
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(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) {
@@ -2304,7 +2268,6 @@ mod merge_tests {
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
PackageDataEntry {
ui_ready: None,
state,
health: health.map(|s| s.to_string()),
exit_code: None,
@@ -2317,37 +2280,6 @@ 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]
fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing
@@ -1,51 +0,0 @@
//! 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());
}
}
-2
View File
@@ -7,5 +7,3 @@
pub mod ai_permissions;
pub mod session_policy;
pub mod transport;
pub mod bitcoin_storage;
-41
View File
@@ -54,21 +54,6 @@ impl StateManager {
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
pub async fn get_initial_message(&self) -> WebSocketMessage {
let (data, rev) = self.get_snapshot().await;
@@ -205,29 +190,3 @@ mod tests {
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);
}
}
-30
View File
@@ -1481,21 +1481,6 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
/// service unit that inherits systemd's default protections (i.e. none
/// of ours), escaping the namespace.
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![
"systemd-run",
"--wait",
@@ -1520,21 +1505,6 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
/// 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.
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![
"systemd-run",
"--wait",
+60 -51
View File
@@ -775,9 +775,7 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
let mut all_target: Vec<u64> = send_denoms.clone();
all_target.extend(&change_denoms);
let swap_result = client
.swap_at_least(&selected_proofs, &all_target, amount_sats)
.await?;
let swap_result = client.swap(&selected_proofs, &all_target).await?;
// Mark original proofs as spent
wallet.mark_spent(&indices);
@@ -1194,11 +1192,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Verify all mints in the token are accepted
let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() {
if !accepted
.mints
.iter()
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
{
if !accepted.mints.iter().any(|m| m == mint_url) {
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
}
}
@@ -1223,7 +1217,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
received_total += amount;
}
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>();
last_reason = Some(e.to_string());
// Continue with other mints if any
@@ -1304,10 +1298,22 @@ pub async fn verify_and_receive_payment(
token_str: &str,
required_sats: u64,
) -> Result<u64> {
let token_str = token_str.trim();
// Synthetic legacy balances are not cryptographic proof of payment.
// Handle legacy tokens
if token_str.starts_with("cashuSend_") {
anyhow::bail!("Legacy ecash cannot authorize a paid download");
let amount = token_str
.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
@@ -1330,45 +1336,52 @@ pub async fn verify_and_receive_payment(
// Parse and validate the token (cashuA or cashuB)
let token = CashuToken::deserialize(token_str)?;
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"))?;
let total = token.total_amount();
if total < required_sats {
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
}
let accepted = load_accepted_mints(data_dir).await?;
if !accepted
.mints
.iter()
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
{
anyhow::bail!("Mint is not in the seller's accepted mints list");
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
total,
required_sats
);
}
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.
// Verify mints are accepted
let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() {
if !accepted.mints.iter().any(|m| m == mint_url) {
anyhow::bail!("Mint '{}' not accepted", mint_url);
}
}
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
let mut wallet = load_wallet(data_dir).await?;
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
let mut received_total = 0u64;
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);
match client.swap(&entry.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) => {
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(
TransactionType::Receive,
@@ -2452,7 +2465,3 @@ mod tests {
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
}
}
#[cfg(test)]
#[path = "payment_tests.rs"]
mod payment_tests;
+32 -77
View File
@@ -153,20 +153,6 @@ fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Erro
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.
pub struct MintClient {
url: String,
@@ -526,21 +512,6 @@ impl MintClient {
/// Swap proofs for new proofs of different denominations.
/// 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> {
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?;
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
@@ -548,35 +519,16 @@ impl MintClient {
// should equal outputs less fee`). Applied here rather than at each
// call site so send, receive and cross-mint swaps are all covered.
// Fee-free mints (Minibits) compute 0 and are unaffected.
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
let inputs_total = inputs
.iter()
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
.context("Input amount overflow")?;
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 inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
let fee = match self.get_keysets().await {
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
Err(e) => {
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
0
}
};
let spendable = inputs_total.saturating_sub(fee);
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 requested: u64 = target_amounts.iter().sum();
let owned_targets: Vec<u64>;
let target_amounts: &[u64] = if requested > spendable {
if spendable == 0 {
@@ -587,10 +539,7 @@ impl MintClient {
debug!(
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
);
// 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 = amount_to_denominations(spendable);
&owned_targets
} else {
target_amounts
@@ -635,9 +584,6 @@ impl MintClient {
let mut new_proofs = Vec::new();
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 mint_key = keyset.key_for_amount(*amount)?;
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
@@ -784,35 +730,43 @@ impl MintClient {
/// 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
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
/// wallets written against the original 8-byte format truncate it when
/// they build a token. The mint then reads the `0x01` version, expects 33
/// bytes, and rejects the swap — reported as
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
/// a Minibits-issued token, 2026-08-17).
///
/// 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
/// safe to attempt: the mint still verifies the proof signature. Unknown
/// or ambiguous short IDs are rejected before redemption.
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
/// safe to attempt: an id that names the wrong keyset fails signature
/// verification at the mint and no coins move. Anything already valid, or
/// with no unambiguous match, is passed through untouched so the mint's
/// own error is what the operator sees.
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));
if !needs_repair {
return Ok(proofs.to_vec());
return proofs.to_vec();
}
// The mint's own keyset list, in the reference implementation's shape
// so its NUT-02 resolver can consume it directly.
let known = self.get_cdk_keysets().await?;
let known = match 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
.iter()
.cloned()
.map(|mut p| {
if is_truncated_v2_keyset_id(&p.id) {
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
.context("The mint cannot resolve this short keyset ID unambiguously")?;
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
p.id = full;
}
Ok(p)
p
})
.collect()
}
@@ -848,7 +802,7 @@ impl MintClient {
let mut all_new_proofs = Vec::new();
for entry in &token.token {
if entry.mint.trim_end_matches('/') != self.url {
if entry.mint != self.url {
debug!(
"Skipping proofs from different mint {} (ours: {})",
entry.mint, self.url
@@ -859,7 +813,8 @@ impl MintClient {
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(total);
let result = self.swap(&entry.proofs, &target_amounts).await?;
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
let result = self.swap(&proofs, &target_amounts).await?;
all_new_proofs.extend(result.new_proofs);
}
@@ -1,428 +0,0 @@
//! 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());
}
}
}
+31 -22
View File
@@ -1746,28 +1746,40 @@ app:
}
}
exempt.sort();
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
// disappeared; Cuprate restricted RPC moved from none to gate-open.
// Compare exact endpoints, not just a count that can hide substitutions.
let expected = [
("bitcoin-core", 8333), ("bitcoin-knots", 8333),
("core-lightning", 9736), ("core-lightning", 9835),
("cuprate", 18183), ("electrumx", 50001),
("fedimint", 8173), ("fedimint", 8174),
("fedimint-gateway", 8176), ("fedimint-gateway", 9737),
("gitea", 2222), ("lnd", 9735), ("lnd", 10009), ("lnd", 18080),
("netbird", 8087), ("netbird-server", 3478), ("netbird-server", 8086),
("phoenixd", 9740), ("pine", 10381), ("pine-openwakeword", 10400),
("pine-piper", 10200), ("pine-whisper", 10300),
("router", 1900), ("router", 5353),
].into_iter().map(|(id, port)| (id.to_owned(), port)).collect::<Vec<_>>();
assert_eq!(exempt, expected, "unauthenticated endpoint set changed; review each exemption");
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
// upstream's own safe-for-public
// subset that wallets connect to directly as a "remote node" over
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
// cuprate's unrestricted RPC (full node control) stays loopback-only
// (auth: local), not in this set.
//
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
// loopback-only JSON API whose own generated http password
// authenticates every request (added with the phoenixd onboarding,
// which did not update this count — exactly the drift this test
// exists to catch).
//
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
// deliberate, rationale-carrying exemption; the release-gate test
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
28,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
/// so they are the second unauthenticated-by-the-gate surface and get the
/// same review guard as `auth: none`. Each must enforce its own login or
/// have an explicitly reviewed public protocol purpose.
/// same review guard as `auth: none`. Each one must be an app that
/// enforces a real login of its own.
#[test]
fn gate_open_ports_are_all_accounted_for() {
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
@@ -1789,8 +1801,6 @@ app:
}
}
open.sort();
// Cuprate 18090 is its deliberately public restricted RPC subset;
// unrestricted node-control RPC remains container-loopback-only.
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
// by anonymous payers), and — since the v1.8.7 platform round — the
@@ -1802,12 +1812,11 @@ app:
open,
vec![
("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16),
("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16),
],
"gate-open port set changed — review login or intentional public protocol purpose"
"gate-open port set changed — every entry must be an app with its own login"
);
}
+53 -29
View File
@@ -310,7 +310,59 @@ impl PodmanClient {
);
continue;
}
port_mappings.push(podman_publish_mapping(port));
// Honour the manifest's protocol (default tcp). netbird's STUN port
// is 3478/udp; forcing tcp here would publish the wrong protocol and
// silently break relay discovery.
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
"udp" => "udp",
"sctp" => "sctp",
_ => "tcp",
};
// Effective bind. A gated port with no declared bind would
// publish 0.0.0.0 — the app would own every host address, which
// is both the exposure itself and the reason the daemon's app
// gate cannot bind those addresses to authenticate them. Pin it
// to loopback so the gate can take the external addresses.
//
// Doing it HERE, at container creation, is the point: the pin and
// the gate's takeover then both come from the daemon and cannot
// disagree. The earlier attempt put this decision in manifest
// data instead, and a node whose manifests lagged the binary
// published Bitcoin's loopback-only RPC across the LAN
// (test node, 2026-08-03).
//
// A port that already declares a bind is never overridden — that
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
// published as they are, so remote wallets keep working.
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
// published port to loopback is how an app hands its external
// addresses to the gate, but it belongs in the manifest, not in
// daemon-side inference:
//
// * `bind` is already honoured by every publish path (here and
// in package::install), so a manifest edit needs no code.
// * inference here would cover only THIS path — proven on
// a test node, where a recreate went through another one and
// the pin never applied.
// * and inferring from an ABSENT field is what republished
// Bitcoin's loopback RPC across the LAN, and came within one
// container-recreate of pinning LND's gRPC/REST and breaking
// every remote wallet.
//
// So the migration ships as `bind: 127.0.0.1` in the signed
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
// overridden by the catalog, which is precisely why the catalog is
// the right and only place to carry it.
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
port_mappings.push(mapping);
}
let mut mounts = Vec::new();
@@ -699,25 +751,6 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
}
// Keep the explicitly declared bind and transport identical to Quadlet. The
// app gate owns external listeners; container publication must not bypass it.
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
"udp" => "udp",
"sctp" => "sctp",
_ => "tcp",
};
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
mapping
}
fn podman_network_settings(
network: Option<&str>,
network_policy: &str,
@@ -1077,15 +1110,6 @@ mod tests {
));
}
#[test]
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert_eq!(podman_network_settings(m.app.container.network.as_deref(), &m.app.security.network_policy), ("slirp4netns", None));
assert_eq!(podman_publish_mapping(&m.app.ports[0]), serde_json::json!({
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
}));
}
#[test]
fn podman_network_settings_uses_networks_map_for_custom_networks() {
assert_eq!(
+19 -34
View File
@@ -618,28 +618,6 @@ impl DockerRuntime {
}
}
// Docker is a development fallback. Refuse Podman-only network modes instead
// of silently installing a different topology; still honor binds for other apps.
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
let network = manifest.app.container.network.as_deref()
.filter(|v| !v.is_empty())
.unwrap_or(&manifest.app.security.network_policy);
if matches!(network, "slirp4netns" | "pasta") {
anyhow::bail!("this app requires rootless Podman networking ({network})");
}
let mut args = Vec::new();
if !network.is_empty() && network != "isolated" {
args.extend(["--network".to_owned(), network.to_owned()]);
}
for port in &manifest.app.ports {
let host = port.host.checked_add(offset).context("published port offset overflow")?;
let bind = if port.bind.is_empty() { String::new() } else { format!("{}:", port.bind) };
let protocol = if port.protocol.is_empty() { "tcp" } else { &port.protocol };
args.extend(["-p".to_owned(), format!("{bind}{host}:{}/{protocol}", port.container)]);
}
Ok(args)
}
#[async_trait]
impl ContainerRuntime for DockerRuntime {
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
@@ -679,7 +657,25 @@ impl ContainerRuntime for DockerRuntime {
cmd.arg("--read-only");
}
cmd.args(docker_network_and_ports(manifest, port_offset)?);
match manifest.app.security.network_policy.as_str() {
"host" => {
cmd.arg("--network").arg("host");
}
"isolated" => {
// Docker uses bridge network by default
}
_ => {
cmd.arg("--network")
.arg(&manifest.app.security.network_policy);
}
}
// Port mappings with offset
for port in &manifest.app.ports {
let host_port = port.host + port_offset;
cmd.arg("-p")
.arg(format!("{}:{}", host_port, port.container));
}
// Volumes
for volume in &manifest.app.volumes {
@@ -1039,17 +1035,6 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
let mut m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert!(docker_network_and_ports(&m, 0).is_err());
m.app.container.network = Some("bridge".into());
m.app.ports[0].protocol = "udp".into();
let args = docker_network_and_ports(&m, 1).unwrap();
assert_eq!(args, vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]);
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
}
#[test]
fn missing_container_classifier_covers_podman5_phrasings() {
// podman 5.x `inspect` phrasing for a missing container.
+3 -5
View File
@@ -16,11 +16,9 @@ lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
GRASP, repository browser, issue, pull-request, or review interfaces.
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. On 2026-09-30 the lockfile was refreshed again for `brace-expansion`
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node
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
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
install reports zero npm advisories; its type-check, 152 unit tests, and
Archipelago subpath production build pass. Keeping this mechanical security
update separate makes both the upstream integration and future dependency
refreshes auditable.
@@ -1,5 +1,5 @@
diff --git a/package-lock.json b/package-lock.json
index 20631bb..86b6f86 100644
index 20631bb..0933917 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -63,7 +63,7 @@
@@ -495,9 +495,9 @@ index 20631bb..86b6f86 100644
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -678,9 +678,9 @@ index 20631bb..86b6f86 100644
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -833,9 +833,9 @@ index 20631bb..86b6f86 100644
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@@ -859,9 +859,9 @@ index 20631bb..86b6f86 100644
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -893,9 +893,9 @@ index 20631bb..86b6f86 100644
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.7.2",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -1445,3 +1445,4 @@ index bd7190c..6aa5a6f 100644
import { vi } from "vitest";
// Mock window.matchMedia
+18 -60
View File
@@ -989,7 +989,7 @@
// ── State ───────────────────────────────────────────────────────
let unit = 'sats';
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
let peerSort = { col: 'peer', dir: 1 };
let activityFilter = 'all';
let logsLoaded = false;
@@ -1142,19 +1142,9 @@
}
async function refreshAll() {
if (state.refreshing) return;
state.refreshing = true;
const icon = document.getElementById('refreshIcon');
if (icon) icon.classList.add('animate-spin-slow');
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([
lndSafe('/v1/getinfo', null),
lndSafe('/v1/channels', { channels: [] }),
@@ -1176,17 +1166,10 @@
state.invoices = (invoices && invoices.invoices) || [];
state.txns = (txns && txns.transactions) || [];
// Preserve known balances on outage; never decode an error as zero.
const [onchain, chanbal] = await Promise.all([
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;
// Balances are separate so one failing endpoint can't blank the rest.
state.onchain = await lndSafe('/v1/balance/blockchain', null);
state.chanbal = await lndSafe('/v1/balance/channels', null);
} finally {
state.refreshing = false;
if (icon) icon.classList.remove('animate-spin-slow');
}
renderAll();
@@ -1209,17 +1192,11 @@
const pill = document.getElementById('headerStatusPill');
const dot = document.getElementById('headerStatusDot');
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
if (!g || waiting) {
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
pill.className = 'pill warn';
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, '—');
if (!g) {
setText('headerStatusText', 'Unreachable');
pill.className = 'pill bad';
dot.className = 'status-dot-sm bg-red';
document.getElementById('syncCard').style.display = 'none';
return;
}
@@ -1260,11 +1237,6 @@
}
// ── Balances ────────────────────────────────────────────────────
function validBalance(value) {
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
}
function renderBalances() {
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
@@ -1281,23 +1253,22 @@
const haveOnchain = !!state.onchain;
const haveChan = !!cb;
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balLightning', haveChan ? lnLocal : null);
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
setText('balLightningSub', !haveChan ? 'waiting for LND'
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
setText('liqLocal', fmtAmount(lnLocal));
setText('liqRemote', fmtAmount(lnRemote));
const total = lnLocal + lnRemote;
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
document.getElementById('liqBarLocal').style.width = localPct + '%';
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
setText('liqHint', total > 0
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
: 'Open a channel to start sending and receiving over Lightning.');
}
@@ -1313,15 +1284,6 @@
function renderSummary() {
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 active = chans.filter(c => c.active).length;
const inactive = chans.length - active;
@@ -1359,10 +1321,6 @@
function renderChannels() {
const el = document.getElementById('channelList');
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();
let list = state.channels.slice();
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
+4 -49
View File
@@ -3,59 +3,14 @@
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.
## Framework incident — closed with operator acceptance
## Blocking incident — before unrelated work
- **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.
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
User requires investigation and a verified fix on the actual node before later
unrelated work. Access is pending; a manual LND restart is only a workaround.
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
- [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.
PRs remain open; the reviewed code has not 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.
- [ ] **ThinkPad X250 kiosk: Bitcoin installation version selector is unreadable
and appears underneath the pruning information.** Operator reports white
styling with invisible text on the actual kiosk; the same flow works in remote
Brave. Reproduce on the X250's kiosk engine and record its version, display
scale and resolution. Inspect the native `<select>` in
`neode-ui/src/components/InstallVersionModal.vue`, its option colors, and the
scroll/stacking behavior in `BaseModal.vue`; these are investigation leads,
not a confirmed cause. Fix contrast and popup visibility without changing
version selection or pruning behavior. Validate Core and Knots, open/closed
and scrolled dropdowns, keyboard/touch selection, and pruning on/off on the
actual kiosk, with remote Brave and mobile regression checks. Browser mocks
alone do not establish that the kiosk rendering is fixed. Track for the next
release; the signed 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)
- Formalize the contributor workflow: releases, CI, maintainers, automated
-12
View File
@@ -290,15 +290,3 @@ app:
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
with `scripts/generate-app-catalog.py` (drift-checked in CI by
`scripts/check-app-catalog-drift.py`).
### Persistent-state backup for network migrations
`app.backup_on_network_change: true` opts an app into a stopped-state snapshot
before an explicitly selected rootless network mode is migrated. The orchestrator
archives writable persistent bind mounts under the node data directory, collapses
nested mounts, excludes the runtime Podman socket, and preserves the previous
Quadlet definition for rollback. Named volumes, outside-data-root state and
symlinked mount roots fail closed rather than silently producing an incomplete
backup. A failed snapshot resumes the original service and leaves migration
pending. Private archives are retained under `migration-backups/`; fresh installs
and unchanged network configurations do not create migration snapshots.
-104
View File
@@ -1,104 +0,0 @@
# Same-node Gitea sources in Portainer
Status: root cause reproduced and network repair verified in disposable Portainer
instances; final migration integration and release acceptance remain in progress.
This change belongs to the next signed catalog, OTA and ISO. It does not modify
published 1.8.21 artifacts.
## Confirmed cause
On the affected X250, Gitea 1.27.3 and Portainer 2.45.0 run in rootless Podman
5.4.2, managed by user Quadlet services. Gitea publishes HTTP on loopback and the
Archipelago app gate serves its public port. Gitea's public ROOT_URL already
matches that gate URL.
Portainer had no explicit network selection and Podman selected pasta. Its
network namespace contained the host's LAN address. A Git request to that same
LAN address therefore reached Portainer's namespace rather than the host gate:
connection refused before authentication. The exact smart-HTTP request from the
host returned 200 with `application/x-git-upload-pack-advertisement`. From
Portainer's actual namespace the LAN request was refused, while its host mapping
returned a Git advertisement and the expected branch tip. Direct container-IP
requests timed out. Container health and host-only HTTP checks missed the defect.
A disposable Portainer using `slirp4netns` successfully created a Source through
Portainer's own API, using the original LAN clone URL. Returning that fixture to
pasta reproduced the refusal; recreating with slirp repaired it while preserving
its account and saved Source. Restart also passed. The requested branch tip and
Compose file were read from that actual Portainer network namespace. No user
stack was deployed. Deployment addresses and repository details are kept outside
this public record.
## Source changes
- Declare Portainer's rootless `slirp4netns` mode in its manifest. No shared static
container IP, host networking, all-interface backend publication or auth bypass.
- Keep Gitea's loopback HTTP backend and gate port; machine Git uses Gitea's
authentication. Remove obsolete port-3000 nginx metadata/template and the old
best-effort installer commands which silently rewrote app.ini and falsely
claimed success. Gitea owns first-run setup and operator configuration.
- Existing Quadlet reconciliation applies Network= drift. Record a durable
pending restart before updating the unit and clear it only after a successful
restart, so failed reloads/restarts and management interruptions retry.
- Detect explicit rootless network-mode drift in the older Podman runtime too.
Unspecified networks do not trigger inferred changes to unrelated apps.
- Portainer opts into `backup_on_network_change`. Before recreation, gracefully
stop the app and archive its writable persistent bind mounts, including nested
Compose state, once each. Runtime sockets are excluded. Save the previous
Quadlet definition, where present. Archives live under the node data directory's
private `migration-backups/<id>/` directory; state is never deleted. Backup
failures resume the original service and fail the migration visibly.
- Keep Podman API and Quadlet bind/network behavior covered by actual-manifest
tests. Docker remains a development fallback: it now preserves bind/protocol
declarations and rejects Podman-only networking instead of silently changing it.
## Operator use and diagnostics
Use Gitea's advertised HTTP(S) clone URL in Portainer Sources, with the Gitea
username and token in the credential fields. On first-run Gitea setup, the public
base URL must match the origin opened through Archipelago (including its port).
Keep a deliberately configured HTTPS/domain origin when one exists. Do not use a
container IP or put a token into the URL. A private repository requires repository
read permission. A successful Source check fetches Git refs; it does not deploy
a stack or establish that a Compose build uses a desired application revision.
`scripts/check-portainer-git-source.py` calls Portainer's own read-only Source
connection test. Supply a private mode-600 JSON credential file containing
`api_key` or `jwt`, and optionally `git: {username, password}`. Pass
`--portainer-url`, `--repository-url` and `--credentials-file`. It does not create
Sources or stacks and prints no credentials or raw server errors. It distinguishes
Portainer login/API failures from Git connection refusal, timeout, DNS/TLS
failure, HTML/login interception and repository authentication failure. TLS
verification stays enabled and API redirects are refused.
## Upgrade and rollback
The signed catalog embeds manifests and overrides installed disk copies. A disk
edit alone cannot deliver this fix. Publish the matching catalog with the tested
runtime, then verify the generated unit, actual network mode and Source API.
Expect a Portainer interruption while the snapshot and recreation run; duration
depends on its saved state size.
Gitea does not need recreation or an app.ini rewrite for this repair.
Keep the previous trusted catalog/runtime for rollback. Restore that catalog
before restoring the saved `previous.container`, reloading user systemd and
starting Portainer; otherwise reconciliation will correctly reapply the new
manifest. The archive is a stopped-state emergency backup, not an instruction to
roll back a live database automatically. Restore it only with Portainer stopped
and after preserving any newer state. Do not replace Gitea data/config, keys,
repositories or the production Portainer database with disposable test data.
## Validation and remaining gates
- Disposable X250 Portainer Source API: old mode refuses; repaired mode succeeds;
saved account/Source survive recreation; restart succeeds.
- Invalid Git credentials produce a repository-authentication error, distinct
from TCP refusal. Requested branch and Compose file read from Portainer context.
- Final expanded backend suite: 1,575 passed, zero failed, four existing ignored
tests, including stopped-state archive round trips and failure preservation. Container runtime suite: 78 passed.
Five diagnostic regression tests passed. Combined tests with the merged
paid-download PRs remain pending.
- Still required before release: live automatic migration with the new runtime,
snapshot/rollback verification, private-repository and install-order acceptance,
lifecycle/reboot convergence, and signed-catalog delivery to the existing app.
Record LFS/registry/SSH/browser checks and actual hardware/runtime coverage.
+1 -28
View File
@@ -1,6 +1,6 @@
# 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.**
**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.**
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.
@@ -376,30 +376,3 @@ rename the address to disguise the problem. A Minibits server change could use
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.
-113
View File
@@ -1,113 +0,0 @@
# 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.
-397
View File
@@ -1,397 +0,0 @@
# 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,14 +2607,21 @@ if [ -f "$SCRIPT_DIR/../../scripts/image-versions.sh" ]; then
echo " ✅ Bundled image-versions.sh"
fi
# Build-source apps need their complete contexts even on unbundled ISOs.
# Keep this identical to the OTA runtime payload; a per-app allowlist silently
# omitted GitWorkshop, FIPS and Cuprate and made fresh installs fail at 70%.
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
[ -d "$DOCKER_UI_DIR" ] || { echo "Missing docker build sources" >&2; exit 1; }
mkdir -p "$ARCH_DIR/docker"
cp -a "$DOCKER_UI_DIR/." "$ARCH_DIR/docker/"
python3 "$SCRIPT_DIR/../../scripts/check-app-build-contexts.py" "$ARCH_DIR"
# Bundle docker UI source files for building custom UIs on first boot
# Always bundle — these are tiny HTML/CSS files, not container images
if true; then
DOCKER_UI_DIR="$SCRIPT_DIR/../../docker"
if [ -d "$DOCKER_UI_DIR" ]; then
echo " Bundling docker UI source files..."
mkdir -p "$ARCH_DIR/docker"
for ui_dir in bitcoin-ui lnd-ui electrs-ui; do
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
echo " ✅ Unbundled build ready (Tor setup included, no container images)"
@@ -0,0 +1,21 @@
# Gitea iframe proxy — strips X-Frame-Options so Gitea works in Archipelago iframe.
# Gitea container binds to port 3001, this proxy listens on port 3000 (the public port).
# Deployed to /etc/nginx/conf.d/gitea-iframe.conf
server {
listen 3000;
server_name _;
client_max_body_size 1G;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.21-alpha",
"version": "1.8.17-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.21-alpha",
"version": "1.8.17-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.21-alpha",
"version": "1.8.17-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
-1
View File
@@ -42,7 +42,6 @@ export interface PackageVersionsResponse {
pinnedVersion: string | null
autoUpdate: boolean
versions: CatalogVersionInfo[]
bitcoinPrune?: boolean | null
}
export interface AppGatePortStatus {
@@ -1,21 +0,0 @@
<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>
@@ -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"
/>
<p class="text-[11px] text-white/40 mt-1">
Defaults to mempool.space. Any Mempool-compatible instance works — you can change this
Defaults to tx1138.com. Any Mempool-compatible instance works — you can change this
any time in Settings → System.
</p>
</div>
+10 -30
View File
@@ -25,27 +25,16 @@
<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">
<fieldset class="space-y-2">
<legend class="text-white/60 text-sm mb-2">{{ t('appDetails.selectVersion') }}</legend>
<!-- Inline options avoid native popup rendering in the kiosk WebView.
They stay in document flow above the pruning explanation. -->
<div class="max-h-40 overflow-y-auto space-y-2 rounded-lg">
<label
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>
<label class="block text-white/60 text-sm">{{ t('appDetails.selectVersion') }}</label>
<select
v-model="selected"
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white pl-3 pr-9 py-2 text-sm focus:outline-none focus:border-blue-400/60"
>
<option v-for="v in versions" :key="v.version" :value="v.version">{{ optionLabel(v) }}</option>
</select>
<p class="text-white/40 text-xs">{{ t('marketplace.installModalHint') }}</p>
</div>
<BitcoinPruningChoice v-if="isBitcoin && !loading" v-model="prune" />
<template #footer>
<div class="flex gap-2 mt-6">
<button
@@ -69,10 +58,9 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModal from './BaseModal.vue'
import BitcoinPruningChoice from './BitcoinPruningChoice.vue'
import { rpcClient, type CatalogVersionInfo } from '../api/rpc-client'
import { displayVersion } from '@/utils/version'
@@ -85,16 +73,13 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
// Emits the version string the runner chose (e.g. "latest" or "29.3.knots20260508").
confirm: [version: string, prune?: boolean]
confirm: [version: string]
}>()
const { t } = useI18n()
const loading = ref(false)
const versions = ref<CatalogVersionInfo[]>([])
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.
function optionLabel(v: CatalogVersionInfo): string {
@@ -107,22 +92,17 @@ function optionLabel(v: CatalogVersionInfo): string {
async function load() {
loading.value = true
prune.value = false
pruneKnown.value = false
versions.value = []
selected.value = ''
try {
const info = await rpcClient.getPackageVersions(props.appId)
// catalog_versions() returns the list default(=latest)-first, so versions[0]
// is the latest — pre-select it.
pruneKnown.value = typeof info.bitcoinPrune === 'boolean'
prune.value = info.bitcoinPrune === true
versions.value = info.versions || []
selected.value = info.default || versions.value.find((v) => v.default)?.version || versions.value[0]?.version || 'latest'
} catch (err) {
if (import.meta.env.DEV) console.warn('[InstallVersionModal] getPackageVersions failed:', err)
// Fall back to the floating "latest" so the install can still proceed.
versions.value = [{ version: 'latest' } as CatalogVersionInfo]
selected.value = 'latest'
} finally {
loading.value = false
@@ -131,7 +111,7 @@ async function load() {
function confirm() {
if (!selected.value) return
emit('confirm', selected.value, isBitcoin.value && (pruneKnown.value || prune.value) ? prune.value : undefined)
emit('confirm', selected.value)
}
watch(
@@ -185,7 +185,7 @@
@change="saveExplorer"
/>
<p class="text-[11px] text-white/40 mt-1">
Any Mempool-compatible instance works. Default: mempool.space.
Any Mempool-compatible instance works. Default: tx1138.com.
</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">
@@ -1,69 +0,0 @@
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)
})
})
@@ -76,24 +76,6 @@ describe('useTxExplorer.openTx', () => {
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 () => {
containerState = 'not-installed'
const { openTx, pendingTx } = useTxExplorer()
@@ -102,23 +84,3 @@ describe('useTxExplorer.openTx', () => {
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)
})
})
+3 -9
View File
@@ -17,8 +17,8 @@ import { ref } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useContainerStore } from '@/stores/container'
export const DEFAULT_TX_EXPLORER = 'https://mempool.space'
export const EXPLORER_PLACEHOLDER = DEFAULT_TX_EXPLORER
export const DEFAULT_TX_EXPLORER = 'https://tx1138.com'
export const EXPLORER_PLACEHOLDER = 'https://mempool.guide'
const KEY = 'archipelago.tx-explorer.v1'
@@ -30,13 +30,7 @@ interface TxExplorerPrefs {
function loadPrefs(): TxExplorerPrefs {
const defaults: TxExplorerPrefs = { url: DEFAULT_TX_EXPLORER, acknowledged: false }
try {
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
return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }
} catch {
return defaults
}
@@ -34,7 +34,6 @@ vi.mock('@/api/rpc-client', () => ({
vi.stubGlobal('open', mockWindowOpen)
import { useAppLauncherStore, senderMatchesApp } from '../appLauncher'
import { useAppStore } from '../app'
describe('useAppLauncherStore', () => {
beforeEach(() => {
@@ -55,25 +54,6 @@ 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', () => {
const store = useAppLauncherStore()
expect(store.isOpen).toBe(false)
@@ -1,42 +0,0 @@
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)
})
})
+1 -23
View File
@@ -239,11 +239,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
const panelPath = ref<string | null>(null)
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)
const mobile = isMobileViewport()
@@ -300,7 +295,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// 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.
const pkg = useAppStore().data?.['package-data']?.[appId]
if (pkg && (pkg['ui-ready'] === false || (pkg.state === 'running' && !isAppReadyForLaunch(pkg)))) {
if (pkg && pkg.state === 'running' && !isAppReadyForLaunch(pkg)) {
useToast().info(`${pkg.manifest?.title || appId} is still starting — try again in a moment`)
return
}
@@ -399,12 +394,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
let launchUrl = normalizeLaunchUrl(payload.url, 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
// 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
@@ -483,17 +472,6 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
// Check /app/{id}/ path-style routes first (HTTPS proxy mode)
const m = u.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i)
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
const appId = PORT_TO_APP_ID[u.port]
if (appId) return appId
+1 -2
View File
@@ -25,7 +25,6 @@ import type { InstallPhase } from '../types/api'
const PHASE_INFO: Record<InstallPhase, { progress: number; message: string; status: InstallProgress['status'] }> = {
'preparing': { progress: 5, message: 'Preparing…', 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' },
'starting-container': { progress: 80, message: 'Starting container…', status: 'starting' },
'waiting-healthy': { progress: 88, message: 'Finalizing first start…', status: 'starting' },
@@ -150,7 +149,7 @@ export const useServerStore = defineStore('server', () => {
uninstallingApps.value.delete(appId)
}
}
}, { deep: true, immediate: true })
}, { deep: true })
function setInstallProgress(appId: string, progress: Partial<InstallProgress> & { id: string; title: string }) {
const existing = installingApps.value.get(appId)
-2
View File
@@ -88,7 +88,6 @@ export const PackageState = {
export type PackageState = typeof PackageState[keyof typeof PackageState]
export interface PackageDataEntry {
'ui-ready'?: boolean // HTTP upstream readiness, separate from container health
state: PackageState
health?: string | null // "healthy", "unhealthy", "starting", or null
'exit-code'?: number | null // container exit code: 0 = clean stop, non-zero = crash
@@ -181,7 +180,6 @@ export type ServiceStatus = typeof ServiceStatus[keyof typeof ServiceStatus]
export type InstallPhase =
| 'preparing'
| 'pulling-image'
| 'preparing-app'
| 'creating-container'
| 'starting-container'
| 'waiting-healthy'
+1 -1
View File
@@ -259,7 +259,7 @@ const canLaunch = computed(() => {
const hasRuntimeAddress = !!pkg.value.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.value.manifest.id)
const hasUI = !!(pkg.value.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl)
return hasUI && pkg.value['ui-ready'] !== false && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
return hasUI && pkg.value.state === 'running' && pkg.value.health !== 'starting' && pkg.value.health !== 'unhealthy'
})
const features = computed(() => {
-18
View File
@@ -39,7 +39,6 @@
:must-open-new-tab="mustOpenNewTab"
:auto-retry-count="autoRetryCount"
:refresh-key="refreshKey"
:ui-ready-blocked="packageEntry?.['ui-ready'] === false"
:blocked-reason="blockedReason"
:blocked-title="blockedTitle"
:warming-up="warmingUp"
@@ -376,20 +375,6 @@ const panelClasses = computed(() => {
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 ---
function onLoad() {
@@ -448,7 +433,6 @@ function refresh() {
function startLoadTimeout() {
if (loadTimeoutId) clearTimeout(loadTimeoutId)
if (packageEntry.value?.['ui-ready'] === false) return
loadTimeoutId = setTimeout(() => {
if (loading.value) {
loading.value = false
@@ -458,13 +442,11 @@ function startLoadTimeout() {
}
function openNewTabAndBack() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value)
closeSession()
}
function openNewTab() {
if (packageEntry.value?.['ui-ready'] === false) return
if (appUrl.value) openExternalUrl(appUrl.value)
}
+9 -14
View File
@@ -672,11 +672,6 @@ async function handleInstall(app: MarketplaceApp) {
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
// front via a full-screen modal (latest pre-selected) instead of silently
// installing the default. Best-effort — if the lookup fails we install directly.
@@ -691,19 +686,19 @@ async function handleInstall(app: MarketplaceApp) {
startInstall(app)
}
function startInstall(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
function startInstall(app: MarketplaceApp, versionOverride?: string) {
if (app.source === 'local') {
installApp(app, versionOverride, prune)
installApp(app, versionOverride)
} else {
installCommunityApp(app, versionOverride, prune)
installCommunityApp(app, versionOverride)
}
}
function onInstallModalConfirm(version: string, prune?: boolean) {
function onInstallModalConfirm(version: string) {
const app = installModalApp.value
showInstallModal.value = false
installModalApp.value = null
if (app) startInstall(app, version, prune)
if (app) startInstall(app, version)
}
function viewAppDetails(app: MarketplaceApp) {
@@ -779,25 +774,25 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { serverStore.clearInstallProgress(app.id) }, 5000)
}
async function installApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
async function installApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
queueInstall(app)
installToast(app)
try {
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }, timeout: 600000 })
await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: versionOverride || app.version }, timeout: 600000 })
} catch (err) {
if (import.meta.env.DEV) console.error('Installation failed:', err)
failInstall(app, err)
}
}
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
queueInstall(app)
installToast(app)
try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version }
if ((app as Record<string, unknown>).containerConfig) {
installParams.containerConfig = (app as Record<string, unknown>).containerConfig
}
+6 -43
View File
@@ -137,7 +137,7 @@
:tier-label="getAppTier(app.id)"
:install-blocked-reason="installBlockedReason(app.id)"
@view="viewAppDetails"
@install="handleInstall(app)"
@install="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
@launch="launchInstalledApp"
/>
</div>
@@ -153,13 +153,7 @@
</div>
</div>
<!-- End Scrollable Apps Section -->
<InstallVersionModal
:show="showInstallModal"
:app-id="installModalApp?.id || ''"
:app="installModalApp"
@close="showInstallModal = false; installModalApp = null"
@confirm="onInstallModalConfirm"
/>
</div>
</template>
@@ -181,13 +175,11 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
import {
type MarketplaceApp,
INSTALLED_ALIASES,
MULTI_VERSION_APP_IDS,
getAppTier,
categorizeCommunityApp,
getCuratedAppList,
@@ -214,8 +206,6 @@ const appStoreSections = computed(() => APP_STORE_SECTIONS)
// Installation state — uses global store so it persists across navigation
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'
function installToast(app: MarketplaceApp) {
@@ -528,34 +518,7 @@ function failInstall(app: MarketplaceApp, err: unknown) {
trackTimeout(() => { server.clearInstallProgress(app.id) }, 5000)
}
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) {
async function installApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id)) return
const blocked = installBlockedReason(app.id)
if (blocked) {
@@ -573,7 +536,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string, prune?:
const installUrl = app.url || app.manifestUrl || app.s9pkUrl
await rpcClient.call({
method: 'package.install',
params: { id: app.id, url: installUrl, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) },
params: { id: app.id, url: installUrl, version: app.version },
timeout: 600000,
})
} catch (err) {
@@ -582,7 +545,7 @@ async function installApp(app: MarketplaceApp, versionOverride?: string, prune?:
}
}
async function installCommunityApp(app: MarketplaceApp, versionOverride?: string, prune?: boolean) {
async function installCommunityApp(app: MarketplaceApp) {
if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return
const blocked = installBlockedReason(app.id)
if (blocked) {
@@ -595,7 +558,7 @@ async function installCommunityApp(app: MarketplaceApp, versionOverride?: string
installToast(app)
try {
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: versionOverride || app.version, ...(prune === undefined ? {} : { prune }) }
const installParams: Record<string, unknown> = { id: app.id, dockerImage: app.dockerImage, version: app.version }
if (app.containerConfig) installParams.containerConfig = app.containerConfig
await rpcClient.call({
method: 'package.install',
+3 -20
View File
@@ -74,7 +74,7 @@
<button
v-if="!isInstalled"
@click="installApp"
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
: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"
>
@@ -90,12 +90,6 @@
</div>
</div>
<BitcoinPruningChoice
v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded"
v-model="pruneOnInstall"
class="hidden md:block"
/>
<!-- Mobile: Two Column Grid Layout -->
<div class="md:hidden">
<!-- Top: Icon + Info -->
@@ -143,7 +137,6 @@
{{ $ver(v.version) }}{{ v.default ? ' — latest' : '' }}{{ v.deprecated ? ' (deprecated)' : '' }}
</option>
</select>
<BitcoinPruningChoice v-if="!isInstalled && isBitcoinInstall && prunePrefsLoaded" v-model="pruneOnInstall" class="mb-4" />
<!-- Bottom: Action Buttons -->
<div class="grid grid-cols-2 gap-2">
@@ -160,7 +153,7 @@
<button
v-else
@click="installApp"
:disabled="demoNoInstall || installing || (isBitcoinInstall && !prunePrefsLoaded) || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
:disabled="demoNoInstall || installing || (!installBlockedReason && !app.manifestUrl && !app.dockerImage)"
: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"
>
@@ -381,7 +374,6 @@
</template>
<script setup lang="ts">
import BitcoinPruningChoice from '@/components/BitcoinPruningChoice.vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { IS_DEMO, isDemoApp } from '@/composables/useDemoIntro'
import { useRouter, useRoute } from 'vue-router'
@@ -416,10 +408,6 @@ const bitcoinPruned = ref(false)
// 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 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 electrumxArchiveWarning = 'You need a full archival bitcoin node before downloading ElectrumX'
@@ -599,13 +587,10 @@ onMounted(() => {
// cached entry is missing or past its TTL, so a repeat open inside the TTL
// paints from cache with no new RPC.
async function loadInstallVersions() {
if (isBitcoinInstall.value || versionsResource.data.value === null || versionsResource.isStale.value) {
if (versionsResource.data.value === null || versionsResource.isStale.value) {
await versionsResource.refresh()
}
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) {
installVersions.value = []
return
@@ -716,7 +701,6 @@ async function installApp() {
id: app.value.id,
dockerImage: app.value.dockerImage,
version: chosenVersion,
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
}
if (app.value.containerConfig) installParams.containerConfig = app.value.containerConfig
await rpcClient.call({
@@ -733,7 +717,6 @@ async function installApp() {
id: app.value.id,
url: installUrl,
version: chosenVersion,
...(isBitcoinInstall.value && (pruneSettingKnown.value || pruneOnInstall.value) ? { prune: pruneOnInstall.value } : {}),
},
timeout: 600000,
})
@@ -2,9 +2,6 @@ import { flushPromises, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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
// mounting a view directly with its heavier deps mocked at the module
@@ -47,37 +44,22 @@ vi.mock('@/composables/useMarketplaceApp', () => ({
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock, action: vi.fn() }),
useToast: () => ({ success: vi.fn(), error: toastErrorMock, info: toastInfoMock }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
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)', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() })))
routerPushMock.mockClear()
toastErrorMock.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(() => {
@@ -107,38 +89,4 @@ describe('Marketplace tracer tab: background refresh failure (D-07)', () => {
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()
})
})
@@ -6,7 +6,7 @@
first, then sync status arrives), and the sync screen is strictly
more informative, so it takes precedence instead of the two
rendering on top of each other. -->
<AppLoadingScreen v-if="loading && !uiReadyBlocked && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
<AppLoadingScreen v-if="loading && !(electrsSync && !electrsSync.stale)" :icon="appIcon" :title="appTitle" :progress="loadProgress" />
</Transition>
<!-- ElectrumX sync screen — shown before the real UI while the on-chain
@@ -43,7 +43,7 @@
</Transition>
<div
v-if="appUrl && !iframeBlocked && !uiReadyBlocked && (!electrsSync || electrsSync.stale)"
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
@@ -66,7 +66,7 @@
reachable yet, so the "App not reachable / retry" overlay would just
paint over the sync progress and read as a hard error. -->
<Transition name="content-fade">
<div v-if="(iframeBlocked || uiReadyBlocked) && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div v-if="iframeBlocked && !electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8">
<!-- Warm-up uses the app's own icon, pulsing, rather than the padlock:
the padlock reads as "blocked/denied" and this state is neither. -->
@@ -78,8 +78,7 @@
</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>
<p class="text-white/50 text-sm mb-6">
<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-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="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>
@@ -96,7 +95,6 @@
Retry now
</button>
<button
v-if="!uiReadyBlocked"
@click="$emit('openNewTabAndBack')"
class="glass-button px-6 py-3 rounded-lg text-sm font-semibold inline-flex items-center gap-2"
>
@@ -110,7 +108,7 @@
</div>
</Transition>
<div v-if="!appUrl && !uiReadyBlocked" class="absolute inset-0 flex items-center justify-center">
<div v-if="!appUrl" class="absolute inset-0 flex items-center justify-center">
<div class="text-center px-8">
<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>
@@ -135,7 +133,6 @@ const props = defineProps<{
mustOpenNewTab: boolean
autoRetryCount: number
refreshKey: number
uiReadyBlocked?: boolean
blockedReason?: string
blockedTitle?: string
// True while the container is up but its probe hasn't answered yet and the
@@ -66,19 +66,3 @@ describe('AppSessionFrame warm-up state', () => {
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,24 +184,3 @@ describe('appsConfig service filtering', () => {
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)
})
})
-6
View File
@@ -244,7 +244,6 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean {
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.
if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
@@ -278,7 +277,6 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
* health check retain the legacy state/port behaviour.
*/
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 hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
@@ -287,10 +285,6 @@ export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
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 (
(appId === 'fedimint' || appId === 'fedimintd') &&
(pkg?.state === PackageState.Starting || (pkg?.state === PackageState.Running && pkg?.health === 'starting'))
@@ -362,60 +362,6 @@ init()
</button>
</div>
<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 -->
<div>
<div class="flex items-center gap-2 mb-3">
+18 -18
View File
@@ -1,30 +1,30 @@
{
"changelog": [
"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."
"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 polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
"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.",
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
],
"components": [
{
"current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.21-alpha",
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
"size_bytes": 64748176
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
},
{
"current_version": "1.8.21-alpha",
"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.21-alpha.tar.gz",
"new_version": "1.8.21-alpha",
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
"size_bytes": 97152546
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
"new_version": "1.8.17-alpha",
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
"size_bytes": 98801608
}
],
"release_date": "2026-09-30",
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
"release_date": "2026-09-15",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.21-alpha"
"version": "1.8.17-alpha"
}
+5 -66
View File
@@ -618,7 +618,7 @@
},
"container": {
"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 [ \"${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"
"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"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -768,7 +768,7 @@
},
"container": {
"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 [ \"${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"
"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"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -1378,67 +1378,6 @@
},
"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": {
"image": "source.archipelago-foundation.org/lfg2025/electrs-ui:1.7.123-alpha",
"manifest": {
@@ -5525,7 +5464,7 @@
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"schema": 1,
"signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07",
"signature": "e716a9069021af87a2252d7561c01153f17c5630d7c36d8fdc1be1c7aa40560d09557513c0e09835a6b76b52b4f7edf0619cbaff02ac1d9d818066f67046e401",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"storefront": {
"popular": [
@@ -5546,10 +5485,10 @@
"id": "archipelago-source",
"installLabel": "Install GitWorkshop",
"launchLabel": "Open GitWorkshop",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy",
"tag": "NGIT // NOSTR // NO SILO"
}
]
},
"updated": "2026-09-29"
"updated": "2026-09-15"
}
+18 -18
View File
@@ -1,30 +1,30 @@
{
"changelog": [
"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."
"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 polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.",
"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.",
"Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs."
],
"components": [
{
"current_version": "1.8.21-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago",
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.21-alpha",
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb",
"size_bytes": 64748176
"new_version": "1.8.17-alpha",
"sha256": "32a7b009eb58f8c9f256e6597711a77ded11e15d5865a3fe16901603264e1f70",
"size_bytes": 64953344
},
{
"current_version": "1.8.21-alpha",
"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.21-alpha.tar.gz",
"new_version": "1.8.21-alpha",
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620",
"size_bytes": 97152546
"current_version": "1.8.17-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.17-alpha/archipelago-frontend-1.8.17-alpha.tar.gz",
"name": "archipelago-frontend-1.8.17-alpha.tar.gz",
"new_version": "1.8.17-alpha",
"sha256": "faf692e9a0e16268357bcac2bf86b62950ae49663e3c95982e54a132bb761980",
"size_bytes": 98801608
}
],
"release_date": "2026-09-30",
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d",
"release_date": "2026-09-15",
"signature": "c8196fe278a5747b3c3ba3bf70998874f1e3e6eedbdab33b9e33c3339a3769ab4431f41d99924ec4cdd15a5ffed299a5af786c7ab5e9d084cdc11beabbee9103",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.21-alpha"
"version": "1.8.17-alpha"
}
+4 -3
View File
@@ -112,9 +112,10 @@ VERSION="$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*
if [ "$SKIP_GATES" = "0" ]; then
stage "release-gate-harness" bash tests/release/run.sh
stage "catalog-drift-strict" python3 scripts/check-app-catalog-drift.py --release --strict
# The release harness runs the full backend suite inside namespaces.
# Never execute unrestricted tests on a node with live wallets/services.
# Full Rust suite — the release harness only runs a 6-module slice;
# ~1000 tests otherwise go unverified at ISO time (hardening plan §H).
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
echo; echo "═══ [gates] SKIPPED (--skip-gates)"
fi
-39
View File
@@ -1,39 +0,0 @@
#!/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.')
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""Test Git from Portainer's server context without creating a Source or stack."""
import argparse
import json
import pathlib
import socket
import stat
import urllib.error
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def classify(error):
text = error.lower()
for category, patterns in (
('connection-refused', ('connection refused',)),
('dns-failure', ('no such host', 'name resolution', 'server misbehaving')),
('timeout', ('timeout', 'timed out', 'deadline exceeded')),
('tls-failure', ('x509:', 'certificate', 'tls handshake')),
('proxy-or-login-interception', ('text/html', '<html', '<!doctype', 'unexpected content-type', 'invalid pkt-len')),
('repository-authentication', ('authentication required', 'authentication failed', 'authorization failed', '401', '403')),
('repository-not-found-or-private', ('repository not found', '404')),
):
if any(pattern in text for pattern in patterns):
return category
return 'git-error'
def safe_url(value):
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
raise ValueError('Use an HTTP(S) URL')
if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment:
raise ValueError('URLs must not contain credentials, query parameters or fragments')
return value.rstrip('/')
def private_json(path):
path = pathlib.Path(path)
if stat.S_IMODE(path.stat().st_mode) & 0o077:
raise ValueError('Credential file must be private (chmod 600)')
return json.loads(path.read_text())
def check(base, repository, credentials, opener=None):
base, repository = safe_url(base), safe_url(repository)
# JWT/API keys and Git credentials travel in headers/body, never URLs or logs.
headers = {'Content-Type': 'application/json'}
if credentials.get('api_key'):
headers['X-API-Key'] = credentials['api_key']
elif credentials.get('jwt'):
headers['Authorization'] = 'Bearer ' + credentials['jwt']
else:
raise ValueError('Credential file needs api_key or jwt')
payload = {'url': repository, 'tlsSkipVerify': False, 'interval': '5m'}
if credentials.get('git'):
payload['authentication'] = credentials['git']
request = urllib.request.Request(base + '/api/gitops/sources/test',
data=json.dumps(payload).encode(), headers=headers)
opener = opener or urllib.request.build_opener(NoRedirect())
try:
with opener.open(request, timeout=45) as response:
result = json.load(response)
except urllib.error.HTTPError as error:
return {'success': False, 'category': 'portainer-authentication' if error.code in (401, 403) else 'portainer-api-error', 'http_status': error.code}
except (urllib.error.URLError, TimeoutError, socket.timeout) as error:
return {'success': False, 'category': 'portainer-api-' + classify(str(error))}
except (ValueError, UnicodeError):
return {'success': False, 'category': 'portainer-api-invalid-response'}
if not isinstance(result, dict) or not isinstance(result.get('success'), bool):
return {'success': False, 'category': 'portainer-api-invalid-response'}
return {'success': result['success'], 'category': 'git-refs-readable' if result['success'] else classify(str(result.get('error', '')))}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--portainer-url', required=True, help='Reachable Portainer origin, without /api')
parser.add_argument('--repository-url', required=True, help='The same clone URL entered in Portainer')
parser.add_argument('--credentials-file', required=True, help='Mode 600 JSON: api_key or jwt; optional git: {username,password}')
args = parser.parse_args()
try:
result = check(args.portainer_url, args.repository_url, private_json(args.credentials_file))
except (OSError, ValueError):
parser.exit(2, 'Invalid URL or private credential file; no credentials were printed.\n')
print(json.dumps(result))
return 0 if result['success'] else 1
if __name__ == '__main__':
raise SystemExit(main())
+11 -42
View File
@@ -47,33 +47,13 @@ podman_rootless() {
}
port_is_listening() {
local port="$1" protocol="${2:-tcp}" listeners
local port="$1"
local protocol="${2:-tcp}"
case "$protocol" in
tcp) listeners=$(ss -ltn 2>/dev/null) || return 2 ;;
udp) listeners=$(ss -lun 2>/dev/null) || return 2 ;;
*) return 2 ;;
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
tcp) ss -ltn 2>/dev/null ;;
udp) ss -lun 2>/dev/null ;;
*) return 1 ;;
esac | awk '{print $4}' | grep -Eq "(^|:)$port$"
}
run_fix() {
@@ -593,27 +573,19 @@ 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)
[ -n "$bindings" ] || continue
local missing=() inspection_failed=false status
local missing=()
local container_binding host_port protocol
while read -r container_binding host_port; do
[ -n "$container_binding" ] && [ -n "$host_port" ] || continue
protocol="${container_binding##*/}"
status=0
port_is_listening "$host_port" "$protocol" || status=$?
case "$status" in
0) ;;
1) missing+=("$host_port/$protocol") ;;
*) inspection_failed=true ;;
esac
if ! port_is_listening "$host_port" "$protocol"; then
missing+=("$host_port/$protocol")
fi
done <<< "$bindings"
if $inspection_failed; then
log "WARN: cannot inspect listeners for $name; leaving it running"
continue
fi
if [ ${#missing[@]} -gt 0 ]; then
log "Restarting $name: missing rootlessport listener(s): ${missing[*]}"
if restart_rootless_container "$name" >/dev/null 2>&1; then
if podman_rootless restart "$name" >/dev/null 2>&1; then
fixed=true
else
log "WARN: failed to restart $name for missing rootlessport listener(s)"
@@ -704,9 +676,6 @@ fix_archipelago_dialout() {
# ── Main ─────────────────────────────────────────────────────
# Allow regression tests to source helpers without running repairs.
[[ "${BASH_SOURCE[0]}" != "$0" ]] && return 0
# If remote host provided, run via SSH
if [ -n "$1" ] && [ "$1" != "--local" ]; then
REMOTE_HOST="$1"
+8 -11
View File
@@ -78,17 +78,15 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
STAGING_DIR=$(mktemp -d -t archipelago-frontend.XXXXXX)
echo "Staging frontend archive in $STAGING_DIR..."
cp -r "$FRONTEND_DIST/." "$STAGING_DIR/"
# create-release.sh folds the freshly built AIUI into FRONTEND_DIST.
# Never overlay it with the older demo bundle (or nest aiui/aiui/).
if [ ! -f "$STAGING_DIR/aiui/index.html" ] || \
[ ! -f "$STAGING_DIR/aiui/BUILD-INFO" ]; then
echo "Error: fresh AIUI payload missing from frontend dist" >&2
exit 1
# Bake AIUI in so fresh installs pick it up. OTA already
# carries-forward the existing aiui/ if the tarball lacks one
# (update.rs:922), but including it here makes the tarball
# the single source of truth instead of relying on a side-
# effect of the in-place swap.
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
echo " Including AIUI from demo/aiui/"
cp -r "$PROJECT_ROOT/demo/aiui" "$STAGING_DIR/aiui"
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
# apply the backend binary and frontend archive. Carry host runtime
# assets inside the frontend tarball; the new backend promotes them
@@ -101,7 +99,6 @@ if [ -z "$FRONTEND_ARCHIVE" ]; then
cp -r "$PROJECT_ROOT/$runtime_path" "$RUNTIME_DIR/$runtime_path"
fi
done
python3 "$PROJECT_ROOT/scripts/check-app-build-contexts.py" "$RUNTIME_DIR"
# KEEP IN SYNC with the `for unit in [...]` array in
# core/archipelago/src/bootstrap.rs (run_runtime_assets). A unit that
# bootstrap installs but this list does not ship simply never reaches a
+9 -5
View File
@@ -169,11 +169,15 @@ else
fi
cd "$PROJECT_ROOT"
# Build AIUI from the same source as the release. The checked-in demo bundle
# can predate source fixes and must never overwrite the production payload.
bash "$SCRIPT_DIR/build-aiui.sh"
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
cp -r "$PROJECT_ROOT/aiui/packages/app/dist" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
# bakes it from demo/aiui independently, but build-iso-release.sh's
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
# consecutive releases (.127, .129) because this fold-in was manual.
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
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
# dist would ship with a perfectly valid sha256. Require the freshly built
-7
View File
@@ -64,13 +64,6 @@ for f in live/vmlinuz live/initrd.img live/filesystem.squashfs \
fi
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 ───────────────────────────────────
if grep -q "boot=live" "$MNT/boot/grub/grub.cfg" 2>/dev/null; then
ok "grub.cfg has boot=live"
-52
View File
@@ -1,52 +0,0 @@
#!/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
case "${ARCHY_TEST_PACKAGE:-archipelago}" in
archipelago) test_target=(-p archipelago --bin archipelago) ;;
archipelago-container) test_target=(-p archipelago-container --lib) ;;
*) echo 'Unsupported isolated test package' >&2; exit 2 ;;
esac
if ! cargo test --manifest-path "$REPO/core/Cargo.toml" "${test_target[@]}" \
--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 "$@"
-50
View File
@@ -1,50 +0,0 @@
#!/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()
@@ -1,33 +0,0 @@
#!/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()

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