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:
commit
daf750688d
@ -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::*;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(®.all_peers().await);
|
||||
if !direct.is_empty() {
|
||||
let _ = crate::fips::anchors::apply(&direct).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">✓✓</span>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user