diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index e4a424c0..0b06b1db 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -146,7 +146,9 @@ impl ApiHandler { Ok(content_server::ServeResult::Forbidden) => Ok(build_response( StatusCode::FORBIDDEN, "application/json", - hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#), + hyper::Body::from( + r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#, + ), )), Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response( StatusCode::NOT_FOUND, diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index ea66666c..7bf331b3 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -260,6 +260,20 @@ impl RpcHandler { })); } + // A 403 carries an actionable reason in its JSON body (e.g. "shared with + // the host's federation peers only — federate first"). Surface that to + // the user instead of a bare "Peer returned: 403 Forbidden". + if response.status() == reqwest::StatusCode::FORBIDDEN { + let status = response.status(); + let body: serde_json::Value = response.json().await.unwrap_or_default(); + let msg = body + .get("error") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("Peer returned: {status}")); + return Err(anyhow::anyhow!(msg)); + } + if !response.status().is_success() { return Err(anyhow::anyhow!("Peer returned: {}", response.status())); } @@ -463,12 +477,16 @@ impl RpcHandler { 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; + // Minting a bolt11 is a tiny request/response — keep it snappy. Cap the + // FIPS attempt hard so a cold overlay can't burn the whole budget, and + // give Tor a short-but-real window (onion circuits need a few seconds). 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)) + .timeout(std::time::Duration::from_secs(25)) + .fips_timeout(std::time::Duration::from_secs(6)) .send_get() .await { @@ -524,11 +542,15 @@ impl RpcHandler { } let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; + // Settlement poll — runs repeatedly, so each call must be quick. Fast-fail + // FIPS and keep a short Tor window; an unreachable peer just reads as + // "not yet paid" and the UI polls again. 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)) + .timeout(std::time::Duration::from_secs(15)) + .fips_timeout(std::time::Duration::from_secs(6)) .send_get() .await { @@ -652,12 +674,15 @@ impl RpcHandler { 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; + // Issuing an address is a tiny request/response — fast-fail FIPS, short + // Tor window (same budget shape as the invoice path, #6). 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)) + .timeout(std::time::Duration::from_secs(25)) + .fips_timeout(std::time::Duration::from_secs(6)) .send_get() .await { @@ -715,7 +740,8 @@ impl RpcHandler { 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)) + .timeout(std::time::Duration::from_secs(15)) + .fips_timeout(std::time::Duration::from_secs(6)) .send_get() .await { diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index 1981048c..7d1f706e 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -156,6 +156,35 @@ impl RpcHandler { /// 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. + /// Whether LND reports it's synced to its Bitcoin chain backend. Used to + /// fail invoice minting FAST with a clear reason while the node's Bitcoin + /// backend is still in initial block download — otherwise the `/v1/invoices` + /// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as + /// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns + /// `Some(false)` only when LND is reachable AND explicitly not synced; + /// `None` when we couldn't tell (let the mint attempt proceed and report its + /// own error rather than guess "syncing"). + pub(crate) async fn lnd_chain_synced(&self) -> Option { + let (client, macaroon_hex) = self.lnd_client().await.ok()?; + let resp = client + .get(format!("{LND_REST_BASE_URL}/v1/getinfo")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await + .ok()?; + let body: serde_json::Value = resp.json().await.ok()?; + body.get("synced_to_chain").and_then(|v| v.as_bool()) + } + + /// Error returned when the node can't mint a Lightning invoice because its + /// Bitcoin backend is still syncing. Kept as one string so every invoice + /// entry point surfaces the same clear, user-facing reason. + fn syncing_invoice_err() -> anyhow::Error { + anyhow::anyhow!( + "Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced." + ) + } + pub(crate) async fn create_invoice( &self, amount_sats: i64, @@ -175,9 +204,12 @@ impl RpcHandler { }); // LND's REST endpoint can briefly drop/reset connections under load // (swap pressure, just-restarted, TLS handshake races), which used to - // hard-fail the buy-file invoice with an opaque 503. Retry the send a - // few times with short backoff so a transient blip doesn't surface as - // a payment failure. The surrounding error now carries the real cause. + // hard-fail the buy-file invoice with an opaque 503. Retry on a + // CONNECTION error with short backoff so a transient blip doesn't + // surface as a payment failure. A *timeout* is NOT retried: it means LND + // accepted the connection but isn't answering the mint (e.g. a degraded + // node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail + // after the first hang and let the caller surface the real reason. let mut last_err: Option = None; let mut resp = None; for attempt in 0..3u32 { @@ -193,10 +225,14 @@ impl RpcHandler { break; } Err(e) => { + let timed_out = e.is_timeout(); last_err = Some(anyhow::anyhow!( - "LND REST connect failed (attempt {}): {e}", + "LND REST send failed (attempt {}): {e}", attempt + 1 )); + if timed_out { + break; + } tokio::time::sleep(std::time::Duration::from_millis(400)).await; } } @@ -204,9 +240,15 @@ impl RpcHandler { let resp = match resp { Some(r) => r, None => { + // If LND is reachable but explicitly not synced to chain, say so — + // it's the most common reason a just-restored/syncing node can't + // mint. Otherwise surface the underlying transport error. + if self.lnd_chain_synced().await == Some(false) { + return Err(Self::syncing_invoice_err()); + } return Err(last_err.unwrap_or_else(|| { anyhow::anyhow!("Failed to reach LND REST to create invoice") - })) + })); } }; @@ -385,13 +427,23 @@ impl RpcHandler { "memo": memo, }); - let resp = client + let resp = match 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")?; + { + Ok(r) => r, + Err(e) => { + // A hung/failed mint while LND is explicitly not synced to chain + // gets a clear, user-facing reason instead of an opaque error. + if self.lnd_chain_synced().await == Some(false) { + return Err(Self::syncing_invoice_err()); + } + return Err(anyhow::anyhow!(e).context("Failed to create invoice")); + } + }; let status = resp.status(); let body: serde_json::Value = resp diff --git a/core/archipelago/src/api/rpc/mesh/assistant.rs b/core/archipelago/src/api/rpc/mesh/assistant.rs index 7187303f..a835d9fe 100644 --- a/core/archipelago/src/api/rpc/mesh/assistant.rs +++ b/core/archipelago/src/api/rpc/mesh/assistant.rs @@ -14,12 +14,12 @@ impl RpcHandler { pub(in crate::api::rpc) async fn handle_mesh_assistant_status( &self, ) -> Result { - let cfg = { + let (cfg, denied_askers) = { let service = self.mesh_service.read().await; let svc = service .as_ref() .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; - svc.assistant_config().await + (svc.assistant_config().await, svc.assistant_denied_askers().await) }; let (ollama_detected, models) = detect_ollama().await; @@ -37,6 +37,7 @@ impl RpcHandler { "ollama_detected": ollama_detected, "claude_available": claude_available, "models": models, + "denied_askers": denied_askers, })) } diff --git a/core/archipelago/src/bitcoin_status.rs b/core/archipelago/src/bitcoin_status.rs index dd3c1d70..4107b7bd 100644 --- a/core/archipelago/src/bitcoin_status.rs +++ b/core/archipelago/src/bitcoin_status.rs @@ -22,11 +22,23 @@ use tracing::{debug, warn}; const CACHE_REFRESH_SECS: u64 = 5; const CACHE_ERROR_BACKOFF_SECS: u64 = 5; +// Grace window before a failing poll marks the snapshot "stale" for the UI. +// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently +// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip +// the UI to "reconnecting…". Only after the cached snapshot is genuinely old — +// several polls failed in a row — do we surface the banner. +const STALE_GRACE_MS: u64 = 20_000; + #[derive(Debug, Clone, Serialize)] pub struct BitcoinNodeStatus { pub ok: bool, pub stale: bool, pub updated_at_ms: u64, + // Server-computed age of the snapshot, filled in at serve time. The browser + // must not derive this itself (Date.now() - updated_at_ms) because that + // compares the browser clock against this node's clock — any skew made a + // fresh snapshot look stale and the "reconnecting…" banner never cleared. + pub age_ms: u64, pub error: Option, pub blockchain_info: Option, pub network_info: Option, @@ -40,6 +52,7 @@ impl Default for BitcoinNodeStatus { ok: false, stale: false, updated_at_ms: 0, + age_ms: 0, error: Some("Connecting to Bitcoin node...".to_string()), blockchain_info: None, network_info: None, @@ -128,7 +141,11 @@ pub fn spawn_status_cache() { if cached.blockchain_info.is_some() { cached.ok = false; - cached.stale = true; + // Only flip to "stale" once the last good snapshot is older + // than the grace window. A brief RPC gap on a busy node keeps + // showing last-known state silently instead of a banner flicker. + let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms); + cached.stale = snapshot_age_ms > STALE_GRACE_MS; cached.error = Some(friendly_transient_error(true, &err_msg)); } else { *cached = BitcoinNodeStatus { @@ -148,12 +165,22 @@ pub fn spawn_status_cache() { } pub async fn get_bitcoin_status() -> BitcoinNodeStatus { - cache().read().await.clone() + let mut status = cache().read().await.clone(); + // Compute age here (server clock only) so the browser never has to subtract + // across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the + // "reconnecting…" banner on its very next poll regardless of browser-clock skew. + if status.updated_at_ms > 0 { + status.age_ms = now_ms().saturating_sub(status.updated_at_ms); + } + status } async fn fetch_bitcoin_status() -> Result { + // 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly + // but correctly; too tight a timeout turned working-but-slow polls into + // failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS. let client = reqwest::Client::builder() - .timeout(Duration::from_secs(8)) + .timeout(Duration::from_secs(12)) .build() .context("build Bitcoin status HTTP client")?; @@ -172,6 +199,7 @@ async fn fetch_bitcoin_status() -> Result { ok: true, stale: false, updated_at_ms: now_ms(), + age_ms: 0, error: None, blockchain_info: Some(blockchain_info), network_info: network_info.ok(), diff --git a/core/archipelago/src/container/companion.rs b/core/archipelago/src/container/companion.rs index 144505ca..dd05cfc0 100644 --- a/core/archipelago/src/container/companion.rs +++ b/core/archipelago/src/container/companion.rs @@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec { ], pre_start: None, bind_mounts: &[], - ports: &[(18083, 80)], - host_network: false, + // Host networking so the app's own nginx can proxy the archipelago backend + // same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The + // previous bridge + 18083→80 mapping forced the browser to fetch the + // backend cross-origin from the app's port, which depended on the host + // nginx route + a CORS Origin/Host match and broke on http-only nodes + // (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now + // listens on 18083 directly (NOT 80 — that would collide with host nginx). + ports: &[], + host_network: true, }]; const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec { @@ -439,12 +446,15 @@ mod tests { } #[test] - fn lnd_ui_uses_port_mapping_not_host_port_80() { + fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() { + // lnd-ui is host-networked (its nginx listens on 18083 directly) so the + // app can proxy the archipelago backend same-origin instead of fetching + // it cross-origin from its app port — see the spec comment for why. let spec = &LND_UI[0]; let u = build_unit(spec, "localhost/lnd-ui:latest"); assert_eq!(u.name, "archy-lnd-ui"); - assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge")); - assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]); + assert!(matches!(u.network, NetworkMode::Host)); + assert!(u.ports.is_empty()); } #[test] diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 12816d48..ef927217 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -365,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata { repo: "https://github.com/fedimint/fedimint".to_string(), tier: "", }, + "fedimint-clientd" | "fmcd" => AppMetadata { + title: "Fedimint Client".to_string(), + description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(), + icon: "/assets/img/app-icons/fedimint.png".to_string(), + repo: "https://github.com/minmoto/fmcd".to_string(), + tier: "", + }, "morphos" | "morphos-server" => AppMetadata { title: "Morphos".to_string(), description: "Self-hosted file converter".to_string(), diff --git a/core/archipelago/src/fips/dial.rs b/core/archipelago/src/fips/dial.rs index 441e0632..5364dda7 100644 --- a/core/archipelago/src/fips/dial.rs +++ b/core/archipelago/src/fips/dial.rs @@ -308,6 +308,14 @@ pub struct PeerRequest<'a> { pub path: &'a str, pub headers: Vec<(&'a str, String)>, pub timeout: std::time::Duration, + /// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung + /// FIPS overlay fails fast within this budget so the Tor fallback still gets + /// its full `timeout` — without it, a stuck FIPS dial can consume the whole + /// caller budget (e.g. a 60s frontend RPC) and the request "times out" even + /// though Tor would have answered (#6, the Pay-with-QR invoice request). + /// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a + /// large content download needs so its long FIPS transfer isn't truncated. + pub fips_timeout: Option, pub service: Option, } @@ -319,10 +327,26 @@ impl<'a> PeerRequest<'a> { path, headers: Vec::new(), timeout: std::time::Duration::from_secs(30), + fips_timeout: None, service: None, } } + /// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a + /// cold/hung overlay path fails fast and the Tor fallback keeps its full + /// budget. Use on short request/response calls (invoice, status); leave + /// unset for large downloads that legitimately need a long FIPS transfer. + pub fn fips_timeout(mut self, t: std::time::Duration) -> Self { + self.fips_timeout = Some(t); + self + } + + /// Timeout to apply to the FIPS attempt — the explicit cap if set, else the + /// overall request timeout. + fn fips_attempt_timeout(&self) -> std::time::Duration { + self.fips_timeout.unwrap_or(self.timeout) + } + /// Tie this request to a user-configurable service preference. If /// the user has set that service to `Fips` or `Tor`, the builder /// respects it. @@ -423,7 +447,7 @@ impl<'a> PeerRequest<'a> { } }; let url = format!("{}{}", base, self.path); - let c = client_with_timeout(self.timeout); + let c = client_with_timeout(self.fips_attempt_timeout()); let mut rb = c.post(&url).json(body); for (k, v) in &self.headers { rb = rb.header(*k, v); @@ -456,7 +480,7 @@ impl<'a> PeerRequest<'a> { } }; let url = format!("{}{}", base, self.path); - let c = client_with_timeout(self.timeout); + let c = client_with_timeout(self.fips_attempt_timeout()); let mut rb = c.get(&url); for (k, v) in &self.headers { rb = rb.header(*k, v); diff --git a/core/archipelago/src/mesh/listener/assist.rs b/core/archipelago/src/mesh/listener/assist.rs index dd8fa705..cf0e07ab 100644 --- a/core/archipelago/src/mesh/listener/assist.rs +++ b/core/archipelago/src/mesh/listener/assist.rs @@ -57,24 +57,32 @@ pub(super) enum AssistReply { /// Entry point: gate the query, run the model, send the answer back via the /// requested reply path. Spawned off the radio loop so it never blocks. +#[allow(clippy::too_many_arguments)] pub(super) async fn run_assist( prompt: String, model_override: Option, req_id: u64, asker_contact_id: u32, sender_name: String, + // Whether the asker's message was cryptographically authenticated (a + // verified signature, or arrival over the federation transport). Required + // for any identity-based allow under `trusted_only`/the allowlist. + authenticated: bool, reply: AssistReply, state: Arc, ) { let asker = asker_contact_id; // Trust + block gate. - if !is_sender_allowed(&state, asker).await { + if !is_sender_allowed(&state, asker, authenticated).await { warn!( from = asker, name = %sender_name, "AssistQuery denied — sender not permitted by assistant policy" ); + // Record who was turned away so the operator can find + allow them from + // the UI (the silent-on-wire denial otherwise only shows in the journal). + record_denied(&state, asker, &sender_name).await; // Silent on the wire (no airtime spent on denials); surface to the UI. let _ = state .event_tx @@ -155,13 +163,25 @@ pub(super) async fn run_assist( } /// Whether `sender_contact_id` may invoke the assistant under the node's policy. -/// Always denies user-blocked contacts. With `trusted_only`, requires a -/// federation-Trusted match on the peer's pubkey or DID. -async fn is_sender_allowed(state: &Arc, sender_contact_id: u32) -> bool { +/// +/// Always denies user-blocked contacts. Identity-based allows (the per-contact +/// allowlist and the federation-Trusted match) require `authenticated == true` — +/// i.e. the asker's message carried a signature that verified against its known +/// key (or it arrived over the federation transport, which verifies upstream). +/// A bare radio packet can CLAIM any key or DID, so without that proof the +/// allowlist and trust list are spoofable; only the explicit "anyone on the +/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker. +async fn is_sender_allowed( + state: &Arc, + sender_contact_id: u32, + authenticated: bool, +) -> bool { let (pubkey_hex, did) = { let peers = state.peers.read().await; match peers.get(&sender_contact_id) { - Some(p) => (p.pubkey_hex.clone(), p.did.clone()), + // Match identity on the bound archipelago key (stable, advert/ + // federation-verified), not the firmware routing key. + Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()), None => (None, None), } }; @@ -180,12 +200,15 @@ async fn is_sender_allowed(state: &Arc, sender_contact_id: u32) -> bo } } - // Explicit per-contact allowlist: a listed pubkey may ask regardless of - // the trusted_only policy (block check above still wins). - if let Some(ref pk) = pubkey_hex { - let allowed = state.assistant.read().await.allowed_contacts.clone(); - if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) { - return true; + // Explicit per-contact allowlist: a listed pubkey may ask regardless of the + // trusted_only policy — but only when the message is authenticated, so a + // spoofed packet claiming an allowlisted key can't slip through. + if authenticated { + if let Some(ref pk) = pubkey_hex { + let allowed = state.assistant.read().await.allowed_contacts.clone(); + if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) { + return true; + } } } @@ -193,7 +216,14 @@ async fn is_sender_allowed(state: &Arc, sender_contact_id: u32) -> bo return true; } - // Trusted-only: match against the federation trust list. + // Trusted-only from here: an unauthenticated asker can never match the trust + // list (it could otherwise just claim a trusted node's public key/DID). + if !authenticated { + return false; + } + + // Match against the federation trust list by the asker's verified archipelago + // pubkey or DID (a radio peer gets these from its signed identity advert). let nodes = crate::federation::load_nodes(&state.data_dir) .await .unwrap_or_default(); @@ -203,6 +233,36 @@ async fn is_sender_allowed(state: &Arc, sender_contact_id: u32) -> bo }) } +/// Newest-first cap on the denied-asker buffer — enough to surface the people +/// who recently tried, without unbounded growth from a spammer. +const MAX_DENIED_ASKERS: usize = 25; + +/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow". +/// Dedupes by contact id (moves an existing entry to the front and refreshes its +/// timestamp/name) so repeated denials from one device don't flood the list. +async fn record_denied(state: &Arc, asker_contact_id: u32, sender_name: &str) { + // Capture the bound archipelago identity key (NOT the firmware routing key): + // one-click "Allow" adds this to the allowlist, which the gate matches on the + // archipelago key. A peer with no advert has no arch key → None → the UI shows + // "no key" (only the "anyone on the mesh" policy can admit it). + let pubkey_hex = { + let peers = state.peers.read().await; + peers + .get(&asker_contact_id) + .and_then(|p| p.arch_pubkey_hex.clone()) + }; + let entry = super::DeniedAsker { + contact_id: asker_contact_id, + name: sender_name.to_string(), + pubkey_hex, + at: chrono::Utc::now().to_rfc3339(), + }; + let mut denied = state.assist_denied.write().await; + denied.retain(|d| d.contact_id != asker_contact_id); + denied.push_front(entry); + denied.truncate(MAX_DENIED_ASKERS); +} + /// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated. /// Returns (text_to_send, was_truncated). fn cap_reply(answer: &str) -> (String, bool) { diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index a7d77c49..ef1d2dbd 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -382,8 +382,13 @@ pub(super) async fn store_plain_message( let name = peer_name.to_string(); let st = Arc::clone(state); tokio::spawn(async move { - super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st) - .await; + // A bare plain-text channel `!ai` carries no signature, so it + // is NOT authenticated — under trusted_only it'll be denied, + // and it can only be answered under the "anyone" policy. + super::assist::run_assist( + prompt, None, req_id, contact_id, name, false, reply, st, + ) + .await; }); } } @@ -484,6 +489,10 @@ pub(super) async fn handle_identity_received( advert_name: format!("Archy-{}", &did[8..16.min(did.len())]), did: Some(did.to_string()), pubkey_hex: Some(ed_pubkey_hex.to_string()), + // The advert signature was verified above, so this is an authenticated + // archipelago identity. Bind it separately so a later refresh_contacts + // (which rewrites pubkey_hex to the firmware routing key) can't drop it. + arch_pubkey_hex: Some(ed_pubkey_hex.to_string()), x25519_pubkey: Some(x25519_bytes), rssi: Some(rssi), snr: None, diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 735bc96d..1d5b201c 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct( sender_name: &str, envelope: TypedEnvelope, ) { - // Verify envelope signature if present, using the sender's known Ed25519 key + // Verify the envelope signature (if present) against the sender's known + // Ed25519 key, and record whether the sender is cryptographically + // authenticated. A federation peer (synthetic high-half contact_id) arrived + // over the Tor relay, which verifies the sender signature upstream before + // injecting here, so it counts as authenticated. This flag gates the + // identity-based `!ai` allows (allowlist / federation-trust) downstream. + let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE; if envelope.sig.is_some() { let peer_pubkey = state .peers .read() .await .get(&sender_contact_id) - .and_then(|p| p.pubkey_hex.as_ref()) + // Verify against the bound archipelago identity key, not the + // firmware routing key — only the former is what the peer signs with. + .and_then(|p| p.identity_pubkey_hex()) .and_then(|hex_str| hex::decode(hex_str).ok()) .and_then(|bytes| { if bytes.len() == 32 { @@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct( }); if let Some(vk) = peer_pubkey { match envelope.verify_signature(&vk) { - Ok(true) => {} + Ok(true) => { + authenticated = true; + } Ok(false) => { warn!( peer = sender_contact_id, @@ -701,6 +711,7 @@ pub(crate) async fn handle_typed_envelope_direct( req_id, cid, name, + authenticated, super::assist::AssistReply::ChatText { contact_id: cid }, st, ) @@ -748,6 +759,7 @@ pub(crate) async fn handle_typed_envelope_direct( query.req_id, sender_contact_id, name, + authenticated, super::assist::AssistReply::Typed { contact_id: sender_contact_id, }, diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 71277ca7..3773fc7b 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -153,6 +153,28 @@ pub struct MeshState { /// Contact-ids with an AI query currently being answered. Caps each asker to /// one in-flight query so a peer can't flood the node's compute / airtime. pub assist_inflight: RwLock>, + /// Recently-denied `!ai` askers (newest first, capped). When `trusted_only` + /// rejects a sender — typically a radio (meshcore) device that presents a + /// firmware key rather than an archipelago DID — we record who tried so the + /// UI can surface them and let the operator one-click allow their key. + /// Silent on the wire (no airtime spent), visible to the operator here. + pub assist_denied: RwLock>, +} + +/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so +/// the operator can add their key to the allowlist without hunting the journal. +#[derive(Debug, Clone, Serialize)] +pub struct DeniedAsker { + /// Meshcore contact id of the asker. + pub contact_id: u32, + /// Best-known display name (advert name) at denial time. + pub name: String, + /// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device + /// that hasn't advertised an archipelago key — such a sender can only be + /// admitted by switching the policy to "anyone", not via the allowlist. + pub pubkey_hex: Option, + /// ISO-8601 timestamp of the (most recent) denial. + pub at: String, } /// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup. @@ -248,6 +270,7 @@ impl MeshState { assistant: RwLock::new(assistant), data_dir, assist_inflight: RwLock::new(HashSet::new()), + assist_denied: RwLock::new(VecDeque::new()), }); (state, rx, cmd_rx) } diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 47a60a67..ea02a47b 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -380,6 +380,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) advert_name: contact.advert_name.clone(), did: existing.and_then(|p| p.did.clone()), pubkey_hex: Some(contact.public_key_hex.clone()), + // Preserve any archipelago identity bound by an earlier + // identity advert — NEVER overwrite it with the firmware + // contact key, or a signed `!ai` query from this peer would + // fail authentication after the next contact refresh. + arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()), x25519_pubkey: existing.and_then(|p| p.x25519_pubkey), rssi: None, snr: None, diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index f204b90a..ee533423 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -46,6 +46,12 @@ const MESH_CONTACTS_FILE: &str = "mesh-contacts.json"; /// high half of u32 space to avoid collision. Both the receive path /// (`inject_typed_from_federation`) and the startup pre-seed use this /// formula so they always produce the same id for the same peer. +/// Mesh contacts at or above this id are synthetic federation peers (the high +/// half of the u32 space). Meshcore radio contacts use the firmware's low-int id +/// space, so this bit cleanly distinguishes "arrived over the authenticated +/// federation transport" from "heard over the radio". +pub(crate) const FEDERATION_CONTACT_ID_BASE: u32 = 0x8000_0000; + pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 { let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default(); if bytes.len() < 4 { @@ -77,6 +83,9 @@ pub(crate) async fn upsert_federation_peer( advert_name: display_name, did: Some(did.to_string()), pubkey_hex: Some(archipelago_pubkey_hex.to_string()), + // Federation peers are authenticated by the Tor relay upstream; their + // archipelago key is known, so bind it as the identity key too. + arch_pubkey_hex: Some(archipelago_pubkey_hex.to_string()), x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey), rssi: existing.as_ref().and_then(|p| p.rssi), snr: existing.as_ref().and_then(|p| p.snr), @@ -1433,6 +1442,12 @@ impl MeshService { self.state.assistant.read().await.clone() } + /// Recently-denied `!ai` askers (newest first) so the UI can offer to allow + /// them. Cleared implicitly as new denials rotate older ones out. + pub async fn assistant_denied_askers(&self) -> Vec { + self.state.assist_denied.read().await.iter().cloned().collect() + } + /// Update the mesh-AI assistant settings live (no listener restart) and /// persist them to the mesh config. `model: Some(None)` clears the override /// (falls back to the built-in default); `None` leaves a field unchanged. diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index 7d65a7e4..ae529ea8 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -32,8 +32,18 @@ pub struct MeshPeer { pub advert_name: String, /// Archipelago DID (did:key:z...) if identity was received. pub did: Option, - /// Ed25519 public key hex if identity was received. + /// Routing key hex. For a radio (meshcore) peer this is the firmware + /// contact public key used to address outbound DMs; for a federation- + /// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT + /// for authentication — see `arch_pubkey_hex`. pub pubkey_hex: Option, + /// Verified archipelago ed25519 identity key hex, bound from a signed + /// identity advert (`handle_identity_received`) or federation seeding. + /// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts` + /// with the firmware routing key, so it stays stable for the `!ai` auth + /// gate, envelope signature verification, and federation-trust matching. + #[serde(default)] + pub arch_pubkey_hex: Option, /// X25519 public key (32 bytes) for key agreement. #[serde(skip)] pub x25519_pubkey: Option<[u8; 32]>, @@ -56,6 +66,19 @@ pub struct MeshPeer { pub reachable: bool, } +impl MeshPeer { + /// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist, + /// envelope signature verification): the verified archipelago identity key + /// if one is bound, otherwise the routing key. Never use the firmware + /// routing key for auth when an archipelago identity is known — a radio + /// peer's firmware key won't match its `nodes.json` archipelago key. + pub fn identity_pubkey_hex(&self) -> Option<&str> { + self.arch_pubkey_hex + .as_deref() + .or(self.pubkey_hex.as_deref()) + } +} + /// Direction of a mesh message. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -191,3 +214,51 @@ pub enum MeshEvent { text: String, }, } + +#[cfg(test)] +mod tests { + use super::*; + + fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer { + MeshPeer { + contact_id: 1, + advert_name: "Test".into(), + did: None, + pubkey_hex: routing.map(|s| s.to_string()), + arch_pubkey_hex: arch.map(|s| s.to_string()), + x25519_pubkey: None, + rssi: None, + snr: None, + last_heard: String::new(), + hops: 0, + last_advert: 0, + reachable: false, + } + } + + #[test] + fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() { + // A radio peer that sent an identity advert: routing key is the firmware + // contact key, but auth must use the bound archipelago key. + let p = peer(Some("archkey"), Some("firmwarekey")); + assert_eq!(p.identity_pubkey_hex(), Some("archkey")); + } + + #[test] + fn identity_falls_back_to_routing_key_when_no_advert() { + // A plain peer with no archipelago identity bound: fall back to whatever + // key we have (federation peers carry the arch key in pubkey_hex). + let p = peer(None, Some("firmwarekey")); + assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey")); + assert_eq!(peer(None, None).identity_pubkey_hex(), None); + } + + #[test] + fn refresh_style_routing_update_does_not_change_identity() { + // Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new + // firmware key while arch_pubkey_hex (identity) is preserved. + let mut p = peer(Some("archkey"), Some("firmware-old")); + p.pubkey_hex = Some("firmware-new".into()); + assert_eq!(p.identity_pubkey_hex(), Some("archkey")); + } +} diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html index fd656fd1..006e4922 100644 --- a/docker/bitcoin-ui/index.html +++ b/docker/bitcoin-ui/index.html @@ -737,6 +737,15 @@ return response.json(); } + // Snapshot age in ms. Prefer the server-computed age_ms (single clock, no + // skew). Fall back to the old browser-vs-server subtraction only for an + // older backend that doesn't send age_ms. Mixing clocks was why the + // "reconnecting…" banner could stick on nodes whose clock drifted. + function snapshotAgeMs(status) { + if (typeof status.age_ms === 'number') return status.age_ms; + return status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY; + } + function cookieValue(name) { return document.cookie .split('; ') @@ -1127,7 +1136,7 @@ const rpcEl = document.getElementById('settingsRpc'); if (rpcEl) { const port = chain === 'main' ? 8332 : (chain === 'test' ? 18332 : (chain === 'signet' ? 38332 : 18443)); - const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY; + const statusAgeMs = snapshotAgeMs(status); const displayStale = status.stale === true && statusAgeMs > 30000; rpcEl.textContent = displayStale ? `Reconnecting on port ${port}` @@ -1143,7 +1152,7 @@ const diskSize = formatBytes(blockchainInfo.size_on_disk || 0); const appearsToBeReindexing = initialBlockDownload && blocks === 0 && headers > 0 && (blockchainInfo.size_on_disk || 0) > 1024 * 1024 * 1024; const previousBlockCount = lastBlockCount; - const statusAgeMs = status.updated_at_ms ? Date.now() - status.updated_at_ms : Number.POSITIVE_INFINITY; + const statusAgeMs = snapshotAgeMs(status); const snapshotAdvanced = previousBlockCount > 0 && blocks > previousBlockCount; const displayStale = status.stale === true && !snapshotAdvanced && statusAgeMs > 30000; diff --git a/docker/lnd-ui/Dockerfile b/docker/lnd-ui/Dockerfile index d38f5e8c..5af0261c 100644 --- a/docker/lnd-ui/Dockerfile +++ b/docker/lnd-ui/Dockerfile @@ -22,6 +22,6 @@ RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \ mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \ /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \ /var/cache/nginx/scgi_temp -EXPOSE 80 +EXPOSE 18083 ENTRYPOINT [] CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/lnd-ui/index.html b/docker/lnd-ui/index.html index 153be432..0df9a22c 100644 --- a/docker/lnd-ui/index.html +++ b/docker/lnd-ui/index.html @@ -433,8 +433,12 @@ const host = window.location.hostname; function getBackendUrl() { + // Same-origin by default: the app's own nginx (:18083) proxies these + // paths to the archipelago backend, so a relative base ('') avoids + // any cross-origin/CORS issues (which broke this on http-only nodes). + // ?backend=http://HOST:5678 still overrides for local dev. const params = new URLSearchParams(window.location.search); - return params.get('backend') || (window.location.protocol + '//' + window.location.hostname); + return params.get('backend') || ''; } function setSettingsTab(tabId) { @@ -499,42 +503,36 @@ async function loadLogs() { const logsContent = document.getElementById('logsContent'); const backendUrl = getBackendUrl(); - if (backendUrl) { - logsContent.textContent = 'Loading logs...'; - try { - const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200'); - if (!res.ok) throw new Error(res.statusText); - const json = await res.json(); - const lines = json.result || json.logs || (Array.isArray(json) ? json : []); - logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines); - } catch (e) { - logsContent.textContent = 'Could not load logs: ' + e.message; - } - } else { - logsContent.textContent = 'Open this app with ?backend=http://HOST:5678 to load logs from the server.'; + logsContent.textContent = 'Loading logs...'; + try { + const res = await fetch(backendUrl + '/api/container/logs?app_id=lnd&lines=200', { credentials: 'include' }); + if (!res.ok) throw new Error(res.statusText); + const json = await res.json(); + const lines = json.result || json.logs || (Array.isArray(json) ? json : []); + logsContent.textContent = Array.isArray(lines) ? lines.join('\n') : String(lines); + } catch (e) { + logsContent.textContent = 'Could not load logs: ' + e.message; } } async function fetchLiveData() { const backendUrl = getBackendUrl(); const data = { channelCount: 0, restReachable: false, grpcReachable: false }; - if (backendUrl) { - try { - const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo'); - if (getinfoRes.ok) { - data.getinfo = await getinfoRes.json(); - data.restReachable = true; - } - } catch (_) {} - try { - const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels'); - if (chRes.ok) { - const ch = await chRes.json(); - data.channelCount = (ch.channels && ch.channels.length) || 0; - } - } catch (_) {} - data.grpcReachable = data.restReachable; - } + try { + const getinfoRes = await fetch(backendUrl + '/proxy/lnd/v1/getinfo', { credentials: 'include' }); + if (getinfoRes.ok) { + data.getinfo = await getinfoRes.json(); + data.restReachable = true; + } + } catch (_) {} + try { + const chRes = await fetch(backendUrl + '/proxy/lnd/v1/channels', { credentials: 'include' }); + if (chRes.ok) { + const ch = await chRes.json(); + data.channelCount = (ch.channels && ch.channels.length) || 0; + } + } catch (_) {} + data.grpcReachable = data.restReachable; applyLiveData(data); } @@ -629,7 +627,7 @@ async function fetchConnectInfo() { try { - const resp = await fetch(window.location.protocol + '//' + window.location.hostname + '/lnd-connect-info', { credentials: 'include' }); + const resp = await fetch(getBackendUrl() + '/lnd-connect-info', { credentials: 'include' }); if (!resp.ok) throw new Error('HTTP ' + resp.status); const data = await resp.json(); if (data.cert_base64url) { diff --git a/docker/lnd-ui/nginx.conf b/docker/lnd-ui/nginx.conf index 1664890f..2c372562 100644 --- a/docker/lnd-ui/nginx.conf +++ b/docker/lnd-ui/nginx.conf @@ -1,13 +1,63 @@ server { - listen 80; + # Host-networked: listen on the app's own port directly (NOT 80, which the + # host's main nginx already owns). The app is reached at http(s)://:18083. + listen 18083; server_name _; root /usr/share/nginx/html; index index.html; - # lnd-connect-info is fetched via absolute URL path, - # handled by the host nginx → backend at :5678 directly + # Proxy the archipelago backend same-origin so the browser never makes a + # cross-origin request (no CORS, no host-nginx route dependency). The app is + # served on this node's :18083; cookies are scoped by host (not port), so the + # browser already carries the `session` (HttpOnly) and `csrf_token` cookies + # set by the main UI. We forward both, plus the X-CSRF-Token header, to the + # backend on 127.0.0.1:5678 (reachable because this container is host-networked). + # + # This mirrors fips-ui / electrs-ui. The old bridge + 18083→80 mapping forced + # cross-origin fetches that broke on http-only nodes (blank fields, QR + # "failed to fetch"). + location = /lnd-connect-info { + proxy_pass http://127.0.0.1:5678/lnd-connect-info; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + location /proxy/lnd/ { + proxy_pass http://127.0.0.1:5678/proxy/lnd/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + add_header Cache-Control "no-store"; + } + + location /api/container/logs { + proxy_pass http://127.0.0.1:5678/api/container/logs; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header X-CSRF-Token $http_x_csrf_token; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_connect_timeout 10s; + proxy_read_timeout 30s; + add_header Cache-Control "no-store"; + } + location / { + add_header Cache-Control "no-cache"; try_files $uri $uri/ /index.html; } } diff --git a/docs/session-handoff-2026-06-18.md b/docs/session-handoff-2026-06-18.md new file mode 100644 index 00000000..6ef9e8c1 --- /dev/null +++ b/docs/session-handoff-2026-06-18.md @@ -0,0 +1,109 @@ +# Session handoff — 2026-06-18 + +> **UPDATE (later same day): ALL OPEN ITEMS RESOLVED + DEPLOYED** (v1.7.99-alpha → .116 + .198). +> - **#6 Pay-with-QR timeout** — real bug (both LNDs confirmed healthy by user). FIPS-first dial ate the whole budget before the working Tor fallback ran. Added `PeerRequest.fips_timeout` cap (`fips/dial.rs`); invoice/onchain request+status calls fast-fail FIPS (6s) + short Tor window (25s/15s); frontend ceilings 60s→45s. Large downloads keep the full FIPS timeout. +> - **#7 `!ai` gate** — added denied-asker capture (`MeshState.assist_denied`/`DeniedAsker`, `assist.rs::record_denied`) → `mesh.assistant-status.denied_askers` → "Recently denied" list with one-click Allow in `MeshAssistantPanel.vue`. +> - **#8 peer-file 403** — NOT a DID reset. Asymmetric federation: .198 had .116 trusted but .116 never added .198. Re-federated (.198 → .116 `nodes.json`, trusted). **Verified:** .116 `/content/` = 403 w/o DID, **200 (177KB png) with .198's DID**. Plus clearer 403 message + client surfaces the body. Listing left visible ("locked preview", user's choice). +> - **Dual-ecash receive** — active modal is `ReceiveBitcoinModal.vue` (not the commented-out `Web5SendReceiveModals.vue`); already used dual-detect `wallet.ecash-receive`, fixed Cashu-only wording. +> - **fedimint-clientd icon** — `docker_packages.rs` arm → `fedimint.png` + `fedimint-clientd.png` asset. +> - **Cashu → 🥜** — `HomeWalletCard.vue`. +> +> Deploy notes confirmed: binary swap needs atomic `mv` over the running file (`cp` → "Text file busy"); frontend rsync WITHOUT `--delete` to preserve the `aiui/` subdir in `/opt/archipelago/web-ui`. + + +Resume point for the multi-issue bug-fix + deploy session on **.116** (archi-thinkpad, +local dev/validation node) and **.198** (resilience node). Work was done in +`~/Projects/archy`. A separate agent's **fedimint dual-ecash** work landed as commit +`4288ae78` during the session (don't re-touch `wallet.rs` / `fedimint_client.rs` / +`prod_orchestrator.rs` / `Web5SendReceiveModals.vue` without checking with them). + +## DEPLOY STATUS — done + +A surgical deploy (binary + frontend + 2 companion images, **not** the .228-centric +`deploy-to-target.sh`, to avoid clobbering .116's custom nginx) shipped to BOTH nodes: + +- **.116**: new binary `/usr/local/bin/archipelago` (backup at `archipelago.bak-pre-deploy-*`), + frontend at `/opt/archipelago/web-ui`, `localhost/{lnd-ui,bitcoin-ui}:latest` rebuilt, + `:local` tags dropped. Verified: `/bitcoin-status` serves `age_ms`; lnd-ui on `Network=host` + listening 18083; `/lnd-connect-info` → 200; both companion containers carry new index.html. +- **.198**: same (binary copied — .198 has **no Rust toolchain**, only npm+podman, so + build-on-.116-then-copy is mandatory). Verified identically. Force-recreated both companions. + +Build notes: release build ~9 min (opt-level 3). Frontend vite outDir = `web/dist/neode-ui/` +(NOT `neode-ui/dist`). Companion images: `ensure_image_present` only builds if image ABSENT, +and prefers `localhost/:local` over `:latest` — so to ship docker changes you must drop +`:local` and rebuild `:latest`, then the reconciler (`needs_repair` compares rendered quadlet +unit vs disk) recreates containers. bitcoin-ui needed an explicit `systemctl --user restart` +(its quadlet unit text didn't change, so the reconciler didn't auto-recreate it). + +## FIXED & DEPLOYED + +1. **Mesh chat/peer double-scroll** — `useControllerNav.ts` (wheel scrolls container under + pointer, not focused el) + `Mesh.vue` (`@wheel.stop.prevent`). +2. **Second-level cloud folder zoom** — `CloudFolder.vue` direction-aware + (`cloud-zoom-forward`/`-back`, matched depth-forward/back magnitudes 0.75↔1.2). +3. **"FIPS Mesh" → "Fuck IPs Mesh"** — `FipsNetworkCard.vue`, `Server.vue`. +4. **.116 connect-wallet QR "failed to fetch"** — lnd-ui migrated to host-network + + same-origin nginx proxy: `companion.rs` (host_network:true, ports:[]), + `docker/lnd-ui/{Dockerfile(EXPOSE 18083),nginx.conf(listen 18083 + proxy /lnd-connect-info, + /proxy/lnd/, /api/container/logs to 127.0.0.1:5678),index.html(getBackendUrl()→'' relative, + credentials:'include')}`. ROOT CAUSE was a cross-origin CORS failure (page on :18083 fetching + :80). Verified working in incognito; the user's earlier "still broken" was a **stale cached + old page**. Unit test `lnd_ui_uses_host_network` passes. +5. **.198 Bitcoin Knots stale "reconnecting" banner** — `bitcoin_status.rs` (new server-computed + `age_ms` field so the browser never subtracts across clocks; 20s `STALE_GRACE_MS` before + flipping stale; RPC timeout 8s→12s) + `docker/bitcoin-ui/index.html` (`snapshotAgeMs()` uses + server `age_ms`, falls back to old calc). Two root causes: browser/node clock skew + no grace + on single failed polls (swap-thrash node). + +## OPEN ISSUES (diagnosed, NOT fixed) + +6. **"Pay with QR" → request timeout** — full invoice chain intact (hardened in `790da4bd`); + 60s timeout = seller node never answers (unreachable transport or hung LND). Runtime, needs + 2 live nodes to repro. NOT a code defect found. + +7. **`!ai` not working** — DIAGNOSED, config fix (awaiting user policy decision). Assistant is + `assistant_trusted_only:true` (`/var/lib/archipelago/mesh-config.json`). The trust gate + `is_sender_allowed` (mesh/listener/assist.rs) only matches askers by archipelago pubkey/DID + against federation-Trusted `nodes.json`, but RADIO (meshcore) askers present a firmware key, + not the archipelago identity, so they're silently denied (journal: "AssistQuery denied … from=15 + name=Arch Optiplex"; federation contact_id ≥ 0x80000000, low ids = radio). Claude key + model + (`claude-opus-4-8`) tested HTTP 200 — NOT the problem. FIX: disable trusted_only, or add the + asker's presented key to the allowlist. Full notes in memory `project_mesh_ai_trusted_only_gate`. + +8. **Peer-file download .116→.198 "Access denied — federation peer required"** — NEW, NOT yet + fixed. Gate at `content.rs:149` (returns on `content_server::ServeResult::Forbidden`). The + requesting node isn't recognized as an authorized federation peer by the content server / + per-file sharing ACL. User's strong hypothesis: a **DID/identity reset** changed a node's DID, + so the sharing ACL / nodes.json holds the OLD identity and no longer matches. User also notes + the file is still VISIBLE in the listing (so listing and download use different identity checks + — inconsistency to investigate). NEXT: read `content_server` Forbidden logic, compare the + requester DID/pubkey vs what's stored; check both nodes' `server_info`/identity vs each other's + `federation/nodes.json`. Same THEME as #7 (identity matching) but a different mechanism. + +## NEW FRONTEND REQUESTS (not started — batch into one frontend rebuild+redeploy) + +- **`fedimint-clientd.svg` 404** — new fedimint core-app (`public/catalog.json:294`) has no icon. + App-icon convention `/assets/img/app-icons/.png` (default) — add a `fedimint-clientd` icon + (there's an existing `fedimint.png` to reuse/adapt). The 404 requests `.svg` so check the + catalog/curated-icon entry. +- **Cashu icon → cashew emoji** (🥜) — change the cashu wallet icon to a cashew nut emoji. +- **Receive › ecash should support BOTH fedimint + cashu paste** — currently the ecash receive + only mentions Cashu for pasting a token; user expected the paste box to redeem both Cashu AND + Fedimint ecash. Lives in the fedimint agent's recently-committed dual-ecash UI + (`Web5SendReceiveModals.vue` / `Web5Wallet.vue` / `WalletSettingsModal.vue`) — investigate what + they built before changing. +- **Console noise** (lower priority): `cdn.tailwindcss.com` production warning in lnd-ui + + bitcoin-ui (uses Tailwind CDN); `api/app-catalog` 502 (check if persistent). Latent backend + nicety: `/lnd-connect-info` emits a DOUBLED `Access-Control-Allow-Origin` (backend empty ACAO + + main-nginx `add_header $http_origin`) — harmless on the new same-origin page but should drop + the backend's redundant CORS since lnd-ui now fetches same-origin. + +## ENV QUICK-REF + +- .116 archi-thinkpad: data `/var/lib/archipelago`, nginx root `/opt/archipelago/web-ui`, + http :80 + custom nginx-proxy-manager; user reaches UI via Tailscale `100.69.68.39` AND LAN. + Deploy SSH key `~/.ssh/archipelago-deploy` is passphraseless; SSH-to-self + .198 work non-interactively. +- .198: `ssh archipelago@192.168.1.198` (passwordless sudo), podman+npm, NO cargo. +- Companion build-dir precedence: `/opt/archipelago/docker` > `~/archy/docker` > `~/Projects/archy/docker`. +- Uncommitted working-tree changes (mine, not yet committed): the 11 files for fixes #1–#5. diff --git a/neode-ui/public/assets/img/app-icons/fedimint-clientd.png b/neode-ui/public/assets/img/app-icons/fedimint-clientd.png new file mode 100644 index 00000000..4a759c55 Binary files /dev/null and b/neode-ui/public/assets/img/app-icons/fedimint-clientd.png differ diff --git a/neode-ui/src/components/GlobalAudioPlayer.vue b/neode-ui/src/components/GlobalAudioPlayer.vue index ec6e2443..cbea60fb 100644 --- a/neode-ui/src/components/GlobalAudioPlayer.vue +++ b/neode-ui/src/components/GlobalAudioPlayer.vue @@ -1,12 +1,10 @@