feat: relay federation infrastructure — room bridging, loop prevention, peer connections
Phase 1 of relay federation: 1. Signal messages: FederationRoomJoin/Leave/ParticipantUpdate added to SignalMessage enum for relay-to-relay room coordination. 2. Room changes: ParticipantOrigin (Local/Federated) tracking, loop prevention (federated media only forwards to local participants), ParticipantSender::Federation with 8-byte room-hash prefixed datagrams, merged participant lists (local + remote), new methods: join_federated(), update_federated_participants(), local_senders(), active_rooms(), local_participants(). 3. FederationManager: connects to configured peers via QUIC with SNI "_federation", reconnects with exponential backoff (5s-300s), exchanges FederationRoomJoin signals, runs recv loops for both signals and media datagrams, creates virtual participants in rooms. 4. Accept-side: _federation SNI handling in main.rs, unknown peer gets helpful "add to relay.toml" log message, recognized peers handed off to FederationManager. TODO: TLS fingerprint verification — currently outbound connections use client_config() which doesn't present a cert, so inbound verification fails. Need mutual TLS or URL-based peer matching. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,11 +27,25 @@ fn next_id() -> ParticipantId {
|
||||
NEXT_PARTICIPANT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Tracks where a participant originates from (for loop prevention).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ParticipantOrigin {
|
||||
/// Connected directly to this relay.
|
||||
Local,
|
||||
/// Virtual participant representing a federated peer relay.
|
||||
Federated { relay_addr: std::net::SocketAddr },
|
||||
}
|
||||
|
||||
/// How to send data to a participant — either via QUIC transport or WebSocket channel.
|
||||
#[derive(Clone)]
|
||||
pub enum ParticipantSender {
|
||||
Quic(Arc<wzp_transport::QuinnTransport>),
|
||||
WebSocket(tokio::sync::mpsc::Sender<Bytes>),
|
||||
/// Federated peer relay — media is prefixed with an 8-byte room hash.
|
||||
Federation {
|
||||
transport: Arc<wzp_transport::QuinnTransport>,
|
||||
room_hash: [u8; 8],
|
||||
},
|
||||
}
|
||||
|
||||
impl ParticipantSender {
|
||||
@@ -50,6 +64,14 @@ impl ParticipantSender {
|
||||
};
|
||||
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}"))
|
||||
}
|
||||
ParticipantSender::Federation { transport, room_hash } => {
|
||||
// Prefix media data with room hash for demuxing on the peer relay
|
||||
let mut tagged = Vec::with_capacity(8 + data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(data);
|
||||
transport.send_raw_datagram(&tagged)
|
||||
.map_err(|e| format!("federation send: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,17 +107,21 @@ struct Participant {
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<String>,
|
||||
alias: Option<String>,
|
||||
origin: ParticipantOrigin,
|
||||
}
|
||||
|
||||
/// A room holding multiple participants.
|
||||
struct Room {
|
||||
participants: Vec<Participant>,
|
||||
/// Remote participants from federated peers (for merged RoomUpdate).
|
||||
federated_participants: HashMap<std::net::SocketAddr, Vec<wzp_proto::packet::RoomParticipant>>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
participants: Vec::new(),
|
||||
federated_participants: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +131,11 @@ impl Room {
|
||||
sender: ParticipantSender,
|
||||
fingerprint: Option<String>,
|
||||
alias: Option<String>,
|
||||
origin: ParticipantOrigin,
|
||||
) -> ParticipantId {
|
||||
let id = next_id();
|
||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
|
||||
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias });
|
||||
info!(room_size = self.participants.len() + 1, participant = id, %addr, ?origin, "joined room");
|
||||
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias, origin });
|
||||
id
|
||||
}
|
||||
|
||||
@@ -125,15 +152,38 @@ impl Room {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a RoomUpdate participant list.
|
||||
fn participant_list(&self) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
/// Get senders with loop prevention for federation.
|
||||
///
|
||||
/// - Media from a **local** participant → send to ALL others (local + federated)
|
||||
/// - Media from a **federated** participant → send to LOCAL participants only
|
||||
/// (the source relay already forwarded to its own locals and other peers)
|
||||
fn others_for_origin(&self, exclude_id: ParticipantId, source_origin: &ParticipantOrigin) -> Vec<ParticipantSender> {
|
||||
self.participants
|
||||
.iter()
|
||||
.filter(|p| p.id != exclude_id)
|
||||
.filter(|p| match source_origin {
|
||||
ParticipantOrigin::Local => true,
|
||||
ParticipantOrigin::Federated { .. } => p.origin == ParticipantOrigin::Local,
|
||||
})
|
||||
.map(|p| p.sender.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build a RoomUpdate participant list (local + federated).
|
||||
fn participant_list(&self) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
let mut list: Vec<_> = self.participants
|
||||
.iter()
|
||||
.filter(|p| p.origin == ParticipantOrigin::Local)
|
||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||
alias: p.alias.clone(),
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
// Merge federated participants from all peer relays
|
||||
for remote in self.federated_participants.values() {
|
||||
list.extend(remote.iter().cloned());
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Get all senders (for broadcasting to everyone including the joiner).
|
||||
@@ -214,7 +264,7 @@ impl RoomManager {
|
||||
return Err("not authorized for this room".to_string());
|
||||
}
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()));
|
||||
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string()), ParticipantOrigin::Local);
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
@@ -235,6 +285,83 @@ impl RoomManager {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Join a room as a federated virtual participant.
|
||||
pub fn join_federated(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
relay_addr: std::net::SocketAddr,
|
||||
sender: ParticipantSender,
|
||||
remote_participants: Vec<wzp_proto::packet::RoomParticipant>,
|
||||
) -> (ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>) {
|
||||
let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new);
|
||||
room.federated_participants.insert(relay_addr, remote_participants);
|
||||
let id = room.add(
|
||||
relay_addr, sender, None, Some("(federated)".to_string()),
|
||||
ParticipantOrigin::Federated { relay_addr },
|
||||
);
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
(id, update, senders)
|
||||
}
|
||||
|
||||
/// Update federated participant list for a room (from FederationParticipantUpdate).
|
||||
pub fn update_federated_participants(
|
||||
&mut self,
|
||||
room_name: &str,
|
||||
relay_addr: std::net::SocketAddr,
|
||||
participants: Vec<wzp_proto::packet::RoomParticipant>,
|
||||
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
room.federated_participants.insert(relay_addr, participants);
|
||||
let update = wzp_proto::SignalMessage::RoomUpdate {
|
||||
count: room.len() as u32,
|
||||
participants: room.participant_list(),
|
||||
};
|
||||
let senders = room.all_senders();
|
||||
Some((update, senders))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the origin of a participant by ID.
|
||||
pub fn participant_origin(&self, room_name: &str, participant_id: ParticipantId) -> Option<ParticipantOrigin> {
|
||||
self.rooms.get(room_name)
|
||||
.and_then(|room| room.participants.iter().find(|p| p.id == participant_id))
|
||||
.map(|p| p.origin.clone())
|
||||
}
|
||||
|
||||
/// Get list of active room names (for federation room announcements).
|
||||
pub fn active_rooms(&self) -> Vec<String> {
|
||||
self.rooms.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get local participant list for a room (excludes federated virtual participants).
|
||||
pub fn local_participants(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> {
|
||||
self.rooms.get(room_name)
|
||||
.map(|room| room.participants.iter()
|
||||
.filter(|p| p.origin == ParticipantOrigin::Local)
|
||||
.map(|p| wzp_proto::packet::RoomParticipant {
|
||||
fingerprint: p.fingerprint.clone().unwrap_or_default(),
|
||||
alias: p.alias.clone(),
|
||||
})
|
||||
.collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get senders for local-only participants in a room (for federation inbound media).
|
||||
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
|
||||
self.rooms.get(room_name)
|
||||
.map(|room| room.participants.iter()
|
||||
.filter(|p| p.origin == ParticipantOrigin::Local)
|
||||
.map(|p| p.sender.clone())
|
||||
.collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
|
||||
pub fn leave(&mut self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
|
||||
if let Some(room) = self.rooms.get_mut(room_name) {
|
||||
@@ -467,6 +594,19 @@ async fn run_participant_plain(
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
ParticipantSender::Federation { transport, room_hash } => {
|
||||
// Send room-tagged datagram to federated peer
|
||||
let data = pkt.to_bytes();
|
||||
let mut tagged = Vec::with_capacity(8 + data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(&data);
|
||||
if let Err(e) = transport.send_raw_datagram(&tagged) {
|
||||
send_errors += 1;
|
||||
if send_errors <= 5 {
|
||||
warn!(room = %room_name, "federation forward error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
@@ -634,6 +774,13 @@ async fn run_participant_trunked(
|
||||
ParticipantSender::WebSocket(_) => {
|
||||
let _ = other.send_raw(&pkt.payload).await;
|
||||
}
|
||||
ParticipantSender::Federation { transport, room_hash } => {
|
||||
let data = pkt.to_bytes();
|
||||
let mut tagged = Vec::with_capacity(8 + data.len());
|
||||
tagged.extend_from_slice(room_hash);
|
||||
tagged.extend_from_slice(&data);
|
||||
let _ = transport.send_raw_datagram(&tagged);
|
||||
}
|
||||
}
|
||||
}
|
||||
let fwd_ms = fwd_start.elapsed().as_millis() as u64;
|
||||
|
||||
Reference in New Issue
Block a user