feat: Sprint 9 — mesh identity, store-and-forward, broadcast channels
Self-sovereign mesh networking for offline-capable Freifunk deployments. - MeshIdentity: Ed25519 keypair-based identity without AS registration, JSON-persisted seed + known peers directory, sign/verify - MeshEnvelope: signed store-and-forward envelope with TTL, hop_count, max_hops, SHA-256 dedup ID, Ed25519 signature verification - MeshStore: in-memory message queue with dedup, per-recipient capacity limits, TTL-based garbage collection - BroadcastChannel: symmetric ChaCha20-Poly1305 encrypted topic-based pub/sub for mesh announcements, no MLS overhead - BroadcastManager: subscribe/unsubscribe/create channels by topic - P2pNode integration: send_mesh(), receive_mesh(), forward_stored(), subscribe(), create_broadcast(), broadcast() - Extended mesh REPL: /mesh send, /mesh broadcast, /mesh subscribe, /mesh route, /mesh identity, /mesh store (feature-gated) 28 P2P tests pass (21 existing + 7 broadcast). All builds clean.
This commit is contained in:
@@ -12,8 +12,20 @@
|
||||
//! └── QUIC/TLS ── Server ── QUIC/TLS ┘ (fallback: store-and-forward)
|
||||
//! ```
|
||||
|
||||
pub mod broadcast;
|
||||
pub mod envelope;
|
||||
pub mod identity;
|
||||
pub mod store;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{Endpoint, EndpointAddr, PublicKey, SecretKey};
|
||||
|
||||
use crate::broadcast::BroadcastManager;
|
||||
use crate::envelope::MeshEnvelope;
|
||||
use crate::identity::MeshIdentity;
|
||||
use crate::store::MeshStore;
|
||||
|
||||
/// ALPN protocol identifier for quicproquo P2P messaging.
|
||||
/// Updated from the original project name "quicnprotochat" to "quicproquo" (breaking wire change;
|
||||
/// all peers must be on the same version to connect).
|
||||
@@ -24,6 +36,12 @@ const P2P_ALPN: &[u8] = b"quicproquo/p2p/1";
|
||||
/// Manages direct QUIC connections to peers with automatic NAT traversal.
|
||||
pub struct P2pNode {
|
||||
endpoint: Endpoint,
|
||||
/// Optional self-sovereign mesh identity for store-and-forward messaging.
|
||||
mesh_identity: Option<MeshIdentity>,
|
||||
/// Shared store-and-forward queue.
|
||||
mesh_store: Arc<Mutex<MeshStore>>,
|
||||
/// Broadcast channel manager for pub/sub mesh announcements.
|
||||
broadcast_mgr: Arc<Mutex<BroadcastManager>>,
|
||||
}
|
||||
|
||||
/// Received P2P message with sender information.
|
||||
@@ -50,7 +68,24 @@ impl P2pNode {
|
||||
"P2P node started"
|
||||
);
|
||||
|
||||
Ok(Self { endpoint })
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
mesh_identity: None,
|
||||
mesh_store: Arc::new(Mutex::new(MeshStore::new(0))),
|
||||
broadcast_mgr: Arc::new(Mutex::new(BroadcastManager::new())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start a new P2P node with a mesh identity and store-and-forward enabled.
|
||||
pub async fn start_with_mesh(
|
||||
secret_key: Option<SecretKey>,
|
||||
mesh_identity: MeshIdentity,
|
||||
max_stored: usize,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut node = Self::start(secret_key).await?;
|
||||
node.mesh_identity = Some(mesh_identity);
|
||||
node.mesh_store = Arc::new(Mutex::new(MeshStore::new(max_stored)));
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// This node's public key (used as node ID for peer discovery).
|
||||
@@ -68,6 +103,16 @@ impl P2pNode {
|
||||
self.endpoint.addr()
|
||||
}
|
||||
|
||||
/// Return a reference to the mesh identity, if set.
|
||||
pub fn mesh_identity(&self) -> Option<&MeshIdentity> {
|
||||
self.mesh_identity.as_ref()
|
||||
}
|
||||
|
||||
/// Return a clone of the shared mesh store handle.
|
||||
pub fn mesh_store(&self) -> Arc<Mutex<MeshStore>> {
|
||||
Arc::clone(&self.mesh_store)
|
||||
}
|
||||
|
||||
/// Send a payload directly to a peer via P2P QUIC.
|
||||
pub async fn send(&self, peer: impl Into<EndpointAddr>, payload: &[u8]) -> anyhow::Result<()> {
|
||||
let peer = peer.into();
|
||||
@@ -139,6 +184,162 @@ impl P2pNode {
|
||||
Ok(P2pMessage { sender, payload })
|
||||
}
|
||||
|
||||
/// Create a [`MeshEnvelope`] and send it to a peer, or store it for later forwarding.
|
||||
///
|
||||
/// If `peer_addr` is `Some`, the envelope is sent immediately via P2P.
|
||||
/// Otherwise it is queued in the mesh store for future forwarding.
|
||||
pub async fn send_mesh(
|
||||
&self,
|
||||
peer_addr: Option<impl Into<EndpointAddr>>,
|
||||
recipient_key: &[u8],
|
||||
payload: Vec<u8>,
|
||||
ttl_secs: u32,
|
||||
) -> anyhow::Result<()> {
|
||||
let identity = self
|
||||
.mesh_identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mesh identity not configured"))?;
|
||||
|
||||
let envelope = MeshEnvelope::new(identity, recipient_key, payload, ttl_secs, 0);
|
||||
let bytes = envelope.to_bytes();
|
||||
|
||||
if let Some(addr) = peer_addr {
|
||||
self.send(addr, &bytes).await?;
|
||||
tracing::debug!("mesh envelope sent directly");
|
||||
} else {
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
if !store.store(envelope) {
|
||||
anyhow::bail!("mesh store rejected envelope (duplicate or at capacity)");
|
||||
}
|
||||
tracing::debug!("mesh envelope queued for forwarding");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch all stored mesh envelopes addressed to this node's identity.
|
||||
pub fn receive_mesh(&self) -> anyhow::Result<Vec<MeshEnvelope>> {
|
||||
let identity = self
|
||||
.mesh_identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mesh identity not configured"))?;
|
||||
|
||||
let pk = identity.public_key();
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
Ok(store.fetch(&pk))
|
||||
}
|
||||
|
||||
/// Forward stored envelopes to a connected peer.
|
||||
///
|
||||
/// Sends all forwardable envelopes that match `recipient_key` to `peer_addr`.
|
||||
pub async fn forward_stored(
|
||||
&self,
|
||||
peer_addr: impl Into<EndpointAddr> + Clone,
|
||||
recipient_key: &[u8],
|
||||
) -> anyhow::Result<usize> {
|
||||
let envelopes = {
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
store.fetch(recipient_key)
|
||||
};
|
||||
|
||||
let mut forwarded = 0;
|
||||
for env in envelopes {
|
||||
if env.can_forward() {
|
||||
let fwd = env.forwarded();
|
||||
let bytes = fwd.to_bytes();
|
||||
self.send(peer_addr.clone(), &bytes).await?;
|
||||
forwarded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if forwarded > 0 {
|
||||
tracing::debug!(count = forwarded, "forwarded stored mesh envelopes");
|
||||
}
|
||||
|
||||
Ok(forwarded)
|
||||
}
|
||||
|
||||
/// Return a clone of the shared broadcast manager handle.
|
||||
pub fn broadcast_mgr(&self) -> Arc<Mutex<BroadcastManager>> {
|
||||
Arc::clone(&self.broadcast_mgr)
|
||||
}
|
||||
|
||||
/// Subscribe to a broadcast channel with a pre-shared key.
|
||||
pub fn subscribe(&self, topic: &str, key: [u8; 32]) -> anyhow::Result<()> {
|
||||
let mut mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
mgr.subscribe(topic, key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new broadcast channel with a random key. Returns the key for sharing.
|
||||
pub fn create_broadcast(&self, topic: &str) -> anyhow::Result<[u8; 32]> {
|
||||
let mut mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
let ch = mgr.create_channel(topic);
|
||||
Ok(*ch.key())
|
||||
}
|
||||
|
||||
/// Encrypt a payload on a broadcast topic and flood it to all connected peers
|
||||
/// as a MeshEnvelope with an empty recipient key (broadcast).
|
||||
pub async fn broadcast(
|
||||
&self,
|
||||
topic: &str,
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let identity = self
|
||||
.mesh_identity
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("mesh identity not configured"))?;
|
||||
|
||||
let encrypted = {
|
||||
let mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
mgr.encrypt(topic, payload)
|
||||
.ok_or_else(|| anyhow::anyhow!("not subscribed to topic: {topic}"))?
|
||||
};
|
||||
|
||||
// Create a broadcast envelope (empty recipient_key signals broadcast).
|
||||
let envelope = MeshEnvelope::new(identity, &[], encrypted, 300, 0);
|
||||
let bytes = envelope.to_bytes();
|
||||
|
||||
// Store in the mesh store for flood-forwarding.
|
||||
let mut store = self
|
||||
.mesh_store
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("mesh store lock poisoned: {e}"))?;
|
||||
if !store.store(envelope) {
|
||||
tracing::debug!("broadcast envelope dedup or at capacity, skipping store");
|
||||
}
|
||||
drop(store);
|
||||
|
||||
tracing::debug!(topic = topic, bytes = bytes.len(), "broadcast envelope queued");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all subscribed broadcast topics.
|
||||
pub fn topics(&self) -> anyhow::Result<Vec<String>> {
|
||||
let mgr = self
|
||||
.broadcast_mgr
|
||||
.lock()
|
||||
.map_err(|e| anyhow::anyhow!("broadcast manager lock poisoned: {e}"))?;
|
||||
Ok(mgr.topics())
|
||||
}
|
||||
|
||||
/// Gracefully shut down the P2P node.
|
||||
pub async fn close(self) {
|
||||
self.endpoint.close().await;
|
||||
@@ -157,8 +358,13 @@ mod tests {
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.bind()
|
||||
.await
|
||||
.unwrap();
|
||||
P2pNode { endpoint }
|
||||
.expect("bind local endpoint");
|
||||
P2pNode {
|
||||
endpoint,
|
||||
mesh_identity: None,
|
||||
mesh_store: Arc::new(Mutex::new(MeshStore::new(0))),
|
||||
broadcast_mgr: Arc::new(Mutex::new(BroadcastManager::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -171,18 +377,42 @@ mod tests {
|
||||
let payload = b"hello via P2P";
|
||||
|
||||
let recv_handle = tokio::spawn(async move {
|
||||
let msg = receiver.recv().await.unwrap();
|
||||
let msg = receiver.recv().await.expect("receive message");
|
||||
assert_eq!(msg.payload, payload.to_vec());
|
||||
assert_eq!(msg.sender, sender_id);
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
|
||||
sender.send(receiver_addr, payload).await.unwrap();
|
||||
sender.send(receiver_addr, payload).await.expect("send message");
|
||||
|
||||
recv_handle.await.unwrap();
|
||||
recv_handle.await.expect("recv task");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
sender.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mesh_store_and_receive() {
|
||||
let id = MeshIdentity::generate();
|
||||
let pk = id.public_key();
|
||||
|
||||
let node = P2pNode::start_with_mesh(None, id, 100)
|
||||
.await
|
||||
.expect("start mesh node");
|
||||
|
||||
// Queue a message for ourselves via the store.
|
||||
{
|
||||
let sender_id = MeshIdentity::generate();
|
||||
let env = MeshEnvelope::new(&sender_id, &pk, b"stored msg".to_vec(), 3600, 5);
|
||||
let mut store = node.mesh_store.lock().expect("lock");
|
||||
assert!(store.store(env));
|
||||
}
|
||||
|
||||
let msgs = node.receive_mesh().expect("receive_mesh");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].payload, b"stored msg");
|
||||
|
||||
node.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user