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

@@ -54,6 +54,9 @@ enum SlashCommand {
Join,
Members,
History { count: usize },
/// Mesh subcommands: /mesh peers, /mesh server <addr>
MeshPeers,
MeshServer { addr: String },
}
fn parse_input(line: &str) -> Input {
@@ -116,6 +119,22 @@ fn parse_input(line: &str) -> Input {
let count = arg.and_then(|s| s.parse().ok()).unwrap_or(20);
Input::Slash(SlashCommand::History { count })
}
"/mesh" => match arg.as_deref() {
Some("peers") => Input::Slash(SlashCommand::MeshPeers),
Some(rest) if rest.starts_with("server ") => {
let addr = rest.trim_start_matches("server ").trim().to_string();
if addr.is_empty() {
display::print_error("usage: /mesh server <host:port>");
Input::Empty
} else {
Input::Slash(SlashCommand::MeshServer { addr })
}
}
_ => {
display::print_error("usage: /mesh peers | /mesh server <host:port>");
Input::Empty
}
},
_ => {
display::print_error(&format!("unknown command: {cmd}. Try /help"));
Input::Empty
@@ -575,6 +594,13 @@ async fn handle_slash(
SlashCommand::Join => cmd_join(session, client).await,
SlashCommand::Members => cmd_members(session),
SlashCommand::History { count } => cmd_history(session, count),
SlashCommand::MeshPeers => cmd_mesh_peers(),
SlashCommand::MeshServer { addr } => {
display::print_status(&format!(
"mesh server hint: reconnect with --server {addr} to use this node"
));
Ok(())
}
};
if let Err(e) = result {
display::print_error(&format!("{e:#}"));
@@ -583,18 +609,49 @@ async fn handle_slash(
fn print_help() {
display::print_status("Commands:");
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
display::print_status(" /create-group <name> - Create a new group");
display::print_status(" /invite <username> - Invite user to current group");
display::print_status(" /remove <username> - Remove a member from the current group");
display::print_status(" /leave - Leave the current group");
display::print_status(" /join - Join a group from pending Welcome");
display::print_status(" /switch <@user|#group> - Switch conversation");
display::print_status(" /list - List all conversations");
display::print_status(" /members - Show members of current conversation");
display::print_status(" /history [N] - Show last N messages (default: 20)");
display::print_status(" /whoami - Show your identity");
display::print_status(" /quit - Exit");
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
display::print_status(" /create-group <name> - Create a new group");
display::print_status(" /invite <username> - Invite user to current group");
display::print_status(" /remove <username> - Remove a member from the current group");
display::print_status(" /leave - Leave the current group");
display::print_status(" /join - Join a group from pending Welcome");
display::print_status(" /switch <@user|#group> - Switch conversation");
display::print_status(" /list - List all conversations");
display::print_status(" /members - Show members of current conversation");
display::print_status(" /history [N] - Show last N messages (default: 20)");
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(" /quit - Exit");
}
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
fn cmd_mesh_peers() -> anyhow::Result<()> {
use super::mesh_discovery::MeshDiscovery;
match MeshDiscovery::start() {
Err(e) => {
display::print_error(&format!("mesh discovery: {e}"));
return Ok(());
}
Ok(disc) => {
display::print_status("scanning for nearby qpq nodes (2s)...");
// Block briefly to collect mDNS announcements from the local network.
std::thread::sleep(std::time::Duration::from_secs(2));
let peers = disc.peers();
if peers.is_empty() {
display::print_status("no qpq nodes found on the local network");
} else {
display::print_status(&format!("found {} node(s):", peers.len()));
for p in &peers {
display::print_status(&format!(" {} at {}", p.domain, p.server_addr));
}
display::print_status("use: /mesh server <host:port> to note the address,");
display::print_status("then reconnect with: qpq --server <host:port>");
}
}
}
Ok(())
}
fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
@@ -725,9 +782,23 @@ async fn cmd_dm(
return Ok(());
}
// Create server-side channel.
// Create or look up the server-side channel.
// was_new=true → this call created the channel; we are the MLS initiator.
// was_new=false → channel already existed; peer is the MLS initiator and has
// sent (or will send) us a Welcome. Wait for try_auto_join.
display::print_status("creating channel...");
let channel_id = create_channel(client, &peer_key).await?;
let (channel_id, was_new) = create_channel(client, &peer_key).await?;
if !was_new {
// Peer is the MLS initiator. Their Welcome is en route; the background
// poller's try_auto_join will process it within the next poll interval
// and auto-switch to the conversation automatically.
display::print_status(&format!(
"DM channel with @{username} exists — peer is initiator, auto-joining via Welcome (arrives within ~1 s)"
));
return Ok(());
}
let conv_id = ConversationId::from_slice(&channel_id)
.context("server returned invalid channel_id length")?;