feat: WZP-S-2 relay auth + WZP-S-3 featherChat signaling bridge
WZP-S-2: Relay token authentication
- New --auth-url flag: relay calls POST {url} with bearer token
- Clients must send SignalMessage::AuthToken as first signal
- Relay validates against featherChat's /v1/auth/validate endpoint
- Rejects unauthenticated clients before they join rooms
- New auth.rs module with validate_token() + tests
WZP-S-3: featherChat signaling bridge
- New featherchat.rs module for CallSignal interop
- WzpCallPayload: wraps SignalMessage + relay_addr + room name
- encode_call_payload/decode_call_payload for JSON serialization
- CallSignalType enum mirrors featherChat's variant
- signal_to_call_type maps WZP signals to FC types
Protocol: Added SignalMessage::AuthToken { token } variant
129 tests passing across all crates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ tracing-subscriber = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = "1"
|
||||||
cpal = { version = "0.15", optional = true }
|
cpal = { version = "0.15", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
138
crates/wzp-client/src/featherchat.rs
Normal file
138
crates/wzp-client/src/featherchat.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//! featherChat signaling bridge.
|
||||||
|
//!
|
||||||
|
//! Sends WZP call signaling (Offer/Answer/Hangup) through featherChat's
|
||||||
|
//! E2E encrypted WebSocket channel as `WireMessage::CallSignal`.
|
||||||
|
//!
|
||||||
|
//! Flow:
|
||||||
|
//! 1. Client connects to featherChat WS with bearer token
|
||||||
|
//! 2. Sends CallOffer as CallSignal(signal_type=Offer, payload=JSON SignalMessage)
|
||||||
|
//! 3. Receives CallAnswer as CallSignal(signal_type=Answer, payload=JSON SignalMessage)
|
||||||
|
//! 4. Extracts relay address from the answer
|
||||||
|
//! 5. Connects QUIC to relay for media
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use wzp_proto::packet::SignalMessage;
|
||||||
|
use wzp_proto::QualityProfile;
|
||||||
|
|
||||||
|
/// featherChat CallSignal types (mirrors warzone-protocol::message::CallSignalType).
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum CallSignalType {
|
||||||
|
Offer,
|
||||||
|
Answer,
|
||||||
|
IceCandidate,
|
||||||
|
Hangup,
|
||||||
|
Reject,
|
||||||
|
Ringing,
|
||||||
|
Busy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CallSignal as sent through featherChat's WireMessage.
|
||||||
|
/// This is what goes in the `payload` field of `WireMessage::CallSignal`.
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct WzpCallPayload {
|
||||||
|
/// The WZP SignalMessage (CallOffer, CallAnswer, etc.) serialized as JSON.
|
||||||
|
pub signal: SignalMessage,
|
||||||
|
/// The relay address to connect to for media (host:port).
|
||||||
|
pub relay_addr: Option<String>,
|
||||||
|
/// Room name on the relay.
|
||||||
|
pub room: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for initiating a call through featherChat.
|
||||||
|
pub struct CallInitParams {
|
||||||
|
/// featherChat server URL (e.g., "wss://chat.example.com/ws").
|
||||||
|
pub server_url: String,
|
||||||
|
/// Bearer token for authentication.
|
||||||
|
pub token: String,
|
||||||
|
/// Target peer fingerprint (who to call).
|
||||||
|
pub target_fingerprint: String,
|
||||||
|
/// Relay address for media transport.
|
||||||
|
pub relay_addr: String,
|
||||||
|
/// Room name on the relay.
|
||||||
|
pub room: String,
|
||||||
|
/// Our identity seed for crypto.
|
||||||
|
pub seed: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a successful call setup.
|
||||||
|
pub struct CallSetupResult {
|
||||||
|
/// Relay address to connect to.
|
||||||
|
pub relay_addr: String,
|
||||||
|
/// Room name.
|
||||||
|
pub room: String,
|
||||||
|
/// The peer's CallAnswer signal (contains ephemeral key, etc.)
|
||||||
|
pub answer: SignalMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a WZP SignalMessage into a featherChat CallSignal payload string.
|
||||||
|
pub fn encode_call_payload(
|
||||||
|
signal: &SignalMessage,
|
||||||
|
relay_addr: Option<&str>,
|
||||||
|
room: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let payload = WzpCallPayload {
|
||||||
|
signal: signal.clone(),
|
||||||
|
relay_addr: relay_addr.map(|s| s.to_string()),
|
||||||
|
room: room.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
serde_json::to_string(&payload).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize a featherChat CallSignal payload back to WZP types.
|
||||||
|
pub fn decode_call_payload(payload: &str) -> Result<WzpCallPayload, String> {
|
||||||
|
serde_json::from_str(payload).map_err(|e| format!("invalid call payload: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map WZP SignalMessage type to featherChat CallSignalType.
|
||||||
|
pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
|
||||||
|
match signal {
|
||||||
|
SignalMessage::CallOffer { .. } => CallSignalType::Offer,
|
||||||
|
SignalMessage::CallAnswer { .. } => CallSignalType::Answer,
|
||||||
|
SignalMessage::IceCandidate { .. } => CallSignalType::IceCandidate,
|
||||||
|
SignalMessage::Hangup { .. } => CallSignalType::Hangup,
|
||||||
|
SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse
|
||||||
|
SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse
|
||||||
|
SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer,
|
||||||
|
SignalMessage::AuthToken { .. } => CallSignalType::Offer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payload_roundtrip() {
|
||||||
|
let signal = SignalMessage::CallOffer {
|
||||||
|
identity_pub: [1u8; 32],
|
||||||
|
ephemeral_pub: [2u8; 32],
|
||||||
|
signature: vec![3u8; 64],
|
||||||
|
supported_profiles: vec![QualityProfile::GOOD],
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
|
||||||
|
let decoded = decode_call_payload(&encoded).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.relay_addr.unwrap(), "relay.example.com:4433");
|
||||||
|
assert_eq!(decoded.room.unwrap(), "myroom");
|
||||||
|
assert!(matches!(decoded.signal, SignalMessage::CallOffer { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signal_type_mapping() {
|
||||||
|
let offer = SignalMessage::CallOffer {
|
||||||
|
identity_pub: [0; 32],
|
||||||
|
ephemeral_pub: [0; 32],
|
||||||
|
signature: vec![],
|
||||||
|
supported_profiles: vec![],
|
||||||
|
};
|
||||||
|
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));
|
||||||
|
|
||||||
|
let hangup = SignalMessage::Hangup {
|
||||||
|
reason: wzp_proto::HangupReason::Normal,
|
||||||
|
};
|
||||||
|
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ pub mod audio_io;
|
|||||||
pub mod bench;
|
pub mod bench;
|
||||||
pub mod call;
|
pub mod call;
|
||||||
pub mod echo_test;
|
pub mod echo_test;
|
||||||
|
pub mod featherchat;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
|
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
|
|||||||
@@ -297,6 +297,10 @@ pub enum SignalMessage {
|
|||||||
|
|
||||||
/// End the call.
|
/// End the call.
|
||||||
Hangup { reason: HangupReason },
|
Hangup { reason: HangupReason },
|
||||||
|
|
||||||
|
/// featherChat bearer token for relay authentication.
|
||||||
|
/// Sent as the first signal message when --auth-url is configured.
|
||||||
|
AuthToken { token: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reasons for ending a call.
|
/// Reasons for ending a call.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ bytes = { workspace = true }
|
|||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
serde_json = "1"
|
||||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||||
quinn = { workspace = true }
|
quinn = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
106
crates/wzp-relay/src/auth.rs
Normal file
106
crates/wzp-relay/src/auth.rs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
//! featherChat token authentication.
|
||||||
|
//!
|
||||||
|
//! When `--auth-url` is configured, the relay validates bearer tokens
|
||||||
|
//! against featherChat's `POST /v1/auth/validate` endpoint before
|
||||||
|
//! allowing clients to join rooms.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
/// Request body for featherChat token validation.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ValidateRequest {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from featherChat token validation.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct ValidateResponse {
|
||||||
|
pub valid: bool,
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
pub alias: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validated client identity.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthenticatedClient {
|
||||||
|
pub fingerprint: String,
|
||||||
|
pub alias: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a bearer token against featherChat's auth endpoint.
|
||||||
|
///
|
||||||
|
/// Calls `POST {auth_url}` with `{ "token": "..." }`.
|
||||||
|
/// Returns the client identity if valid, or an error string.
|
||||||
|
pub async fn validate_token(
|
||||||
|
auth_url: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<AuthenticatedClient, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("http client error: {e}"))?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.post(auth_url)
|
||||||
|
.json(&ValidateRequest {
|
||||||
|
token: token.to_string(),
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("auth request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(format!("auth endpoint returned {}", resp.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ValidateResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("invalid auth response: {e}"))?;
|
||||||
|
|
||||||
|
if body.valid {
|
||||||
|
let fingerprint = body
|
||||||
|
.fingerprint
|
||||||
|
.ok_or_else(|| "valid response missing fingerprint".to_string())?;
|
||||||
|
info!(%fingerprint, alias = ?body.alias, "token validated");
|
||||||
|
Ok(AuthenticatedClient {
|
||||||
|
fingerprint,
|
||||||
|
alias: body.alias,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
warn!("token validation failed");
|
||||||
|
Err("invalid token".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_request_serializes() {
|
||||||
|
let req = ValidateRequest {
|
||||||
|
token: "abc123".to_string(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&req).unwrap();
|
||||||
|
assert!(json.contains("abc123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_response_deserializes() {
|
||||||
|
let json = r#"{"valid": true, "fingerprint": "abcd1234", "alias": "manwe"}"#;
|
||||||
|
let resp: ValidateResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(resp.valid);
|
||||||
|
assert_eq!(resp.fingerprint.unwrap(), "abcd1234");
|
||||||
|
assert_eq!(resp.alias.unwrap(), "manwe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_response_deserializes() {
|
||||||
|
let json = r#"{"valid": false}"#;
|
||||||
|
let resp: ValidateResponse = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(!resp.valid);
|
||||||
|
assert!(resp.fingerprint.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ pub struct RelayConfig {
|
|||||||
pub jitter_max_depth: usize,
|
pub jitter_max_depth: usize,
|
||||||
/// Logging level (trace, debug, info, warn, error).
|
/// Logging level (trace, debug, info, warn, error).
|
||||||
pub log_level: String,
|
pub log_level: String,
|
||||||
|
/// featherChat auth validation URL (e.g., "https://chat.example.com/v1/auth/validate").
|
||||||
|
/// If set, clients must present a valid token before joining rooms.
|
||||||
|
pub auth_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RelayConfig {
|
impl Default for RelayConfig {
|
||||||
@@ -30,6 +33,7 @@ impl Default for RelayConfig {
|
|||||||
jitter_target_depth: 50,
|
jitter_target_depth: 50,
|
||||||
jitter_max_depth: 250,
|
jitter_max_depth: 250,
|
||||||
log_level: "info".to_string(),
|
log_level: "info".to_string(),
|
||||||
|
auth_url: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! It operates on FEC-protected packets, managing loss recovery and adaptive
|
//! It operates on FEC-protected packets, managing loss recovery and adaptive
|
||||||
//! quality transitions.
|
//! quality transitions.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod handshake;
|
pub mod handshake;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
|||||||
@@ -38,17 +38,23 @@ fn parse_args() -> RelayConfig {
|
|||||||
.parse().expect("invalid --remote address"),
|
.parse().expect("invalid --remote address"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
"--auth-url" => {
|
||||||
|
i += 1;
|
||||||
|
config.auth_url = Some(
|
||||||
|
args.get(i).expect("--auth-url requires a URL").to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>]");
|
eprintln!("Usage: wzp-relay [--listen <addr>] [--remote <addr>] [--auth-url <url>]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
eprintln!("Options:");
|
||||||
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
eprintln!(" --listen <addr> Listen address (default: 0.0.0.0:4433)");
|
||||||
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
eprintln!(" --remote <addr> Remote relay for forwarding (disables room mode)");
|
||||||
|
eprintln!(" --auth-url <url> featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)");
|
||||||
|
eprintln!(" When set, clients must send a bearer token as first signal message.");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Room mode (default):");
|
eprintln!("Room mode (default):");
|
||||||
eprintln!(" Clients join rooms by name. Packets are forwarded to all");
|
eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU).");
|
||||||
eprintln!(" other participants in the same room (SFU model).");
|
|
||||||
eprintln!(" Room name comes from QUIC SNI or defaults to 'default'.");
|
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
@@ -154,6 +160,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Room manager (room mode only)
|
// Room manager (room mode only)
|
||||||
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
let room_mgr = Arc::new(Mutex::new(RoomManager::new()));
|
||||||
|
|
||||||
|
if let Some(ref url) = config.auth_url {
|
||||||
|
info!(url, "auth enabled — clients must present featherChat token");
|
||||||
|
} else {
|
||||||
|
info!("auth disabled — any client can connect (use --auth-url to enable)");
|
||||||
|
}
|
||||||
|
|
||||||
info!("Listening for connections...");
|
info!("Listening for connections...");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -164,12 +176,11 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let remote_transport = remote_transport.clone();
|
let remote_transport = remote_transport.clone();
|
||||||
let room_mgr = room_mgr.clone();
|
let room_mgr = room_mgr.clone();
|
||||||
|
let auth_url = config.auth_url.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
|
|
||||||
// Extract room name from QUIC handshake data (SNI).
|
|
||||||
// The web bridge connects with the room name as server_name.
|
|
||||||
let room_name = connection
|
let room_name = connection
|
||||||
.handshake_data()
|
.handshake_data()
|
||||||
.and_then(|hd| {
|
.and_then(|hd| {
|
||||||
@@ -180,7 +191,45 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
info!(%addr, room = %room_name, "new client");
|
// Auth check: if --auth-url is set, expect first signal message to be a token
|
||||||
|
if let Some(ref url) = auth_url {
|
||||||
|
info!(%addr, "waiting for auth token...");
|
||||||
|
match transport.recv_signal().await {
|
||||||
|
Ok(Some(wzp_proto::SignalMessage::AuthToken { token })) => {
|
||||||
|
match wzp_relay::auth::validate_token(url, &token).await {
|
||||||
|
Ok(client) => {
|
||||||
|
info!(
|
||||||
|
%addr,
|
||||||
|
fingerprint = %client.fingerprint,
|
||||||
|
alias = ?client.alias,
|
||||||
|
"authenticated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(%addr, "auth failed: {e}");
|
||||||
|
transport.close().await.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(_)) => {
|
||||||
|
error!(%addr, "expected AuthToken as first signal, got something else");
|
||||||
|
transport.close().await.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
error!(%addr, "connection closed before auth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!(%addr, "signal recv error during auth: {e}");
|
||||||
|
transport.close().await.ok();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(%addr, room = %room_name, "client joined");
|
||||||
|
|
||||||
if let Some(remote) = remote_transport {
|
if let Some(remote) = remote_transport {
|
||||||
// Forward mode — same as before
|
// Forward mode — same as before
|
||||||
|
|||||||
Reference in New Issue
Block a user