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:
@@ -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: {}",
|
||||
|
||||
Reference in New Issue
Block a user