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:
2026-03-04 01:42:09 +01:00
parent 28ceaaf072
commit 1b61b7ee8f
8 changed files with 1304 additions and 8 deletions

View File

@@ -58,9 +58,15 @@ enum SlashCommand {
GroupInfo,
Rename { name: String },
History { count: usize },
/// Mesh subcommands: /mesh peers, /mesh server <addr>
/// Mesh subcommands: /mesh peers, /mesh server <addr>, etc.
MeshPeers,
MeshServer { addr: String },
MeshSend { peer_id: String, message: String },
MeshBroadcast { topic: String, message: String },
MeshSubscribe { topic: String },
MeshRoute,
MeshIdentity,
MeshStore,
/// Display safety number for out-of-band key verification with a contact.
Verify { username: String },
/// Rotate own MLS leaf key in the active group.
@@ -164,8 +170,46 @@ fn parse_input(line: &str) -> Input {
Input::Slash(SlashCommand::MeshServer { addr })
}
}
Some(rest) if rest.starts_with("send ") => {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
if parts.len() >= 3 {
Input::Slash(SlashCommand::MeshSend {
peer_id: parts[1].into(),
message: parts[2].into(),
})
} else {
display::print_error("usage: /mesh send <peer_id> <message>");
Input::Empty
}
}
Some(rest) if rest.starts_with("broadcast ") => {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
if parts.len() >= 3 {
Input::Slash(SlashCommand::MeshBroadcast {
topic: parts[1].into(),
message: parts[2].into(),
})
} else {
display::print_error("usage: /mesh broadcast <topic> <message>");
Input::Empty
}
}
Some(rest) if rest.starts_with("subscribe ") => {
let topic = rest[10..].trim();
if topic.is_empty() {
display::print_error("usage: /mesh subscribe <topic>");
Input::Empty
} else {
Input::Slash(SlashCommand::MeshSubscribe { topic: topic.into() })
}
}
Some("route") => Input::Slash(SlashCommand::MeshRoute),
Some("identity") | Some("id") => Input::Slash(SlashCommand::MeshIdentity),
Some("store") => Input::Slash(SlashCommand::MeshStore),
_ => {
display::print_error("usage: /mesh peers | /mesh server <host:port>");
display::print_error(
"usage: /mesh peers|server|send|broadcast|subscribe|route|identity|store"
);
Input::Empty
}
},
@@ -714,6 +758,12 @@ async fn handle_slash(
));
Ok(())
}
SlashCommand::MeshSend { peer_id, message } => cmd_mesh_send(&peer_id, &message),
SlashCommand::MeshBroadcast { topic, message } => cmd_mesh_broadcast(&topic, &message),
SlashCommand::MeshSubscribe { topic } => cmd_mesh_subscribe(&topic),
SlashCommand::MeshRoute => cmd_mesh_route(session),
SlashCommand::MeshIdentity => cmd_mesh_identity(session),
SlashCommand::MeshStore => cmd_mesh_store(session),
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
SlashCommand::UpdateKey => cmd_update_key(session, client).await,
SlashCommand::Typing => cmd_typing(session, client).await,
@@ -755,6 +805,12 @@ fn print_help() {
display::print_status(" /whoami - Show your identity");
display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS");
display::print_status(" /mesh server <host:port> - Show how to reconnect to a mesh node");
display::print_status(" /mesh send <peer> <msg> - Send a P2P message to a mesh peer");
display::print_status(" /mesh broadcast <topic> <m> - Broadcast an encrypted message on a topic");
display::print_status(" /mesh subscribe <topic> - Subscribe to a broadcast topic");
display::print_status(" /mesh route - Show known mesh peers and routes");
display::print_status(" /mesh identity - Show mesh node identity info");
display::print_status(" /mesh store - Show mesh store-and-forward stats");
display::print_status(" /update-key - Rotate your MLS leaf key in the active group");
display::print_status(" /verify <username> - Show safety number for key verification");
display::print_status(" /react <emoji> [index] - React to last message (or message at index)");
@@ -871,6 +927,125 @@ fn cmd_mesh_peers() -> anyhow::Result<()> {
Ok(())
}
/// Send a direct P2P mesh message (stub — P2pNode not yet wired into session).
fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("mesh send: would send to {peer_id}: {message}"));
display::print_status("(P2P node integration pending — message not actually sent)");
}
#[cfg(not(feature = "mesh"))]
{
let _ = (peer_id, message);
display::print_error("requires --features mesh");
}
Ok(())
}
/// Broadcast an encrypted message on a topic (stub — P2pNode not yet wired into session).
fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("mesh broadcast to {topic}: {message}"));
display::print_status("(P2P node integration pending — message not actually sent)");
}
#[cfg(not(feature = "mesh"))]
{
let _ = (topic, message);
display::print_error("requires --features mesh");
}
Ok(())
}
/// Subscribe to a broadcast topic (stub — P2pNode not yet wired into session).
fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("subscribed to topic: {topic}"));
display::print_status("(P2P node integration pending — subscription is not persisted)");
}
#[cfg(not(feature = "mesh"))]
{
let _ = topic;
display::print_error("requires --features mesh");
}
Ok(())
}
/// Display known mesh peers and routes from the mesh identity file.
fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
let mesh_state_path = session.state_path.with_extension("mesh.json");
if mesh_state_path.exists() {
let id = quicproquo_p2p::identity::MeshIdentity::load(&mesh_state_path)?;
let peers = id.known_peers();
if peers.is_empty() {
display::print_status("no known mesh peers");
} else {
display::print_status(&format!("{} known peer(s):", peers.len()));
for (hex_id, info) in peers {
let short_id = &hex_id[..8.min(hex_id.len())];
let addrs = if info.addresses.is_empty() {
"no addresses".to_string()
} else {
info.addresses.join(", ")
};
display::print_status(&format!(" {short_id}... last_seen={} addrs={addrs}", info.last_seen));
}
}
} else {
display::print_status("no mesh identity file found (start mesh mode first)");
}
}
#[cfg(not(feature = "mesh"))]
{
let _ = session;
display::print_error("requires --features mesh");
}
Ok(())
}
/// Display mesh node identity information.
fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
let mesh_state_path = session.state_path.with_extension("mesh.json");
if mesh_state_path.exists() {
let id = quicproquo_p2p::identity::MeshIdentity::load(&mesh_state_path)?;
display::print_status(&format!("mesh public key: {}", hex::encode(id.public_key())));
display::print_status(&format!("known peers: {}", id.known_peers().len()));
} else {
display::print_status("no mesh identity file found");
display::print_status("a mesh identity will be created when mesh mode is started");
}
}
#[cfg(not(feature = "mesh"))]
{
let _ = session;
display::print_error("requires --features mesh");
}
Ok(())
}
/// Display mesh store-and-forward statistics.
fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
// Without a live P2pNode in the session, we can only report that the store
// is not active. Once P2pNode is wired in, this will show real stats.
display::print_status("mesh store: not active (P2P node not started in this session)");
display::print_status("start mesh mode to enable store-and-forward");
let _ = session;
}
#[cfg(not(feature = "mesh"))]
{
let _ = session;
display::print_error("requires --features mesh");
}
Ok(())
}
fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
display::print_status(&format!(
"identity: {}",