merge: mesh multiversion and transport pills

# Conflicts:
#	core/archipelago/src/mesh/listener/decode.rs
#	core/archipelago/src/mesh/meshtastic.rs
This commit is contained in:
archipelago 2026-06-30 05:19:58 -04:00
commit daf750688d
11 changed files with 381 additions and 45 deletions

View File

@ -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
/// `<lan-ip>: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<SeedAnchor> {
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::<std::net::SocketAddr>() 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::*;

View File

@ -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,

View File

@ -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<MeshState>) -> 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<MeshState>,
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<MeshState>, 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

View File

@ -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;

View File

@ -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<u8>)>,
/// 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<u8>)>,
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<bool> {
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<Option<InboundFrame>> {
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<u32> {
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<u8>)> {
/// 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<u8>)>) -> 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<u8>)> {
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<u8>)> {
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<u8>)> {
_ => {}
}
}
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

View File

@ -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<serde_json::Value>,
sender_seq: u64,
transport: Option<String>,
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,

View File

@ -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<String>,
/// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...).
#[serde(default = "default_message_type")]
pub message_type: String,

View File

@ -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<std::sync::Arc<crate::transport::PeerRegistry>> = 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<Box<dyn crate::transport::NodeTransport>> = 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(&reg.all_peers().await);
if !direct.is_empty() {
let _ = crate::fips::anchors::apply(&direct).await;
}
}
}
});
}

View File

@ -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<string, any> | null

View File

@ -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 {
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>

View File

@ -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; }