From bd567cd165293878ca3b4d474eacc10b06229746 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 19:21:07 -0400 Subject: [PATCH] feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony - Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge, fedimint_client, fedimint RPC, wallet wiring - Paid peer content: content invoices + streaming content server + content RPCs - Seed-phrase ceremony/reveal RPCs and CLI ceremony tool - LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and decoupled-update wiring; Fedimint Client core app in catalog Co-Authored-By: Claude Opus 4.8 (1M context) --- app-catalog/catalog.json | 12 + apps/fedimint-clientd/manifest.yml | 75 ++++ core/archipelago/src/api/handler/content.rs | 279 +++++++++++++ core/archipelago/src/api/handler/mod.rs | 37 +- .../src/api/handler/node_message.rs | 11 +- .../src/api/handler/remote_input.rs | 14 + .../src/api/handler/remote_relay.rs | 45 ++- core/archipelago/src/api/rpc/content.rs | 366 ++++++++++++++++++ core/archipelago/src/api/rpc/dispatcher.rs | 21 + core/archipelago/src/api/rpc/fedimint.rs | 128 ++++++ core/archipelago/src/api/rpc/lnd/wallet.rs | 178 +++++++++ .../archipelago/src/api/rpc/mesh/messaging.rs | 11 + core/archipelago/src/api/rpc/mesh/status.rs | 19 +- core/archipelago/src/api/rpc/mod.rs | 1 + .../archipelago/src/api/rpc/package/stacks.rs | 220 ++++++++++- core/archipelago/src/api/rpc/seed_rpc.rs | 150 ++++++- core/archipelago/src/api/rpc/update.rs | 48 +++ core/archipelago/src/ceremony.rs | 138 +++++++ .../src/container/prod_orchestrator.rs | 154 ++++++++ core/archipelago/src/content_invoice.rs | 80 ++++ core/archipelago/src/content_server.rs | 132 ++++++- core/archipelago/src/federation/pending.rs | 63 ++- core/archipelago/src/main.rs | 9 + .../archipelago/src/mesh/listener/dispatch.rs | 4 + core/archipelago/src/mesh/listener/mod.rs | 4 + core/archipelago/src/mesh/mod.rs | 6 + core/archipelago/src/node_message.rs | 52 ++- core/archipelago/src/port_allocator.rs | 1 + core/archipelago/src/server.rs | 34 ++ core/archipelago/src/update.rs | 129 +++++- .../archipelago/src/wallet/fedimint_client.rs | 285 ++++++++++++++ core/archipelago/src/wallet/mod.rs | 1 + docker/fmcd/Dockerfile | 21 + docker/fmcd/fmcd-run | 17 + 34 files changed, 2677 insertions(+), 68 deletions(-) create mode 100644 apps/fedimint-clientd/manifest.yml create mode 100644 core/archipelago/src/api/rpc/fedimint.rs create mode 100644 core/archipelago/src/ceremony.rs create mode 100644 core/archipelago/src/content_invoice.rs create mode 100644 core/archipelago/src/wallet/fedimint_client.rs create mode 100644 docker/fmcd/Dockerfile create mode 100644 docker/fmcd/fmcd-run diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json index e410d4e5..8874ef29 100644 --- a/app-catalog/catalog.json +++ b/app-catalog/catalog.json @@ -290,6 +290,18 @@ "dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint" }, + { + "id": "fedimint-clientd", + "title": "Fedimint Client", + "version": "0.8.0", + "description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.", + "icon": "/assets/img/app-icons/fedimint.png", + "author": "Fedimint", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0", + "repoUrl": "https://github.com/minmoto/fmcd" + }, { "id": "fedimint-gateway", "title": "Fedimint Gateway", diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml new file mode 100644 index 00000000..49de9e7b --- /dev/null +++ b/apps/fedimint-clientd/manifest.yml @@ -0,0 +1,75 @@ +app: + id: fedimint-clientd + name: Fedimint Client + version: 0.8.0 + description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API. + + container: + # fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client + # 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push + # this to the node registry. Pin the tag to match the REST shapes coded in + # core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2). + image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0 + pull_policy: if-not-present + network: archy-net + # No entrypoint override: the image's resilient `fmcd-run` launcher loops + # fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an + # unreachable default never crash-loops. All config comes from FMCD_* env + # below. Nodes can join more federations via wallet.fedimint-join. + secret_env: + - key: FMCD_PASSWORD + secret_file: fmcd-password + data_uid: "1000:1000" + + # NOTE: this is a CLIENT, not the guardian — it does not require the local + # `fedimint` app. It joins external federations (default below), so it can be + # bundled standalone on every node. + dependencies: + - storage: 2Gi + + resources: + cpu_limit: 1 + memory_limit: 1Gi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: true + # NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh + # relays to reach iroh-transport federations. Lock down once the default + # federation's reachability model is finalized. + network_policy: open + + ports: + # fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the + # host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178. + - host: 8178 + container: 8080 + protocol: tcp + + volumes: + # Same dir the first-boot bundled path uses + where the wallet bridge reads + # the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned. + - type: bind + source: /var/lib/archipelago/fmcd + target: /data + options: [rw] + + environment: + - FMCD_ADDR=0.0.0.0:8080 + - FMCD_MODE=rest + - FMCD_DATA_DIR=/data + # Default federation joined out-of-the-box (guardian on .116, iroh + # transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so + # it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in + # core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate + # join reliability from a real second node before relying on auto-bundle. + - FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp + + health_check: + type: http + endpoint: http://localhost:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index d96c35e3..4ab0f11c 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -66,6 +66,21 @@ impl ApiHandler { .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning) + // or X-Onchain-Address (on-chain) — both authorize the download if this + // node issued+settled them, and both resolve against the same shared + // entitlement store keyed by the token string (#46). + let invoice_hash = headers + .get("x-invoice-hash") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| { + headers + .get("x-onchain-address") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }); + // Extract federation peer DID from X-Federation-DID header let peer_did = headers .get("x-federation-did") @@ -82,6 +97,7 @@ impl ApiHandler { &config.data_dir, content_id, payment_token.as_deref(), + invoice_hash.as_deref(), peer_did.as_deref(), range, ) @@ -140,6 +156,261 @@ impl ApiHandler { } } + /// Seller side (#46): mint a Lightning invoice for a paid catalog item so a + /// buyer can pay from any external wallet. Path: GET /content/{id}/invoice. + /// Records a pending entitlement keyed by the invoice's payment hash. + pub(super) async fn handle_content_invoice( + &self, + path: &str, + ) -> Result> { + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/invoice")) + .unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + let catalog = content_server::load_catalog(&self.config.data_dir) + .await + .unwrap_or_default(); + let item = match catalog.items.iter().find(|i| i.id == content_id) { + Some(i) => i, + None => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )) + } + }; + let price_sats = match &item.access { + content_server::AccessControl::Paid { price_sats } => *price_sats, + _ => { + // Not a paid item — no invoice to issue. + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"Item is not paid"}"#), + )); + } + }; + + let memo = format!("Archipelago peer file {content_id}"); + match self + .rpc_handler + .create_invoice(price_sats as i64, &memo) + .await + { + Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => { + crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await; + let body = serde_json::json!({ + "bolt11": bolt11, + "payment_hash": payment_hash, + "price_sats": price_sats, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + Ok(_) => Ok(build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "application/json", + hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#), + )), + Err(e) => { + let body = serde_json::json!({ + "error": format!("Could not create invoice: {e}") + }); + Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + } + } + + /// Seller side (#46): report whether a previously-issued invoice has settled. + /// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the + /// entitlement is marked paid so the buyer can then download the file. + pub(super) async fn handle_content_invoice_status( + &self, + path: &str, + ) -> Result> { + let rest = path.strip_prefix("/content/").unwrap_or(""); + let (content_id, payment_hash) = match rest.split_once("/invoice-status/") { + Some((id, hash)) => (id, hash), + None => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )) + } + }; + if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )); + } + + // The hash must be one we issued for exactly this content item. + match crate::content_invoice::lookup(payment_hash).await { + Some((cid, _)) if cid == content_id => {} + _ => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"Unknown invoice"}"#), + )) + } + } + + // Already paid? Otherwise ask our LND and persist the result. + let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await; + if !paid { + if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await { + crate::content_invoice::mark_paid(payment_hash).await; + paid = true; + } + } + + let body = serde_json::json!({ "paid": paid }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + + /// Seller side (#46): issue a fresh on-chain address for a paid catalog item + /// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a + /// pending entitlement keyed by the address; price doubles as expected amount. + pub(super) async fn handle_content_onchain( + &self, + path: &str, + ) -> Result> { + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/onchain")) + .unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + let catalog = content_server::load_catalog(&self.config.data_dir) + .await + .unwrap_or_default(); + let price_sats = match catalog.items.iter().find(|i| i.id == content_id) { + Some(i) => match &i.access { + content_server::AccessControl::Paid { price_sats } => *price_sats, + _ => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"Item is not paid"}"#), + )) + } + }, + None => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )) + } + }; + + match self.rpc_handler.new_onchain_address().await { + Ok(address) if !address.is_empty() => { + crate::content_invoice::record_pending(&address, content_id, price_sats).await; + let body = serde_json::json!({ + "address": address, + "amount_sats": price_sats, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + _ => { + let body = serde_json::json!({ + "error": "Could not generate an on-chain address (is the wallet ready?)" + }); + Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + } + } + + /// Seller side (#46): report whether an on-chain payment to a previously- + /// issued address has arrived (>= price, >= 1 conf). Path: + /// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid. + pub(super) async fn handle_content_onchain_status( + &self, + path: &str, + ) -> Result> { + let rest = path.strip_prefix("/content/").unwrap_or(""); + let (content_id, address) = match rest.split_once("/onchain-status/") { + Some((id, addr)) => (id, addr), + None => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )) + } + }; + if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )); + } + // The address must be one we issued for exactly this content item. + let price = match crate::content_invoice::lookup(address).await { + Some((cid, price)) if cid == content_id => price, + _ => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"Unknown address"}"#), + )) + } + }; + + let mut paid = crate::content_invoice::is_paid_for(address, content_id).await; + if !paid { + if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { + crate::content_invoice::mark_paid(address).await; + paid = true; + } + } + let body = serde_json::json!({ "paid": paid }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + /// Serve a degraded preview of paid content (blurred image or first 2% of video). pub(super) async fn handle_content_preview( path: &str, @@ -190,6 +461,14 @@ impl ApiHandler { .body(hyper::Body::from(bytes)) .unwrap()) } + Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder() + .status(StatusCode::UNSUPPORTED_MEDIA_TYPE) + .header("Content-Type", "text/plain") + .header("X-Content-Preview", "unavailable") + .body(hyper::Body::from( + "Preview unavailable for this media (needs re-encoding)", + )) + .unwrap()), Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response( StatusCode::NOT_FOUND, "text/plain", diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 89977115..a86839bd 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -44,6 +44,11 @@ pub struct ApiHandler { session_store: SessionStore, /// Broadcast channel for relaying companion app input to remote browsers. input_relay_tx: broadcast::Sender, + /// Reverse broadcast channel: the kiosk browser publishes "open this URL + /// externally" requests here, and the companion (phone) socket forwards them + /// to the phone's default browser. Lets "open in external browser" apps — + /// which the kiosk can't usefully open itself — launch on the controller. + external_open_tx: broadcast::Sender, /// Content-addressed blob store for attachments shared over mesh/federation. blob_store: Arc, /// Our own node pubkey (hex) — used to self-sign debug/test capabilities. @@ -71,6 +76,7 @@ impl ApiHandler { .await?, ); let (input_relay_tx, _) = broadcast::channel(64); + let (external_open_tx, _) = broadcast::channel(16); // Derive a blob-store capability key from the node's Ed25519 signing // key. SHA-256 domain-separated so rotating the identity rotates @@ -100,6 +106,7 @@ impl ApiHandler { metrics_store, session_store, input_relay_tx, + external_open_tx, blob_store, self_pubkey_hex, }) @@ -356,7 +363,12 @@ impl ApiHandler { tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing"); return Ok(Self::unauthorized()); } - return Self::handle_remote_input(req, self.input_relay_tx.clone()).await; + return Self::handle_remote_input( + req, + self.input_relay_tx.clone(), + self.external_open_tx.subscribe(), + ) + .await; } // Remote relay WebSocket — browser receives companion input events @@ -365,7 +377,12 @@ impl ApiHandler { tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing"); return Ok(Self::unauthorized()); } - return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await; + return Self::handle_remote_relay( + req, + self.input_relay_tx.subscribe(), + self.external_open_tx.clone(), + ) + .await; } // Convert body to bytes for non-WS routes @@ -480,6 +497,22 @@ impl ApiHandler { Self::handle_content_preview(p, &self.config).await } + // Lightning-invoice peer-file sale (#46): mint invoice / poll settlement + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => { + self.handle_content_invoice(p).await + } + (Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => { + self.handle_content_invoice_status(p).await + } + + // On-chain peer-file sale (#46): issue address / poll for payment + (Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => { + self.handle_content_onchain_status(p).await + } + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => { + self.handle_content_onchain(p).await + } + // Content serving — peers access shared content over Tor (no session auth) (Method::GET, p) if p.starts_with("/content/") => { Self::handle_content_request(p, &headers, &self.config).await diff --git a/core/archipelago/src/api/handler/node_message.rs b/core/archipelago/src/api/handler/node_message.rs index d0d794d3..b591b661 100644 --- a/core/archipelago/src/api/handler/node_message.rs +++ b/core/archipelago/src/api/handler/node_message.rs @@ -19,6 +19,8 @@ impl ApiHandler { signature: Option, #[serde(default)] encrypted: bool, + #[serde(default)] + msg_id: Option, } let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming { from_pubkey: None, @@ -26,6 +28,7 @@ impl ApiHandler { message: None, signature: None, encrypted: false, + msg_id: None, }); if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref()) { @@ -152,7 +155,13 @@ impl ApiHandler { let clean_from = sanitize_html(from); let clean_msg = sanitize_html(&plaintext); let clean_name = incoming.from_name.as_deref().map(sanitize_html); - node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await; + node_msg::store_received( + &clean_from, + &clean_msg, + clean_name.as_deref(), + incoming.msg_id.as_deref(), + ) + .await; } Ok(build_response( StatusCode::OK, diff --git a/core/archipelago/src/api/handler/remote_input.rs b/core/archipelago/src/api/handler/remote_input.rs index 4d769f13..04623dd9 100644 --- a/core/archipelago/src/api/handler/remote_input.rs +++ b/core/archipelago/src/api/handler/remote_input.rs @@ -211,6 +211,7 @@ impl ApiHandler { pub(super) async fn handle_remote_input( req: Request, relay_tx: broadcast::Sender, + mut external_open_rx: broadcast::Receiver, ) -> Result> { // Extract optional player ID from query string: /ws/remote-input?p=1 let player_id: Option = req @@ -266,6 +267,19 @@ impl ApiHandler { break; } } + // Forward kiosk "open this URL externally" requests down to + // the companion so the link opens in the phone's browser. + ext = external_open_rx.recv() => { + match ext { + Ok(text) => { + if tx.send(Message::Text(text)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => {} + } + } msg = rx.next() => { match msg { Some(Ok(Message::Text(text))) => { diff --git a/core/archipelago/src/api/handler/remote_relay.rs b/core/archipelago/src/api/handler/remote_relay.rs index 4beada52..608b1cc1 100644 --- a/core/archipelago/src/api/handler/remote_relay.rs +++ b/core/archipelago/src/api/handler/remote_relay.rs @@ -11,9 +11,16 @@ use super::ApiHandler; impl ApiHandler { /// WebSocket endpoint for browser clients to receive relayed companion input. /// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events. + /// + /// The kiosk also uses this socket in the *reverse* direction: when an "open + /// in external browser" app is launched, the kiosk can't usefully open it + /// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the + /// URL and publish it on `external_open_tx`, which the companion (phone) + /// socket forwards so the link opens in the phone's default browser. pub(super) async fn handle_remote_relay( req: Request, mut relay_rx: broadcast::Receiver, + external_open_tx: broadcast::Sender, ) -> Result> { let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req) .map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?; @@ -63,10 +70,20 @@ impl ApiHandler { Err(broadcast::error::RecvError::Closed) => break, } } - // Handle client-side messages (pong, close) + // Handle client-side messages (pong, close, open-url requests) client_msg = rx.next() => { match client_msg { Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {} + Some(Ok(Message::Text(text))) => { + // The only kiosk→server message we accept is an + // external-open request: {"t":"o","url":"https://…"}. + if let Some(url) = parse_open_url(&text) { + debug!("Relaying external-open to companion: {}", url); + let _ = external_open_tx.send( + format!(r#"{{"t":"o","url":{}}}"#, json_string(&url)) + ); + } + } Some(Ok(Message::Close(_))) | None => break, _ => {} } @@ -81,3 +98,29 @@ impl ApiHandler { Ok(response) } } + +/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL +/// only if it's a well-formed http(s) URL. Anything else (other message tags, +/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a +/// compromised kiosk page can't push arbitrary URIs to the phone. +fn parse_open_url(text: &str) -> Option { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + if v.get("t").and_then(|t| t.as_str()) != Some("o") { + return None; + } + let url = v.get("url").and_then(|u| u.as_str())?.trim(); + if url.len() > 2048 { + return None; + } + let lower = url.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") { + Some(url.to_string()) + } else { + None + } +} + +/// Serialize a string as a JSON string literal (with surrounding quotes). +fn json_string(s: &str) -> String { + serde_json::Value::String(s.to_string()).to_string() +} diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 8b77fd44..903b2b86 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -436,6 +436,372 @@ impl RpcHandler { })) } + /// Buyer side (#46): ask the selling node to mint a Lightning invoice for a + /// paid item so the buyer can pay from any external wallet. Returns the + /// bolt11 invoice + payment hash to render as a QR and poll for settlement. + pub(super) async fn handle_content_request_invoice( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + + let path = format!("/content/{}/invoice", content_id); + 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) + .timeout(std::time::Duration::from_secs(60)) + .send_get() + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e); + return Ok(serde_json::json!({ + "error": "Could not reach the peer over mesh or Tor — it may be offline." + })); + } + }; + + if !response.status().is_success() { + return Ok(serde_json::json!({ + "error": format!("Seller could not create an invoice ({}).", response.status()) + })); + } + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse invoice response")?; + Ok(body) + } + + /// Buyer side (#46): poll the selling node for invoice settlement. + pub(super) async fn handle_content_invoice_status( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + let payment_hash = params + .get("payment_hash") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?; + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + // Payment hash is hex from the seller; keep it strictly hex so it's safe + // to interpolate into the request path. + if payment_hash.is_empty() + || payment_hash.len() > 128 + || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(anyhow::anyhow!("Invalid payment_hash")); + } + + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash); + let (response, _transport) = + match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) + .service(crate::settings::transport::PeerService::PeerFiles) + .timeout(std::time::Duration::from_secs(30)) + .send_get() + .await + { + Ok(v) => v, + Err(_) => { + // Treat an unreachable peer as "not yet paid" so the UI keeps polling. + return Ok(serde_json::json!({ "paid": false, "unreachable": true })); + } + }; + if !response.status().is_success() { + return Ok(serde_json::json!({ "paid": false })); + } + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse invoice-status response")?; + Ok(body) + } + + /// Buyer side (#46): download a paid item after the invoice settled, passing + /// the payment hash so the seller's content gate releases the file. + pub(super) async fn handle_content_download_peer_invoice( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + let payment_hash = params + .get("payment_hash") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?; + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(anyhow::anyhow!("Invalid payment_hash")); + } + + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + + let path = format!("/content/{}", content_id); + 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-Invoice-Hash", payment_hash.to_string()) + .timeout(std::time::Duration::from_secs(900)) + .send_get() + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("invoice download dial failed for {}: {:#}", onion, e); + return Ok(serde_json::json!({ + "error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again." + })); + } + }; + let _ = crate::federation::record_peer_transport( + &self.config.data_dir, + None, + Some(onion), + &transport.to_string(), + ) + .await; + + if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { + return Ok(serde_json::json!({ + "error": "Seller has not registered this payment yet — wait for settlement and retry." + })); + } + if !response.status().is_success() { + return Ok(serde_json::json!({ + "error": format!("Peer returned an error ({}).", response.status()) + })); + } + + let bytes = response + .bytes() + .await + .context("Failed to read response body")?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(serde_json::json!({ + "data": encoded, + "size": bytes.len(), + })) + } + + /// Buyer side (#46): ask the seller for a fresh on-chain address to pay. + pub(super) async fn handle_content_request_onchain( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + + let path = format!("/content/{}/onchain", content_id); + 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) + .timeout(std::time::Duration::from_secs(60)) + .send_get() + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e); + return Ok(serde_json::json!({ + "error": "Could not reach the peer over mesh or Tor — it may be offline." + })); + } + }; + if !response.status().is_success() { + return Ok(serde_json::json!({ + "error": format!("Seller could not provide an address ({}).", response.status()) + })); + } + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse onchain response")?; + Ok(body) + } + + /// Buyer side (#46): poll the selling node for on-chain payment detection. + pub(super) async fn handle_content_onchain_status( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + let address = params + .get("address") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing address"))?; + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + // Bitcoin addresses are alphanumeric; keep strictly so for safe path use. + if address.is_empty() || address.len() > 100 || !address.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(anyhow::anyhow!("Invalid address")); + } + + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + let path = format!("/content/{}/onchain-status/{}", content_id, address); + let (response, _transport) = + match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path) + .service(crate::settings::transport::PeerService::PeerFiles) + .timeout(std::time::Duration::from_secs(30)) + .send_get() + .await + { + Ok(v) => v, + Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })), + }; + if !response.status().is_success() { + return Ok(serde_json::json!({ "paid": false })); + } + let body: serde_json::Value = response + .json() + .await + .context("Failed to parse onchain-status response")?; + Ok(body) + } + + /// Buyer side (#46): download a paid item after the on-chain payment was + /// detected, passing the address so the seller's content gate releases it. + pub(super) async fn handle_content_download_peer_onchain( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let onion = params + .get("onion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing onion address"))?; + let content_id = params + .get("content_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing content_id"))?; + let address = params + .get("address") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing address"))?; + if !is_valid_v3_onion(onion) { + return Err(anyhow::anyhow!("Invalid v3 onion address")); + } + if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) { + return Err(anyhow::anyhow!("Invalid address")); + } + + let (data, _) = self.state_manager.get_snapshot().await; + let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; + let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + + let path = format!("/content/{}", content_id); + 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-Onchain-Address", address.to_string()) + .timeout(std::time::Duration::from_secs(900)) + .send_get() + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("onchain download dial failed for {}: {:#}", onion, e); + return Ok(serde_json::json!({ + "error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again." + })); + } + }; + let _ = crate::federation::record_peer_transport( + &self.config.data_dir, + None, + Some(onion), + &transport.to_string(), + ) + .await; + + if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { + return Ok(serde_json::json!({ + "error": "Seller has not registered this payment yet — wait for confirmation and retry." + })); + } + if !response.status().is_success() { + return Ok(serde_json::json!({ + "error": format!("Peer returned an error ({}).", response.status()) + })); + } + + let bytes = response + .bytes() + .await + .context("Failed to read response body")?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + Ok(serde_json::json!({ + "data": encoded, + "size": bytes.len(), + })) + } + /// Fetch a preview of paid content from a peer (no payment required). pub(super) async fn handle_content_preview_peer( &self, diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index cdc65627..c33913d0 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -33,6 +33,7 @@ impl RpcHandler { "seed.restore" => self.handle_seed_restore(params).await, "seed.save-encrypted" => self.handle_seed_save_encrypted(params).await, "seed.status" => self.handle_seed_status().await, + "seed.reveal" => self.handle_seed_reveal(params).await, // Container orchestration (for Archipelago-managed containers) "container-install" => self.handle_container_install(params).await, @@ -237,6 +238,11 @@ impl RpcHandler { "wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await, "wallet.ecash-history" => self.handle_wallet_ecash_history().await, "wallet.networking-profits" => self.handle_wallet_networking_profits().await, + // Fedimint ecash (via fedimint-clientd sidecar) + "wallet.fedimint-list" => self.handle_wallet_fedimint_list().await, + "wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await, + "wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await, + "wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await, // Container registries "registry.list" => self.handle_registry_list().await, @@ -270,6 +276,16 @@ impl RpcHandler { "content.browse-peer" => self.handle_content_browse_peer(params).await, "content.download-peer" => self.handle_content_download_peer(params).await, "content.download-peer-paid" => self.handle_content_download_peer_paid(params).await, + "content.request-invoice" => self.handle_content_request_invoice(params).await, + "content.invoice-status" => self.handle_content_invoice_status(params).await, + "content.download-peer-invoice" => { + self.handle_content_download_peer_invoice(params).await + } + "content.request-onchain" => self.handle_content_request_onchain(params).await, + "content.onchain-status" => self.handle_content_onchain_status(params).await, + "content.download-peer-onchain" => { + self.handle_content_download_peer_onchain(params).await + } "content.preview-peer" => self.handle_content_preview_peer(params).await, // DWN (Decentralized Web Node) @@ -472,6 +488,11 @@ impl RpcHandler { let p = params.unwrap_or(serde_json::json!({})); self.handle_update_test_mirror(&p).await } + "update.get-source" => self.handle_update_get_source().await, + "update.set-source" => { + let p = params.unwrap_or(serde_json::json!({})); + self.handle_update_set_source(&p).await + } "update.apply" => self.handle_update_apply().await, "update.git-apply" => self.handle_update_git_apply().await, "update.rollback" => self.handle_update_rollback().await, diff --git a/core/archipelago/src/api/rpc/fedimint.rs b/core/archipelago/src/api/rpc/fedimint.rs new file mode 100644 index 00000000..1be5a2a7 --- /dev/null +++ b/core/archipelago/src/api/rpc/fedimint.rs @@ -0,0 +1,128 @@ +//! Fedimint ecash RPCs — bridge to the `fedimint-clientd` sidecar. +//! +//! Companion to the Cashu wallet RPCs in [`super::wallet`]. Joining/holding +//! Fedimint ecash is delegated to the clientd container via +//! [`crate::wallet::fedimint_client::FedimintClient`]; here we expose the +//! node's JSON-RPC surface and keep a local registry of joined federations so +//! the list survives clientd being temporarily unreachable. +//! +//! See `docs/dual-ecash-design.md`. + +use super::RpcHandler; +use crate::wallet::fedimint_client::{self, FedimintClient, JoinedFederation}; +use anyhow::Result; + +impl RpcHandler { + /// `wallet.fedimint-list` — joined federations with live balances. + pub(super) async fn handle_wallet_fedimint_list(&self) -> Result { + // Best-effort: make sure the default federation is joined/tracked. + let _ = fedimint_client::ensure_default_federation(&self.config.data_dir).await; + + let reg = fedimint_client::load_registry(&self.config.data_dir).await?; + + // Live balances are best-effort: if clientd is down we still return the + // tracked federations (with 0 balance) rather than failing the call. + let info = match FedimintClient::from_node(&self.config.data_dir).await { + Ok(client) => client.info().await.ok(), + Err(_) => None, + }; + + let federations: Vec = reg + .federations + .iter() + .map(|f| { + let balance_sats = info + .as_ref() + .and_then(|i| i.get(&f.federation_id)) + .and_then(|e| { + e.get("totalAmountMsat") + .or_else(|| e.get("totalMsat")) + .and_then(|v| v.as_u64()) + }) + .map(|msat| msat / 1000) + .unwrap_or(0); + serde_json::json!({ + "federation_id": f.federation_id, + "name": f.name, + "balance_sats": balance_sats, + }) + }) + .collect(); + + Ok(serde_json::json!({ "federations": federations })) + } + + /// `wallet.fedimint-join` — join a federation by invite code. + pub(super) async fn handle_wallet_fedimint_join( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let invite_code = params + .get("invite_code") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing invite_code"))?; + + let client = FedimintClient::from_node(&self.config.data_dir).await?; + let federation_id = client.join(invite_code).await?; + + // Try to label it from the federation meta (best-effort). + let name = client + .info() + .await + .ok() + .and_then(|i| { + i.get(&federation_id) + .and_then(|e| e.get("meta")) + .and_then(|m| m.get("federation_name").or_else(|| m.get("federation_expiry_timestamp"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }); + + let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?; + if !reg.federations.iter().any(|f| f.federation_id == federation_id) { + reg.federations.push(JoinedFederation { + federation_id: federation_id.clone(), + name, + }); + fedimint_client::save_registry(&self.config.data_dir, ®).await?; + } + + Ok(serde_json::json!({ "federation_id": federation_id })) + } + + /// `wallet.fedimint-leave` — stop tracking a federation locally. + pub(super) async fn handle_wallet_fedimint_leave( + &self, + params: Option, + ) -> Result { + let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?; + let federation_id = params + .get("federation_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing federation_id"))?; + + let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?; + let before = reg.federations.len(); + reg.federations.retain(|f| f.federation_id != federation_id); + let removed = reg.federations.len() != before; + if removed { + fedimint_client::save_registry(&self.config.data_dir, ®).await?; + } + + Ok(serde_json::json!({ "removed": removed })) + } + + /// `wallet.fedimint-balance` — total sats across all joined federations. + pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result { + // Soft-fail to zero when clientd isn't installed/running, so the unified + // wallet balance still renders from the Cashu side. + let balance_sats = match FedimintClient::from_node(&self.config.data_dir).await { + Ok(client) => client.total_balance_sats().await.unwrap_or(0), + Err(_) => 0, + }; + Ok(serde_json::json!({ "balance_sats": balance_sats })) + } +} diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index ecd9f17b..6fed4286 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -151,6 +151,184 @@ impl RpcHandler { } /// Create a Lightning invoice. + /// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`. + /// + /// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side + /// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode + /// it as hex so it can be used as a stable lookup key and passed in URLs. + pub(crate) async fn create_invoice( + &self, + amount_sats: i64, + memo: &str, + ) -> Result<(String, String)> { + if amount_sats < 0 { + return Err(anyhow::anyhow!("Amount must be non-negative")); + } + if memo.len() > 639 { + return Err(anyhow::anyhow!("Memo too long (max 639 bytes)")); + } + + let (client, macaroon_hex) = self.lnd_client().await?; + let invoice_body = serde_json::json!({ + "value": amount_sats.to_string(), + "memo": memo, + }); + let resp = client + .post(format!("{LND_REST_BASE_URL}/v1/invoices")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .json(&invoice_body) + .send() + .await + .context("Failed to create invoice")?; + + let status = resp.status(); + let body: serde_json::Value = resp + .json() + .await + .context("Failed to parse invoice response")?; + if !status.is_success() { + let msg = body + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(anyhow::anyhow!("Failed to create invoice: {}", msg)); + } + + let payment_request = body + .get("payment_request") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // r_hash is base64 in LND's REST response — convert to hex. + use base64::Engine; + let payment_hash_hex = body + .get("r_hash") + .and_then(|v| v.as_str()) + .and_then(|b64| { + base64::engine::general_purpose::STANDARD + .decode(b64) + .ok() + }) + .map(hex::encode) + .unwrap_or_default(); + + Ok((payment_request, payment_hash_hex)) + } + + /// Look up an invoice by hex payment hash; true if it has settled. + pub(crate) async fn invoice_is_settled(&self, payment_hash_hex: &str) -> Result { + if payment_hash_hex.is_empty() || hex::decode(payment_hash_hex).is_err() { + return Err(anyhow::anyhow!("Invalid payment hash")); + } + let (client, macaroon_hex) = self.lnd_client().await?; + // LND REST: GET /v1/invoice/{r_hash_str} where r_hash_str is hex. + let resp = client + .get(format!("{LND_REST_BASE_URL}/v1/invoice/{payment_hash_hex}")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("Failed to look up invoice")?; + if !resp.status().is_success() { + return Ok(false); + } + let body: serde_json::Value = resp + .json() + .await + .context("Failed to parse invoice lookup response")?; + let settled = body + .get("settled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + || body.get("state").and_then(|v| v.as_str()) == Some("SETTLED"); + Ok(settled) + } + + /// Generate a fresh on-chain receive address (seller side, #46). + pub(crate) async fn new_onchain_address(&self) -> Result { + let (client, macaroon_hex) = self.lnd_client().await?; + let resp = client + .get(format!("{LND_REST_BASE_URL}/v1/newaddress")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("Failed to get new address")?; + if !resp.status().is_success() { + return Err(anyhow::anyhow!("LND newaddress failed: {}", resp.status())); + } + let body: serde_json::Value = resp + .json() + .await + .context("Failed to parse newaddress response")?; + body.get("address") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("LND newaddress returned no address")) + } + + /// True if an on-chain payment of >= `min_sats` to `address` has been seen + /// with at least one confirmation (seller side, #46). Conservative on + /// purpose: requires a confirmation + exact-address + sufficient-amount so a + /// file sale is never released on an unconfirmed (reorg-able) tx. + pub(crate) async fn onchain_received(&self, address: &str, min_sats: u64) -> Result { + if address.is_empty() { + return Err(anyhow::anyhow!("Empty address")); + } + let (client, macaroon_hex) = self.lnd_client().await?; + let resp = client + .get(format!("{LND_REST_BASE_URL}/v1/transactions")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .context("Failed to list transactions")?; + if !resp.status().is_success() { + return Ok(false); + } + let body: serde_json::Value = resp + .json() + .await + .context("Failed to parse transactions response")?; + let i64_field = |tx: &serde_json::Value, k: &str| -> i64 { + tx.get(k) + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()) + .or_else(|| tx.get(k).and_then(|v| v.as_i64())) + .unwrap_or(0) + }; + let txs = body + .get("transactions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + for tx in &txs { + if i64_field(tx, "num_confirmations") < 1 { + continue; + } + if i64_field(tx, "amount") < min_sats as i64 { + continue; + } + let pays_addr = tx + .get("dest_addresses") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().any(|a| a.as_str() == Some(address))) + .unwrap_or(false) + || tx + .get("output_details") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter().any(|o| { + o.get("address").and_then(|a| a.as_str()) == Some(address) + }) + }) + .unwrap_or(false); + if pays_addr { + return Ok(true); + } + } + Ok(false) + } + pub(in crate::api::rpc) async fn handle_lnd_createinvoice( &self, params: Option, diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 1f597450..2aa64c25 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -110,6 +110,15 @@ impl RpcHandler { if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) { config.advert_name = Some(name.to_string()); } + if let Some(announce) = params + .get("announce_block_headers") + .and_then(|v| v.as_bool()) + { + config.announce_block_headers = announce; + } + if let Some(receive) = params.get("receive_block_headers").and_then(|v| v.as_bool()) { + config.receive_block_headers = receive; + } mesh::save_config(&self.config.data_dir, &config).await?; @@ -124,6 +133,8 @@ impl RpcHandler { "configured": true, "enabled": config.enabled, "device_path": config.device_path, + "announce_block_headers": config.announce_block_headers, + "receive_block_headers": config.receive_block_headers, })) } } diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 1624c7fa..3f41cf43 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -5,26 +5,33 @@ use anyhow::Result; impl RpcHandler { /// mesh.status — Get mesh radio status, device info, and peer count. pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result { + // Block-header send/receive prefs live in MeshConfig; surface them in + // status so the UI toggles (issue #28) can show the persisted state. + let config = mesh::load_config(&self.config.data_dir).await?; let service = self.mesh_service.read().await; - if let Some(svc) = service.as_ref() { + let mut value = if let Some(svc) = service.as_ref() { let status = svc.status().await; - Ok(serde_json::to_value(status)?) + serde_json::to_value(status)? } else { // No service running — return basic config + device detection - let config = mesh::load_config(&self.config.data_dir).await?; let devices = mesh::detect_devices().await; - Ok(serde_json::json!({ + serde_json::json!({ "enabled": config.enabled, "device_connected": false, "device_type": "unknown", "device_path": config.device_path, - "channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()), + "channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()), "detected_devices": devices, "peer_count": 0, "messages_sent": 0, "messages_received": 0, - })) + }) + }; + if let Some(obj) = value.as_object_mut() { + obj.insert("announce_block_headers".into(), config.announce_block_headers.into()); + obj.insert("receive_block_headers".into(), config.receive_block_headers.into()); } + Ok(value) } /// mesh.peers — List discovered mesh peers. diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index ee0439c5..ac30275a 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -9,6 +9,7 @@ mod credentials; mod dispatcher; mod dwn; mod federation; +mod fedimint; mod fips; mod handshake; mod identity; diff --git a/core/archipelago/src/api/rpc/package/stacks.rs b/core/archipelago/src/api/rpc/package/stacks.rs index b871839a..b0459735 100644 --- a/core/archipelago/src/api/rpc/package/stacks.rs +++ b/core/archipelago/src/api/rpc/package/stacks.rs @@ -1804,14 +1804,22 @@ impl RpcHandler { let host_ip = detect_netbird_public_host_ip() .await .unwrap_or_else(|| self.config.host_ip.clone()); - write_netbird_config_files(&host_ip).await?; + // Create the network FIRST so we can read back the gateway it was + // assigned — that gateway is Podman's aardvark DNS, which the proxy's + // nginx needs as an explicit `resolver` to re-resolve container names + // (issue #15: without it nginx caches a container IP and 502s forever + // once that IP changes on restart/reboot). let _ = podman_stack_status( &["network", "create", "netbird-net"], PODMAN_STACK_PROBE_TIMEOUT, ) .await; + let resolver_ip = netbird_net_resolver_ip().await; + write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?; + ensure_netbird_tls_cert(&host_ip).await?; + let mut server_cmd = tokio::process::Command::new("podman"); server_cmd.args([ "run", @@ -1849,6 +1857,10 @@ impl RpcHandler { "netbird-dashboard", "--network", "netbird-net", + // Explicit alias so the proxy can always resolve `netbird-dashboard` + // via Podman DNS — don't rely on implicit container-name aliasing. + "--network-alias", + "netbird-dashboard", "--restart=unless-stopped", "--env-file", "/var/lib/archipelago/netbird/dashboard.env", @@ -1865,10 +1877,16 @@ impl RpcHandler { "--network", "netbird-net", "--restart=unless-stopped", + // 8087 publishes the TLS listener — netbird's dashboard requires a + // secure context (window.crypto.subtle / OIDC PKCE), issue #15. "-p", - "8087:80", + "8087:443", "-v", "/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro", + "-v", + "/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro", + "-v", + "/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro", NETBIRD_PROXY_IMAGE, ]); run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?; @@ -1913,9 +1931,108 @@ async fn read_or_generate_b64_secret(name: &str) -> String { secret } -async fn write_netbird_config_files(host_ip: &str) -> Result<()> { - let public_origin = format!("http://{}:8087", host_ip); +/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS +/// resolver on this address, so nginx can use it as an explicit `resolver` to +/// re-resolve container names at request time. Falls back to Podman's usual +/// first-pool gateway if the inspect fails (best effort — config is rewritten +/// on every (re)install). +async fn netbird_net_resolver_ip() -> String { + let out = tokio::process::Command::new("podman") + .args([ + "network", + "inspect", + "netbird-net", + "--format", + "{{range .Subnets}}{{.Gateway}}{{end}}", + ]) + .output() + .await; + if let Ok(o) = out { + let gw = String::from_utf8_lossy(&o.stdout).trim().to_string(); + if !gw.is_empty() && gw.parse::().is_ok() { + return gw; + } + } + "10.89.0.1".to_string() +} + +/// Generate a self-signed TLS cert for the netbird proxy if absent. The +/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the +/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it +/// once when opening netbird in a tab). SAN covers the LAN IP plus +/// localhost/127.0.0.1 so it's valid however the box is reached locally. +async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> { + let dir = "/var/lib/archipelago/netbird"; + let crt = format!("{dir}/tls.crt"); + let key = format!("{dir}/tls.key"); + if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() { + return Ok(()); + } + let _ = tokio::fs::create_dir_all(dir).await; + let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost"); + let status = tokio::process::Command::new("openssl") + .args([ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + &key, + "-out", + &crt, + "-days", + "3650", + "-subj", + &format!("/CN={host_ip}"), + "-addext", + &san, + ]) + .status() + .await + .context("failed to run openssl for netbird TLS cert")?; + if !status.success() { + anyhow::bail!("openssl failed to generate netbird TLS cert"); + } + Ok(()) +} + +async fn write_netbird_config_files( + host_ip: &str, + lan_ip: &str, + resolver_ip: &str, +) -> Result<()> { + // netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers + // only expose in a SECURE context — so the proxy serves HTTPS and every + // origin here is https (issue #15: over plain http the dashboard threw + // "window.crypto.subtle is unavailable" and never reached login). + let public_origin = format!("https://{}:8087", host_ip); let server_origin = format!("http://{}:8086", host_ip); + // A single box is reached via several addresses. Allow the OIDC login flow + // to redirect back to whichever origin the user actually used, otherwise + // post-login lands on the wrong host and the dashboard shows + // "Unauthenticated" (issue #15). The browser-side CORS is handled in the + // nginx proxy; this covers the redirect-URI allow-list. + let lan_origin = format!("https://{}:8087", lan_ip); + let mut redirect_origins = vec![public_origin.clone()]; + if lan_origin != public_origin { + redirect_origins.push(lan_origin); + } + let dashboard_redirect_uris = redirect_origins + .iter() + .flat_map(|o| { + [ + format!(" - \"{o}/nb-auth\""), + format!(" - \"{o}/nb-silent-auth\""), + ] + }) + .collect::>() + .join("\n"); + let dashboard_logout_uris = redirect_origins + .iter() + .map(|o| format!(" - \"{o}/\"")) + .collect::>() + .join("\n"); let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await; let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await; let config = format!( @@ -1935,10 +2052,9 @@ async fn write_netbird_config_files(host_ip: &str) -> Result<()> { localAuthDisabled: false signKeyRefreshEnabled: false dashboardRedirectURIs: - - "{public_origin}/nb-auth" - - "{public_origin}/nb-silent-auth" +{dashboard_redirect_uris} dashboardPostLogoutRedirectURIs: - - "{public_origin}/" +{dashboard_logout_uris} cliRedirectURIs: - "http://localhost:53000/" store: @@ -1972,12 +2088,23 @@ LETSENCRYPT_DOMAIN=none let nginx_conf = format!( r#"server {{ - listen 80; + listen 443 ssl; server_name _; - # Route browser API/auth through the host-published server port. Rootless - # Podman can give netbird-server a new container IP on restart while nginx - # keeps an old resolved address, which breaks login with 502s. + # netbird's dashboard needs a secure context (window.crypto.subtle for OIDC + # PKCE), so the proxy terminates TLS with a self-signed cert (issue #15). + ssl_certificate /etc/nginx/tls.crt; + ssl_certificate_key /etc/nginx/tls.key; + + # Rootless Podman can hand a container a new IP across restarts/reboots. + # nginx resolves a literal upstream name ONCE at startup and caches it, so + # after the IP moves every request 502s with "host unreachable" (issue #15, + # observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix: + # point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and + # use VARIABLE upstreams, which forces nginx to re-resolve the container + # names at request time. Everything is reached container-to-container by + # name so nothing depends on host-published ports either. + resolver {resolver_ip} valid=10s ipv6=off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -1986,24 +2113,60 @@ LETSENCRYPT_DOMAIN=none proxy_http_version 1.1; location ~ ^/(relay|ws-proxy/) {{ - proxy_pass http://host.containers.internal:8086; + set $nb_server netbird-server; + proxy_pass http://$nb_server:80; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 1d; }} location ~ ^/(api|oauth2)(/|$) {{ - proxy_pass http://host.containers.internal:8086; + # The dashboard is a SPA whose API/OIDC base URL is baked at build time + # to one host:port. A single box is reached via several addresses (LAN + # IP, Tailscale 100.x, hostname), so those fetches are cross-origin and + # the browser blocks them with no Access-Control-Allow-Origin (issue + # #15, observed live on .198). Reflect the caller's Origin so the + # self-hosted management/OIDC API is reachable from any of them, and + # answer the CORS preflight here. + if ($request_method = OPTIONS) {{ + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; + add_header Access-Control-Max-Age 86400 always; + add_header Content-Length 0; + return 204; + }} + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; + set $nb_server netbird-server; + proxy_pass http://$nb_server:80; }} location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{ - grpc_pass grpc://netbird-server:80; + set $nb_server netbird-server; + grpc_pass grpc://$nb_server:80; grpc_read_timeout 1d; grpc_send_timeout 1d; }} + # OIDC callback routes are client-side SPA routes with NO prebuilt page in + # the dashboard bundle, so proxying them straight through 404s — which + # crashes the dashboard's auth init and shows "Unauthenticated" with dead + # buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth + # returned 404). Serve the dashboard's index.html at these paths (URL + # unchanged) so react-oidc boots and completes the login / silent-SSO. + location ~ ^/(nb-auth|nb-silent-auth) {{ + set $nb_dashboard netbird-dashboard; + rewrite ^.*$ /index.html break; + proxy_pass http://$nb_dashboard:80; + }} + location / {{ - proxy_pass http://netbird-dashboard:80; + set $nb_dashboard netbird-dashboard; + proxy_pass http://$nb_dashboard:80; }} }} @@ -2024,10 +2187,29 @@ async fn detect_netbird_public_host_ip() -> Option { .await .ok()?; let stdout = String::from_utf8_lossy(&output.stdout); - stdout - .split_whitespace() - .find(|ip| ip.starts_with("100.") && ip.contains('.')) - .map(str::to_string) + let ips: Vec<&str> = stdout.split_whitespace().filter(|s| s.contains('.')).collect(); + + // Prefer the LAN address as the canonical origin — that's what users browse + // to on the local network. Baking the Tailscale 100.x address here broke + // LAN access with cross-origin/redirect mismatches (issue #15). Tailscale + // (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP. + let is_private_lan = |ip: &str| { + ip.starts_with("192.168.") + || ip.starts_with("10.") + || (ip.starts_with("172.") + && ip + .split('.') + .nth(1) + .and_then(|o| o.parse::().ok()) + .map(|o| (16..=31).contains(&o)) + .unwrap_or(false)) + }; + if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) { + return Some(lan.to_string()); + } + ips.iter() + .find(|ip| ip.starts_with("100.")) + .map(|s| s.to_string()) } #[cfg(test)] diff --git a/core/archipelago/src/api/rpc/seed_rpc.rs b/core/archipelago/src/api/rpc/seed_rpc.rs index 65caa925..22e5057a 100644 --- a/core/archipelago/src/api/rpc/seed_rpc.rs +++ b/core/archipelago/src/api/rpc/seed_rpc.rs @@ -60,6 +60,30 @@ impl RpcHandler { /// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys. /// Returns the words for the user to write down. pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result { + // Serialize concurrent / retried generate calls. The web client aborts + // at 15s and retries internally (up to 3x), and the onboarding view + // re-fires every 4s while the server is still booting on slow first-boot + // hardware. Without this guard each hit would mint a brand-new seed and + // overwrite the node keys mid-flight, leaving the words shown to the user + // out of sync with what `seed.verify` expects — the classic "error at the + // DID-creation screen". Holding the lock across the whole op fully + // serializes them. + let mut state = ONBOARDING_MNEMONIC.lock().await; + + // Idempotent fast-path: a fresh pending mnemonic already exists, so the + // node keys are already on disk. Return the SAME words rather than + // regenerating, so every retry yields a consistent result. + if let Some(existing) = state.as_ref() { + if existing.created_at.elapsed() < MNEMONIC_TTL { + let words: Vec = existing + .words + .split_whitespace() + .map(str::to_string) + .collect(); + return Ok(serde_json::json!({ "words": words })); + } + } + let (mnemonic, seed) = crate::seed::MasterSeed::generate()?; // Derive and write node Ed25519 key. @@ -89,16 +113,14 @@ impl RpcHandler { // the onboarding RPC returns immediately. spawn_post_onboarding_fips_activate(self.config.data_dir.clone()); - let words: Vec<&str> = mnemonic.words().collect(); + let words: Vec = mnemonic.words().map(str::to_string).collect(); - // Hold mnemonic in memory for the verify step. - { - let mut state = ONBOARDING_MNEMONIC.lock().await; - *state = Some(OnboardingMnemonicState { - words: mnemonic.to_string(), - created_at: std::time::Instant::now(), - }); - } + // Hold mnemonic in memory for the verify step. We already own the lock + // guard (`state`) from the top of the function, so just write through it. + *state = Some(OnboardingMnemonicState { + words: mnemonic.to_string(), + created_at: std::time::Instant::now(), + }); Ok(serde_json::json!({ "words": words, @@ -149,11 +171,13 @@ impl RpcHandler { let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?; let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default(); - // Clear mnemonic from memory now that it's verified. - { - let mut state = ONBOARDING_MNEMONIC.lock().await; - *state = None; - } + // Intentionally DO NOT clear the mnemonic here. The web client aborts + // slow requests at 15s and retries internally; if we wiped it on the + // first (successful) verify, a retried request would fail with + // "No pending seed generation or session expired" even though the user + // did everything right. The mnemonic is bounded by MNEMONIC_TTL (10 min) + // and is overwritten on the next generate, so leaving it makes verify + // idempotent without meaningfully widening the in-memory window. // Save the encrypted seed for convenience backup. // Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted. @@ -290,4 +314,102 @@ impl RpcHandler { "next_index": next_index, })) } + + /// Reveal the node's 24-word recovery phrase after onboarding. Heavily + /// gated, because this is the keys to the whole node: + /// - requires a full authenticated session (enforced upstream: this + /// method is NOT in the public auth whitelist), + /// - re-verifies the login password, + /// - requires a valid TOTP code when 2FA is enabled (replay-protected), + /// - decrypts `identity/master_seed.enc` with the backup passphrase + /// (defaults to the login password when the user used the same value). + /// The words are returned to the caller only and never logged. + pub(in crate::api::rpc) async fn handle_seed_reveal( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let mut password = params + .get("password") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if password.is_empty() { + anyhow::bail!("Password is required to reveal the recovery phrase"); + } + + // Nothing to reveal if this node never stored an encrypted seed. + if !crate::seed::seed_exists(&self.config.data_dir) { + anyhow::bail!( + "This node has no encrypted seed backup, so the recovery phrase \ + cannot be shown. It was only displayed once during setup." + ); + } + + // 1) Re-authenticate with the login password. + if !self.auth_manager.verify_password(&password).await? { + password.zeroize(); + anyhow::bail!("Incorrect password"); + } + + // 2) Require a valid 2FA code when TOTP is enabled (replay-protected). + if self.auth_manager.is_totp_enabled().await.unwrap_or(false) { + let code = params + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if code.is_empty() { + password.zeroize(); + anyhow::bail!("A 2FA code is required to reveal the recovery phrase"); + } + let totp_data = self + .auth_manager + .get_totp_data() + .await? + .ok_or_else(|| anyhow::anyhow!("2FA is enabled but no TOTP data found"))?; + let secret = crate::totp::decrypt_secret(&totp_data, &password) + .context("Could not unlock 2FA with this password")?; + match crate::totp::verify_code(&secret, &code, &totp_data.used_steps)? { + Some(step) => { + // Record the used step for replay protection, pruning old ones. + let mut data = totp_data; + data.used_steps.push(step); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let cutoff = (now / 30) - 10; // ~5 minutes + data.used_steps.retain(|s| *s > cutoff); + let _ = self.auth_manager.update_totp(data).await; + } + None => { + password.zeroize(); + anyhow::bail!("Invalid 2FA code"); + } + } + } + + // 3) Decrypt the stored seed. The backup passphrase may differ from the + // login password, so accept an explicit one and fall back to the + // password when the user used the same value for both. + let passphrase = params + .get("passphrase") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let secret_phrase = passphrase.unwrap_or_else(|| password.clone()); + let reveal = + crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await; + password.zeroize(); + let mnemonic = reveal.map_err(|_| { + anyhow::anyhow!( + "Could not decrypt the saved seed. If you set a separate backup \ + passphrase during setup, enter that passphrase." + ) + })?; + + let words: Vec = mnemonic.words().map(|w| w.to_string()).collect(); + let word_count = words.len(); + Ok(serde_json::json!({ "words": words, "word_count": word_count })) + } } diff --git a/core/archipelago/src/api/rpc/update.rs b/core/archipelago/src/api/rpc/update.rs index 61f83753..eb832e4e 100644 --- a/core/archipelago/src/api/rpc/update.rs +++ b/core/archipelago/src/api/rpc/update.rs @@ -253,6 +253,54 @@ impl RpcHandler { Ok(serde_json::json!({ "mirrors": list })) } + /// Report the node's swarm prefs (fetch source + whether it provides to the + /// swarm) plus swarm capability, so the UI can show whether DHT mode is + /// actually usable on this build. + pub(super) async fn handle_update_get_source(&self) -> Result { + let source = update::load_update_source(&self.config.data_dir).await; + let provide_dht = update::load_provide_dht(&self.config.data_dir).await; + let source_str = match source { + update::UpdateSource::Origin => "origin", + update::UpdateSource::Swarm => "swarm", + }; + Ok(serde_json::json!({ + "source": source_str, + // Whether this node seeds/serves blobs to peers (default true). + "provide_dht": provide_dht, + // Compiled with the iroh swarm engine? If false, "swarm" mode has no + // peers and silently behaves like origin. + "swarm_available": cfg!(feature = "iroh-swarm"), + // Runtime swarm-assist gate from config (ARCHIPELAGO_SWARM_ENABLED). + "swarm_enabled": self.config.swarm_enabled, + })) + } + + /// Update the node's swarm prefs. Params (both optional, at least one): + /// `{ source?: "origin" | "swarm", provide?: bool }`. + pub(super) async fn handle_update_set_source( + &self, + params: &serde_json::Value, + ) -> Result { + let mut touched = false; + if let Some(s) = params.get("source").and_then(|v| v.as_str()) { + let source = match s { + "origin" => update::UpdateSource::Origin, + "swarm" => update::UpdateSource::Swarm, + _ => anyhow::bail!("source must be \"origin\" or \"swarm\""), + }; + update::save_update_source(&self.config.data_dir, source).await?; + touched = true; + } + if let Some(provide) = params.get("provide").and_then(|v| v.as_bool()) { + update::save_provide_dht(&self.config.data_dir, provide).await?; + touched = true; + } + if !touched { + anyhow::bail!("expected \"source\" and/or \"provide\""); + } + self.handle_update_get_source().await + } + /// Add a mirror to the end of the list. Params: `{ url, label? }`. /// Duplicates (same URL) are replaced rather than added twice. pub(super) async fn handle_update_add_mirror( diff --git a/core/archipelago/src/ceremony.rs b/core/archipelago/src/ceremony.rs new file mode 100644 index 00000000..b491639c --- /dev/null +++ b/core/archipelago/src/ceremony.rs @@ -0,0 +1,138 @@ +//! Release-root signing ceremony — the publisher-side counterpart to +//! `trust::anchor`. Run as a subcommand of the same binary so it reuses the +//! exact key derivation (`seed::derive_release_root_ed25519`) and canonical +//! signing (`trust::signed_doc::sign_detached`) the fleet verifies against. +//! +//! Usage (the mnemonic is read from the `RELEASE_MASTER_MNEMONIC` env var or +//! stdin — never an argv so it stays out of shell history / `ps`): +//! +//! ```text +//! archipelago ceremony gen +//! Generate a fresh 24-word release master mnemonic and print it plus the +//! derived release-root pubkey + did. Back the mnemonic up OFFLINE. +//! +//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony pubkey +//! Print the release-root pubkey hex (for ARCHY_RELEASE_ROOT_PUBKEY / +//! trust::anchor::RELEASE_ROOT_PUBKEY_HEX) and the signer did:key. +//! +//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony sign +//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert +//! `signature` + `signed_by` over the canonical form, matching exactly +//! what `trust::verify_detached` recomputes on every node. +//! ``` + +use anyhow::{bail, Context, Result}; +use ed25519_dalek::SigningKey; + +use crate::seed::{self, MasterSeed}; +use crate::trust::{did, signed_doc}; + +const ENV_MNEMONIC: &str = "RELEASE_MASTER_MNEMONIC"; + +/// True if argv selects the ceremony subcommand. Checked before any server init. +pub fn is_ceremony_invocation() -> bool { + std::env::args().nth(1).as_deref() == Some("ceremony") +} + +/// Entry point for `archipelago ceremony …`. Returns Ok(()) on success; the +/// caller (main) should exit without starting the server. +pub fn run() -> Result<()> { + let sub = std::env::args().nth(2).unwrap_or_default(); + match sub.as_str() { + "gen" => cmd_gen(), + "pubkey" => cmd_pubkey(), + "sign" => { + let file = std::env::args() + .nth(3) + .context("usage: archipelago ceremony sign ")?; + cmd_sign(&file) + } + other => { + bail!( + "unknown ceremony subcommand {:?}; expected gen | pubkey | sign ", + other + ) + } + } +} + +fn cmd_gen() -> Result<()> { + let (mnemonic, seed) = MasterSeed::generate().context("generate mnemonic")?; + let key = seed::derive_release_root_ed25519(&seed).context("derive release-root")?; + eprintln!("⚠ Back this mnemonic up OFFLINE. It is the ONLY way to re-derive"); + eprintln!(" the release-root signing key. Anyone with it can sign for the fleet.\n"); + println!("RELEASE_MASTER_MNEMONIC=\"{}\"", mnemonic); + print_key(&key); + Ok(()) +} + +fn cmd_pubkey() -> Result<()> { + let key = load_release_root_key()?; + print_key(&key); + Ok(()) +} + +fn cmd_sign(path: &str) -> Result<()> { + let key = load_release_root_key()?; + + let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?; + let mut value: serde_json::Value = + serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?; + { + let obj = value + .as_object_mut() + .context("document root must be a JSON object")?; + // Re-sign cleanly: drop any prior signature so the preimage matches. + obj.remove("signature"); + obj.remove("signed_by"); + } + + let (signature, signed_by) = + signed_doc::sign_detached(&key, &value).context("sign document")?; + + let obj = value.as_object_mut().expect("checked above"); + obj.insert("signature".into(), serde_json::Value::String(signature)); + obj.insert("signed_by".into(), serde_json::Value::String(signed_by.clone())); + + let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?; + let tmp = format!("{path}.tmp"); + std::fs::write(&tmp, format!("{pretty}\n")).with_context(|| format!("write {tmp}"))?; + std::fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?; + + eprintln!("✓ signed {path}"); + eprintln!(" signed_by: {signed_by}"); + Ok(()) +} + +/// Derive the release-root signing key from the mnemonic in env/stdin. +fn load_release_root_key() -> Result { + let phrase = read_mnemonic()?; + let (_mnemonic, seed) = + MasterSeed::from_mnemonic_words(phrase.trim()).context("invalid release master mnemonic")?; + seed::derive_release_root_ed25519(&seed).context("derive release-root") +} + +/// Read the mnemonic from `RELEASE_MASTER_MNEMONIC` or, if unset, stdin. +fn read_mnemonic() -> Result { + if let Ok(v) = std::env::var(ENV_MNEMONIC) { + if !v.trim().is_empty() { + return Ok(v); + } + } + use std::io::Read; + eprintln!("Paste the release master mnemonic, then Ctrl-D:"); + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("read mnemonic from stdin")?; + if buf.trim().is_empty() { + bail!("no mnemonic provided (set {ENV_MNEMONIC} or pipe it on stdin)"); + } + Ok(buf) +} + +fn print_key(key: &SigningKey) { + let vk = key.verifying_key(); + println!("RELEASE_ROOT_PUBKEY_HEX={}", hex::encode(vk.to_bytes())); + println!("signed_by_did={}", did::did_key_for_ed25519(&vk)); +} diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index cf9739c5..f83f609b 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -281,6 +281,109 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> { )) } +/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING +/// container. +/// +/// For each writable bind mount, write-probe as the container's own process +/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec` +/// as root) to that service uid:gid. Because the chown runs in the container's +/// user namespace, podman translates it to the correct host owner regardless of +/// the rootless idmap — so there is NO host-side UID guessing, and it works for +/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps. +/// This is the durable replacement for the per-app hardcoded host chowns. +/// +/// Drift-checked via the write-probe, so it only `chown`s when the volume is +/// actually unwritable — cheap enough to call on every reconcile. Best-effort: +/// returns true if it repaired something; never fails reconcile (a degraded app +/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17). +async fn ensure_running_container_ownership(name: &str) -> bool { + async fn podman_stdout(args: &[&str]) -> Option { + let out = tokio::process::Command::new("podman") + .args(args) + .output() + .await + .ok()?; + if !out.status.success() { + return None; + } + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + // The uid:gid the container's main process actually runs as. + let uid = match podman_stdout(&["exec", name, "id", "-u"]).await { + Some(u) if !u.is_empty() => u, + _ => return false, // can't exec (no shell / not running) — nothing to do + }; + let gid = podman_stdout(&["exec", name, "id", "-g"]) + .await + .filter(|g| !g.is_empty()) + .unwrap_or_else(|| uid.clone()); + + // Writable bind-mount destinations only. + let dests = match podman_stdout(&[ + "inspect", + name, + "--format", + "{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}", + ]) + .await + { + Some(d) => d, + None => return false, + }; + + let mut repaired = false; + for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) { + // Never touch system / socket bind mounts. + if dest == "/" + || dest.starts_with("/proc") + || dest.starts_with("/sys") + || dest.starts_with("/dev") + || dest.starts_with("/run") + || dest.starts_with("/etc") + || dest.ends_with(".sock") + { + continue; + } + + // Drift check: can the service user write here already? + let probe = format!( + "t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null" + ); + let writable = tokio::process::Command::new("podman") + .args(["exec", name, "sh", "-c", &probe]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + if writable { + continue; + } + + // Repair inside the container's userns — podman maps to the right host uid. + let chown = tokio::process::Command::new("podman") + .args(["exec", "-u", "0", name, "chown", "-R", &format!("{uid}:{gid}"), dest]) + .output() + .await; + match chown { + Ok(o) if o.status.success() => { + repaired = true; + tracing::warn!( + container = %name, dest, uid = %uid, + "repaired unwritable volume ownership (in-container chown)" + ); + } + Ok(o) => tracing::warn!( + container = %name, dest, + "volume ownership repair failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}"), + } + } + repaired +} + async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs); loop { @@ -1155,6 +1258,30 @@ impl ProdContainerOrchestrator { } } } + + // App-agnostic volume-ownership self-heal. Sweep EVERY running container + // (registry/manifest apps AND legacy compose stacks like immich) and + // repair any that can't write their bind mounts — the durable, app- + // agnostic replacement for per-app hardcoded host chowns. Drift-checked, + // so steady state is just cheap in-container write-probes; only a broken + // volume is chowned (in-userns, mapping-proof) and its container + // restarted to recover. Fixes the class of EACCES crash-loops fleet-wide + // and self-heals existing nodes after OTA. (immich .198, 2026-06-17.) + if let Ok(containers) = self.runtime.list_containers().await { + for c in containers + .iter() + .filter(|c| matches!(c.state, ContainerState::Running)) + { + if ensure_running_container_ownership(&c.name).await { + tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover"); + let _ = tokio::process::Command::new("podman") + .args(["restart", &c.name]) + .output() + .await; + } + } + } + report } @@ -1180,6 +1307,33 @@ impl ProdContainerOrchestrator { let _guard = lock.lock().await; self.ensure_app_secrets(&app_id).await?; + + // Don't fight the Bitcoin-implementation switch: bitcoin-core and + // bitcoin-knots share port 8332, so if the *other* variant is already + // running the inactive one can never start — the reconciler would just + // churn "address already in use" and report a reconcile failure. Skip + // it, mirroring the health monitor's same skip. (#47) + if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) { + "bitcoin-core" => Some("bitcoin-knots"), + "bitcoin-knots" | "bitcoin" => Some("bitcoin-core"), + _ => None, + } { + if let Ok(list) = self.runtime.list_containers().await { + let other_running = list.iter().any(|c| { + c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict + && matches!(c.state, ContainerState::Running) + }); + if other_running { + tracing::debug!( + app_id = %app_id, + conflict, + "skipping reconcile — the other Bitcoin implementation is running" + ); + return Ok(ReconcileAction::NoOp); + } + } + } + let mut resolved_manifest = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved_manifest)?; let name = compute_container_name(&lm.manifest); diff --git a/core/archipelago/src/content_invoice.rs b/core/archipelago/src/content_invoice.rs new file mode 100644 index 00000000..1ecab188 --- /dev/null +++ b/core/archipelago/src/content_invoice.rs @@ -0,0 +1,80 @@ +//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46). +//! +//! When a buyer asks to pay for a paid catalog item with an external wallet (as +//! opposed to the local-ecash fast path), the *selling* node mints a Lightning +//! invoice on its own LND and records a pending entitlement here, keyed by the +//! invoice's payment hash. The buyer pays the invoice from any wallet and polls +//! for settlement; once the seller's LND confirms the invoice is settled we mark +//! the entitlement paid, and the content gate (`content_server::serve_content`) +//! then releases the file to anyone presenting that payment hash. +//! +//! State is in-memory and bounded by a TTL. If the seller restarts before the +//! buyer pays, the buyer simply requests a fresh invoice — no value is lost +//! because an unpaid invoice represents no money. + +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; + +/// How long a pending/paid entitlement is retained. Generous enough for a human +/// to pay an invoice and download, short enough to keep the map small. +const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour + +#[derive(Clone)] +struct Entitlement { + content_id: String, + price_sats: u64, + paid: bool, + created_at: Instant, +} + +static ENTITLEMENTS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Drop expired entries. Caller must hold the lock. +fn prune(map: &mut HashMap) { + map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL); +} + +/// Record a freshly-minted invoice as a pending (unpaid) entitlement. +pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) { + let mut map = ENTITLEMENTS.lock().await; + prune(&mut map); + map.insert( + payment_hash.to_string(), + Entitlement { + content_id: content_id.to_string(), + price_sats, + paid: false, + created_at: Instant::now(), + }, + ); +} + +/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired. +pub async fn mark_paid(payment_hash: &str) { + let mut map = ENTITLEMENTS.lock().await; + prune(&mut map); + if let Some(e) = map.get_mut(payment_hash) { + e.paid = true; + } +} + +/// The content_id + price an entitlement was issued for, if still live. +pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> { + let mut map = ENTITLEMENTS.lock().await; + prune(&mut map); + map.get(payment_hash) + .map(|e| (e.content_id.clone(), e.price_sats)) +} + +/// True if `payment_hash` is a paid entitlement for exactly `content_id`. +/// This is the gate the content server consults to release a file. +pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool { + let mut map = ENTITLEMENTS.lock().await; + prune(&mut map); + map.get(payment_hash) + .map(|e| e.paid && e.content_id == content_id) + .unwrap_or(false) +} diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 2566388e..0118c8ea 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -198,6 +198,7 @@ pub async fn serve_content( data_dir: &Path, id: &str, payment_token: Option<&str>, + invoice_hash: Option<&str>, peer_did: Option<&str>, range: Option, ) -> Result { @@ -236,12 +237,24 @@ pub async fn serve_content( // Check access control match &item.access { AccessControl::Paid { price_sats } => { - // Verify payment token + // Two ways to satisfy payment: + // (a) a valid ecash token (the local-wallet fast path), or + // (b) a Lightning-invoice payment hash this node issued and has + // since confirmed settled (the "pay from any wallet" path, #46). + let mut authorized = false; if let Some(token) = payment_token { - if !verify_payment_token(data_dir, token, *price_sats).await { - return Ok(ServeResult::PaymentRequired(*price_sats)); + if verify_payment_token(data_dir, token, *price_sats).await { + authorized = true; } - } else { + } + if !authorized { + if let Some(hash) = invoice_hash { + if crate::content_invoice::is_paid_for(hash, id).await { + authorized = true; + } + } + } + if !authorized { return Ok(ServeResult::PaymentRequired(*price_sats)); } } @@ -317,10 +330,63 @@ pub enum PreviewResult { BlurPreview(Vec, String), /// Truncated preview for paid video (first ~2% of bytes). TruncatedPreview(Vec, String, u64), + /// A preview can't be produced for this media without re-encoding (e.g. a + /// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't + /// play). The UI shows its "preview unavailable" overlay instead of a + /// broken player. (#35) + PreviewUnavailable, /// Content not found. NotFound, } +/// Scan an MP4's top-level boxes and report whether `moov` appears before +/// `mdat` ("faststart"). Returns `Some(true)` if faststart (a byte prefix is +/// playable), `Some(false)` if the media data precedes the index (a prefix +/// will NOT play), or `None` if neither box is found / the file isn't parseable +/// as ISO-BMFF (caller falls back to the legacy prefix behavior). +async fn mp4_is_faststart(path: &std::path::Path) -> Option { + use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom}; + let mut f = tokio::fs::File::open(path).await.ok()?; + let file_len = f.metadata().await.ok()?.len(); + let mut pos: u64 = 0; + // Bound the walk so a malformed file can't spin forever. + for _ in 0..1024 { + if pos.saturating_add(8) > file_len { + return None; + } + f.seek(SeekFrom::Start(pos)).await.ok()?; + let mut hdr = [0u8; 8]; + if f.read_exact(&mut hdr).await.is_err() { + return None; + } + let mut size = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as u64; + let btype = &hdr[4..8]; + let mut header_len = 8u64; + if size == 1 { + // 64-bit extended size. + let mut ext = [0u8; 8]; + if f.read_exact(&mut ext).await.is_err() { + return None; + } + size = u64::from_be_bytes(ext); + header_len = 16; + } else if size == 0 { + // Box runs to EOF — it's the last one. + size = file_len.saturating_sub(pos); + } + match btype { + b"moov" => return Some(true), // index before media → faststart + b"mdat" => return Some(false), // media before index → not faststart + _ => {} + } + if size < header_len { + return None; // malformed + } + pos = pos.checked_add(size)?; + } + None +} + /// Serve a preview of content by ID. For paid content, returns degraded previews: /// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur) /// - Videos: first 2% of file bytes (minimum 512KB for codec headers) @@ -358,6 +424,26 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result Vec { + let mut v = size.to_be_bytes().to_vec(); + v.extend_from_slice(typ); + v + } + + #[tokio::test] + async fn detects_faststart_moov_before_mdat() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("fast.mp4"); + let mut data = Vec::new(); + data.extend(box_hdr(16, b"ftyp")); + data.extend([0u8; 8]); + data.extend(box_hdr(8, b"moov")); + data.extend(box_hdr(8, b"mdat")); + tokio::fs::write(&p, &data).await.unwrap(); + assert_eq!(mp4_is_faststart(&p).await, Some(true)); + } + + #[tokio::test] + async fn detects_non_faststart_mdat_before_moov() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("slow.mp4"); + let mut data = Vec::new(); + data.extend(box_hdr(16, b"ftyp")); + data.extend([0u8; 8]); + data.extend(box_hdr(16, b"mdat")); + data.extend([0u8; 8]); + data.extend(box_hdr(8, b"moov")); + tokio::fs::write(&p, &data).await.unwrap(); + assert_eq!(mp4_is_faststart(&p).await, Some(false)); + } +} diff --git a/core/archipelago/src/federation/pending.rs b/core/archipelago/src/federation/pending.rs index 310eaded..dc0d0e96 100644 --- a/core/archipelago/src/federation/pending.rs +++ b/core/archipelago/src/federation/pending.rs @@ -117,9 +117,12 @@ fn expire_stale(requests: &mut Vec) { /// or `None` if the request was deduplicated or rate-limited. /// /// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a -/// `Pending` entry, do not insert a second one — the user will see the -/// existing row and act on that. Otherwise count `Pending` entries per -/// pubkey and reject anything beyond `MAX_PENDING_PER_PUBKEY`. +/// `Pending` OR `Approved` entry, do not insert a second one. Including +/// `Approved` is what stops an already-approved peer from re-spawning a fresh +/// pending row every time their request re-syncs (the reported "approve, Poll +/// Now, see approved + a new pending" loop). `Rejected` is intentionally NOT +/// matched so a previously-rejected peer can still ask again later. Otherwise +/// count `Pending` entries per pubkey and reject beyond `MAX_PENDING_PER_PUBKEY`. pub async fn insert_inbound( data_dir: &Path, from_nostr_pubkey: String, @@ -131,13 +134,13 @@ pub async fn insert_inbound( let mut requests = load_pending(data_dir).await?; expire_stale(&mut requests); - let already_pending = requests.iter().any(|r| { + let already_handled = requests.iter().any(|r| { r.from_nostr_pubkey == from_nostr_pubkey && r.from_did == from_did - && matches!(r.state, PendingState::Pending) + && matches!(r.state, PendingState::Pending | PendingState::Approved) && !r.outbound }); - if already_pending { + if already_handled { save_pending(data_dir, &requests).await?; return Ok(None); } @@ -271,6 +274,54 @@ mod tests { assert!(r2.is_none(), "duplicate Pending request should be ignored"); } + #[tokio::test] + async fn test_approved_request_does_not_respawn_pending() { + // Regression for the "approve → Poll Now → approved + a fresh pending" + // loop: once a request is Approved, a re-synced inbound for the same + // peer must NOT create a new Pending row. + let dir = tempfile::tempdir().unwrap(); + let r1 = insert_inbound( + dir.path(), + "npk1".into(), + "npub1".into(), + "did:key:zABC".into(), + None, + None, + ) + .await + .unwrap() + .expect("first insert stored"); + + set_state(dir.path(), &r1.id, PendingState::Approved) + .await + .unwrap(); + + let r2 = insert_inbound( + dir.path(), + "npk1".into(), + "npub1".into(), + "did:key:zABC".into(), + None, + None, + ) + .await + .unwrap(); + assert!( + r2.is_none(), + "an already-approved peer must not re-spawn a pending request" + ); + + let pending = load_pending(dir.path()).await.unwrap(); + assert_eq!( + pending + .iter() + .filter(|r| matches!(r.state, PendingState::Pending)) + .count(), + 0, + "no Pending rows should remain after approval + re-sync" + ); + } + #[tokio::test] async fn test_rate_limit() { let dir = tempfile::tempdir().unwrap(); diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 20db5609..41501e55 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -33,10 +33,12 @@ mod bitcoin_rpc; mod bitcoin_status; mod blobs; mod bootstrap; +mod ceremony; mod config; mod constants; mod container; mod content_hash; +mod content_invoice; mod content_server; mod crash_recovery; mod credentials; @@ -85,6 +87,13 @@ use server::Server; #[tokio::main] async fn main() -> Result<()> { + // Release-root signing ceremony: a publisher-side subcommand of the same + // binary. Handle it before any server/tracing init so its stdout stays + // clean (machine-readable KEY=VALUE lines) and it never touches node state. + if ceremony::is_ceremony_invocation() { + return ceremony::run(); + } + let startup_start = std::time::Instant::now(); crash_recovery::init_start_time(); diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 927fac4a..cf236d07 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -697,6 +697,10 @@ async fn dispatch_block_header( sender_name: &str, state: &Arc, ) { + // Respect the receive toggle (issue #28): nodes can opt out of inbound headers. + if !state.receive_block_headers { + return; + } // Compact binary format: height(8) + hash(32) + timestamp(4) match super::super::bitcoin_relay::decode_compact_block_header(&envelope.v) { Ok((height, hash_hex, timestamp)) => { diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index d52584e1..5cabba36 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -102,6 +102,8 @@ pub struct MeshState { pub session_manager: Arc, /// Whether to encrypt directed relay messages (config toggle for rollback). pub encrypt_relay: bool, + /// Whether to accept inbound Bitcoin block headers from peers (issue #28). + pub receive_block_headers: bool, /// Last-seen presence heartbeats per peer pubkey hex: (status, last_active_epoch, received_at). pub presence: RwLock>, /// Contacts store — alias/notes/pinned/blocked per peer pubkey hex. @@ -151,6 +153,7 @@ impl MeshState { relay_tracker: Option>, stego_mode: super::steganography::SteganographyMode, encrypt_relay: bool, + receive_block_headers: bool, session_manager: Arc, our_ed_pubkey_hex: String, ) -> ( @@ -187,6 +190,7 @@ impl MeshState { chunk_buffer: RwLock::new(HashMap::new()), session_manager, encrypt_relay, + receive_block_headers, presence: RwLock::new(HashMap::new()), contacts: RwLock::new(HashMap::new()), our_ed_pubkey_hex, diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 46984dd3..8e4cac1d 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -169,6 +169,10 @@ pub struct MeshConfig { /// Announce new Bitcoin block headers over mesh (internet-connected nodes only). #[serde(default)] pub announce_block_headers: bool, + /// Accept Bitcoin block headers received over mesh from peers. On by default; + /// turn off to ignore inbound headers (the receive half of issue #28). + #[serde(default = "default_true")] + pub receive_block_headers: bool, /// Steganographic encoding mode for mesh messages (Normal = disabled). #[serde(default)] pub steganography_mode: steganography::SteganographyMode, @@ -192,6 +196,7 @@ impl Default for MeshConfig { advert_name: None, mesh_only_mode: None, announce_block_headers: false, + receive_block_headers: true, steganography_mode: steganography::SteganographyMode::Normal, encrypt_relay_messages: true, } @@ -360,6 +365,7 @@ impl MeshService { Some(Arc::clone(&relay_tracker)), config.steganography_mode, config.encrypt_relay_messages, + config.receive_block_headers, Arc::clone(&session_manager), ed_pubkey_hex.to_string(), ); diff --git a/core/archipelago/src/node_message.rs b/core/archipelago/src/node_message.rs index 660b3640..1b5feeda 100644 --- a/core/archipelago/src/node_message.rs +++ b/core/archipelago/src/node_message.rs @@ -17,6 +17,10 @@ pub struct IncomingMessage { /// Sender's node name (for display in group chat). #[serde(default)] pub from_name: Option, + /// Sender-assigned unique id for the message. Used to dedup reliably even + /// when a slow-Tor retry/redelivery arrives outside the time window (#31). + #[serde(default)] + pub msg_id: Option, pub message: String, pub timestamp: String, /// "sent" or "received" @@ -141,18 +145,34 @@ fn persist() { } /// Store a received message (called from HTTP handler). -pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) { +pub fn store_received_sync( + from_pubkey: &str, + message: &str, + from_name: Option<&str>, + msg_id: Option<&str>, +) { let ts = chrono::Utc::now().to_rfc3339(); let mut guard = store().lock().unwrap_or_else(|e| e.into_inner()); - // Deduplication: skip if same pubkey + message within last 30 seconds - let dominated = guard.messages.iter().rev().take(20).any(|m| { - m.from_pubkey == from_pubkey - && m.message == message - && m.direction == "received" - && within_seconds(&m.timestamp, &ts, 30) - }); - if dominated { + // Deduplication. When the sender supplied a unique id, dedup on + // (from_pubkey, msg_id) across all retained history — this is robust even + // when a slow-Tor redelivery arrives well outside any time window (#31). + // Older senders send no id; fall back to the legacy same-pubkey+message + // within-30s heuristic. + let duplicate = if let Some(id) = msg_id { + guard + .messages + .iter() + .any(|m| m.from_pubkey == from_pubkey && m.msg_id.as_deref() == Some(id)) + } else { + guard.messages.iter().rev().take(20).any(|m| { + m.from_pubkey == from_pubkey + && m.message == message + && m.direction == "received" + && within_seconds(&m.timestamp, &ts, 30) + }) + }; + if duplicate { return; } @@ -160,6 +180,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<& from_pubkey: from_pubkey.to_string(), from_onion: None, from_name: from_name.map(|s| s.to_string()), + msg_id: msg_id.map(|s| s.to_string()), message: message.to_string(), timestamp: ts, direction: "received".to_string(), @@ -169,8 +190,13 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<& persist(); } -pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) { - store_received_sync(from_pubkey, message, from_name); +pub async fn store_received( + from_pubkey: &str, + message: &str, + from_name: Option<&str>, + msg_id: Option<&str>, +) { + store_received_sync(from_pubkey, message, from_name, msg_id); } /// Store a sent message (for display in Archipelago channel). @@ -180,6 +206,7 @@ pub fn store_sent(message: &str) { from_pubkey: "me".to_string(), from_onion: None, from_name: None, + msg_id: None, message: message.to_string(), timestamp: chrono::Utc::now().to_rfc3339(), direction: "sent".to_string(), @@ -335,6 +362,9 @@ pub async fn send_to_peer( "message": payload_message, "timestamp": chrono::Utc::now().to_rfc3339(), "encrypted": encrypted, + // Unique per-message id so receivers can dedup reliably even across + // slow-Tor retries/redeliveries (#31). Old receivers ignore it. + "msg_id": uuid::Uuid::new_v4().to_string(), }); if let Some(name) = from_name { body["from_name"] = serde_json::Value::String(name.to_string()); diff --git a/core/archipelago/src/port_allocator.rs b/core/archipelago/src/port_allocator.rs index e3a824f8..98e85329 100644 --- a/core/archipelago/src/port_allocator.rs +++ b/core/archipelago/src/port_allocator.rs @@ -18,6 +18,7 @@ const RESERVED_PORTS: &[u16] = &[ 4080, 8999, 50001, // Mempool stack 23000, // BTCPay 8173, 8174, 8175, // Fedimint + 8178, // Fedimint client daemon (fedimint-clientd REST) 8123, // Home Assistant 3000, // Grafana 11434, // Ollama diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index bf9b6025..6c093938 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -266,6 +266,39 @@ impl Server { warn!("Mesh service start failed (non-fatal): {}", e); } else { info!("📡 Mesh networking started"); + + // Push mesh peer changes to open WebSockets instantly + // instead of the UI polling every 5s (#48): subscribe to + // mesh events and nudge the data-model revision (debounced) + // so /ws/db clients refetch peers on discovery/update. + let mut rx = mesh_service.state().event_tx.subscribe(); + let sm = state_manager.clone(); + tokio::spawn(async move { + use tokio::time::{Duration, Instant}; + let mut last: Option = None; + loop { + match rx.recv().await { + Ok(crate::mesh::MeshEvent::PeerDiscovered(_)) + | Ok(crate::mesh::MeshEvent::PeerUpdated(_)) => { + // Debounce advert storms to ~2 Hz. + if last + .map(|t| t.elapsed() < Duration::from_millis(500)) + .unwrap_or(false) + { + continue; + } + last = Some(Instant::now()); + let (data, _) = sm.get_snapshot().await; + sm.update_data(data).await; + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + continue + } + Err(_) => break, // sender dropped → mesh stopped + } + } + }); } } api_handler @@ -1285,6 +1318,7 @@ fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port: fn fallback_package_port(app_id: &str) -> Option { match app_id { "fedimint" | "fedimintd" => Some(8175), + "fedimint-clientd" => Some(8178), "filebrowser" => Some(8083), "indeedhub" => Some(7778), "nginx-proxy-manager" => Some(8081), diff --git a/core/archipelago/src/update.rs b/core/archipelago/src/update.rs index 2c3e7fe8..651cb94a 100644 --- a/core/archipelago/src/update.rs +++ b/core/archipelago/src/update.rs @@ -202,6 +202,106 @@ pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<( Ok(()) } +// ─── Update/app fetch source (origin vs DHT swarm) ────────────────────────── +// +// User-selectable per node, persisted in `data_dir/update-source.json`. This is +// the live-testing switch: keep `Origin` (default) to pull releases/app blobs +// purely over HTTP from the configured mirrors — the known-good path — or flip +// to `Swarm` on a test node to exercise the DHT (iroh swarm-assist), knowing the +// origin still always wins as fallback. Independent of the compile-time +// `iroh-swarm` feature and the `swarm_enabled` config: if the swarm engine isn't +// present, `Swarm` simply has no peers to consult and behaves like `Origin`. + +const UPDATE_SOURCE_FILE: &str = "update-source.json"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum UpdateSource { + /// HTTP origin/mirrors only. The safe default and the universal fallback. + #[default] + Origin, + /// Try DHT swarm peers first for content-addressed blobs, origin always wins. + Swarm, +} + +fn default_true() -> bool { + true +} + +/// Node-level swarm preferences, persisted together in `update-source.json`. +/// Two independent switches: +/// - `source`: where THIS node fetches (origin vs swarm). Default origin. +/// - `provide_dht`: whether this node SEEDS/serves blobs to peers. Default on +/// (opt-out) so the swarm has providers; nodes that don't want to serve can +/// turn it off without affecting how they fetch. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +struct SwarmPrefs { + #[serde(default)] + source: UpdateSource, + #[serde(default = "default_true")] + provide_dht: bool, +} + +impl Default for SwarmPrefs { + fn default() -> Self { + Self { + source: UpdateSource::default(), + provide_dht: true, + } + } +} + +fn update_source_path(data_dir: &Path) -> std::path::PathBuf { + data_dir.join(UPDATE_SOURCE_FILE) +} + +async fn load_swarm_prefs(data_dir: &Path) -> SwarmPrefs { + match fs::read_to_string(update_source_path(data_dir)).await { + Ok(s) => serde_json::from_str::(&s).unwrap_or_default(), + Err(_) => SwarmPrefs::default(), + } +} + +async fn save_swarm_prefs(data_dir: &Path, prefs: &SwarmPrefs) -> Result<()> { + fs::create_dir_all(data_dir) + .await + .with_context(|| format!("mkdir {}", data_dir.display()))?; + let path = update_source_path(data_dir); + let tmp = path.with_extension("json.tmp"); + let json = serde_json::to_vec_pretty(prefs).context("serialize swarm prefs")?; + fs::write(&tmp, json) + .await + .with_context(|| format!("write {}", tmp.display()))?; + fs::rename(&tmp, &path) + .await + .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; + Ok(()) +} + +/// Load the node's selected fetch source. Missing/corrupt file → `Origin`. +pub async fn load_update_source(data_dir: &Path) -> UpdateSource { + load_swarm_prefs(data_dir).await.source +} + +/// Persist the node's selected fetch source (preserving `provide_dht`). +pub async fn save_update_source(data_dir: &Path, source: UpdateSource) -> Result<()> { + let mut prefs = load_swarm_prefs(data_dir).await; + prefs.source = source; + save_swarm_prefs(data_dir, &prefs).await +} + +/// Whether this node seeds/serves blobs to peers. Default true (opt-out). +pub async fn load_provide_dht(data_dir: &Path) -> bool { + load_swarm_prefs(data_dir).await.provide_dht +} + +/// Persist whether this node provides to the swarm (preserving `source`). +pub async fn save_provide_dht(data_dir: &Path, provide: bool) -> Result<()> { + let mut prefs = load_swarm_prefs(data_dir).await; + prefs.provide_dht = provide; + save_swarm_prefs(data_dir, &prefs).await +} + /// Parse a manifest URL and return its `scheme://host[:port]` prefix. /// Used by `rewrite_manifest_origins` so a manifest fetched from a /// mirror points component downloads back at the same mirror rather @@ -795,6 +895,23 @@ pub async fn download_update(data_dir: &Path) -> Result { DOWNLOAD_BYTES.store(0, Ordering::Relaxed); DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed); + // Consult swarm peers only when the node has opted into DHT mode. In Origin + // mode (default) this stays empty so every component goes straight to the + // HTTP origin — instant, no-rebuild fallback while live-testing the swarm. + let update_source = load_update_source(data_dir).await; + let provide_dht = load_provide_dht(data_dir).await; + let swarm_providers = if update_source == UpdateSource::Swarm { + crate::swarm::providers() + } else { + Vec::new() + }; + if update_source == UpdateSource::Swarm { + info!( + providers = swarm_providers.len(), + "Update source = DHT swarm (origin still wins as fallback)" + ); + } + for component in &manifest.components { if is_canceled() { DOWNLOAD_TOTAL.store(0, Ordering::Relaxed); @@ -825,7 +942,7 @@ pub async fn download_update(data_dir: &Path) -> Result { let dest_ref = &dest; let source = crate::swarm::fetch_content_addressed( &digest, - &crate::swarm::providers(), + &swarm_providers, &dest, move || async move { download_component_resumable(client_ref, component, dest_ref, downloaded).await @@ -844,9 +961,13 @@ pub async fn download_update(data_dir: &Path) -> Result { } } // This is a PUBLIC release blob and it just passed both the BLAKE3 and - // SHA-256 gates — announce that we can now seed it to peers. Best-effort - // and inert unless the iroh swarm is active; never blocks the install. - crate::swarm::announce_held_blob(&digest.hex, &dest).await; + // SHA-256 gates — announce that we can now seed it to peers. Gated on + // the node's "provide to swarm" preference (default on); best-effort, + // inert unless the iroh swarm is active, and never blocks the install. + // Independent of fetch source: an origin-fetching node can still seed. + if provide_dht { + crate::swarm::announce_held_blob(&digest.hex, &dest).await; + } } else { download_component_resumable(&client, component, &dest, downloaded).await?; } diff --git a/core/archipelago/src/wallet/fedimint_client.rs b/core/archipelago/src/wallet/fedimint_client.rs new file mode 100644 index 00000000..2c6682d0 --- /dev/null +++ b/core/archipelago/src/wallet/fedimint_client.rs @@ -0,0 +1,285 @@ +//! Thin HTTP bridge to the `fedimint-clientd` sidecar container. +//! +//! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the +//! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation +//! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth), +//! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API. +//! +//! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd +//! v0.3.x and must be pinned to the vendored image tag. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use tokio::fs; +use tracing::debug; + +const CLIENTD_TIMEOUT_SECS: u64 = 15; +const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60; + +/// Default host port the `fedimint-clientd` container is mapped to (its own +/// default 8080 collides with LND REST, so the manifest maps it to 8178). +const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178"; + +/// Federation joined out-of-the-box on every node. The fmcd container also +/// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync. +/// +/// The preferred default federation (guardian on .116, iroh transport). +/// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does +/// NAT traversal, so it's reachable fleet-wide — the right fleet default. +/// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT +/// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate +/// reliability from a separate node. ensure_default_federation is best-effort. +/// See docs/dual-ecash-design.md. +pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp"; + +/// One joined federation, persisted locally so the list survives clientd being +/// temporarily down. Balances are always read live from clientd. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinedFederation { + pub federation_id: String, + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct FederationRegistry { + pub federations: Vec, +} + +const REGISTRY_FILE: &str = "wallet/fedimint_federations.json"; + +pub async fn load_registry(data_dir: &Path) -> Result { + let path = data_dir.join(REGISTRY_FILE); + if !path.exists() { + return Ok(FederationRegistry::default()); + } + let content = fs::read_to_string(&path) + .await + .context("Failed to read fedimint federation registry")?; + Ok(serde_json::from_str(&content).unwrap_or_default()) +} + +pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> { + let dir = data_dir.join("wallet"); + fs::create_dir_all(&dir) + .await + .context("Failed to create wallet dir")?; + let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?; + fs::write(data_dir.join(REGISTRY_FILE), content) + .await + .context("Failed to write fedimint federation registry")?; + Ok(()) +} + +/// Idempotently ensure the node has joined the default federation and that it +/// is tracked in the local registry. Best-effort: silently no-ops if clientd +/// isn't installed/running yet. Joining is idempotent on the clientd side. +pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> { + let client = match FedimintClient::from_node(data_dir).await { + Ok(c) => c, + Err(_) => return Ok(()), // clientd not configured yet + }; + let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await { + Ok(id) => id, + Err(e) => { + debug!("default federation autojoin skipped: {e}"); + return Ok(()); + } + }; + let mut reg = load_registry(data_dir).await?; + if !reg.federations.iter().any(|f| f.federation_id == federation_id) { + reg.federations.push(JoinedFederation { + federation_id, + name: None, + }); + save_registry(data_dir, ®).await?; + } + Ok(()) +} + +/// HTTP client for a `fedimint-clientd` instance. +pub struct FedimintClient { + base_url: String, + password: String, + client: reqwest::Client, +} + +impl FedimintClient { + pub fn new(base_url: &str, password: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS)) + .build() + .context("Failed to build HTTP client for fedimint-clientd")?; + Ok(Self::with_client(base_url, password, client)) + } + + pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + password: password.to_string(), + client, + } + } + + /// Resolve URL + password from env / node secret, with sane defaults. + /// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port. + /// Password: `FEDIMINT_CLIENTD_PASSWORD` else `/fedimint-clientd/password`. + pub async fn from_node(data_dir: &Path) -> Result { + let base_url = + std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string()); + let password = match std::env::var("FMCD_PASSWORD") { + Ok(p) if !p.is_empty() => p, + _ => { + let secret = data_dir.join("fmcd").join("password"); + fs::read_to_string(&secret) + .await + .map(|s| s.trim().to_string()) + .context( + "Fedimint client not configured (no FMCD_PASSWORD and no \ + fmcd/password secret). Install the Fedimint client app.", + )? + } + }; + Self::new(&base_url, &password) + } + + fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + // fmcd uses HTTP Basic auth with a fixed username `fmcd`. + req.basic_auth("fmcd", Some(&self.password)) + } + + async fn post(&self, path: &str, body: serde_json::Value) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .auth(self.client.post(&url)) + .json(&body) + .send() + .await + .with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?; + Self::parse(resp, path).await + } + + async fn get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + let resp = self + .auth(self.client.get(&url)) + .timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS)) + .send() + .await + .with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?; + Self::parse(resp, path).await + } + + async fn parse(resp: reqwest::Response, path: &str) -> Result { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("fedimint-clientd {path} returned {status}: {text}"); + } + if text.is_empty() { + return Ok(serde_json::json!({})); + } + serde_json::from_str(&text) + .with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}")) + } + + /// `GET /v2/admin/info` — per-federation holdings keyed by federationId. + pub async fn info(&self) -> Result { + self.get("/v2/admin/info").await + } + + /// `POST /v2/admin/join` — join a federation by invite code; returns its federationId. + pub async fn join(&self, invite_code: &str) -> Result { + let res = self + .post( + "/v2/admin/join", + serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }), + ) + .await?; + let id = res + .get("thisFederationId") + .or_else(|| res.get("federationId")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + match id { + Some(id) => { + debug!("joined fedimint federation {id}"); + Ok(id) + } + // Older/newer clientd may return the full info map; fall back to info(). + None => self.latest_federation_id().await, + } + } + + /// Total balance across all joined federations, in sats. + pub async fn total_balance_sats(&self) -> Result { + let info = self.info().await?; + Ok(sum_msat(&info) / 1000) + } + + /// Balance of one federation in sats (0 if unknown). + pub async fn federation_balance_sats(&self, federation_id: &str) -> Result { + let info = self.info().await?; + let msat = info + .get(federation_id) + .and_then(federation_msat) + .unwrap_or(0); + Ok(msat / 1000) + } + + /// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes. + pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result { + let res = self + .post( + "/v2/mint/spend", + serde_json::json!({ + "federationId": federation_id, + "amountMsat": amount_sats * 1000, + "allowOverpay": true, + "timeout": 3600, + "includeInvite": false, + }), + ) + .await?; + res.get("notes") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response")) + } + + /// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats. + pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result { + let res = self + .post( + "/v2/mint/reissue", + serde_json::json!({ "federationId": federation_id, "notes": notes }), + ) + .await?; + let msat = res + .get("amountMsat") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?; + Ok(msat / 1000) + } + + async fn latest_federation_id(&self) -> Result { + let info = self.info().await?; + info.as_object() + .and_then(|m| m.keys().next_back().cloned()) + .ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none")) + } +} + +fn federation_msat(entry: &serde_json::Value) -> Option { + entry + .get("totalAmountMsat") + .or_else(|| entry.get("totalMsat")) + .and_then(|v| v.as_u64()) +} + +fn sum_msat(info: &serde_json::Value) -> u64 { + info.as_object() + .map(|m| m.values().filter_map(federation_msat).sum()) + .unwrap_or(0) +} diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs index b4131bcc..098543f9 100644 --- a/core/archipelago/src/wallet/mod.rs +++ b/core/archipelago/src/wallet/mod.rs @@ -4,5 +4,6 @@ pub mod bdhke; pub mod cashu; pub mod ecash; +pub mod fedimint_client; pub mod mint_client; pub mod profits; diff --git a/docker/fmcd/Dockerfile b/docker/fmcd/Dockerfile new file mode 100644 index 00000000..a3dfdbfe --- /dev/null +++ b/docker/fmcd/Dockerfile @@ -0,0 +1,21 @@ +# fmcd (Fedimint client daemon) runtime image. +# +# The fmcd binary is built from source (github.com/minmoto/fmcd v0.8.0, +# fedimint-client 0.8.2 — iroh-capable) with Rust 1.86.0, then copied in here. +# Base must match the build host's glibc (Debian trixie / glibc 2.41). +# Binary is dynamically linked against libstdc++ (statically-bundled rocksdb) +# and uses rustls (no openssl). Build context must contain the `fmcd` binary. +FROM debian:trixie-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libstdc++6 \ + && rm -rf /var/lib/apt/lists/* + +COPY fmcd /usr/local/bin/fmcd +COPY fmcd-run /usr/local/bin/fmcd-run +RUN chmod +x /usr/local/bin/fmcd /usr/local/bin/fmcd-run + +EXPOSE 8080 +# Resilient launcher (retries on join failure instead of crash-looping). +# All config is read from FMCD_* env vars. +ENTRYPOINT ["fmcd-run"] diff --git a/docker/fmcd/fmcd-run b/docker/fmcd/fmcd-run new file mode 100644 index 00000000..b5d92792 --- /dev/null +++ b/docker/fmcd/fmcd-run @@ -0,0 +1,17 @@ +#!/bin/sh +# Resilient launcher for fmcd. +# +# fmcd requires >=1 federation to boot — if the default federation is +# unreachable at first boot it exits non-zero. Rather than let the container +# crash-loop (and on a node, spam restarts), retry here with a backoff so the +# join happens in the background once the federation becomes reachable. Once +# fmcd is up it runs forever; this loop only re-runs it on exit. +# +# All config comes from FMCD_* env (FMCD_ADDR, FMCD_MODE, FMCD_DATA_DIR, +# FMCD_INVITE_CODE, FMCD_PASSWORD), so fmcd needs no CLI args here. +set -u +while true; do + fmcd || true + echo "[fmcd-run] fmcd exited (federation unreachable?); retrying in 30s" >&2 + sleep 30 +done