Verify recovery support before spending and route node catalogs through nginx

This commit is contained in:
archipelago
2026-10-06 17:42:11 -04:00
parent 4228ce443c
commit e3775771ca
8 changed files with 152 additions and 10 deletions
+29 -3
View File
@@ -83,7 +83,7 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
/// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location ~ ^/api/(?:app-catalog|node-app-catalog)$ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_SOURCE_PROXY_BLOCK: &str = " # GitWorkshop follows the dashboard origin so LAN, Tailscale, FIPS, Tor,\n # hostnames and reverse proxies all use the connection that already works.\n location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n }\n";
@@ -1783,6 +1783,14 @@ fn heal_missing_nostr_signer(content: &str) -> Option<String> {
.then(|| content.replace(anchor, &format!("{}{}", NGINX_NOSTR_SIGNER_BLOCK, anchor)))
}
/// Keep both authenticated catalog endpoints on the backend in every vhost.
fn heal_node_catalog_route(content: &str) -> String {
content.replace(
"location /api/app-catalog {",
"location ~ ^/api/(?:app-catalog|node-app-catalog)$ {",
)
}
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
@@ -1795,7 +1803,8 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
|| content.contains(" location /electrs-status {");
let missing_app_catalog = content
.contains(" # DWN endpoints — peer access over Tor (no auth)")
&& !content.contains("location /api/app-catalog");
&& !content.contains("location /api/app-catalog")
&& !content.contains("location ~ ^/api/(?:app-catalog|node-app-catalog)$ {");
let missing_bitcoin_status = content.contains(" location /electrs-status {")
&& !content.contains("location /bitcoin-status");
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
@@ -1816,7 +1825,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
let missing_source_proxy = heal_missing_source_proxy(&content).is_some();
let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some();
let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some();
let legacy_catalog_route = content.contains("location /api/app-catalog {");
if !missing_app_catalog
&& !legacy_catalog_route
&& !missing_bitcoin_status
&& !missing_lnd_proxy
&& !missing_peer_content
@@ -1833,7 +1844,7 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
return Ok(false);
}
let mut patched = content.clone();
let mut patched = heal_node_catalog_route(&content);
if let Some(p) = heal_stale_web_search_block(&patched) {
patched = p;
@@ -2012,6 +2023,21 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
#[cfg(test)]
mod tests {
#[test]
fn catalog_routes_upgrade_both_vhosts_without_changing_access_guards() {
let old = "server { if ($guard) { return 404; } location /api/app-catalog { proxy_pass http://127.0.0.1:5678; } }\nserver { location /api/app-catalog { proxy_set_header Cookie $http_cookie; } }";
let fixed = super::heal_node_catalog_route(old);
assert_eq!(fixed.matches("location ~ ^/api/(?:app-catalog|node-app-catalog)$ {").count(), 2);
assert!(fixed.contains("if ($guard) { return 404; }"));
assert!(fixed.contains("proxy_set_header Cookie $http_cookie;"));
assert_eq!(super::heal_node_catalog_route(&fixed), fixed);
let route = regex::Regex::new(r"^/api/(?:app-catalog|node-app-catalog)$").unwrap();
assert!(route.is_match("/api/node-app-catalog"));
assert!(route.is_match("/api/app-catalog"));
assert!(!route.is_match("/api/node-app-catalog/extra"));
assert!(!route.is_match("/api/unrelated"));
}
use super::*;
#[tokio::test]
+11 -2
View File
@@ -849,10 +849,19 @@ pub async fn send_token_recoverable(
} else {
let mut denominations = amount_to_denominations(amount_sats);
denominations.extend(amount_to_denominations(excess));
let prepared = mint_client(data_dir, &binding.mint_url)
.await?
let client = mint_client(data_dir, &binding.mint_url).await?;
let prepared = client
.prepare_swap_at_least(&proofs, &denominations, amount_sats)
.await?;
// Establish recovery support before reserving or spending inputs.
// Newly derived outputs must not already exist at the mint.
let existing = client.restore_prepared_swap(&prepared).await.map_err(|_| {
anyhow::anyhow!("The mint could not verify payment recovery support; no funds spent")
})?;
anyhow::ensure!(
existing.is_none(),
"Payment outputs already exist at the mint; no funds spent"
);
Request::Swap(prepared)
};
journal.prepare(binding.clone(), request).await?
+2 -2
View File
@@ -991,13 +991,13 @@ impl MintClient {
let echoed: Vec<BlindedMessageRequest> = serde_json::from_value(
body.get("outputs")
.cloned()
.unwrap_or(serde_json::json!([])),
.context("Mint restore response omitted outputs")?,
)
.context("Failed to parse restored outputs")?;
let signatures: Vec<BlindSignature> = serde_json::from_value(
body.get("signatures")
.cloned()
.unwrap_or(serde_json::json!([])),
.context("Mint restore response omitted signatures")?,
)
.context("Failed to parse restored signatures")?;
@@ -717,6 +717,32 @@ async fn journal_recovers_lost_swap_reply_and_commits_change_once() {
);
}
#[tokio::test]
async fn recoverable_send_rejects_broken_recovery_before_reserving_or_spending() {
let mint = Mint::start(0, None).await;
let root = tempfile::tempdir().unwrap();
let mut wallet = WalletState::default();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]);
save_wallet(root.path(), &wallet).await.unwrap();
*mint.restore_reply.lock().unwrap() = Some(json!({}));
let id = uuid::Uuid::new_v4().to_string();
let context = "ab".repeat(32);
let error = send_token_recoverable(
root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context,
).await.unwrap_err();
assert!(error.to_string().contains("recovery support"));
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8);
assert!(mint.requests.lock().unwrap().is_empty());
// Once the mint responds correctly the same unspent operation can proceed.
*mint.restore_reply.lock().unwrap() = None;
assert!(send_token_recoverable(
root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context,
).await.is_ok());
assert_eq!(mint.requests.lock().unwrap().len(), 1);
assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4);
}
#[tokio::test]
async fn recoverable_send_reuses_original_operation_after_ambiguous_mint_response() {
let mint = Mint::start(0, None).await;
@@ -916,6 +942,10 @@ async fn seed_restore_matches_points_and_rejects_foreign_or_duplicated_metadata(
let mut wrong_amount = signature.clone();
wrong_amount["amount"] = json!(8);
for response in [
json!({}),
json!({"outputs":[]}),
json!({"signatures":[]}),
json!({"outputs":null,"signatures":[]}),
json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}),
json!({"outputs":[foreign],"signatures":[signature.clone()]}),
json!({"outputs":[output.clone()],"signatures":[wrong_keyset]}),
+60 -1
View File
@@ -1,6 +1,7 @@
# Node-scoped demo apps and persistent media
Status: implementation under qualification; **not deployed or accepted**.
Status: managed demo deployed to the authorized node on6October; playback and
full lifecycle acceptance remain open. This is not a global catalog release.
The V4V demo is restricted to Yaya. The global catalog and other nodes must not
receive an install button or banner for this prototype. Its manifest lives in
@@ -104,3 +105,61 @@ node catalog, copied-data installation, lifecycle checks, physical companion
checks and final dashboard cold-launch regression remain required. Deployment
writes to dev and Yaya are paused because another session installed a mining
candidate on both nodes; reconcile source before replacing either build.
## Signed managed installation — 6October
The operator signed the prepared node-only catalog. Pinned-root verification
passed, and canonical payload comparison matched the reviewed unsigned file.
The signer reordered JSON keys; raw-file reconstruction was not a valid payload
comparison. Neither catalog contents nor its audience were changed.
A fresh consistent backup preserved the original demo data/media and password
hash. The unused managed volumes were refreshed and verified byte-for-byte with
ownership/modes retained. An initial copy attempt found rsync unavailable; the
copy was completed using the standard library before catalog activation. The
original demo remains running on its original port with its volumes untouched.
Catalog activation preserved the backend binary, session key and all preexisting
app container identities/start times. The first public endpoint check exposed
missing nginx routing: the SPA returned HTML for the node catalog. Both dashboard
vhosts now route the two exact catalog endpoint names to the authenticated
backend. The original nginx configuration was backed up and nginx validation and
reload passed. Source template and upgrade repair are updated; their new backend
regression run is pending. Public management guards were not modified.
Both HTTP and HTTPS catalog checks return401 without authentication and200 with
the existing owner session. HTTPS diagnostics ignored the previously documented
legacy certificate trust problem; ordinary browser trust is not claimed fixed.
The signed response contains only the node-demo-v4v entry. The initial manual RPC
omitted required dockerImage and failed before creating the app; the corrected
normal install request used the exact image from the signed manifest.
The installed app reports running/ui-ready and its container health endpoint
returns200. A real deployed dashboard browser, with no catalog/package fixtures,
shows the Sovereign Music listing and launches the retained-password login screen
at390px with no app-gate screen. The operator login/song check has been requested.
Managed restart, playback controls, desktop/browser/companion acceptance and
audience/lifecycle checks remain open until their results are recorded.
Qualification update: normal managed restart returned to running/ui-ready with
container health200; the original demo remained running. Desktop1440px also
passes real listing/launch-to-login checks. The full isolated backend suite for
recovery preflight and catalog route migration passed1,785tests, zero failures,
five existing skips (`/tmp/archy-node-catalog-route-tests.log`).
### Operator changes and live player regression
The operator now explicitly requests Nostr sign-in and native signer integration
instead of the alpha password mode. This supersedes the earlier instruction to
omit native signing for this demo. Preserve cryptographic login and user consent;
qualify actual platform signer, cancellation, logout, browser and companion flows.
A newly qualified app image/manifest and node-only catalog signature are required.
The operator reports music continues after closing the deployed app but the native
bottom player does not appear. Earlier fixture tests do not close this real
installation regression. Test the actual signed catalog and package state, close,
controls and reopening the same frame before accepting the repair.
The requested promotion uses the final intro cymatic still as its background,
with a music/play graphic on the right or the app's For You banner treatment.
The previously captured login background does not satisfy this updated request.
+7
View File
@@ -289,3 +289,10 @@ zero failures and five existing skips:
`/tmp/archy-recoverable-send-executor-final-tests.log`.
No real payment or deployment was performed. Purchase RPC integration, durable
seller settlement/receipt recovery and the end-to-end acceptance remain open.
Recovery preflight now rejects malformed or unsupported restore responses before
reserving or spending inputs, and refuses already-issued newly derived outputs.
Required restore fields cannot silently default to empty. The malformed-response
regression verifies unchanged spendable balance/no swap, then successful retry
when the mint responds correctly. Full isolated1,784passed initially; combined
catalog-route qualification1,785passed, zero failures/five existing skips.
+11
View File
@@ -432,6 +432,17 @@ functional/UX review. These are additions to existing groups, not closed work.
the selected relationship; describe exactly what changed.
- Anchor removal buttons at card bottoms. Show an immediate per-action spinner,
prevent duplicate submissions, and show an accurate completion/error toast.
- Add a bottom-anchored Network map button to the Connected Nodes container,
linking directly to the network map screen on desktop and mobile.
- Include peer and trusted-node counts in the Connected Nodes top summary row,
with explicit labels and a compact responsive layout. Derive counts from the
authoritative relationship/trust state, update them automatically, and avoid
double-counting identities in the overall node total when categories overlap.
- Lay out these card-footer actions for mobile as well as desktop: maintain
bottom alignment within the card, clear labels, comfortable tap targets and
consistent spacing. Stack actions when needed instead of squeezing them;
prevent clipping, overlap and horizontal overflow. Check narrow phone widths,
enlarged text and loading states while preserving the existing design system.
- Measure and reduce removal latency. Verify whether an authenticated existing
FIPS connection is preferred; implement that priority where supported, with
bounded fallback and no weakening of identity or authorization checks.
+2 -2
View File
@@ -473,7 +473,7 @@ server {
# so the browser doesn't hit CORS/CSP. Without this block nginx falls
# through to the SPA index.html and the frontend gets HTML back instead
# of JSON.
location /api/app-catalog {
location ~ ^/api/(?:app-catalog|node-app-catalog)$ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -1380,7 +1380,7 @@ server {
# so the browser doesn't hit CORS/CSP. Without this block nginx falls
# through to the SPA index.html and the frontend gets HTML back instead
# of JSON.
location /api/app-catalog {
location ~ ^/api/(?:app-catalog|node-app-catalog)$ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;