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
co-authored by Claude Opus 4.7
parent cd8763f468
commit 7e4fed7967
4 changed files with 115 additions and 13 deletions
+35
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,