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) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-19 09:52:26 -04:00
co-authored by Claude Opus 4.8
parent c4855526fe
commit 1bce694ebb
37 changed files with 1260 additions and 208 deletions
+3 -1
View File
@@ -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,
+30 -4
View File
@@ -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
{
+59 -7
View File
@@ -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<bool> {
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<anyhow::Error> = 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
@@ -14,12 +14,12 @@ impl RpcHandler {
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
&self,
) -> Result<serde_json::Value> {
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,
}))
}
+31 -3
View File
@@ -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<String>,
pub blockchain_info: Option<serde_json::Value>,
pub network_info: Option<serde_json::Value>,
@@ -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<BitcoinNodeStatus> {
// 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<BitcoinNodeStatus> {
ok: true,
stale: false,
updated_at_ms: now_ms(),
age_ms: 0,
error: None,
blockchain_info: Some(blockchain_info),
network_info: network_info.ok(),
+15 -5
View File
@@ -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]
@@ -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(),
+26 -2
View File
@@ -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<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>,
}
@@ -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);
+72 -12
View File
@@ -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<String>,
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<MeshState>,
) {
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<MeshState>, 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<MeshState>,
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<MeshState>, 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<MeshState>, 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<MeshState>, 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<MeshState>, 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) {
+11 -2
View File
@@ -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,
+15 -3
View File
@@ -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,
},
+23
View File
@@ -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<HashSet<u32>>,
/// 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<VecDeque<DeniedAsker>>,
}
/// 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<String>,
/// 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)
}
@@ -380,6 +380,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
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,
+15
View File
@@ -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<listener::DeniedAsker> {
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.
+72 -1
View File
@@ -32,8 +32,18 @@ pub struct MeshPeer {
pub advert_name: String,
/// Archipelago DID (did:key:z...) if identity was received.
pub did: Option<String>,
/// 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<String>,
/// 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<String>,
/// 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"));
}
}