feat: DM epoch fix, federation relay, and mDNS mesh discovery

- schema: createChannel returns wasNew :Bool to elect the MLS initiator
  unambiguously; prevents duplicate group creation on concurrent /dm calls
- core: group helpers for epoch tracking and key-package lifecycle
- server: federation subsystem — mTLS QUIC server-to-server relay with
  Cap'n Proto RPC; enqueue/batchEnqueue relay unknown recipients to their
  home domain via FederationClient
- server: mDNS _quicproquo._udp.local. service announcement on startup
- server: storage + sql_store — identity_exists, peek/ack, federation
  home-server lookup helpers
- client: /mesh peers REPL command (mDNS discovery, feature = "mesh")
- client: MeshDiscovery — background mDNS browse with ServiceDaemon
- client: was_new=false path in cmd_dm waits for peer Welcome instead of
  creating a duplicate initiator group
- p2p: fix ALPN from quicnprotochat/p2p/1 → quicproquo/p2p/1
- workspace: re-include quicproquo-p2p in members
This commit is contained in:
2026-03-03 14:41:56 +01:00
parent e24497bf90
commit c8398d6cb7
27 changed files with 3375 additions and 303 deletions

View File

@@ -56,5 +56,8 @@ toml = { version = "0.8" }
metrics = "0.22"
metrics-exporter-prometheus = "0.15"
# mDNS service announcement for local mesh / Freifunk node discovery.
mdns-sd = "0.12"
[dev-dependencies]
tempfile = "3"

View File

@@ -72,6 +72,7 @@ pub struct FederationPeerConfig {
}
#[derive(Debug)]
#[allow(dead_code)] // federation not yet wired up
pub struct EffectiveFederationConfig {
pub enabled: bool,
pub domain: String,

View File

@@ -2,6 +2,8 @@
//!
//! A bare `username` (no `@`) is treated as local.
#![allow(dead_code)] // federation not yet wired up
/// A parsed federated address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FederatedAddress {

View File

@@ -2,15 +2,16 @@
//!
//! Uses a lazy connection pool (DashMap) to reuse QUIC connections to known peers.
#![allow(dead_code)] // federation not yet wired up
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use anyhow::Context;
use dashmap::DashMap;
use quinn::Endpoint;
use crate::config::{EffectiveFederationConfig, FederationPeerConfig};
use crate::config::EffectiveFederationConfig;
/// Outbound federation client for relaying to peer servers.
pub struct FederationClient {

View File

@@ -11,6 +11,4 @@ pub mod routing;
pub mod service;
pub mod tls;
pub use address::FederatedAddress;
pub use client::FederationClient;
pub use routing::Destination;

View File

@@ -46,7 +46,7 @@ pub fn build_federation_server_config(
let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13])
.with_client_cert_verifier(client_verifier)
.with_single_cert(cert_chain, key)?;
tls.alpn_protocols = vec![b"qnpc-fed".to_vec()];
tls.alpn_protocols = vec![b"quicproquo/federation/1".to_vec()];
let crypto = QuicServerConfig::try_from(tls)
.map_err(|e| anyhow::anyhow!("invalid federation server TLS config: {e}"))?;

View File

@@ -354,6 +354,65 @@ async fn main() -> anyhow::Result<()> {
None
};
// ── mDNS local mesh discovery ─────────────────────────────────────────────
// Announce this server on the local network so mesh-mode clients (and other
// Freifunk nodes) can discover it automatically without manual configuration.
// Non-critical: failures are logged as warnings; the server starts regardless.
let _mdns_daemon = {
let listen_port: u16 = listen.port();
// Use the federation domain as the mDNS instance name when available.
let mdns_instance = effective
.federation
.as_ref()
.map(|f| f.domain.clone())
.unwrap_or_else(|| "qpq-server".to_string());
// mDNS host names must end with a dot.
let mdns_host = if mdns_instance.ends_with('.') {
mdns_instance.clone()
} else {
format!("{mdns_instance}.local.")
};
match mdns_sd::ServiceDaemon::new() {
Ok(daemon) => {
let mut props = std::collections::HashMap::new();
props.insert("ver".to_string(), "1".to_string());
props.insert("server".to_string(), effective.listen.clone());
props.insert("domain".to_string(), mdns_instance.clone());
match mdns_sd::ServiceInfo::new(
"_quicproquo._udp.local.",
&mdns_instance,
&mdns_host,
&[] as &[std::net::IpAddr],
listen_port,
Some(props),
) {
Ok(info) => match daemon.register(info) {
Ok(()) => {
tracing::info!(
instance = %mdns_instance,
port = listen_port,
"mDNS: announced qpq server on local network (_quicproquo._udp.local.)"
);
}
Err(e) => {
tracing::warn!(error = %e, "mDNS: service registration failed; mesh discovery disabled");
}
},
Err(e) => {
tracing::warn!(error = %e, "mDNS: failed to build service info; mesh discovery disabled");
}
}
Some(daemon)
}
Err(e) => {
tracing::warn!(error = %e, "mDNS: daemon start failed; mesh discovery disabled");
None
}
}
};
// capnp-rpc is !Send (Rc internals), so all RPC tasks must stay on a LocalSet.
let local = LocalSet::new();
local

View File

@@ -51,12 +51,14 @@ impl NodeServiceImpl {
));
}
let channel_id = match self.store.create_channel(&identity, &peer_key) {
Ok(id) => id,
let (channel_id, was_new) = match self.store.create_channel(&identity, &peer_key) {
Ok(pair) => pair,
Err(e) => return Promise::err(storage_err(e)),
};
results.get().set_channel_id(&channel_id);
let mut r = results.get();
r.set_channel_id(&channel_id);
r.set_was_new(was_new);
Promise::ok(())
}
}

View File

@@ -100,6 +100,42 @@ impl NodeServiceImpl {
}
}
// Federation routing: if the recipient's home server differs from ours, relay the
// message to the remote server instead of enqueueing locally. This enables
// cross-node delivery in a Freifunk / community mesh deployment.
if let (Some(fed_client), Some(local_domain)) =
(&self.federation_client, &self.local_domain)
{
let dest = crate::federation::routing::resolve_destination(
&self.store,
&recipient_key,
local_domain,
);
if let crate::federation::routing::Destination::Remote(remote_domain) = dest {
let fed = Arc::clone(fed_client);
let rk = recipient_key;
let pl = payload;
let ch = channel_id;
tracing::info!(
recipient_prefix = %fmt_hex(&rk[..4]),
domain = %remote_domain,
"federation: routing enqueue to remote server"
);
return Promise::from_future(async move {
let seq = fed
.relay_enqueue(&remote_domain, &rk, &pl, &ch)
.await
.map_err(|e| {
capnp::Error::failed(format!("federation relay failed: {e}"))
})?;
results.get().set_seq(seq);
metrics::record_enqueue_total();
metrics::record_enqueue_bytes(pl.len() as u64);
Ok(())
});
}
}
// DM channel authz: channel_id.len() == 16 means a created channel; caller and recipient must be the two members.
if channel_id.len() == 16 {
let members = match self.store.get_channel_members(&channel_id) {
@@ -591,7 +627,8 @@ impl NodeServiceImpl {
}
}
let mut seqs = Vec::with_capacity(recipient_keys.len() as usize);
// Eagerly collect recipient keys so params can be dropped before any async work.
let mut recipient_key_vecs: Vec<Vec<u8>> = Vec::with_capacity(recipient_keys.len() as usize);
for i in 0..recipient_keys.len() {
let rk = match recipient_keys.get(i) {
Ok(v) => v.to_vec(),
@@ -604,7 +641,7 @@ impl NodeServiceImpl {
));
}
// Per-recipient DM channel membership check.
// Per-recipient DM channel membership check (only when channel_id is a 16-byte UUID).
if channel_id.len() == 16 {
let members = match self.store.get_channel_members(&channel_id) {
Ok(Some(m)) => m,
@@ -631,44 +668,79 @@ impl NodeServiceImpl {
}
}
match self.store.queue_depth(&rk, &channel_id) {
Ok(depth) if depth >= MAX_QUEUE_DEPTH => {
return Promise::err(coded_error(
E015_QUEUE_FULL,
format!("queue depth {} exceeds limit {}", depth, MAX_QUEUE_DEPTH),
));
}
Err(e) => return Promise::err(storage_err(e)),
_ => {}
recipient_key_vecs.push(rk);
}
let n = recipient_key_vecs.len();
let store = Arc::clone(&self.store);
let waiters = Arc::clone(&self.waiters);
let fed_client = self.federation_client.clone();
let local_domain = self.local_domain.clone();
// Use an async future to support federation relay alongside local enqueue.
// All storage operations are synchronous; only federation relay calls are await-ed.
Promise::from_future(async move {
let mut seqs = Vec::with_capacity(n);
for rk in &recipient_key_vecs {
// Federation routing: relay to the recipient's home server when remote.
let dest = if let (Some(ref _fed), Some(ref domain)) = (&fed_client, &local_domain) {
crate::federation::routing::resolve_destination(&store, rk, domain)
} else {
crate::federation::routing::Destination::Local
};
let seq = match dest {
crate::federation::routing::Destination::Remote(ref remote_domain) => {
let fed = fed_client.as_deref().ok_or_else(|| {
capnp::Error::failed("federation client unavailable for remote routing".into())
})?;
tracing::info!(
recipient_prefix = %fmt_hex(&rk[..4]),
domain = %remote_domain,
"federation: routing batch enqueue to remote server"
);
fed.relay_enqueue(remote_domain, rk, &payload, &channel_id)
.await
.map_err(|e| {
capnp::Error::failed(format!("federation relay failed: {e}"))
})?
}
crate::federation::routing::Destination::Local => {
match store.queue_depth(rk, &channel_id) {
Ok(depth) if depth >= MAX_QUEUE_DEPTH => {
return Err(coded_error(
E015_QUEUE_FULL,
format!("queue depth {} exceeds limit {MAX_QUEUE_DEPTH}", depth),
));
}
Err(e) => return Err(storage_err(e)),
_ => {}
}
store
.enqueue(rk, &channel_id, payload.clone())
.map_err(storage_err)?
}
};
seqs.push(seq);
metrics::record_enqueue_total();
metrics::record_enqueue_bytes(payload.len() as u64);
crate::auth::waiter(&waiters, rk).notify_waiters();
}
let seq = match self
.store
.enqueue(&rk, &channel_id, payload.clone())
.map_err(storage_err)
{
Ok(seq) => seq,
Err(e) => return Promise::err(e),
};
seqs.push(seq);
let mut list = results.get().init_seqs(seqs.len() as u32);
for (i, seq) in seqs.iter().enumerate() {
list.set(i as u32, *seq);
}
metrics::record_enqueue_total();
metrics::record_enqueue_bytes(payload.len() as u64);
tracing::info!(
recipient_count = n,
payload_len = payload.len(),
"audit: batch_enqueue"
);
crate::auth::waiter(&self.waiters, &rk).notify_waiters();
}
let mut list = results.get().init_seqs(seqs.len() as u32);
for (i, seq) in seqs.iter().enumerate() {
list.set(i as u32, *seq);
}
tracing::info!(
recipient_count = recipient_keys.len(),
payload_len = payload.len(),
"audit: batch_enqueue"
);
Promise::ok(())
Ok(())
})
}
}

View File

@@ -457,7 +457,7 @@ impl Store for SqlStore {
.map_err(|e| StorageError::Db(e.to_string()))
}
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<Vec<u8>, StorageError> {
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec<u8>, bool), StorageError> {
let (a, b) = if member_a < member_b {
(member_a.to_vec(), member_b.to_vec())
} else {
@@ -473,7 +473,7 @@ impl Store for SqlStore {
.optional()
.map_err(|e| StorageError::Db(e.to_string()))?;
if let Some(id) = existing {
return Ok(id);
return Ok((id, false));
}
let mut channel_id = [0u8; 16];
rand::thread_rng().fill_bytes(&mut channel_id);
@@ -482,7 +482,7 @@ impl Store for SqlStore {
params![channel_id.as_slice(), a, b],
)
.map_err(|e| StorageError::Db(e.to_string()))?;
Ok(channel_id.to_vec())
Ok((channel_id.to_vec(), true))
}
fn get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError> {
@@ -721,4 +721,107 @@ mod tests {
let b_msgs = store.fetch(&rk, b"ch-b").unwrap();
assert_eq!(b_msgs, vec![(0u64, b"b1".to_vec())]);
}
#[test]
fn create_channel_was_new_first_call() {
let store = open_in_memory();
let a = [10u8; 32];
let b = [11u8; 32];
let (id, was_new) = store.create_channel(&a, &b).unwrap();
assert_eq!(id.len(), 16, "channel_id must be 16 bytes");
assert!(was_new, "first create_channel must return was_new=true");
}
#[test]
fn create_channel_idempotent_same_direction() {
let store = open_in_memory();
let a = [12u8; 32];
let b = [13u8; 32];
let (id1, was_new1) = store.create_channel(&a, &b).unwrap();
let (id2, was_new2) = store.create_channel(&a, &b).unwrap();
assert_eq!(id1, id2, "repeated call must return same channel_id");
assert!(was_new1);
assert!(!was_new2, "second call must return was_new=false");
}
#[test]
fn create_channel_idempotent_reversed_direction() {
let store = open_in_memory();
let a = [14u8; 32];
let b = [15u8; 32];
let (id1, was_new1) = store.create_channel(&a, &b).unwrap();
let (id2, was_new2) = store.create_channel(&b, &a).unwrap();
assert_eq!(id1, id2, "reversed-key call must return same channel_id");
assert!(was_new1);
assert!(!was_new2, "reversed-key second call must return was_new=false");
}
#[test]
fn create_channel_different_pairs_isolated() {
let store = open_in_memory();
let a = [16u8; 32];
let b = [17u8; 32];
let c = [18u8; 32];
let (id_ab, _) = store.create_channel(&a, &b).unwrap();
let (id_ac, _) = store.create_channel(&a, &c).unwrap();
let (id_bc, _) = store.create_channel(&b, &c).unwrap();
assert_ne!(id_ab, id_ac);
assert_ne!(id_ab, id_bc);
assert_ne!(id_ac, id_bc);
}
#[test]
fn create_channel_get_members_roundtrip() {
let store = open_in_memory();
let a = [20u8; 32];
let b = [21u8; 32];
let (id, _) = store.create_channel(&a, &b).unwrap();
let members = store.get_channel_members(&id).unwrap();
assert!(members.is_some(), "get_channel_members must return Some after create");
let (ma, mb) = members.unwrap();
// members stored in canonical (lex) order
let (expected_a, expected_b) = if a < b {
(a.to_vec(), b.to_vec())
} else {
(b.to_vec(), a.to_vec())
};
assert_eq!(ma, expected_a);
assert_eq!(mb, expected_b);
}
#[test]
fn get_channel_members_unknown_id_returns_none() {
let store = open_in_memory();
assert!(store.get_channel_members(&[0u8; 16]).unwrap().is_none());
}
#[test]
fn resolve_identity_key_after_store() {
let store = open_in_memory();
let ik = [30u8; 32];
store.store_user_record("carol", b"record".to_vec()).unwrap();
store.store_user_identity_key("carol", ik.to_vec()).unwrap();
let resolved = store.resolve_identity_key(&ik).unwrap();
assert_eq!(resolved, Some("carol".to_string()));
}
#[test]
fn resolve_identity_key_unknown_returns_none() {
let store = open_in_memory();
let unknown = [31u8; 32];
assert!(store.resolve_identity_key(&unknown).unwrap().is_none());
}
#[test]
fn resolve_identity_key_two_users_distinct() {
let store = open_in_memory();
let ik_a = [32u8; 32];
let ik_b = [33u8; 32];
store.store_user_record("user_a", b"ra".to_vec()).unwrap();
store.store_user_record("user_b", b"rb".to_vec()).unwrap();
store.store_user_identity_key("user_a", ik_a.to_vec()).unwrap();
store.store_user_identity_key("user_b", ik_b.to_vec()).unwrap();
assert_eq!(store.resolve_identity_key(&ik_a).unwrap(), Some("user_a".to_string()));
assert_eq!(store.resolve_identity_key(&ik_b).unwrap(), Some("user_b".to_string()));
}
}

View File

@@ -127,9 +127,12 @@ pub trait Store: Send + Sync {
/// Resolve a peer's P2P endpoint address.
fn resolve_endpoint(&self, identity_key: &[u8]) -> Result<Option<Vec<u8>>, StorageError>;
/// Create a 1:1 channel between two members. Returns 16-byte channel_id (UUID).
/// Members are stored in sorted order for deterministic lookup.
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<Vec<u8>, StorageError>;
/// Create a 1:1 channel between two members.
/// Returns `(channel_id, was_new)` where `was_new` is true iff the channel was created by
/// this call (false = it already existed). Members are stored in sorted order for deterministic
/// lookup — both `create_channel(a, b)` and `create_channel(b, a)` return the same channel_id.
/// The caller who receives `was_new = true` is the MLS group initiator and must send the Welcome.
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec<u8>, bool), StorageError>;
/// Get the two members of a channel by channel_id (16 bytes). Returns (member_a, member_b) in sorted order.
fn get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError>;
@@ -137,6 +140,7 @@ pub trait Store: Send + Sync {
// ── Federation ──────────────────────────────────────────────────────────
/// Store the home server domain for an identity key.
#[allow(dead_code)] // federation not yet wired up
fn store_identity_home_server(
&self,
identity_key: &[u8],
@@ -157,6 +161,7 @@ pub trait Store: Send + Sync {
) -> Result<(), StorageError>;
/// List all active federation peers.
#[allow(dead_code)] // federation not yet wired up
fn list_federation_peers(&self) -> Result<Vec<(String, bool)>, StorageError>;
}
@@ -647,7 +652,7 @@ impl Store for FileBackedStore {
Ok(map.get(identity_key).cloned())
}
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<Vec<u8>, StorageError> {
fn create_channel(&self, member_a: &[u8], member_b: &[u8]) -> Result<(Vec<u8>, bool), StorageError> {
let (a, b) = if member_a < member_b {
(member_a.to_vec(), member_b.to_vec())
} else {
@@ -655,14 +660,14 @@ impl Store for FileBackedStore {
};
let mut map = lock(&self.channels)?;
if let Some((channel_id, _)) = map.iter().find(|(_, (ma, mb))| ma == &a && mb == &b) {
return Ok(channel_id.clone());
return Ok((channel_id.clone(), false));
}
let mut channel_id = [0u8; 16];
rand::thread_rng().fill_bytes(&mut channel_id);
let channel_id = channel_id.to_vec();
map.insert(channel_id.clone(), (a, b));
self.flush_channels(&self.channels_path, &*map)?;
Ok(channel_id)
Ok((channel_id, true))
}
fn get_channel_members(&self, channel_id: &[u8]) -> Result<Option<(Vec<u8>, Vec<u8>)>, StorageError> {
@@ -812,12 +817,40 @@ mod tests {
let a = vec![1u8; 32];
let b = vec![2u8; 32];
assert_eq!(store.get_channel_members(&[0u8; 16]).unwrap(), None);
let id1 = store.create_channel(&a, &b).unwrap();
let (id1, was_new1) = store.create_channel(&a, &b).unwrap();
assert_eq!(id1.len(), 16);
assert!(was_new1, "first call must return was_new=true");
let members = store.get_channel_members(&id1).unwrap().unwrap();
assert_eq!(members.0, a);
assert_eq!(members.1, b);
let id2 = store.create_channel(&b, &a).unwrap();
let (id2, was_new2) = store.create_channel(&b, &a).unwrap();
assert_eq!(id1, id2, "reversed key order must return same channel_id");
assert!(!was_new2, "second call (reversed) must return was_new=false");
}
#[test]
fn create_channel_idempotent_same_direction() {
let (_dir, store) = temp_store();
let a = vec![3u8; 32];
let b = vec![4u8; 32];
let (id1, was_new1) = store.create_channel(&a, &b).unwrap();
let (id2, was_new2) = store.create_channel(&a, &b).unwrap();
assert_eq!(id1, id2);
assert!(was_new1);
assert!(!was_new2);
}
#[test]
fn create_channel_different_pairs_get_different_ids() {
let (_dir, store) = temp_store();
let a = vec![5u8; 32];
let b = vec![6u8; 32];
let c = vec![7u8; 32];
let (id_ab, _) = store.create_channel(&a, &b).unwrap();
let (id_ac, _) = store.create_channel(&a, &c).unwrap();
let (id_bc, _) = store.create_channel(&b, &c).unwrap();
assert_ne!(id_ab, id_ac);
assert_ne!(id_ab, id_bc);
assert_ne!(id_ac, id_bc);
}
}