fix(mesh): dedup across transports + persistent radio-contact blocklist

Two mesh fixes bundled so the deploy lands them together:

Doubled messages (radio + federation): dedup at store_message now runs
a third cross-transport check keyed on (sender_seq, plaintext, 120s).
The existing (sender_pubkey, sender_seq) match missed the common case
where the same envelope arrives via LoRa radio (sender_pubkey looked
up from the firmware key) and again via Tor federation (sender_pubkey
= archipelago ed25519), because the two lookups disagree. The new
cross-transport match closes that gap without loosening legacy paths.

Stale contacts after clear-all: meshcore's on-device contact table is
persistent and reads back into peers on the next refresh_contacts, so
the previous "nuclear" clear wiped app state for a few seconds before
the old rows reappeared. New persistent `radio_contact_blocklist`
(mesh-ignored-radio-contacts.json) captures the pubkeys present at
clear-time; `refresh_contacts` filters them on read and the filter
survives restart. Federation-synthetic peers are excluded from the
snapshot so the list rebuilds normally on the next gossip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-04-18 14:02:34 -04:00
parent cd8763f468
commit 7e4fed7967
4 changed files with 115 additions and 13 deletions

View File

@ -233,6 +233,45 @@ impl RpcHandler {
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let state = svc.state();
// Snapshot the firmware pubkeys we currently know about, then
// add them to the radio-contact blocklist. MeshCore's on-device
// contact table is persistent and reads back stale rows on the
// next refresh_contacts, so without this step `clear-all` only
// wipes the app view for a few seconds before the old entries
// reappear. The blocklist is also saved to disk so the filter
// survives a restart.
let firmware_pubkeys: Vec<String> = state
.peers
.read()
.await
.values()
.filter_map(|p| {
// Federation-synthetic peers have their contact_id in the
// high half of u32 and carry the archipelago key — those
// aren't firmware contacts and must not go on the list.
if p.contact_id & 0x8000_0000 != 0 {
None
} else {
p.pubkey_hex.clone()
}
})
.collect();
{
let mut set = state.radio_contact_blocklist.write().await;
for pk in &firmware_pubkeys {
set.insert(pk.clone());
}
}
let persisted: Vec<String> = state
.radio_contact_blocklist
.read()
.await
.iter()
.cloned()
.collect();
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
state.peers.write().await.clear();
state.messages.write().await.clear();
state.contacts.write().await.clear();

View File

@ -15,7 +15,7 @@ mod session;
use super::types::*;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, RwLock};
@ -106,6 +106,12 @@ pub struct MeshState {
/// by `RpcHandler` after startup so the mesh listener can persist inline
/// file bytes into the same store the HTTP layer serves.
pub blob_store: RwLock<Option<Arc<crate::blobs::BlobStore>>>,
/// Firmware-pubkey-hex of radio contacts the user has chosen to ignore
/// (via mesh.clear-all). `refresh_contacts` skips any device contact
/// whose pubkey is in this set, preventing the meshcore firmware's
/// persistent contact table from regenerating rows the user just
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
pub radio_contact_blocklist: RwLock<HashSet<String>>,
}
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
@ -172,6 +178,7 @@ impl MeshState {
contacts: RwLock::new(HashMap::new()),
our_ed_pubkey_hex,
blob_store: RwLock::new(None),
radio_contact_blocklist: RwLock::new(HashSet::new()),
});
(state, rx, cmd_rx)
}
@ -208,26 +215,39 @@ impl MeshState {
// collides with an earlier one (e.g. two 👍 reactions to different
// targets, or "ok" reply twice in a row).
//
// For received messages, prefer MessageKey (sender_pubkey, sender_seq)
// as the dedup identity — it's exact and cross-transport-safe. Fall
// back to (peer, plaintext, 30s window) only for legacy plain-text
// frames that arrive without a sender_seq.
// Dedup runs THREE checks, any match drops the incoming message:
// (a) (sender_pubkey, sender_seq) — exact MessageKey match
// (b) (sender_seq, plaintext, 120s) — cross-transport match when
// the same envelope arrives via radio and federation: radio
// populates sender_pubkey from the firmware key, federation
// populates it from the archipelago ed25519 key, so (a) misses
// but the seq+text still uniquely identifies the envelope
// (c) (peer_contact_id, plaintext, 30s) — legacy plain-text frames
// without a sender_seq at all
if matches!(msg.direction, MessageDirection::Received) {
let dominated = if msg.sender_pubkey.is_some() && msg.sender_seq.is_some() {
messages.iter().rev().take(40).any(|m| {
let has_seq = msg.sender_seq.is_some();
let key_match = has_seq
&& msg.sender_pubkey.is_some()
&& messages.iter().rev().take(40).any(|m| {
matches!(m.direction, MessageDirection::Received)
&& m.sender_pubkey == msg.sender_pubkey
&& m.sender_seq == msg.sender_seq
})
} else {
messages.iter().rev().take(20).any(|m| {
});
let cross_transport_match = has_seq
&& messages.iter().rev().take(40).any(|m| {
matches!(m.direction, MessageDirection::Received)
&& m.sender_seq == msg.sender_seq
&& m.plaintext == msg.plaintext
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 120)
});
let legacy_match = !has_seq
&& messages.iter().rev().take(20).any(|m| {
matches!(m.direction, MessageDirection::Received)
&& m.peer_contact_id == msg.peer_contact_id
&& m.plaintext == msg.plaintext
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
})
};
if dominated {
});
if key_match || cross_transport_match || legacy_match {
return;
}
}

View File

@ -158,8 +158,16 @@ async fn refresh_contacts(
) {
match device.get_contacts().await {
Ok(contacts) => {
// Skip firmware contacts the user has explicitly wiped via
// mesh.clear-all. MeshCore keeps its own persistent contact
// table the app can't remove from, so we filter on read to
// keep cleared entries out of the chat list.
let blocklist = state.radio_contact_blocklist.read().await.clone();
let mut peers = state.peers.write().await;
for (idx, contact) in contacts.iter().enumerate() {
if blocklist.contains(&contact.public_key_hex) {
continue;
}
let contact_id = idx as u32;
let existing = peers.get(&contact_id);
let peer = super::super::types::MeshPeer {

View File

@ -34,6 +34,7 @@ use tokio::sync::watch;
use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
/// Derive a stable synthetic `contact_id` for a federation peer from its
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
@ -178,6 +179,27 @@ pub async fn save_config(data_dir: &Path, config: &MeshConfig) -> Result<()> {
Ok(())
}
pub async fn load_ignored_radio_contacts(data_dir: &Path) -> Vec<String> {
let path = data_dir.join(MESH_IGNORED_RADIO_FILE);
if !path.exists() {
return Vec::new();
}
match fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str::<Vec<String>>(&s).unwrap_or_default(),
Err(_) => Vec::new(),
}
}
pub async fn save_ignored_radio_contacts(data_dir: &Path, pubkeys: &[String]) -> Result<()> {
fs::create_dir_all(data_dir).await.ok();
let content = serde_json::to_string_pretty(pubkeys)
.context("Failed to serialize ignored-radio list")?;
fs::write(data_dir.join(MESH_IGNORED_RADIO_FILE), content)
.await
.context("Failed to write ignored-radio list")?;
Ok(())
}
/// Detect serial devices that could be mesh radios.
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
pub async fn detect_devices() -> Vec<String> {
@ -264,6 +286,19 @@ impl MeshService {
// radio — which never happens for nodes that only share Tor.
seed_federation_peers_into_mesh(&state, data_dir).await;
// Load the radio-contact blocklist so previously-wiped firmware
// contacts stay hidden after restart. Without this, meshcore's
// persistent on-device contact table regenerates the rows on the
// next refresh_contacts cycle and the user sees stale entries
// they already cleared.
{
let ignored = load_ignored_radio_contacts(data_dir).await;
let mut set = state.radio_contact_blocklist.write().await;
for pk in ignored {
set.insert(pk);
}
}
Ok(Self {
state,
config,