From 1bce694ebb4aa8a25048ea22ef1a67e65bdf6e9b Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 19 Jun 2026 05:03:18 -0400 Subject: [PATCH] feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes UI (this session): - Global audio player now scales the whole interface into the space above it on desktop (sidebar + main) and docks directly above the tab bar on mobile; it stays visible while navigating. - Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip with a single fixed, internally-scrolling pane (page no longer scrolls); tabs hide while a conversation is open; floating back button; collapsible Device panel (starts collapsed); keyboard-aware conversation sizing via VisualViewport so the chat sits just above the keyboard. - Cloud file grid: uniform 4/3 card heights (folders + images match). - Swipe left/right switches tabs on the Apps and Web5 screens. - Map tool fills its pane (no bottom gap); fix skewed Share Location toggle on mobile (global min-height rule was deforming the switch). - Trim redundant helper copy from the mesh AI tab. Also bundles pre-existing in-progress work that was already in the tree: mesh listener/session + wallet + container + bitcoin-status backend changes, docker UI updates, and assorted other UI tweaks. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/archipelago/src/api/handler/content.rs | 4 +- core/archipelago/src/api/rpc/content.rs | 34 ++- core/archipelago/src/api/rpc/lnd/wallet.rs | 66 +++++- .../archipelago/src/api/rpc/mesh/assistant.rs | 5 +- core/archipelago/src/bitcoin_status.rs | 34 ++- core/archipelago/src/container/companion.rs | 20 +- .../src/container/docker_packages.rs | 7 + core/archipelago/src/fips/dial.rs | 28 ++- core/archipelago/src/mesh/listener/assist.rs | 84 ++++++- core/archipelago/src/mesh/listener/decode.rs | 13 +- .../archipelago/src/mesh/listener/dispatch.rs | 18 +- core/archipelago/src/mesh/listener/mod.rs | 23 ++ core/archipelago/src/mesh/listener/session.rs | 5 + core/archipelago/src/mesh/mod.rs | 15 ++ core/archipelago/src/mesh/types.rs | 73 +++++- docker/bitcoin-ui/index.html | 13 +- docker/lnd-ui/Dockerfile | 2 +- docker/lnd-ui/index.html | 62 +++-- docker/lnd-ui/nginx.conf | 56 ++++- docs/session-handoff-2026-06-18.md | 109 +++++++++ .../assets/img/app-icons/fedimint-clientd.png | Bin 0 -> 41944 bytes neode-ui/src/components/GlobalAudioPlayer.vue | 39 +++- neode-ui/src/components/MeshMap.vue | 3 + .../src/components/ReceiveBitcoinModal.vue | 13 +- .../src/components/cloud/FileCardGrid.vue | 9 +- neode-ui/src/composables/useControllerNav.ts | 12 +- neode-ui/src/stores/mesh.ts | 8 + neode-ui/src/style.css | 48 +++- neode-ui/src/views/CloudFolder.vue | 80 +++++-- neode-ui/src/views/Dashboard.vue | 68 ++++++ neode-ui/src/views/Mesh.vue | 110 ++++++--- neode-ui/src/views/PeerFiles.vue | 216 +++++++++++++++--- neode-ui/src/views/Server.vue | 2 +- neode-ui/src/views/home/HomeWalletCard.vue | 4 +- .../src/views/mesh/MeshAssistantPanel.vue | 58 ++++- neode-ui/src/views/mesh/mesh-styles.css | 125 +++++++++- neode-ui/src/views/server/FipsNetworkCard.vue | 2 +- 37 files changed, 1260 insertions(+), 208 deletions(-) create mode 100644 docs/session-handoff-2026-06-18.md create mode 100644 neode-ui/public/assets/img/app-icons/fedimint-clientd.png 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 0000000000000000000000000000000000000000..4a759c5501a48a0fe42c3c4992759a4c73cd8265 GIT binary patch literal 41944 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 literal 0 HcmV?d00001 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 @@