diff --git a/core/archipelago/src/fips/anchors.rs b/core/archipelago/src/fips/anchors.rs index c92a5b48..9362d15b 100644 --- a/core/archipelago/src/fips/anchors.rs +++ b/core/archipelago/src/fips/anchors.rs @@ -216,6 +216,44 @@ pub struct ApplyResult { pub message: String, } +/// FIPS UDP transport port (matches `transports.udp.bind_addr` in the generated +/// `fips.yaml`). Direct peer links dial this, NOT the HTTP/LAN messaging port. +const FIPS_UDP_PORT: u16 = 8668; + +/// Build transient seed-anchor entries that dial LAN-discovered federation peers +/// directly over their FIPS UDP transport. For each peer the registry knows both +/// a LAN socket address AND a FIPS npub for, point a `udp` anchor at +/// `:8668`. This lets co-located federation nodes form a DIRECT FIPS link +/// instead of depending on the global anchor's spanning tree to route between +/// them (the cause of every dial falling back to Tor when the anchor link flaps). +/// +/// This is FIPS's own UDP transport over the LAN — not Tailscale, not the LAN +/// HTTP messaging port. NOT persisted to `seed-anchors.json`: recomputed each +/// apply tick from live LAN discovery, so a peer's changing IP self-corrects and +/// stale entries never accumulate. `fipsctl connect` is idempotent, so +/// re-applying just keeps the link warm. +pub fn lan_fips_anchors(peers: &[crate::transport::PeerRecord]) -> Vec { + let mut out = Vec::new(); + for p in peers { + let (Some(lan), Some(npub)) = (p.lan_address.as_deref(), p.fips_npub.as_deref()) else { + continue; + }; + // lan_address is the peer's HTTP/LAN socket ("ip:port"); reuse only its IP + // and target the FIPS UDP port. SocketAddr::new(...).to_string() formats + // IPv6 with brackets correctly. + let Ok(sa) = lan.parse::() else { + continue; + }; + out.push(SeedAnchor { + npub: npub.to_string(), + address: std::net::SocketAddr::new(sa.ip(), FIPS_UDP_PORT).to_string(), + transport: "udp".to_string(), + label: "LAN federation peer (direct FIPS)".to_string(), + }); + } + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index 5803aa50..96de2f50 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -403,6 +403,7 @@ pub(super) async fn store_plain_message_with_encryption( timestamp: chrono::Utc::now().to_rfc3339(), delivered: true, encrypted, + transport: Some("lora".to_string()), message_type: "text".to_string(), typed_payload: None, sender_pubkey: None, @@ -635,6 +636,7 @@ pub(super) async fn handle_received_message( timestamp: chrono::Utc::now().to_rfc3339(), delivered: true, encrypted, + transport: Some("lora".to_string()), message_type: "text".to_string(), typed_payload: None, sender_pubkey: None, diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 1d5b201c..02cf090e 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -34,7 +34,10 @@ async fn store_typed_message( plaintext: display_text.to_string(), timestamp: chrono::Utc::now().to_rfc3339(), delivered: true, + // transport + E2E are stamped post-dispatch by + // handle_typed_envelope_direct, which alone knows the receive transport. encrypted: false, + transport: None, message_type: type_label.to_string(), typed_payload, sender_pubkey, @@ -70,7 +73,67 @@ pub(super) async fn handle_typed_message( return; } }; + // Radio-delivered → "lora". Stamp after dispatch (see stamp helper). + let before = max_message_id(state).await; handle_typed_envelope_direct(state, sender_contact_id, sender_name, envelope).await; + stamp_received_transport(state, sender_contact_id, before, "lora", false).await; +} + +/// Highest stored message id right now. Paired with `stamp_received_transport` +/// to identify messages a dispatch call just stored (ids are monotonic). +pub(crate) async fn max_message_id(state: &Arc) -> u64 { + state + .messages + .read() + .await + .iter() + .map(|m| m.id) + .max() + .unwrap_or(0) +} + +/// Stamp the per-message transport pill (and E2E flag) onto every RECEIVED +/// message from `sender_contact_id` stored since `after_id` — i.e. the ones the +/// just-completed `handle_typed_envelope_direct` produced. This is how both the +/// radio path ("lora") and the federation path ("fips"/"tor") tag inbound +/// messages without threading transport through all 20 typed-dispatch sites. +/// `encrypted` only ever sets the flag true (a federation envelope is E2E), +/// never clears a true set elsewhere. +pub(crate) async fn stamp_received_transport( + state: &Arc, + sender_contact_id: u32, + after_id: u64, + transport: &str, + encrypted: bool, +) { + let mut messages = state.messages.write().await; + for m in messages.iter_mut() { + if m.id > after_id + && matches!(m.direction, MessageDirection::Received) + && m.peer_contact_id == sender_contact_id + { + if m.transport.is_none() { + m.transport = Some(transport.to_string()); + } + if encrypted { + m.encrypted = true; + } + } + } +} + +/// Mark every RECEIVED message stored since `after_id` as end-to-end encrypted. +/// Used by the session loop to stamp the E2E pill on a meshtastic frame the radio +/// reported PKI-encrypted (the synthetic frame can't carry that flag, and the +/// typed-dispatch store path defaults `encrypted` to false). One inbound frame +/// yields at most one received message, so no sender filter is needed. +pub(crate) async fn stamp_received_encrypted(state: &Arc, after_id: u64) { + let mut messages = state.messages.write().await; + for m in messages.iter_mut() { + if m.id > after_id && matches!(m.direction, MessageDirection::Received) { + m.encrypted = true; + } + } } /// Dispatch a pre-decoded TypedEnvelope. Shared between the radio receive diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 3ba5c570..7bee563c 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -4,7 +4,8 @@ use super::super::meshtastic::MeshtasticDevice; use super::super::serial::MeshcoreDevice; use super::super::types::*; use super::{ - frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL, + dispatch, frames, MeshCommand, MeshState, ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, + SYNC_INTERVAL, }; use anyhow::{Context, Result}; use std::sync::Arc; @@ -152,6 +153,15 @@ impl MeshRadioDevice { Self::Meshtastic(device) => device.try_recv_frame().await, } } + + /// PKI-E2E status of the last inbound frame (meshtastic only; meshcore's + /// per-message E2E is derived in the frames decrypt path). Take-and-clear. + fn take_rx_encrypted(&mut self) -> bool { + match self { + Self::Meshcore(_) => false, + Self::Meshtastic(device) => device.take_rx_encrypted(), + } + } } /// Scan all candidate serial ports and open the first supported mesh device found. @@ -728,11 +738,19 @@ pub(super) async fn run_mesh_session( Ok(Some(frame)) => { // Successful read resets the failure counter consecutive_write_failures = 0; + // For meshtastic, the PKI-E2E status of this frame can't + // ride the synthetic meshcore frame — snapshot the message + // id high-water mark, dispatch, then stamp the E2E pill on + // whatever received message this frame produced. + let before_id = dispatch::max_message_id(state).await; let should_action = frames::handle_frame( &frame, state, our_x25519_secret, ).await; + if device.take_rx_encrypted() { + dispatch::stamp_received_encrypted(state, before_id).await; + } if should_action { // Contact discovery or messages waiting — sync both refresh_contacts(&mut device, state).await; diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index ef720425..5b0d7264 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -72,6 +72,19 @@ const FROM_RADIO_LOCKDOWN_STATUS: u64 = 18; const FROM_RADIO_REGION_PRESETS: u64 = 19; /// Channel.role value for the PRIMARY channel (broadcasts ride here). const CHANNEL_ROLE_PRIMARY: u64 = 1; +/// Channel.role value for a SECONDARY channel (extra channels we also decode). +const CHANNEL_ROLE_SECONDARY: u64 = 2; +/// Slot index our private archipelago channel occupies (secondary). Slot 0 is +/// kept as the off-the-shelf default public channel so archy interoperates with +/// stock Meshtastic devices (LongFast) AND picks up default-channel users. +const ARCHY_CHANNEL_INDEX: u64 = 1; +/// Meshtastic's default-channel PSK is the single byte 0x01 ("use the well-known +/// default key"); the firmware also reports it expanded to these 16 bytes. Treat +/// EITHER form as "the default channel" so we never reboot-loop re-setting it. +const DEFAULT_PSK_BYTE: &[u8] = &[1]; +const DEFAULT_PSK_EXPANDED: &[u8] = &[ + 0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01, +]; /// Config.lora oneof field number (carries a `LoRaConfig`). const CONFIG_LORA_FIELD: u64 = 6; /// LoRaConfig field numbers we set when provisioning the radio's region. @@ -113,7 +126,17 @@ pub struct MeshtasticDevice { /// provisions a shared channel here the same way it provisions the region. /// `None` until a primary `Channel` frame is seen. current_primary_channel: Option<(String, Vec)>, + /// The radio's current SECONDARY channel at `ARCHY_CHANNEL_INDEX`, learned + /// from `want_config`. This is where our private "archipelago" channel lives + /// (slot 0 stays the public default). `None` until that slot's `Channel` + /// frame is seen. + current_secondary_channel: Option<(String, Vec)>, device_path: String, + /// PKI-encryption status of the most recent inbound text frame yielded by + /// `try_recv_frame`. The synthetic meshcore-style frame can't carry it, so + /// the session loop reads it via `take_rx_encrypted()` right after dispatch + /// to stamp the message's E2E pill. Set true only for `pki_encrypted` DMs. + last_rx_encrypted: bool, } impl MeshtasticDevice { @@ -142,7 +165,9 @@ impl MeshtasticDevice { peer_pubkeys: HashMap::new(), current_region: None, current_primary_channel: None, + current_secondary_channel: None, device_path: path.to_string(), + last_rx_encrypted: false, }) } @@ -288,11 +313,31 @@ impl MeshtasticDevice { return Ok(false); } match self.current_region { - Some(cur) if cur == code => Ok(false), - _ => { + // The radio already has a REAL region (US, EU_868, ANZ, …). RESPECT + // it — never override the region the user flashed/configured. Forcing + // our configured region onto, say, a US radio would put it on an + // illegal band and cut it off from its local mesh. Off-the-shelf + // devices keep whatever region they came with; `code` (the + // mesh-config region) is only the fallback for a fresh radio below. + Some(cur) if cur != REGION_UNSET => { + if cur != code { + debug!( + device_region = cur, + configured_region = code, + "Respecting the radio's own LoRa region (not overriding with the configured one)" + ); + } + Ok(false) + } + // Region is UNSET → a fresh radio is RF-silent and can't mesh at all. + // Set the operator-configured region so it can transmit/receive. + Some(_) => { self.set_lora_region(code).await?; Ok(true) } + // Region unknown (never reported in want_config) — don't guess / + // don't override; leave it for the user to set. + None => Ok(false), } } @@ -338,41 +383,57 @@ impl MeshtasticDevice { Ok(()) } - /// Ensure the radio's PRIMARY channel matches the shared archy channel so - /// all nodes can decode each other. Region gets two radios onto the same - /// band; a matching channel (name + psk → channel hash) gets them decoding - /// each other's traffic — without it they hear each other but drop every - /// packet as undecryptable. The psk is derived deterministically from the - /// channel name, so every archy node with the same `channel_name` converges - /// on the same channel (the parity equivalent of meshcore's named channel). + /// Provision archy's two channels so the radio works like off-the-shelf + /// Meshtastic AND carries our private group: + /// - slot 0 (PRIMARY) = the DEFAULT public channel (name "", default key) + /// → archy interoperates with every stock device on LongFast and picks + /// up default-channel users; our own NodeInfo broadcasts ride here. + /// - slot 1 (SECONDARY) = "archipelago" (deterministic psk from the name) + /// → the private archy↔archy group channel (parity with meshcore). /// - /// Returns `Ok(true)` when it wrote a new channel (the device reboots to - /// apply, so the caller should restart the session); `Ok(false)` when no - /// change was needed — never reboot-loops. + /// Writes at most ONE channel per call (each write reboots the radio), so the + /// existing reboot→reconnect→re-check loop converges over a couple of cycles + /// without ever reboot-looping. Returns `Ok(true)` when it wrote something. pub async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result { - let Some(channel_name) = channel_name else { + // 1) Primary must be the default public channel (off-the-shelf interop). + if !primary_is_default(&self.current_primary_channel) { + self.set_channel(0, "", DEFAULT_PSK_BYTE, CHANNEL_ROLE_PRIMARY) + .await?; + return Ok(true); + } + // 2) Secondary slot = our private archipelago channel (when configured). + let Some(channel_name) = channel_name.filter(|n| !n.is_empty()) else { return Ok(false); }; - if channel_name.is_empty() { - return Ok(false); - } let desired_psk = derive_channel_psk(channel_name); let already = matches!( - &self.current_primary_channel, + &self.current_secondary_channel, Some((name, psk)) if name == channel_name && psk == &desired_psk ); if already { Ok(false) } else { - self.set_channel(channel_name, &desired_psk).await?; + self.set_channel( + ARCHY_CHANNEL_INDEX, + channel_name, + &desired_psk, + CHANNEL_ROLE_SECONDARY, + ) + .await?; Ok(true) } } - /// Write the PRIMARY channel via `AdminMessage { set_channel: Channel { … } }` + /// Write a channel slot via `AdminMessage { set_channel: Channel { … } }` /// (the same local-admin path as `set_advert_name`). The firmware reboots to /// apply it. - pub async fn set_channel(&mut self, name: &str, psk: &[u8]) -> Result<()> { + pub async fn set_channel( + &mut self, + index: u64, + name: &str, + psk: &[u8], + role: u64, + ) -> Result<()> { let Some(node_num) = self.node_num else { anyhow::bail!("Meshtastic set_channel: node_num unknown"); }; @@ -382,11 +443,11 @@ impl MeshtasticDevice { encode_len_field(2, psk, &mut settings); encode_len_field(3, name.as_bytes(), &mut settings); - // Channel { index(1)=0, settings(2), role(3)=PRIMARY } + // Channel { index(1), settings(2), role(3) } let mut channel = Vec::new(); - encode_varint_field_into(1, 0, &mut channel); + encode_varint_field_into(1, index, &mut channel); encode_len_field(2, &settings, &mut channel); - encode_varint_field_into(3, CHANNEL_ROLE_PRIMARY, &mut channel); + encode_varint_field_into(3, role, &mut channel); // AdminMessage { set_channel(33): Channel } let mut admin = Vec::new(); @@ -397,8 +458,17 @@ impl MeshtasticDevice { .await .context("Failed to send Meshtastic set_channel admin packet")?; - info!(node_num, channel = %name, "Set Meshtastic primary channel (device will reboot to apply)"); - self.current_primary_channel = Some((name.to_string(), psk.to_vec())); + let slot = if role == CHANNEL_ROLE_PRIMARY { + "primary(default)" + } else { + "secondary(archipelago)" + }; + info!(node_num, index, channel = %name, slot, "Set Meshtastic channel (device will reboot to apply)"); + if role == CHANNEL_ROLE_PRIMARY { + self.current_primary_channel = Some((name.to_string(), psk.to_vec())); + } else { + self.current_secondary_channel = Some((name.to_string(), psk.to_vec())); + } Ok(()) } @@ -563,10 +633,25 @@ impl MeshtasticDevice { } pub async fn try_recv_frame(&mut self) -> Result> { - let Some(frame) = self.read_from_radio().await? else { - return Ok(None); - }; - Ok(self.handle_from_radio(&frame)) + // Drain a bounded batch of frames per poll, processing EACH for its side + // effects (my_info/config/channel/node_info) and returning the first that + // yields an inbound text frame. The old one-frame-per-poll behavior + // returned Ok(None) for every non-text frame, so the caller slept 50ms + // between frames; under Meshtastic's frequent NodeInfo/telemetry stream a + // received text packet queued behind them and the read buffer's 64KB cap + // could drain (drop) it before it was ever decoded — silently killing + // reception while sends kept working. Draining keeps the buffer short so + // the text frame is decoded the same poll it arrives. Bounded to 64 so a + // continuous flood still yields back to the session select! loop. + for _ in 0..64 { + let Some(frame) = self.read_from_radio().await? else { + return Ok(None); + }; + if let Some(inbound) = self.handle_from_radio(&frame) { + return Ok(Some(inbound)); + } + } + Ok(None) } /// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a @@ -682,9 +767,13 @@ impl MeshtasticDevice { None } FROM_RADIO_CHANNEL => { - if let Some((name, psk)) = parse_primary_channel(value) { - debug!(name = %name, psk_len = psk.len(), "Meshtastic primary channel from device"); - self.current_primary_channel = Some((name, psk)); + if let Some((index, role, name, psk)) = parse_channel(value) { + debug!(index, role, name = %name, psk_len = psk.len(), "Meshtastic channel from device"); + if role == CHANNEL_ROLE_PRIMARY { + self.current_primary_channel = Some((name, psk)); + } else if index == ARCHY_CHANNEL_INDEX { + self.current_secondary_channel = Some((name, psk)); + } } None } @@ -752,6 +841,13 @@ impl MeshtasticDevice { &mut self.peer_pubkeys, ) } + + /// Take + clear the PKI-E2E status of the last inbound text frame. The + /// session loop calls this right after dispatching a received frame to stamp + /// the message's E2E pill (meshtastic DMs are E2E only when PKI-encrypted). + pub fn take_rx_encrypted(&mut self) -> bool { + std::mem::take(&mut self.last_rx_encrypted) + } } fn packet_to_inbound_frame( @@ -930,11 +1026,25 @@ fn parse_config_lora_region(data: &[u8]) -> Option { None } -/// Extract `(name, psk)` from a `Channel` message, but only for the PRIMARY -/// channel (role == 1) — that's the one broadcasts ride on and whose hash must -/// match for two radios to decode each other. Returns `None` for secondary / -/// disabled channels so the caller keeps the primary it already learned. -fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { +/// True when the radio's primary channel is the off-the-shelf DEFAULT public +/// channel (empty name + the default key, in either its 1-byte or expanded +/// form). Used so we only rewrite the primary when it's been clobbered (e.g. an +/// older archy that set "archipelago" as primary) — never on a stock radio. +fn primary_is_default(primary: &Option<(String, Vec)>) -> bool { + match primary { + Some((name, psk)) => { + name.is_empty() + && (psk.as_slice() == DEFAULT_PSK_BYTE || psk.as_slice() == DEFAULT_PSK_EXPANDED) + } + None => false, + } +} + +/// Extract `(index, role, name, psk)` from a `Channel` message. The caller +/// stores the primary (slot 0) and our secondary slot separately so it can keep +/// both the public default channel and the private archipelago channel in sync. +fn parse_channel(data: &[u8]) -> Option<(u64, u64, String, Vec)> { + let mut index = 0u64; let mut role = 0u64; let mut name = String::new(); let mut psk = Vec::new(); @@ -943,6 +1053,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { let (field, value, next) = next_field(data, idx)?; idx = next; match (field, value) { + (1, FieldValue::Varint(v)) => index = v, (3, FieldValue::Varint(v)) => role = v, (2, FieldValue::Bytes(b)) => { let mut j = 0; @@ -959,11 +1070,7 @@ fn parse_primary_channel(data: &[u8]) -> Option<(String, Vec)> { _ => {} } } - if role == CHANNEL_ROLE_PRIMARY { - Some((name, psk)) - } else { - None - } + Some((index, role, name, psk)) } /// Derive the 32-byte channel PSK deterministically from the channel name, so diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 1659d8c6..ee5d3cae 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1194,6 +1194,11 @@ impl MeshService { display_text, typed_payload, sender_seq, + Some("lora".to_string()), + // Archy↔archy typed envelopes over LoRa are identity-signed; the + // radio E2E flag (meshtastic PKI / meshcore session) isn't + // threaded to the send side yet, so don't over-claim E2E here. + false, ) .await) } @@ -1249,6 +1254,11 @@ impl MeshService { display_text, typed_payload, sender_seq, + // Transport is finalized below once the background send resolves + // FIPS vs Tor; mark E2E now — a federation envelope is + // identity-signed and rides an encrypted transport. + None, + true, ) .await; @@ -1258,6 +1268,10 @@ impl MeshService { // MeshMessage and the UI's delivery indicator tracks the receipt. let peer_onion_owned = peer_onion.to_string(); let data_dir_owned = self.data_dir.clone(); + // Finalize the Sent record's transport pill once we know which leg + // (FIPS/Tor) actually delivered it. + let state_for_transport = self.state.clone(); + let sent_msg_id = msg.id; tokio::spawn(async move { let fips_npub = crate::federation::fips_npub_for_onion(&data_dir_owned, &peer_onion_owned).await; @@ -1278,6 +1292,12 @@ impl MeshService { match req.send_json(&body).await { Ok((resp, transport)) if resp.status().is_success() => { tracing::debug!(contact_id, transport = %transport, "Federation envelope delivered"); + // Tag the Sent bubble with the leg that delivered it (the + // transport pill: "fips" / "tor"). + let mut messages = state_for_transport.messages.write().await; + if let Some(m) = messages.iter_mut().find(|m| m.id == sent_msg_id) { + m.transport = Some(transport.to_string()); + } } Ok((resp, transport)) => warn!( contact_id, @@ -1342,6 +1362,22 @@ impl MeshService { Some(&display_name), ) .await; + // The inbound HTTP gives no FIPS-vs-Tor signal, so label the message + // with the leg most recently used with this peer (federation storage's + // `last_transport`), defaulting to Tor. Federation envelopes are E2E + // (identity-signed over an encrypted transport). + let transport_label = { + let nodes = crate::federation::load_nodes(&self.data_dir) + .await + .unwrap_or_default(); + nodes + .iter() + .find(|n| n.pubkey == from_pubkey_hex) + .and_then(|n| n.last_transport.clone()) + .filter(|t| t == "fips" || t == "tor") + .unwrap_or_else(|| "tor".to_string()) + }; + let before = listener::dispatch::max_message_id(&self.state).await; listener::dispatch::handle_typed_envelope_direct( &self.state, contact_id, @@ -1349,6 +1385,14 @@ impl MeshService { envelope, ) .await; + listener::dispatch::stamp_received_transport( + &self.state, + contact_id, + before, + &transport_label, + true, + ) + .await; Ok(()) } @@ -1458,7 +1502,10 @@ impl MeshService { plaintext: display_text.to_string(), timestamp: chrono::Utc::now().to_rfc3339(), delivered: false, + // Channel broadcasts use the shared channel PSK, not per-identity + // E2E — so not an E2E message, but it does travel over the radio. encrypted: false, + transport: Some("lora".to_string()), message_type: type_label.to_string(), typed_payload, sender_pubkey: Some(self.our_ed_pubkey_hex.clone()), @@ -1494,7 +1541,15 @@ impl MeshService { .await .map_err(|_| anyhow::anyhow!("Mesh listener not running"))?; return Ok(self - .record_sent_typed(contact_id, "text", text, None, seq) + .record_sent_typed( + contact_id, + "text", + text, + None, + seq, + Some("lora".to_string()), + false, + ) .await); } // Sign the envelope with our archipelago identity key so the receiver @@ -1544,6 +1599,8 @@ impl MeshService { display_text: &str, typed_payload: Option, sender_seq: u64, + transport: Option, + encrypted: bool, ) -> MeshMessage { let msg_id = self.state.next_id().await; let peer_name = self @@ -1561,7 +1618,8 @@ impl MeshService { plaintext: display_text.to_string(), timestamp: chrono::Utc::now().to_rfc3339(), delivered: false, - encrypted: false, + encrypted, + transport, message_type: type_label.to_string(), typed_payload, sender_pubkey: Some(self.our_ed_pubkey_hex.clone()), @@ -1612,7 +1670,9 @@ impl MeshService { plaintext: text.to_string(), timestamp: chrono::Utc::now().to_rfc3339(), delivered: false, + // Plain channel broadcast over the radio (shared PSK, not E2E). encrypted: false, + transport: Some("lora".to_string()), message_type: "text".to_string(), typed_payload: None, sender_pubkey: None, diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index ae529ea8..e34e9df1 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -104,6 +104,12 @@ pub struct MeshMessage { pub delivered: bool, /// Whether the message was end-to-end encrypted. pub encrypted: bool, + /// How this message actually traveled, for the per-message transport pill: + /// "lora" (mesh radio), "fips", or "tor". `None` until known (a Sent + /// federation message is finalized once the background send resolves the + /// transport). Surfaced in the UI beside the E2E badge. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, /// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...). #[serde(default = "default_message_type")] pub message_type: String, diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index 47978f83..e8b6facd 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -355,6 +355,9 @@ impl Server { } // Initialize transport router (unified routing: mesh > lan > tor) + // Hoisted so the FIPS seed-anchor loop below can auto-peer LAN-discovered + // federation peers directly over FIPS (see that loop). + let mut fips_peer_registry: Option> = None; { let data_dir = config.data_dir.clone(); let did = @@ -368,6 +371,7 @@ impl Server { match crate::transport::PeerRegistry::load(&data_dir).await { Ok(registry) => { let registry = std::sync::Arc::new(registry); + fips_peer_registry = Some(registry.clone()); let mut transports: Vec> = Vec::new(); // Tor transport (always register — availability checked dynamically) @@ -640,6 +644,7 @@ impl Server { // onboarding before we start dialing. { let data_dir = config.data_dir.clone(); + let fips_peer_registry = fips_peer_registry.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(30)).await; let mut interval = tokio::time::interval(Duration::from_secs(300)); @@ -654,6 +659,23 @@ impl Server { tracing::debug!("Seed-anchor apply: load failed (non-fatal): {}", e) } } + + // Auto-peer federation nodes we've discovered on the LAN + // directly over FIPS, so co-located peers don't depend on the + // (often flaky) global anchor's spanning tree to route to each + // other. For every peer the registry knows both a LAN address + // AND a FIPS npub for, dial it on its FIPS UDP transport port + // (8668) at its LAN IP. This is FIPS's own transport over the + // LAN — NOT Tailscale, NOT the HTTP/LAN messaging port. Pure + // FIPS. `fipsctl connect` is idempotent, so re-applying every + // tick just keeps the direct link warm; unknown/remote peers + // (no LAN address) are left to the anchor as before. + if let Some(reg) = fips_peer_registry.as_ref() { + let direct = crate::fips::anchors::lan_fips_anchors(®.all_peers().await); + if !direct.is_empty() { + let _ = crate::fips::anchors::apply(&direct).await; + } + } } }); } diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 9bb95e5f..b4a272e9 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -78,6 +78,9 @@ export interface MeshMessage { timestamp: string delivered: boolean encrypted: boolean + /// How the message traveled: "lora" (mesh radio), "fips", or "tor". + /// Drives the per-message transport pill. Absent until known. + transport?: string | null message_type?: MeshMessageTypeLabel // eslint-disable-next-line @typescript-eslint/no-explicit-any typed_payload?: Record | null diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index cc79db2a..33186b70 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -1076,6 +1076,17 @@ function isEditedMessage(msg: MeshMessage): number | null { function isDeletedMessage(msg: MeshMessage): boolean { return msg.message_type === 'delete' || msg.typed_payload?.deleted === true } +/// Short label for the per-message transport pill (Mesh / FIPS / Tor), or null +/// when the transport isn't known. Covers both meshcore and meshtastic since +/// the field lives on the shared MeshMessage. +function transportLabel(msg: MeshMessage): string | null { + switch (msg.transport) { + case 'lora': return 'Mesh' + case 'fips': return 'FIPS' + case 'tor': return 'Tor' + default: return null + } +} // Read-receipt: after render, if the bottom message is from the peer (direction='received') // and has a MessageKey, fire mesh.send-read-receipt up to that seq. Debounced so scroll @@ -1798,6 +1809,7 @@ function isImageMime(mime?: string): boolean {
{{ msg.plaintext }}
+ {{ transportLabel(msg) }} E2E (edited) ✓✓ diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index e75e2494..e7f7383b 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -148,6 +148,11 @@ .mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; } .mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); } .mesh-chat-e2e { font-size: 0.55rem; font-weight: 700; color: #4ade80; padding: 0 3px; border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 3px; } +/* Per-message transport pill (Mesh / FIPS / Tor), styled like the E2E badge. */ +.mesh-chat-transport { font-size: 0.55rem; font-weight: 700; padding: 0 3px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.85; } +.mesh-chat-transport.transport-lora { color: #f59e0b; } /* Mesh/LoRa — amber */ +.mesh-chat-transport.transport-fips { color: #a78bfa; } /* FIPS — violet */ +.mesh-chat-transport.transport-tor { color: #818cf8; } /* Tor — indigo */ .mesh-chat-ack { font-size: 0.7rem; color: #3b82f6; } .mesh-chat-compose { padding: 12px 16px; border-top: 1px solid rgba(255, 255, 255, 0.06); flex-shrink: 0; } .mesh-chat-send-error { color: #ef4444; font-size: 0.75rem; margin-bottom: 6px; }