diff --git a/crates/wzp-client/src/featherchat.rs b/crates/wzp-client/src/featherchat.rs index 428a27b..abb3d84 100644 --- a/crates/wzp-client/src/featherchat.rs +++ b/crates/wzp-client/src/featherchat.rs @@ -110,7 +110,8 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType { SignalMessage::SessionForward { .. } => CallSignalType::Offer, // reuse SignalMessage::SessionForwardAck { .. } => CallSignalType::Offer, // reuse SignalMessage::RoomUpdate { .. } => CallSignalType::Offer, // reuse - SignalMessage::FederationRoomJoin { .. } + SignalMessage::FederationHello { .. } + | SignalMessage::FederationRoomJoin { .. } | SignalMessage::FederationRoomLeave { .. } | SignalMessage::FederationParticipantUpdate { .. } => CallSignalType::Offer, // relay-only } diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index 2352f54..a4d7bfb 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -659,6 +659,12 @@ pub enum SignalMessage { // ── Federation signals (relay-to-relay) ── + /// Federation: initial handshake — the connecting relay identifies itself. + FederationHello { + /// TLS certificate fingerprint of the connecting relay. + tls_fingerprint: String, + }, + /// Federation: a room exists on the sending relay with active local participants. FederationRoomJoin { room: String, diff --git a/crates/wzp-relay/src/config.rs b/crates/wzp-relay/src/config.rs index abf73d8..d18db41 100644 --- a/crates/wzp-relay/src/config.rs +++ b/crates/wzp-relay/src/config.rs @@ -15,6 +15,16 @@ pub struct PeerConfig { pub label: Option, } +/// A trusted relay — accepts inbound federation without needing the peer's address. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TrustedConfig { + /// Expected TLS certificate fingerprint (hex, with colons). + pub fingerprint: String, + /// Optional human-readable label. + #[serde(default)] + pub label: Option, +} + /// Configuration for the relay daemon. /// /// All fields have defaults, so a minimal TOML file only needs the @@ -63,6 +73,10 @@ pub struct RelayConfig { /// Federation peer relays. #[serde(default)] pub peers: Vec, + /// Trusted relay fingerprints — accept inbound federation from these relays. + /// Unlike [[peers]], no url is needed — the peer connects to us. + #[serde(default)] + pub trusted: Vec, /// Debug tap: log packet headers for matching rooms ("*" = all rooms). /// Activated via --debug-tap or debug_tap = "room" in TOML. pub debug_tap: Option, @@ -85,6 +99,7 @@ impl Default for RelayConfig { ws_port: None, static_dir: None, peers: Vec::new(), + trusted: Vec::new(), debug_tap: None, } } diff --git a/crates/wzp-relay/src/federation.rs b/crates/wzp-relay/src/federation.rs index 2711aa0..1acf019 100644 --- a/crates/wzp-relay/src/federation.rs +++ b/crates/wzp-relay/src/federation.rs @@ -17,7 +17,7 @@ use tracing::{error, info, warn}; use wzp_proto::{MediaTransport, SignalMessage}; use wzp_transport::QuinnTransport; -use crate::config::PeerConfig; +use crate::config::{PeerConfig, TrustedConfig}; use crate::room::{self, ParticipantSender, RoomManager}; /// Compute 8-byte room hash for federation datagram tagging. @@ -31,6 +31,7 @@ pub fn room_hash(room_name: &str) -> [u8; 8] { /// Manages federation connections to peer relays. pub struct FederationManager { peers: Vec, + trusted: Vec, room_mgr: Arc>, endpoint: quinn::Endpoint, local_tls_fp: String, @@ -39,12 +40,14 @@ pub struct FederationManager { impl FederationManager { pub fn new( peers: Vec, + trusted: Vec, room_mgr: Arc>, endpoint: quinn::Endpoint, local_tls_fp: String, ) -> Self { Self { peers, + trusted, room_mgr, endpoint, local_tls_fp, @@ -89,7 +92,6 @@ impl FederationManager { } /// Find a configured peer by source IP address. - /// Used for inbound connections where the client doesn't present a TLS cert. pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> { let addr_ip = addr.ip(); self.peers.iter().find(|p| { @@ -98,6 +100,25 @@ impl FederationManager { .unwrap_or(false) }) } + + /// Find a trusted relay by TLS fingerprint. + pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> { + self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp)) + } + + /// Check if an inbound federation connection is trusted (by IP match in [[peers]] or fingerprint in [[trusted]]). + /// Returns the label for logging. + pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option { + // Check [[peers]] by IP + if let Some(peer) = self.find_peer_by_addr(addr) { + return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone())); + } + // Check [[trusted]] by fingerprint + if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) { + return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string())); + } + None + } } /// Normalize a fingerprint string (remove colons, lowercase). @@ -134,7 +155,13 @@ async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result anyhow::Result<()> { info!(" fingerprint = \"{tls_fp}\""); } - // Log configured peers + // Log configured peers and trusted relays if !config.peers.is_empty() { info!(count = config.peers.len(), "federation peers configured"); for p in &config.peers { info!(url = %p.url, label = ?p.label, " peer"); } } + if !config.trusted.is_empty() { + info!(count = config.trusted.len(), "trusted relays configured"); + for t in &config.trusted { + info!(fingerprint = %t.fingerprint, label = ?t.label, " trusted"); + } + } let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?; // Forward mode @@ -328,9 +334,10 @@ async fn main() -> anyhow::Result<()> { let room_mgr = Arc::new(Mutex::new(RoomManager::new())); // Federation manager - let federation_mgr = if !config.peers.is_empty() { + let federation_mgr = if !config.peers.is_empty() || !config.trusted.is_empty() { let fm = Arc::new(wzp_relay::federation::FederationManager::new( config.peers.clone(), + config.trusted.clone(), room_mgr.clone(), endpoint.clone(), tls_fp.clone(), @@ -512,20 +519,36 @@ async fn main() -> anyhow::Result<()> { // Federation connections use SNI "_federation" if room_name == "_federation" { if let Some(ref fm) = federation_mgr { - // Match inbound peer by source IP (client connections don't present TLS certs) - if let Some(peer_config) = fm.find_peer_by_addr(addr) { - let peer_config = peer_config.clone(); + // Wait for FederationHello to identify the connecting relay + let hello_fp = match tokio::time::timeout( + std::time::Duration::from_secs(5), + transport.recv_signal(), + ).await { + Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint }))) => tls_fingerprint, + _ => { + warn!(%addr, "federation: no hello received, closing"); + return; + } + }; + + if let Some(label) = fm.check_inbound_trust(addr, &hello_fp) { + let peer_config = wzp_relay::config::PeerConfig { + url: addr.to_string(), + fingerprint: hello_fp, + label: Some(label.clone()), + }; let fm = fm.clone(); - info!(%addr, label = ?peer_config.label, "inbound federation connection accepted"); + info!(%addr, label = %label, "inbound federation accepted (trusted)"); fm.handle_inbound(transport, peer_config).await; } else { - warn!(%addr, "unknown relay wants to federate"); + warn!(%addr, fp = %hello_fp, "unknown relay wants to federate"); info!(" to accept, add to relay.toml:"); - info!(" [[peers]]"); - info!(" url = \"{addr}\""); + info!(" [[trusted]]"); + info!(" fingerprint = \"{hello_fp}\""); + info!(" label = \"Relay at {addr}\""); } } else { - info!(%addr, "federation connection rejected (no peers configured)"); + info!(%addr, "federation connection rejected (no federation configured)"); } return; }