feat: [[trusted]] config + FederationHello for one-sided federation
- Added [[trusted]] config: relay B can accept inbound federation from relay A by fingerprint alone, without knowing A's address. A connects to B with [[peers]], B trusts A with [[trusted]]. - FederationHello signal: outbound connections send their TLS fingerprint as first signal. The accepting relay verifies it against [[peers]] (by IP) or [[trusted]] (by fingerprint). - Tested 3-relay chain: A→B←C. Both A and C connect to B, B trusts both. B correctly accepts both inbound connections. Room announcements flow A→B and C→B. - Remaining: B needs to announce rooms back to A and C on the same connection so media can flow A→B→C. Currently A has no virtual participant for B, so media doesn't reach B's SFU for forwarding. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,16 @@ pub struct PeerConfig {
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<PeerConfig>,
|
||||
/// 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<TrustedConfig>,
|
||||
/// Debug tap: log packet headers for matching rooms ("*" = all rooms).
|
||||
/// Activated via --debug-tap <room> or debug_tap = "room" in TOML.
|
||||
pub debug_tap: Option<String>,
|
||||
@@ -85,6 +99,7 @@ impl Default for RelayConfig {
|
||||
ws_port: None,
|
||||
static_dir: None,
|
||||
peers: Vec::new(),
|
||||
trusted: Vec::new(),
|
||||
debug_tap: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PeerConfig>,
|
||||
trusted: Vec<TrustedConfig>,
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
endpoint: quinn::Endpoint,
|
||||
local_tls_fp: String,
|
||||
@@ -39,12 +40,14 @@ pub struct FederationManager {
|
||||
impl FederationManager {
|
||||
pub fn new(
|
||||
peers: Vec<PeerConfig>,
|
||||
trusted: Vec<TrustedConfig>,
|
||||
room_mgr: Arc<Mutex<RoomManager>>,
|
||||
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<String> {
|
||||
// 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<Ar
|
||||
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
|
||||
// TODO: verify peer TLS fingerprint once we have cert access
|
||||
let transport = Arc::new(QuinnTransport::new(conn));
|
||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected to peer");
|
||||
// Send hello with our TLS fingerprint so the peer can verify us
|
||||
let hello = SignalMessage::FederationHello {
|
||||
tls_fingerprint: fm.local_tls_fp.clone(),
|
||||
};
|
||||
transport.send_signal(&hello).await
|
||||
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
|
||||
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected to peer (hello sent)");
|
||||
Ok(transport)
|
||||
}
|
||||
|
||||
|
||||
@@ -303,13 +303,19 @@ async fn main() -> 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user