T1.5: Migrate emit/parse sites to v2 wire format

This commit is contained in:
Siavash Sameni
2026-05-11 12:36:45 +04:00
parent 9680b6ff34
commit c93d302656
120 changed files with 5953 additions and 2888 deletions

View File

@@ -65,9 +65,8 @@ fn main() {
} else { } else {
"aarch64-linux-android" "aarch64-linux-android"
}; };
let lib_dir = format!( let lib_dir =
"{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}" format!("{ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/{arch}");
);
println!("cargo:rustc-link-search=native={lib_dir}"); println!("cargo:rustc-link-search=native={lib_dir}");
// Copy libc++_shared.so to the jniLibs directory // Copy libc++_shared.so to the jniLibs directory
@@ -82,9 +81,7 @@ fn main() {
}; };
// Try to copy to the Gradle jniLibs directory // Try to copy to the Gradle jniLibs directory
let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let jni_dir = format!( let jni_dir = format!("{manifest}/../../android/app/src/main/jniLibs/{jni_abi}");
"{manifest}/../../android/app/src/main/jniLibs/{jni_abi}"
);
if let Ok(_) = std::fs::create_dir_all(&jni_dir) { if let Ok(_) = std::fs::create_dir_all(&jni_dir) {
let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so")); let _ = std::fs::copy(&shared_so, format!("{jni_dir}/libc++_shared.so"));
println!("cargo:warning=Copied libc++_shared.so to {jni_dir}"); println!("cargo:warning=Copied libc++_shared.so to {jni_dir}");
@@ -127,7 +124,12 @@ fn fetch_oboe() -> Option<PathBuf> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let oboe_dir = out_dir.join("oboe"); let oboe_dir = out_dir.join("oboe");
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() { if oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists()
{
return Some(oboe_dir); return Some(oboe_dir);
} }
@@ -143,7 +145,12 @@ fn fetch_oboe() -> Option<PathBuf> {
match status { match status {
Ok(s) if s.success() => { Ok(s) if s.success() => {
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() { if oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists()
{
Some(oboe_dir) Some(oboe_dir)
} else { } else {
None None

View File

@@ -326,7 +326,10 @@ pub fn pin_to_big_core() {
&set, &set,
); );
if ret != 0 { if ret != 0 {
warn!("sched_setaffinity failed: {}", std::io::Error::last_os_error()); warn!(
"sched_setaffinity failed: {}",
std::io::Error::last_os_error()
);
} else { } else {
info!(start, num_cpus, "pinned to big cores"); info!(start, num_cpus, "pinned to big cores");
} }

View File

@@ -77,7 +77,8 @@ impl AudioRing {
} }
} }
self.write_pos.store(w.wrapping_add(count), Ordering::Release); self.write_pos
.store(w.wrapping_add(count), Ordering::Release);
count count
} }
@@ -112,7 +113,8 @@ impl AudioRing {
out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) }; out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
} }
self.read_pos.store(r.wrapping_add(count), Ordering::Release); self.read_pos
.store(r.wrapping_add(count), Ordering::Release);
count count
} }

View File

@@ -46,7 +46,11 @@ const PROFILES: [QualityProfile; 6] = [
]; ];
fn profile_to_index(p: &QualityProfile) -> u8 { fn profile_to_index(p: &QualityProfile) -> u8 {
PROFILES.iter().position(|pp| pp.codec == p.codec).map(|i| i as u8).unwrap_or(3) PROFILES
.iter()
.position(|pp| pp.codec == p.codec)
.map(|i| i as u8)
.unwrap_or(3)
} }
fn index_to_profile(idx: u8) -> Option<QualityProfile> { fn index_to_profile(idx: u8) -> Option<QualityProfile> {
@@ -149,9 +153,10 @@ impl WzpEngine {
.enable_all() .enable_all()
.build()?; .build()?;
let relay_addr: SocketAddr = config.relay_addr.parse().map_err(|e| { let relay_addr: SocketAddr = config
anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr) .relay_addr
})?; .parse()
.map_err(|e| anyhow::anyhow!("invalid relay address '{}': {e}", config.relay_addr))?;
let room = config.room.clone(); let room = config.room.clone();
let identity_seed = config.identity_seed; let identity_seed = config.identity_seed;
@@ -165,7 +170,16 @@ impl WzpEngine {
let state_clone = state.clone(); let state_clone = state.clone();
runtime.block_on(async move { runtime.block_on(async move {
if let Err(e) = run_call(relay_addr, &room, &identity_seed, profile, auto_profile, alias.as_deref(), state_clone).await if let Err(e) = run_call(
relay_addr,
&room,
&identity_seed,
profile,
auto_profile,
alias.as_deref(),
state_clone,
)
.await
{ {
error!("call failed: {e}"); error!("call failed: {e}");
} }
@@ -233,16 +247,21 @@ impl WzpEngine {
let server_fp = conn let server_fp = conn
.peer_identity() .peer_identity()
.and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok()) .and_then(|id| id.downcast::<Vec<rustls::pki_types::CertificateDer>>().ok())
.and_then(|certs| certs.first().map(|c| { .and_then(|certs| {
use std::hash::{Hash, Hasher}; certs.first().map(|c| {
let mut h = std::collections::hash_map::DefaultHasher::new(); use std::hash::{Hash, Hasher};
c.as_ref().hash(&mut h); let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{:016x}", h.finish()) c.as_ref().hash(&mut h);
})) format!("{:016x}", h.finish())
})
})
.unwrap_or_default(); .unwrap_or_default();
conn.close(0u32.into(), b"ping"); conn.close(0u32.into(), b"ping");
Ok::<_, anyhow::Error>(format!(r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#, rtt_ms, server_fp)) Ok::<_, anyhow::Error>(format!(
r#"{{"rtt_ms":{},"server_fingerprint":"{}"}}"#,
rtt_ms, server_fp
))
}); });
// Shutdown runtime cleanly with timeout // Shutdown runtime cleanly with timeout
@@ -392,7 +411,11 @@ impl WzpEngine {
} }
/// Answer an incoming direct call. /// Answer an incoming direct call.
pub fn answer_call(&self, call_id: &str, mode: wzp_proto::CallAcceptMode) -> Result<(), anyhow::Error> { pub fn answer_call(
&self,
call_id: &str,
mode: wzp_proto::CallAcceptMode,
) -> Result<(), anyhow::Error> {
let _ = self.state.command_tx.send(EngineCommand::AnswerCall { let _ = self.state.command_tx.send(EngineCommand::AnswerCall {
call_id: call_id.to_string(), call_id: call_id.to_string(),
accept_mode: mode, accept_mode: mode,
@@ -412,7 +435,9 @@ impl WzpEngine {
/// Stores the type atomically; the recv task polls it on each packet. /// Stores the type atomically; the recv task polls it on each packet.
pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) { pub fn on_network_changed(&self, network_type: u8, bandwidth_kbps: u32) {
info!(network_type, bandwidth_kbps, "on_network_changed"); info!(network_type, bandwidth_kbps, "on_network_changed");
self.state.pending_network_type.store(network_type, Ordering::Release); self.state
.pending_network_type
.store(network_type, Ordering::Release);
} }
pub fn get_stats(&self) -> CallStats { pub fn get_stats(&self) -> CallStats {
@@ -518,12 +543,16 @@ async fn run_call(
.ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?; .ok_or_else(|| anyhow::anyhow!("connection closed before CallAnswer"))?;
let (relay_ephemeral_pub, chosen_profile) = match answer { let (relay_ephemeral_pub, chosen_profile) = match answer {
SignalMessage::CallAnswer { ephemeral_pub, chosen_profile, .. } => (ephemeral_pub, chosen_profile), SignalMessage::CallAnswer {
ephemeral_pub,
chosen_profile,
..
} => (ephemeral_pub, chosen_profile),
other => { other => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"expected CallAnswer, got {:?}", "expected CallAnswer, got {:?}",
std::mem::discriminant(&other) std::mem::discriminant(&other)
)) ));
} }
}; };
@@ -725,9 +754,7 @@ async fn run_call(
if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 { if send_errors <= 3 || last_send_error_log.elapsed().as_secs() >= 1 {
warn!( warn!(
seq = s, seq = s,
send_errors, send_errors, frames_dropped, "send_media error (dropping packet): {e}"
frames_dropped,
"send_media error (dropping packet): {e}"
); );
last_send_error_log = Instant::now(); last_send_error_log = Instant::now();
} }
@@ -820,7 +847,11 @@ async fn run_call(
avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us), avg_total_us = avg(t_agc_us + t_opus_us + t_fec_us + t_send_us),
"send stats" "send stats"
); );
t_agc_us = 0; t_opus_us = 0; t_fec_us = 0; t_send_us = 0; t_frames = 0; t_agc_us = 0;
t_opus_us = 0;
t_fec_us = 0;
t_send_us = 0;
t_frames = 0;
last_stats_log = Instant::now(); last_stats_log = Instant::now();
} }
} }
@@ -849,12 +880,9 @@ async fn run_call(
// when a packet arrives with seq > expected_seq, the frames in // when a packet arrives with seq > expected_seq, the frames in
// between are missing and we attempt to reconstruct them via // between are missing and we attempt to reconstruct them via
// DRED before decoding the newly-arrived packet. // DRED before decoding the newly-arrived packet.
let mut dred_decoder = let mut dred_decoder = DredDecoderHandle::new().expect("opus_dred_decoder_create failed");
DredDecoderHandle::new().expect("opus_dred_decoder_create failed"); let mut dred_parse_scratch = DredState::new().expect("opus_dred_alloc failed (scratch)");
let mut dred_parse_scratch = let mut last_good_dred = DredState::new().expect("opus_dred_alloc failed (good state)");
DredState::new().expect("opus_dred_alloc failed (scratch)");
let mut last_good_dred =
DredState::new().expect("opus_dred_alloc failed (good state)");
let mut last_good_dred_seq: Option<u16> = None; let mut last_good_dred_seq: Option<u16> = None;
let mut expected_seq: Option<u16> = None; let mut expected_seq: Option<u16> = None;
let mut dred_reconstructions: u64 = 0; let mut dred_reconstructions: u64 = 0;
@@ -884,7 +912,9 @@ async fn run_call(
// Check for network transport change from ConnectivityManager // Check for network transport change from ConnectivityManager
{ {
let net = state.pending_network_type.swap(PROFILE_NO_CHANGE, Ordering::Acquire); let net = state
.pending_network_type
.swap(PROFILE_NO_CHANGE, Ordering::Acquire);
if net != PROFILE_NO_CHANGE { if net != PROFILE_NO_CHANGE {
use wzp_proto::NetworkContext; use wzp_proto::NetworkContext;
let ctx = match net { let ctx = match net {
@@ -927,12 +957,7 @@ async fn run_call(
// would accumulate block_id=0 duplicates that never // would accumulate block_id=0 duplicates that never
// decode. Codec2 packets still feed RaptorQ. // decode. Codec2 packets still feed RaptorQ.
if !pkt_is_opus { if !pkt_is_opus {
let _ = fec_dec.add_symbol( let _ = fec_dec.add_symbol(pkt_block, pkt_symbol, is_repair, &pkt.payload);
pkt_block,
pkt_symbol,
is_repair,
&pkt.payload,
);
} }
// Source packets: decode directly // Source packets: decode directly
@@ -952,7 +977,10 @@ async fn run_call(
frame_duration_ms: 20, frame_duration_ms: 20,
frames_per_block: 5, frames_per_block: 5,
}, },
other => QualityProfile { codec: other, ..QualityProfile::GOOD }, other => QualityProfile {
codec: other,
..QualityProfile::GOOD
},
}; };
info!(from = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder"); info!(from = ?decoder.codec_id(), to = ?pkt.header.codec_id, "recv: switching decoder");
let _ = decoder.set_profile(switch_profile); let _ = decoder.set_profile(switch_profile);
@@ -984,10 +1012,7 @@ async fn run_call(
// Update DRED state from the current packet. // Update DRED state from the current packet.
match dred_decoder.parse_into(&mut dred_parse_scratch, &pkt.payload) { match dred_decoder.parse_into(&mut dred_parse_scratch, &pkt.payload) {
Ok(available) if available > 0 => { Ok(available) if available > 0 => {
std::mem::swap( std::mem::swap(&mut dred_parse_scratch, &mut last_good_dred);
&mut dred_parse_scratch,
&mut last_good_dred,
);
last_good_dred_seq = Some(pkt.header.seq); last_good_dred_seq = Some(pkt.header.seq);
} }
Ok(_) => { Ok(_) => {
@@ -1006,8 +1031,7 @@ async fn run_call(
let current_profile_frame_samples = let current_profile_frame_samples =
(48_000 * profile.frame_duration_ms as i32) / 1000; (48_000 * profile.frame_duration_ms as i32) / 1000;
let available = last_good_dred.samples_available(); let available = last_good_dred.samples_available();
let pcm_slice_len = let pcm_slice_len = current_profile_frame_samples as usize;
current_profile_frame_samples as usize;
for gap_idx in 0..gap { for gap_idx in 0..gap {
let missing_seq = expected.wrapping_add(gap_idx); let missing_seq = expected.wrapping_add(gap_idx);
@@ -1026,28 +1050,24 @@ async fn run_call(
None => -1, None => -1,
}; };
let reconstructed = if offset_samples > 0 let reconstructed =
&& offset_samples <= available if offset_samples > 0 && offset_samples <= available {
{ decoder
decoder .reconstruct_from_dred(
.reconstruct_from_dred( &last_good_dred,
&last_good_dred, offset_samples,
offset_samples, &mut decode_buf[..pcm_slice_len],
&mut decode_buf[..pcm_slice_len], )
) .ok()
.ok() } else {
} else { None
None };
};
match reconstructed { match reconstructed {
Some(samples) => { Some(samples) => {
playout_agc.process_frame( playout_agc
&mut decode_buf[..samples], .process_frame(&mut decode_buf[..samples]);
); state.playout_ring.write(&decode_buf[..samples]);
state
.playout_ring
.write(&decode_buf[..samples]);
dred_reconstructions += 1; dred_reconstructions += 1;
frames_decoded += 1; frames_decoded += 1;
} }
@@ -1144,7 +1164,10 @@ async fn run_call(
} }
} }
Ok(None) => { Ok(None) => {
info!(frames_decoded, fec_recovered, "relay disconnected (stream ended)"); info!(
frames_decoded,
fec_recovered, "relay disconnected (stream ended)"
);
break; break;
} }
Err(e) => { Err(e) => {
@@ -1162,7 +1185,10 @@ async fn run_call(
} }
} }
} }
info!(frames_decoded, fec_recovered, recv_errors, "recv task ended"); info!(
frames_decoded,
fec_recovered, recv_errors, "recv task ended"
);
}; };
// Stats task — polls path quality + quinn RTT every 500ms // Stats task — polls path quality + quinn RTT every 500ms
@@ -1195,7 +1221,10 @@ async fn run_call(
let signal_task = async { let signal_task = async {
loop { loop {
match transport_signal.recv_signal().await { match transport_signal.recv_signal().await {
Ok(Some(SignalMessage::RoomUpdate { count, participants })) => { Ok(Some(SignalMessage::RoomUpdate {
count,
participants,
})) => {
info!(count, "RoomUpdate received"); info!(count, "RoomUpdate received");
let members: Vec<crate::stats::RoomMember> = participants let members: Vec<crate::stats::RoomMember> = participants
.iter() .iter()
@@ -1209,7 +1238,10 @@ async fn run_call(
stats.room_participant_count = count; stats.room_participant_count = count;
stats.room_participants = members; stats.room_participants = members;
} }
Ok(Some(SignalMessage::QualityDirective { recommended_profile, reason })) => { Ok(Some(SignalMessage::QualityDirective {
recommended_profile,
reason,
})) => {
let idx = profile_to_index(&recommended_profile); let idx = profile_to_index(&recommended_profile);
info!( info!(
codec = ?recommended_profile.codec, codec = ?recommended_profile.codec,
@@ -1247,7 +1279,9 @@ async fn run_call(
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_millis(500), std::time::Duration::from_millis(500),
transport.connection().closed(), transport.connection().closed(),
).await { )
.await
{
Ok(_) => info!("QUIC connection closed cleanly"), Ok(_) => info!("QUIC connection closed cleanly"),
Err(_) => info!("QUIC close timed out (relay may not have ack'd)"), Err(_) => info!("QUIC close timed out (relay may not have ack'd)"),
} }

View File

@@ -3,9 +3,9 @@
use std::panic; use std::panic;
use std::sync::Once; use std::sync::Once;
use jni::JNIEnv;
use jni::objects::{JClass, JObject, JString}; use jni::objects::{JClass, JObject, JString};
use jni::sys::{jboolean, jint, jlong, jstring}; use jni::sys::{jboolean, jint, jlong, jstring};
use jni::JNIEnv;
use tracing::{error, info}; use tracing::{error, info};
use wzp_proto::QualityProfile; use wzp_proto::QualityProfile;
@@ -26,19 +26,20 @@ const PROFILE_AUTO: jint = 7;
fn profile_from_int(value: jint) -> QualityProfile { fn profile_from_int(value: jint) -> QualityProfile {
match value { match value {
0 => QualityProfile::GOOD, // Opus 24k 0 => QualityProfile::GOOD, // Opus 24k
1 => QualityProfile::DEGRADED, // Opus 6k 1 => QualityProfile::DEGRADED, // Opus 6k
2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k 2 => QualityProfile::CATASTROPHIC, // Codec2 1.2k
3 => QualityProfile { // Codec2 3.2k 3 => QualityProfile {
// Codec2 3.2k
codec: wzp_proto::CodecId::Codec2_3200, codec: wzp_proto::CodecId::Codec2_3200,
fec_ratio: 0.5, fec_ratio: 0.5,
frame_duration_ms: 20, frame_duration_ms: 20,
frames_per_block: 5, frames_per_block: 5,
}, },
4 => QualityProfile::STUDIO_32K, // Opus 32k 4 => QualityProfile::STUDIO_32K, // Opus 32k
5 => QualityProfile::STUDIO_48K, // Opus 48k 5 => QualityProfile::STUDIO_48K, // Opus 48k
6 => QualityProfile::STUDIO_64K, // Opus 64k 6 => QualityProfile::STUDIO_64K, // Opus 64k
_ => QualityProfile::GOOD, // auto falls back to GOOD _ => QualityProfile::GOOD, // auto falls back to GOOD
} }
} }
@@ -101,11 +102,26 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
profile_j: jint, profile_j: jint,
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default(); let relay_addr: String = env
let room: String = env.get_string(&room_j).map(|s| s.into()).unwrap_or_default(); .get_string(&relay_addr_j)
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default(); .map(|s| s.into())
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default(); .unwrap_or_default();
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default(); let room: String = env
.get_string(&room_j)
.map(|s| s.into())
.unwrap_or_default();
let seed_hex: String = env
.get_string(&seed_hex_j)
.map(|s| s.into())
.unwrap_or_default();
let token: String = env
.get_string(&token_j)
.map(|s| s.into())
.unwrap_or_default();
let alias: String = env
.get_string(&alias_j)
.map(|s| s.into())
.unwrap_or_default();
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
@@ -128,7 +144,11 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartCall(
auto_profile: profile_j == PROFILE_AUTO, auto_profile: profile_j == PROFILE_AUTO,
relay_addr, relay_addr,
room, room,
auth_token: if token.is_empty() { Vec::new() } else { token.into_bytes() }, auth_token: if token.is_empty() {
Vec::new()
} else {
token.into_bytes()
},
identity_seed, identity_seed,
alias: if alias.is_empty() { None } else { Some(alias) }, alias: if alias.is_empty() { None } else { Some(alias) },
}; };
@@ -241,7 +261,8 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeOnNetworkChang
) { ) {
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| { let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
h.engine.on_network_changed(network_type as u8, bandwidth_kbps as u32); h.engine
.on_network_changed(network_type as u8, bandwidth_kbps as u32);
})); }));
} }
@@ -307,13 +328,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeWriteAudioDire
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut()); let ptr = env
.get_direct_buffer_address(&buffer)
.unwrap_or(std::ptr::null_mut());
if ptr.is_null() || sample_count <= 0 { if ptr.is_null() || sample_count <= 0 {
return 0; return 0;
} }
let samples = unsafe { let samples =
std::slice::from_raw_parts(ptr as *const i16, sample_count as usize) unsafe { std::slice::from_raw_parts(ptr as *const i16, sample_count as usize) };
};
h.engine.write_audio(samples) as jint h.engine.write_audio(samples) as jint
})); }));
result.unwrap_or(0) result.unwrap_or(0)
@@ -332,13 +354,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeReadAudioDirec
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
let ptr = env.get_direct_buffer_address(&buffer).unwrap_or(std::ptr::null_mut()); let ptr = env
.get_direct_buffer_address(&buffer)
.unwrap_or(std::ptr::null_mut());
if ptr.is_null() || max_samples <= 0 { if ptr.is_null() || max_samples <= 0 {
return 0; return 0;
} }
let samples = unsafe { let samples =
std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize) unsafe { std::slice::from_raw_parts_mut(ptr as *mut i16, max_samples as usize) };
};
h.engine.read_audio(samples) as jint h.engine.read_audio(samples) as jint
})); }));
result.unwrap_or(0) result.unwrap_or(0)
@@ -367,7 +390,10 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePingRelay<'a>(
) -> jstring { ) -> jstring {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
let relay: String = env.get_string(&relay_j).map(|s| s.into()).unwrap_or_default(); let relay: String = env
.get_string(&relay_j)
.map(|s| s.into())
.unwrap_or_default();
match h.engine.ping_relay(&relay) { match h.engine.ping_relay(&relay) {
Ok(json) => Some(json), Ok(json) => Some(json),
Err(_) => None, Err(_) => None,
@@ -399,10 +425,22 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
let relay_addr: String = env.get_string(&relay_addr_j).map(|s| s.into()).unwrap_or_default(); let relay_addr: String = env
let seed_hex: String = env.get_string(&seed_hex_j).map(|s| s.into()).unwrap_or_default(); .get_string(&relay_addr_j)
let token: String = env.get_string(&token_j).map(|s| s.into()).unwrap_or_default(); .map(|s| s.into())
let alias: String = env.get_string(&alias_j).map(|s| s.into()).unwrap_or_default(); .unwrap_or_default();
let seed_hex: String = env
.get_string(&seed_hex_j)
.map(|s| s.into())
.unwrap_or_default();
let token: String = env
.get_string(&token_j)
.map(|s| s.into())
.unwrap_or_default();
let alias: String = env
.get_string(&alias_j)
.map(|s| s.into())
.unwrap_or_default();
h.engine.start_signaling( h.engine.start_signaling(
&relay_addr, &relay_addr,
@@ -414,8 +452,14 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeStartSignaling
match result { match result {
Ok(Ok(())) => 0, Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("start_signaling failed: {e}"); -1 } Ok(Err(e)) => {
Err(_) => { error!("start_signaling panicked"); -1 } error!("start_signaling failed: {e}");
-1
}
Err(_) => {
error!("start_signaling panicked");
-1
}
} }
} }
@@ -430,14 +474,23 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativePlaceCall<'a>(
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
let target: String = env.get_string(&target_fp_j).map(|s| s.into()).unwrap_or_default(); let target: String = env
.get_string(&target_fp_j)
.map(|s| s.into())
.unwrap_or_default();
h.engine.place_call(&target) h.engine.place_call(&target)
})); }));
match result { match result {
Ok(Ok(())) => 0, Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("place_call failed: {e}"); -1 } Ok(Err(e)) => {
Err(_) => { error!("place_call panicked"); -1 } error!("place_call failed: {e}");
-1
}
Err(_) => {
error!("place_call panicked");
-1
}
} }
} }
@@ -453,7 +506,10 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>
) -> jint { ) -> jint {
let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let h = unsafe { handle_ref(handle) }; let h = unsafe { handle_ref(handle) };
let call_id: String = env.get_string(&call_id_j).map(|s| s.into()).unwrap_or_default(); let call_id: String = env
.get_string(&call_id_j)
.map(|s| s.into())
.unwrap_or_default();
let accept_mode = match mode { let accept_mode = match mode {
0 => wzp_proto::CallAcceptMode::Reject, 0 => wzp_proto::CallAcceptMode::Reject,
1 => wzp_proto::CallAcceptMode::AcceptTrusted, 1 => wzp_proto::CallAcceptMode::AcceptTrusted,
@@ -464,7 +520,13 @@ pub unsafe extern "system" fn Java_com_wzp_engine_WzpEngine_nativeAnswerCall<'a>
match result { match result {
Ok(Ok(())) => 0, Ok(Ok(())) => 0,
Ok(Err(e)) => { error!("answer_call failed: {e}"); -1 } Ok(Err(e)) => {
Err(_) => { error!("answer_call panicked"); -1 } error!("answer_call failed: {e}");
-1
}
Err(_) => {
error!("answer_call panicked");
-1
}
} }
} }

View File

@@ -26,6 +26,6 @@ pub mod audio_android;
pub mod audio_ring; pub mod audio_ring;
pub mod commands; pub mod commands;
pub mod engine; pub mod engine;
pub mod jni_bridge;
pub mod pipeline; pub mod pipeline;
pub mod stats; pub mod stats;
pub mod jni_bridge;

View File

@@ -9,8 +9,8 @@ use wzp_codec::{AdaptiveDecoder, AdaptiveEncoder, AutoGainControl, EchoCanceller
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder}; use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult}; use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::quality::AdaptiveQualityController; use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::traits::QualityController; use wzp_proto::traits::QualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::{MediaPacket, QualityProfile}; use wzp_proto::{MediaPacket, QualityProfile};
use crate::audio_android::FRAME_SAMPLES; use crate::audio_android::FRAME_SAMPLES;
@@ -58,14 +58,12 @@ pub struct Pipeline {
impl Pipeline { impl Pipeline {
/// Create a new pipeline configured for the given quality profile. /// Create a new pipeline configured for the given quality profile.
pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> { pub fn new(profile: QualityProfile) -> Result<Self, anyhow::Error> {
let encoder = AdaptiveEncoder::new(profile) let encoder =
.map_err(|e| anyhow::anyhow!("encoder init: {e}"))?; AdaptiveEncoder::new(profile).map_err(|e| anyhow::anyhow!("encoder init: {e}"))?;
let decoder = AdaptiveDecoder::new(profile) let decoder =
.map_err(|e| anyhow::anyhow!("decoder init: {e}"))?; AdaptiveDecoder::new(profile).map_err(|e| anyhow::anyhow!("decoder init: {e}"))?;
let fec_encoder = let fec_encoder = RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize);
RaptorQFecEncoder::with_defaults(profile.frames_per_block as usize); let fec_decoder = RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
let fec_decoder =
RaptorQFecDecoder::with_defaults(profile.frames_per_block as usize);
let jitter_buffer = JitterBuffer::new(10, 250, 3); let jitter_buffer = JitterBuffer::new(10, 250, 3);
let quality_ctrl = AdaptiveQualityController::new(); let quality_ctrl = AdaptiveQualityController::new();
@@ -211,10 +209,7 @@ impl Pipeline {
/// ///
/// Returns a new profile if a tier transition occurred. /// Returns a new profile if a tier transition occurred.
#[allow(unused)] #[allow(unused)]
pub fn observe_quality( pub fn observe_quality(&mut self, report: &wzp_proto::QualityReport) -> Option<QualityProfile> {
&mut self,
report: &wzp_proto::QualityReport,
) -> Option<QualityProfile> {
let new_profile = self.quality_ctrl.observe(report); let new_profile = self.quality_ctrl.observe(report);
if let Some(ref profile) = new_profile { if let Some(ref profile) = new_profile {
if let Err(e) = self.encoder.set_profile(*profile) { if let Err(e) = self.encoder.set_profile(*profile) {

View File

@@ -86,7 +86,7 @@ struct ParticipantStats {
/// Detected lost packets (sequence gaps) /// Detected lost packets (sequence gaps)
lost: u64, lost: u64,
/// Last seen sequence number /// Last seen sequence number
last_seq: u16, last_seq: u32,
/// Whether we've seen the first packet (for gap detection) /// Whether we've seen the first packet (for gap detection)
seq_initialized: bool, seq_initialized: bool,
/// EWMA jitter in ms /// EWMA jitter in ms
@@ -181,7 +181,7 @@ impl ParticipantStats {
/// distinguish streams by proximity of consecutive sequence numbers. /// distinguish streams by proximity of consecutive sequence numbers.
fn find_or_create_participant( fn find_or_create_participant(
participants: &mut Vec<ParticipantStats>, participants: &mut Vec<ParticipantStats>,
seq: u16, seq: u32,
codec: CodecId, codec: CodecId,
) -> usize { ) -> usize {
for (i, p) in participants.iter().enumerate() { for (i, p) in participants.iter().enumerate() {
@@ -304,7 +304,7 @@ struct TimelineEntry {
#[allow(dead_code)] #[allow(dead_code)]
codec: CodecId, codec: CodecId,
#[allow(dead_code)] #[allow(dead_code)]
seq: u16, seq: u32,
#[allow(dead_code)] #[allow(dead_code)]
payload_len: usize, payload_len: usize,
loss_pct: f64, loss_pct: f64,
@@ -333,21 +333,25 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
let mut timeline: Vec<TimelineEntry> = Vec::new(); let mut timeline: Vec<TimelineEntry> = Vec::new();
// Decrypt session from --key (optional) // Decrypt session from --key (optional)
let mut decrypt_session: Option<wzp_crypto::ChaChaSession> = args.key.as_ref().and_then(|hex| { let mut decrypt_session: Option<wzp_crypto::ChaChaSession> =
if hex.len() != 64 { return None; } args.key.as_ref().and_then(|hex| {
let mut key = [0u8; 32]; if hex.len() != 64 {
for (i, chunk) in hex.as_bytes().chunks(2).enumerate() { return None;
let s = std::str::from_utf8(chunk).unwrap_or("00"); }
key[i] = u8::from_str_radix(s, 16).unwrap_or(0); let mut key = [0u8; 32];
} for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
Some(wzp_crypto::ChaChaSession::new(key)) let s = std::str::from_utf8(chunk).unwrap_or("00");
}); key[i] = u8::from_str_radix(s, 16).unwrap_or(0);
}
Some(wzp_crypto::ChaChaSession::new(key))
});
let mut decrypt_ok: u64 = 0; let mut decrypt_ok: u64 = 0;
let mut decrypt_fail: u64 = 0; let mut decrypt_fail: u64 = 0;
while let Some((ts_us, pkt)) = reader.next_packet()? { while let Some((ts_us, pkt)) = reader.next_packet()? {
let now = Instant::now(); let now = Instant::now();
let idx = find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id); let idx =
find_or_create_participant(&mut participants, pkt.header.seq, pkt.header.codec_id);
participants[idx].ingest(&pkt, now); participants[idx].ingest(&pkt, now);
total_packets += 1; total_packets += 1;
@@ -362,8 +366,10 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
if decrypt_ok <= 5 || decrypt_ok % 100 == 0 { if decrypt_ok <= 5 || decrypt_ok % 100 == 0 {
eprintln!( eprintln!(
" decrypt ok: seq={} codec={:?} payload={}B → plaintext={}B", " decrypt ok: seq={} codec={:?} payload={}B → plaintext={}B",
pkt.header.seq, pkt.header.codec_id, pkt.header.seq,
pkt.payload.len(), plaintext.len() pkt.header.codec_id,
pkt.payload.len(),
plaintext.len()
); );
} }
} }
@@ -402,7 +408,13 @@ async fn run_replay(path: &str, args: &Args) -> anyhow::Result<()> {
// Generate HTML if requested // Generate HTML if requested
if let Some(html_path) = &args.html { if let Some(html_path) = &args.html {
generate_html_report(html_path, &participants, &timeline, total_packets, &reader.header)?; generate_html_report(
html_path,
&participants,
&timeline,
total_packets,
&reader.header,
)?;
eprintln!("HTML report: {}", html_path); eprintln!("HTML report: {}", html_path);
} }
@@ -587,12 +599,12 @@ async fn run_no_tui(
w.write_packet(&pkt, now)?; w.write_packet(&pkt, now)?;
} }
} }
Ok(Ok(None)) => break, // connection closed Ok(Ok(None)) => break, // connection closed
Ok(Err(e)) => { Ok(Err(e)) => {
tracing::warn!("recv error: {e}"); tracing::warn!("recv error: {e}");
break; break;
} }
Err(_) => {} // timeout, loop again Err(_) => {} // timeout, loop again
} }
if print_timer.elapsed() >= Duration::from_secs(2) { if print_timer.elapsed() >= Duration::from_secs(2) {
print_stats(participants, *total_packets); print_stats(participants, *total_packets);
@@ -603,7 +615,11 @@ async fn run_no_tui(
} }
fn print_stats(participants: &[ParticipantStats], total: u64) { fn print_stats(participants: &[ParticipantStats], total: u64) {
eprintln!("--- {} participants | {} total packets ---", participants.len(), total); eprintln!(
"--- {} participants | {} total packets ---",
participants.len(),
total
);
for p in participants { for p in participants {
eprintln!( eprintln!(
" {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {:.0}s", " {}: {} pkts, {:.1}% loss, {:.0}ms jitter, {:?}, {:.0}s",
@@ -693,10 +709,7 @@ async fn run_tui(
// Always restore terminal, even on error // Always restore terminal, even on error
crossterm::terminal::disable_raw_mode()?; crossterm::terminal::disable_raw_mode()?;
crossterm::execute!( crossterm::execute!(std::io::stdout(), crossterm::terminal::LeaveAlternateScreen)?;
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen
)?;
result result
} }
@@ -723,7 +736,7 @@ fn draw_ui(
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), // header Constraint::Length(3), // header
Constraint::Min(5), // participant table Constraint::Min(5), // participant table
Constraint::Length(3), // footer Constraint::Length(3), // footer
]) ])
.split(f.area()); .split(f.area());
@@ -735,7 +748,11 @@ fn draw_ui(
total_packets, total_packets,
elapsed_str elapsed_str
)) ))
.block(Block::default().borders(Borders::ALL).title(" Protocol Analyzer ")); .block(
Block::default()
.borders(Borders::ALL)
.title(" Protocol Analyzer "),
);
f.render_widget(header, chunks[0]); f.render_widget(header, chunks[0]);
// Participant table // Participant table
@@ -780,9 +797,11 @@ fn draw_ui(
Constraint::Length(10), // Duration Constraint::Length(10), // Duration
]; ];
let table = Table::new(rows, widths) let table = Table::new(rows, widths).header(header_row).block(
.header(header_row) Block::default()
.block(Block::default().borders(Borders::ALL).title(" Participants ")); .borders(Borders::ALL)
.title(" Participants "),
);
f.render_widget(table, chunks[1]); f.render_widget(table, chunks[1]);
// Footer // Footer
@@ -832,7 +851,10 @@ async fn main() -> anyhow::Result<()> {
let _crypto_session: Option<std::sync::Mutex<wzp_crypto::ChaChaSession>> = let _crypto_session: Option<std::sync::Mutex<wzp_crypto::ChaChaSession>> =
if let Some(ref key_hex) = args.key { if let Some(ref key_hex) = args.key {
if key_hex.len() != 64 { if key_hex.len() != 64 {
eprintln!("Error: --key must be 64 hex characters (32 bytes). Got {} chars.", key_hex.len()); eprintln!(
"Error: --key must be 64 hex characters (32 bytes). Got {} chars.",
key_hex.len()
);
std::process::exit(1); std::process::exit(1);
} }
let mut key_bytes = [0u8; 32]; let mut key_bytes = [0u8; 32];
@@ -841,9 +863,9 @@ async fn main() -> anyhow::Result<()> {
key_bytes[i] = u8::from_str_radix(hex_str, 16).unwrap_or(0); key_bytes[i] = u8::from_str_radix(hex_str, 16).unwrap_or(0);
} }
eprintln!("Encrypted payload decoding enabled (key loaded)."); eprintln!("Encrypted payload decoding enabled (key loaded).");
Some(std::sync::Mutex::new( Some(std::sync::Mutex::new(wzp_crypto::ChaChaSession::new(
wzp_crypto::ChaChaSession::new(key_bytes), key_bytes,
)) )))
} else { } else {
None None
}; };
@@ -854,14 +876,12 @@ async fn main() -> anyhow::Result<()> {
} }
// Live mode requires relay and room // Live mode requires relay and room
let relay = args let relay = args.relay.as_deref().ok_or_else(|| {
.relay anyhow::anyhow!("relay address required for live mode (use --replay for offline)")
.as_deref() })?;
.ok_or_else(|| anyhow::anyhow!("relay address required for live mode (use --replay for offline)"))?; let room = args.room.as_deref().ok_or_else(|| {
let room = args anyhow::anyhow!("--room required for live mode (use --replay for offline)")
.room })?;
.as_deref()
.ok_or_else(|| anyhow::anyhow!("--room required for live mode (use --replay for offline)"))?;
// TLS crypto provider // TLS crypto provider
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();

View File

@@ -6,10 +6,10 @@
//! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing` //! Audio callbacks are **lock-free**: they read/write directly to an `AudioRing`
//! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path. //! (atomic SPSC ring buffer). No Mutex, no channel, no allocation on the hot path.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{anyhow, Context}; use anyhow::{Context, anyhow};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig}; use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn}; use tracing::{info, warn};
@@ -78,7 +78,10 @@ impl AudioCapture {
return; return;
} }
if !logged.swap(true, Ordering::Relaxed) { if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} f32 samples", data.len()); eprintln!(
"[audio] capture callback: {} f32 samples",
data.len()
);
} }
let mut tmp = [0i16; FRAME_SAMPLES]; let mut tmp = [0i16; FRAME_SAMPLES];
for chunk in data.chunks(FRAME_SAMPLES) { for chunk in data.chunks(FRAME_SAMPLES) {
@@ -103,7 +106,10 @@ impl AudioCapture {
return; return;
} }
if !logged.swap(true, Ordering::Relaxed) { if !logged.swap(true, Ordering::Relaxed) {
eprintln!("[audio] capture callback: {} i16 samples", data.len()); eprintln!(
"[audio] capture callback: {} i16 samples",
data.len()
);
} }
ring.write(data); ring.write(data);
}, },

View File

@@ -54,13 +54,13 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use anyhow::{anyhow, Context}; use anyhow::{Context, anyhow};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{SampleFormat, SampleRate, StreamConfig}; use cpal::{SampleFormat, SampleRate, StreamConfig};
use tracing::{info, warn}; use tracing::{info, warn};
use webrtc_audio_processing::{ use webrtc_audio_processing::{
Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig, Config, EchoCancellation, EchoCancellationSuppressionLevel, InitializationConfig,
NoiseSuppression, NoiseSuppressionLevel, Processor, NUM_SAMPLES_PER_FRAME, NUM_SAMPLES_PER_FRAME, NoiseSuppression, NoiseSuppressionLevel, Processor,
}; };
use crate::audio_ring::AudioRing; use crate::audio_ring::AudioRing;
@@ -97,8 +97,8 @@ fn get_or_init_processor() -> anyhow::Result<Arc<Mutex<Processor>>> {
num_render_channels: APM_NUM_CHANNELS as i32, num_render_channels: APM_NUM_CHANNELS as i32,
..Default::default() ..Default::default()
}; };
let mut processor = Processor::new(&init_config) let mut processor =
.map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?; Processor::new(&init_config).map_err(|e| anyhow!("webrtc APM init failed: {e:?}"))?;
let config = Config { let config = Config {
echo_cancellation: Some(EchoCancellation { echo_cancellation: Some(EchoCancellation {

View File

@@ -5,8 +5,8 @@
//! to the speaker, so it can cancel the echo from the mic signal internally. //! to the speaker, so it can cancel the echo from the mic signal internally.
//! This is the same engine FaceTime and other Apple apps use. //! This is the same engine FaceTime and other Apple apps use.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Context; use anyhow::Context;
use coreaudio::audio_unit::audio_format::LinearPcmFlags; use coreaudio::audio_unit::audio_format::LinearPcmFlags;
@@ -146,7 +146,8 @@ impl VpioAudio {
) )
.context("failed to set render callback")?; .context("failed to set render callback")?;
au.initialize().context("failed to initialize VoiceProcessingIO")?; au.initialize()
.context("failed to initialize VoiceProcessingIO")?;
au.start().context("failed to start VoiceProcessingIO")?; au.start().context("failed to start VoiceProcessingIO")?;
info!("VoiceProcessingIO started (OS-level AEC enabled)"); info!("VoiceProcessingIO started (OS-level AEC enabled)");

View File

@@ -15,24 +15,24 @@
//! `wzp-client`'s lib.rs can transparently re-export either one as //! `wzp-client`'s lib.rs can transparently re-export either one as
//! `AudioCapture`. //! `AudioCapture`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{anyhow, Context}; use anyhow::{Context, anyhow};
use tracing::{info, warn}; use tracing::{info, warn};
use windows::core::{Interface, GUID}; use windows::Win32::Foundation::{BOOL, CloseHandle, WAIT_OBJECT_0};
use windows::Win32::Foundation::{CloseHandle, BOOL, WAIT_OBJECT_0};
use windows::Win32::Media::Audio::{ use windows::Win32::Media::Audio::{
eCapture, eCommunications, AudioCategory_Communications, AudioClientProperties,
IAudioCaptureClient, IAudioClient, IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator,
AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, WAVEFORMATEX, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,
WAVE_FORMAT_PCM, AudioCategory_Communications, AudioClientProperties, IAudioCaptureClient, IAudioClient,
IAudioClient2, IMMDeviceEnumerator, MMDeviceEnumerator, WAVE_FORMAT_PCM, WAVEFORMATEX,
eCapture, eCommunications,
}; };
use windows::Win32::System::Com::{ use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED, CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
}; };
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject, INFINITE}; use windows::Win32::System::Threading::{CreateEventW, INFINITE, WaitForSingleObject};
use windows::core::{GUID, Interface};
use crate::audio_ring::AudioRing; use crate::audio_ring::AudioRing;
@@ -138,9 +138,8 @@ unsafe fn capture_thread_main(
} }
let _com_guard = ComGuard; let _com_guard = ComGuard;
let enumerator: IMMDeviceEnumerator = let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)
CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) .context("CoCreateInstance(MMDeviceEnumerator) failed")?;
.context("CoCreateInstance(MMDeviceEnumerator) failed")?;
// eCommunications role (not eConsole) — this picks the device the user // eCommunications role (not eConsole) — this picks the device the user
// has designated for communications in Sound Settings. It's the one // has designated for communications in Sound Settings. It's the one
@@ -206,12 +205,13 @@ unsafe fn capture_thread_main(
&wave_format, &wave_format,
Some(&GUID::zeroed()), Some(&GUID::zeroed()),
) )
.context("IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16")?; .context(
"IAudioClient::Initialize failed — Windows rejected communications-mode 48k mono i16",
)?;
// Event-driven capture: Windows signals this handle each time a new // Event-driven capture: Windows signals this handle each time a new
// audio packet is available. We wait on it from the loop below. // audio packet is available. We wait on it from the loop below.
let event = CreateEventW(None, false, false, None) let event = CreateEventW(None, false, false, None).context("CreateEventW failed")?;
.context("CreateEventW failed")?;
audio_client audio_client
.SetEventHandle(event) .SetEventHandle(event)
.context("SetEventHandle failed")?; .context("SetEventHandle failed")?;
@@ -285,10 +285,8 @@ unsafe fn capture_thread_main(
// Because we asked for 48 kHz mono i16, each frame is // Because we asked for 48 kHz mono i16, each frame is
// exactly one i16. Windows's AUTOCONVERTPCM handles the // exactly one i16. Windows's AUTOCONVERTPCM handles the
// conversion from whatever the engine mix format is. // conversion from whatever the engine mix format is.
let samples = std::slice::from_raw_parts( let samples =
buffer_ptr as *const i16, std::slice::from_raw_parts(buffer_ptr as *const i16, num_frames as usize);
num_frames as usize,
);
ring.write(samples); ring.write(samples);
} }

View File

@@ -6,8 +6,8 @@ use std::time::{Duration, Instant};
use wzp_crypto::ChaChaSession; use wzp_crypto::ChaChaSession;
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder}; use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
use wzp_proto::QualityProfile; use wzp_proto::QualityProfile;
use wzp_proto::traits::{CryptoSession, FecDecoder, FecEncoder};
use crate::call::{CallConfig, CallDecoder, CallEncoder}; use crate::call::{CallConfig, CallDecoder, CallEncoder};
@@ -201,9 +201,13 @@ pub fn bench_fec_recovery(loss_pct: f32) -> FecResult {
// Deterministic shuffle for reproducibility using a simple seed // Deterministic shuffle for reproducibility using a simple seed
// We use a basic Fisher-Yates with a fixed-per-block seed // We use a basic Fisher-Yates with a fixed-per-block seed
let mut indices: Vec<usize> = (0..all_symbols.len()).collect(); let mut indices: Vec<usize> = (0..all_symbols.len()).collect();
let mut seed = (block_idx as u64).wrapping_mul(6364136223846793005).wrapping_add(1); let mut seed = (block_idx as u64)
.wrapping_mul(6364136223846793005)
.wrapping_add(1);
for i in (1..indices.len()).rev() { for i in (1..indices.len()).rev() {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let j = (seed >> 33) as usize % (i + 1); let j = (seed >> 33) as usize % (i + 1);
indices.swap(i, j); indices.swap(i, j);
} }

View File

@@ -24,8 +24,14 @@ fn run_codec() {
print_header("Codec Roundtrip (Opus 24kbps)"); print_header("Codec Roundtrip (Opus 24kbps)");
let r = bench::bench_codec_roundtrip(); let r = bench::bench_codec_roundtrip();
print_row("Frames", &format!("{}", r.frames)); print_row("Frames", &format!("{}", r.frames));
print_row("Encode total", &format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0)); print_row(
print_row("Decode total", &format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0)); "Encode total",
&format!("{:.2} ms", r.total_encode.as_secs_f64() * 1000.0),
);
print_row(
"Decode total",
&format!("{:.2} ms", r.total_decode.as_secs_f64() * 1000.0),
);
print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us)); print_row("Avg encode", &format!("{:.1} us", r.avg_encode_us));
print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us)); print_row("Avg decode", &format!("{:.1} us", r.avg_decode_us));
print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec)); print_row("Throughput", &format!("{:.0} frames/sec", r.frames_per_sec));
@@ -41,7 +47,10 @@ fn run_fec(loss_pct: f32) {
print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct)); print_row("Recovery rate", &format!("{:.1}%", r.recovery_rate_pct));
print_row("Source bytes", &format!("{}", r.total_source_bytes)); print_row("Source bytes", &format!("{}", r.total_source_bytes));
print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes)); print_row("Repair (overhead) bytes", &format!("{}", r.overhead_bytes));
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0)); print_row(
"Total time",
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
);
print_footer(); print_footer();
} }
@@ -49,7 +58,10 @@ fn run_crypto() {
print_header("Crypto (ChaCha20-Poly1305)"); print_header("Crypto (ChaCha20-Poly1305)");
let r = bench::bench_encrypt_decrypt(); let r = bench::bench_encrypt_decrypt();
print_row("Packets", &format!("{}", r.packets)); print_row("Packets", &format!("{}", r.packets));
print_row("Total time", &format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0)); print_row(
"Total time",
&format!("{:.2} ms", r.total_time.as_secs_f64() * 1000.0),
);
print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec)); print_row("Throughput", &format!("{:.0} pkt/sec", r.packets_per_sec));
print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec)); print_row("Bandwidth", &format!("{:.2} MB/sec", r.megabytes_per_sec));
print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us)); print_row("Avg latency", &format!("{:.2} us", r.avg_latency_us));
@@ -60,9 +72,18 @@ fn run_pipeline() {
print_header("Full Pipeline (E2E)"); print_header("Full Pipeline (E2E)");
let r = bench::bench_full_pipeline(); let r = bench::bench_full_pipeline();
print_row("Frames", &format!("{}", r.frames)); print_row("Frames", &format!("{}", r.frames));
print_row("Encode pipeline", &format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0)); print_row(
print_row("Decode pipeline", &format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0)); "Encode pipeline",
print_row("Avg E2E latency", &format!("{:.1} us/frame", r.avg_e2e_latency_us)); &format!("{:.2} ms", r.total_encode_pipeline.as_secs_f64() * 1000.0),
);
print_row(
"Decode pipeline",
&format!("{:.2} ms", r.total_decode_pipeline.as_secs_f64() * 1000.0),
);
print_row(
"Avg E2E latency",
&format!("{:.1} us/frame", r.avg_e2e_latency_us),
);
print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in)); print_row("PCM in", &format!("{} bytes", r.pcm_bytes_in));
print_row("Wire out", &format!("{} bytes", r.wire_bytes_out)); print_row("Wire out", &format!("{} bytes", r.wire_bytes_out));
print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio)); print_row("Overhead ratio", &format!("{:.3}x", r.overhead_ratio));

View File

@@ -165,10 +165,7 @@ pub fn generate_dialer_targets(
// First: all known ports (guaranteed targets) // First: all known ports (guaranteed targets)
for &port in known_ports { for &port in known_ports {
targets.push(SocketAddr::new( targets.push(SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port));
std::net::IpAddr::V4(acceptor_ip),
port,
));
} }
// Fill remaining with random ports (birthday attack) // Fill remaining with random ports (birthday attack)
@@ -178,10 +175,7 @@ pub fn generate_dialer_targets(
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
for _ in 0..remaining { for _ in 0..remaining {
let port = rng.gen_range(1024..=65535u16); let port = rng.gen_range(1024..=65535u16);
let addr = SocketAddr::new( let addr = SocketAddr::new(std::net::IpAddr::V4(acceptor_ip), port);
std::net::IpAddr::V4(acceptor_ip),
port,
);
if !targets.contains(&addr) { if !targets.contains(&addr) {
targets.push(addr); targets.push(addr);
} }
@@ -339,7 +333,10 @@ mod tests {
fn acceptor_ports_serializes() { fn acceptor_ports_serializes() {
let result = AcceptorPorts { let result = AcceptorPorts {
external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)), external_ip: Some(Ipv4Addr::new(203, 0, 113, 5)),
ports: vec![PortMapping { local_port: 12345, external_port: 54321 }], ports: vec![PortMapping {
local_port: 12345,
external_port: 54321,
}],
attempted: 32, attempted: 32,
succeeded: 1, succeeded: 1,
}; };

View File

@@ -13,11 +13,11 @@ use wzp_codec::{
}; };
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder}; use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::jitter::{JitterBuffer, PlayoutResult}; use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::packet::QualityReport;
use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext}; use wzp_proto::packet::{MediaHeader, MediaPacket, MiniFrameContext};
use wzp_proto::quality::AdaptiveQualityController; use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder}; use wzp_proto::traits::{AudioDecoder, AudioEncoder, FecDecoder, FecEncoder};
use wzp_proto::packet::QualityReport; use wzp_proto::{CodecId, MediaType, QualityProfile};
use wzp_proto::{CodecId, QualityProfile};
/// Configuration for a call session. /// Configuration for a call session.
pub struct CallConfig { pub struct CallConfig {
@@ -205,7 +205,7 @@ pub struct CallEncoder {
/// Current profile. /// Current profile.
profile: QualityProfile, profile: QualityProfile,
/// Outbound sequence counter. /// Outbound sequence counter.
seq: u16, seq: u32,
/// Current FEC block. /// Current FEC block.
block_id: u8, block_id: u8,
/// Frame index within current block. /// Frame index within current block.
@@ -318,17 +318,15 @@ impl CallEncoder {
if self.cn_counter % 10 == 0 { if self.cn_counter % 10 == 0 {
let cn_pkt = MediaPacket { let cn_pkt = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::ComfortNoise, codec_id: CodecId::ComfortNoise,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq: self.seq, seq: self.seq,
timestamp: self.timestamp_ms, timestamp: self.timestamp_ms,
fec_block: self.block_id, fec_block: u16::from(self.block_id),
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(vec![self.cn_level as u8]), payload: Bytes::from(vec![self.cn_level as u8]),
quality_report: None, quality_report: None,
@@ -354,30 +352,31 @@ impl CallEncoder {
// can cleanly identify "no RaptorQ block to assemble" and new // can cleanly identify "no RaptorQ block to assemble" and new
// receivers can short-circuit their FEC ingest path. // receivers can short-circuit their FEC ingest path.
let is_opus = self.profile.codec.is_opus(); let is_opus = self.profile.codec.is_opus();
let (fec_block, fec_symbol, fec_ratio_encoded) = if is_opus { let (fec_block, fec_ratio) = if is_opus {
(0u8, 0u8, 0u8) (0u16, 0u8)
} else { } else {
( (
self.block_id, u16::from(self.block_id) | (u16::from(self.frame_in_block) << 8),
self.frame_in_block,
MediaHeader::encode_fec_ratio(self.profile.fec_ratio), MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
) )
}; };
// Build source media packet // Build source media packet
let mut flags = 0u8;
if self.pending_quality_report.is_some() {
flags |= MediaHeader::FLAG_QUALITY;
}
let source_pkt = MediaPacket { let source_pkt = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags,
media_type: MediaType::Audio,
codec_id: self.profile.codec, codec_id: self.profile.codec,
has_quality_report: self.pending_quality_report.is_some(), stream_id: 0,
fec_ratio_encoded, fec_ratio,
seq: self.seq, seq: self.seq,
timestamp: self.timestamp_ms, timestamp: self.timestamp_ms,
fec_block, fec_block,
fec_symbol,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(encoded.clone()), payload: Bytes::from(encoded.clone()),
quality_report: self.pending_quality_report.take(), quality_report: self.pending_quality_report.take(),
@@ -402,19 +401,15 @@ impl CallEncoder {
for (sym_idx, repair_data) in repairs { for (sym_idx, repair_data) in repairs {
output.push(MediaPacket { output.push(MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: true, flags: MediaHeader::FLAG_REPAIR,
media_type: MediaType::Audio,
codec_id: self.profile.codec, codec_id: self.profile.codec,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: MediaHeader::encode_fec_ratio( fec_ratio: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
self.profile.fec_ratio,
),
seq: self.seq, seq: self.seq,
timestamp: self.timestamp_ms, timestamp: self.timestamp_ms,
fec_block: self.block_id, fec_block: u16::from(self.block_id) | (u16::from(sym_idx) << 8),
fec_symbol: sym_idx,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(repair_data), payload: Bytes::from(repair_data),
quality_report: None, quality_report: None,
@@ -508,7 +503,7 @@ pub struct CallDecoder {
last_good_dred: DredState, last_good_dred: DredState,
/// Sequence number of the packet that produced `last_good_dred`. `None` /// Sequence number of the packet that produced `last_good_dred`. `None`
/// if no packet has yielded DRED state yet (cold start or legacy sender). /// if no packet has yielded DRED state yet (cold start or legacy sender).
last_good_dred_seq: Option<u16>, last_good_dred_seq: Option<u32>,
/// Phase 4 telemetry counter: gaps recovered via DRED reconstruction. /// Phase 4 telemetry counter: gaps recovered via DRED reconstruction.
pub dred_reconstructions: u64, pub dred_reconstructions: u64,
/// Phase 4 telemetry counter: gaps filled via classical Opus PLC /// Phase 4 telemetry counter: gaps filled via classical Opus PLC
@@ -570,9 +565,9 @@ impl CallDecoder {
// ignored — a graceful mixed-version degradation). // ignored — a graceful mixed-version degradation).
if !packet.header.codec_id.is_opus() { if !packet.header.codec_id.is_opus() {
let _ = self.fec_dec.add_symbol( let _ = self.fec_dec.add_symbol(
packet.header.fec_block, (packet.header.fec_block & 0xFF) as u8,
packet.header.fec_symbol, (packet.header.fec_block >> 8) as u8,
packet.header.is_repair, packet.header.is_repair(),
&packet.payload, &packet.payload,
); );
} }
@@ -582,7 +577,7 @@ impl CallDecoder {
// swap with the cached `last_good_dred` so later gap reconstruction // swap with the cached `last_good_dred` so later gap reconstruction
// has fresh neural redundancy to draw from. Parsing happens before // has fresh neural redundancy to draw from. Parsing happens before
// the jitter push because the jitter buffer consumes the packet. // the jitter push because the jitter buffer consumes the packet.
if packet.header.codec_id.is_opus() && !packet.header.is_repair { if packet.header.codec_id.is_opus() && !packet.header.is_repair() {
match self match self
.dred_decoder .dred_decoder
.parse_into(&mut self.dred_parse_scratch, &packet.payload) .parse_into(&mut self.dred_parse_scratch, &packet.payload)
@@ -611,7 +606,7 @@ impl CallDecoder {
// Source packets (Opus or Codec2) go to the jitter buffer for decode. // Source packets (Opus or Codec2) go to the jitter buffer for decode.
// Repair packets never reach the jitter buffer; for Codec2 they're // Repair packets never reach the jitter buffer; for Codec2 they're
// used by the FEC decoder above, for Opus they're dropped here. // used by the FEC decoder above, for Opus they're dropped here.
if !packet.header.is_repair { if !packet.header.is_repair() {
self.jitter.push(packet); self.jitter.push(packet);
} }
} }
@@ -711,12 +706,12 @@ impl CallDecoder {
if let Some(last_seq) = self.last_good_dred_seq { if let Some(last_seq) = self.last_good_dred_seq {
// How many frames ahead of the missing seq is the // How many frames ahead of the missing seq is the
// last-good packet? Use wrapping arithmetic for the // last-good packet? Use wrapping arithmetic for the
// u16 seq space. // u32 seq space.
let seq_delta = last_seq.wrapping_sub(seq); let seq_delta = last_seq.wrapping_sub(seq);
// Reject stale or backward state. u16 wraparound // Reject stale or backward state. u32 wraparound
// would make a "seq went backward" delta very large; // would make a "seq went backward" delta very large;
// cap at a sane forward-looking window. // cap at a sane forward-looking window.
const MAX_SEQ_DELTA: u16 = 128; const MAX_SEQ_DELTA: u32 = 128;
if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA { if seq_delta > 0 && seq_delta <= MAX_SEQ_DELTA {
let frame_samples = let frame_samples =
(48_000 * self.profile.frame_duration_ms as i32) / 1000; (48_000 * self.profile.frame_duration_ms as i32) / 1000;
@@ -785,7 +780,7 @@ impl CallDecoder {
/// Phase 3b introspection: sequence number of the most recently parsed /// Phase 3b introspection: sequence number of the most recently parsed
/// valid DRED state, or `None` if no Opus packet has yielded DRED data /// valid DRED state, or `None` if no Opus packet has yielded DRED data
/// yet. Used by tests to debug reconstruction eligibility. /// yet. Used by tests to debug reconstruction eligibility.
pub fn last_good_dred_seq(&self) -> Option<u16> { pub fn last_good_dred_seq(&self) -> Option<u32> {
self.last_good_dred_seq self.last_good_dred_seq
} }
@@ -852,7 +847,7 @@ mod tests {
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty()); assert!(!packets.is_empty());
assert_eq!(packets[0].header.seq, 0); assert_eq!(packets[0].header.seq, 0);
assert!(!packets[0].header.is_repair); assert!(!packets[0].header.is_repair());
} }
/// Phase 2: Opus packets have zero FEC header fields — no block, no /// Phase 2: Opus packets have zero FEC header fields — no block, no
@@ -875,10 +870,9 @@ mod tests {
assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet"); assert_eq!(packets.len(), 1, "Opus must emit exactly 1 source packet");
let hdr = &packets[0].header; let hdr = &packets[0].header;
assert!(hdr.codec_id.is_opus()); assert!(hdr.codec_id.is_opus());
assert!(!hdr.is_repair); assert!(!hdr.is_repair());
assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0"); assert_eq!(hdr.fec_block, 0, "Opus fec_block must be 0");
assert_eq!(hdr.fec_symbol, 0, "Opus fec_symbol must be 0"); assert_eq!(hdr.fec_ratio, 0, "Opus fec_ratio must be 0");
assert_eq!(hdr.fec_ratio_encoded, 0, "Opus fec_ratio_encoded must be 0");
} }
/// Phase 2: Opus never emits repair packets, regardless of how many /// Phase 2: Opus never emits repair packets, regardless of how many
@@ -902,7 +896,7 @@ mod tests {
for _ in 0..20 { for _ in 0..20 {
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
total_packets += packets.len(); total_packets += packets.len();
repair_count += packets.iter().filter(|p| p.header.is_repair).count(); repair_count += packets.iter().filter(|p| p.header.is_repair()).count();
} }
assert_eq!(repair_count, 0, "Opus must emit zero repair packets"); assert_eq!(repair_count, 0, "Opus must emit zero repair packets");
assert_eq!( assert_eq!(
@@ -934,7 +928,7 @@ mod tests {
for _ in 0..16 { for _ in 0..16 {
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
for p in &packets { for p in &packets {
if p.header.is_repair { if p.header.is_repair() {
repair_count += 1; repair_count += 1;
} }
} }
@@ -953,17 +947,15 @@ mod tests {
let pkt = MediaPacket { let pkt = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq: 0, seq: 0,
timestamp: 0, timestamp: 0,
fec_block: 0, fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(vec![0u8; 60]), payload: Bytes::from(vec![0u8; 60]),
quality_report: None, quality_report: None,
@@ -1025,17 +1017,15 @@ mod tests {
encoded.truncate(n); encoded.truncate(n);
let pkt = MediaPacket { let pkt = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq: i, seq: i as u32,
timestamp: (i as u32) * 20, timestamp: (i as u32) * 20,
fec_block: 0, fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(encoded), payload: Bytes::from(encoded),
quality_report: None, quality_report: None,
@@ -1105,9 +1095,7 @@ mod tests {
let dred_delta = dec.dred_reconstructions - baseline_dred; let dred_delta = dec.dred_reconstructions - baseline_dred;
let plc_delta = dec.classical_plc_invocations - baseline_plc; let plc_delta = dec.classical_plc_invocations - baseline_plc;
eprintln!( eprintln!("[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}");
"[phase3b probe] post-drain: dred_delta={dred_delta} plc_delta={plc_delta}"
);
assert!( assert!(
dred_delta >= 1, dred_delta >= 1,
"expected ≥1 DRED reconstruction on single-packet loss, \ "expected ≥1 DRED reconstruction on single-packet loss, \
@@ -1168,7 +1156,7 @@ mod tests {
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
for pkt in packets { for pkt in packets {
// Drop every 5th source packet to simulate loss. // Drop every 5th source packet to simulate loss.
if !pkt.header.is_repair && i % 5 == 3 { if !pkt.header.is_repair() && i % 5 == 3 {
continue; continue;
} }
dec.ingest(pkt); dec.ingest(pkt);
@@ -1322,20 +1310,18 @@ mod tests {
// ---- JitterStats telemetry tests ---- // ---- JitterStats telemetry tests ----
fn make_test_packet(seq: u16) -> MediaPacket { fn make_test_packet(seq: u32) -> MediaPacket {
MediaPacket { MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq, seq,
timestamp: seq as u32 * 20, timestamp: seq * 20,
fec_block: 0, fec_block: 0,
fec_symbol: seq as u8,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(vec![0u8; 60]), payload: Bytes::from(vec![0u8; 60]),
quality_report: None, quality_report: None,
@@ -1347,7 +1333,7 @@ mod tests {
let config = CallConfig::default(); let config = CallConfig::default();
let mut dec = CallDecoder::new(&config); let mut dec = CallDecoder::new(&config);
for i in 0..5u16 { for i in 0..5u32 {
dec.ingest(make_test_packet(i)); dec.ingest(make_test_packet(i));
} }
@@ -1377,7 +1363,7 @@ mod tests {
let mut dec = CallDecoder::new(&config); let mut dec = CallDecoder::new(&config);
// Generate some stats: ingest packets and trigger underruns on empty buffer // Generate some stats: ingest packets and trigger underruns on empty buffer
for i in 0..3u16 { for i in 0..3u32 {
dec.ingest(make_test_packet(i)); dec.ingest(make_test_packet(i));
} }
// Also call decode on empty decoder to get underruns // Also call decode on empty decoder to get underruns
@@ -1456,10 +1442,7 @@ mod tests {
cn_packets >= 1, cn_packets >= 1,
"should have at least one CN packet, got {cn_packets}" "should have at least one CN packet, got {cn_packets}"
); );
assert!( assert!(enc.frames_suppressed > 0, "frames_suppressed should be > 0");
enc.frames_suppressed > 0,
"frames_suppressed should be > 0"
);
} }
// ---- DredTuner integration tests ---- // ---- DredTuner integration tests ----
@@ -1506,7 +1489,10 @@ mod tests {
// Verify the encoder still works after tuning. // Verify the encoder still works after tuning.
let pcm = voice_frame_20ms(0); let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty(), "encoder must still produce packets after DRED tuning"); assert!(
!packets.is_empty(),
"encoder must still produce packets after DRED tuning"
);
} }
/// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling. /// DredTuner jitter spike triggers pre-emptive DRED boost to ceiling.
@@ -1524,11 +1510,15 @@ mod tests {
// Jitter spikes to 40ms (8x baseline of ~5ms). // Jitter spikes to 40ms (8x baseline of ~5ms).
let tuning = tuner.update(0.0, 50, 40); let tuning = tuner.update(0.0, 50, 40);
assert!(tuner.spike_boost_active(), "jitter spike should activate boost"); assert!(
tuner.spike_boost_active(),
"jitter spike should activate boost"
);
assert!(tuning.is_some()); assert!(tuning.is_some());
// Ceiling for Opus24k is 50 frames = 500 ms. // Ceiling for Opus24k is 50 frames = 500 ms.
assert_eq!( assert_eq!(
tuning.unwrap().dred_frames, 50, tuning.unwrap().dred_frames,
50,
"spike should push to ceiling" "spike should push to ceiling"
); );
} }
@@ -1604,12 +1594,18 @@ mod tests {
let pcm = voice_frame_20ms(0); let pcm = voice_frame_20ms(0);
let packets = enc.encode_frame(&pcm).unwrap(); let packets = enc.encode_frame(&pcm).unwrap();
assert!(!packets.is_empty()); assert!(!packets.is_empty());
assert!(packets[0].header.has_quality_report, "first packet should have quality report"); assert!(
packets[0].header.has_quality(),
"first packet should have quality report"
);
assert!(packets[0].quality_report.is_some()); assert!(packets[0].quality_report.is_some());
// Next frame should NOT have quality_report (it was consumed) // Next frame should NOT have quality_report (it was consumed)
let packets2 = enc.encode_frame(&voice_frame_20ms(960)).unwrap(); let packets2 = enc.encode_frame(&voice_frame_20ms(960)).unwrap();
assert!(!packets2[0].header.has_quality_report, "second packet should not have quality report"); assert!(
!packets2[0].header.has_quality(),
"second packet should not have quality report"
);
assert!(packets2[0].quality_report.is_none()); assert!(packets2[0].quality_report.is_none());
} }
} }

View File

@@ -108,7 +108,11 @@ fn parse_args() -> CliArgs {
"--signal" => signal = true, "--signal" => signal = true,
"--call" => { "--call" => {
i += 1; i += 1;
call_target = Some(args.get(i).expect("--call requires a fingerprint").to_string()); call_target = Some(
args.get(i)
.expect("--call requires a fingerprint")
.to_string(),
);
} }
"--send-tone" => { "--send-tone" => {
i += 1; i += 1;
@@ -185,8 +189,12 @@ fn parse_args() -> CliArgs {
); );
} }
"--sweep" => sweep = true, "--sweep" => sweep = true,
"--netcheck" => { netcheck = true; } "--netcheck" => {
"--version-check" => { version_check = true; } netcheck = true;
}
"--version-check" => {
version_check = true;
}
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-client [options] [relay-addr]"); eprintln!("Usage: wzp-client [options] [relay-addr]");
eprintln!(); eprintln!();
@@ -197,13 +205,19 @@ fn parse_args() -> CliArgs {
eprintln!(" --record <file.raw> Record received audio to raw PCM file"); eprintln!(" --record <file.raw> Record received audio to raw PCM file");
eprintln!(" --echo-test <secs> Run automated echo quality test"); eprintln!(" --echo-test <secs> Run automated echo quality test");
eprintln!(" --drift-test <secs> Run automated clock-drift measurement"); eprintln!(" --drift-test <secs> Run automated clock-drift measurement");
eprintln!(" --sweep Run jitter buffer parameter sweep (local, no network)"); eprintln!(
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"); " --sweep Run jitter buffer parameter sweep (local, no network)"
);
eprintln!(
" --seed <hex> Identity seed (64 hex chars, featherChat compatible)"
);
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)"); eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
eprintln!(" --room <name> Room name (hashed for privacy before sending)"); eprintln!(" --room <name> Room name (hashed for privacy before sending)");
eprintln!(" --token <token> featherChat bearer token for relay auth"); eprintln!(" --token <token> featherChat bearer token for relay auth");
eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)"); eprintln!(" --metrics-file <path> Write JSONL telemetry to file (1 line/sec)");
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)"); eprintln!(
" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)"
);
eprintln!(); eprintln!();
eprintln!("Default relay: 127.0.0.1:4433"); eprintln!("Default relay: 127.0.0.1:4433");
std::process::exit(0); std::process::exit(0);
@@ -265,9 +279,7 @@ async fn main() -> anyhow::Result<()> {
if cli.netcheck { if cli.netcheck {
let config = wzp_client::netcheck::NetcheckConfig { let config = wzp_client::netcheck::NetcheckConfig {
stun_config: wzp_client::stun::StunConfig::default(), stun_config: wzp_client::stun::StunConfig::default(),
relays: vec![ relays: vec![("relay".into(), cli.relay_addr)],
("relay".into(), cli.relay_addr),
],
timeout: std::time::Duration::from_secs(5), timeout: std::time::Duration::from_secs(5),
test_portmap: true, test_portmap: true,
test_ipv6: true, test_ipv6: true,
@@ -283,7 +295,8 @@ async fn main() -> anyhow::Result<()> {
let client_config = wzp_transport::client_config(); let client_config = wzp_transport::client_config();
let bind_addr: SocketAddr = "0.0.0.0:0".parse()?; let bind_addr: SocketAddr = "0.0.0.0:0".parse()?;
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?; let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let conn = wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?; let conn =
wzp_transport::connect(&endpoint, cli.relay_addr, "version", client_config).await?;
match conn.accept_uni().await { match conn.accept_uni().await {
Ok(mut recv) => { Ok(mut recv) => {
let data = recv.read_to_end(256).await.unwrap_or_default(); let data = recv.read_to_end(256).await.unwrap_or_default();
@@ -291,7 +304,10 @@ async fn main() -> anyhow::Result<()> {
println!("{} {}", cli.relay_addr, version.trim()); println!("{} {}", cli.relay_addr, version.trim());
} }
Err(e) => { Err(e) => {
eprintln!("relay {} does not support version query: {e}", cli.relay_addr); eprintln!(
"relay {} does not support version query: {e}",
cli.relay_addr
);
} }
} }
endpoint.close(0u32.into(), b"done"); endpoint.close(0u32.into(), b"done");
@@ -331,8 +347,7 @@ async fn main() -> anyhow::Result<()> {
"0.0.0.0:0".parse()? "0.0.0.0:0".parse()?
}; };
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?; let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let connection = let connection = wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?;
info!("Connected to relay"); info!("Connected to relay");
@@ -343,10 +358,12 @@ async fn main() -> anyhow::Result<()> {
{ {
let shutdown_transport = transport.clone(); let shutdown_transport = transport.clone();
tokio::spawn(async move { tokio::spawn(async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) let mut sigterm =
.expect("failed to register SIGTERM handler"); tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) .expect("failed to register SIGTERM handler");
.expect("failed to register SIGINT handler"); let mut sigint =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.expect("failed to register SIGINT handler");
tokio::select! { tokio::select! {
_ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); } _ = sigterm.recv() => { info!("SIGTERM received, closing connection..."); }
_ = sigint.recv() => { info!("SIGINT received, closing connection..."); } _ = sigint.recv() => { info!("SIGINT received, closing connection..."); }
@@ -354,7 +371,9 @@ async fn main() -> anyhow::Result<()> {
// Close the QUIC connection immediately (APPLICATION_CLOSE frame). // Close the QUIC connection immediately (APPLICATION_CLOSE frame).
// Don't call process::exit — let the main task detect the closed // Don't call process::exit — let the main task detect the closed
// connection and perform clean shutdown (e.g., save recordings). // connection and perform clean shutdown (e.g., save recordings).
shutdown_transport.connection().close(0u32.into(), b"shutdown"); shutdown_transport
.connection()
.close(0u32.into(), b"shutdown");
}); });
} }
@@ -372,7 +391,8 @@ async fn main() -> anyhow::Result<()> {
&*transport, &*transport,
&seed.0, &seed.0,
None, // alias — desktop client doesn't set one yet None, // alias — desktop client doesn't set one yet
).await?; )
.await?;
info!("crypto handshake complete"); info!("crypto handshake complete");
if cli.live { if cli.live {
@@ -382,7 +402,9 @@ async fn main() -> anyhow::Result<()> {
} }
#[cfg(not(feature = "audio"))] #[cfg(not(feature = "audio"))]
{ {
anyhow::bail!("--live requires the 'audio' feature (build with: cargo build --features audio)"); anyhow::bail!(
"--live requires the 'audio' feature (build with: cargo build --features audio)"
);
} }
} else if let Some(secs) = cli.echo_test_secs { } else if let Some(secs) = cli.echo_test_secs {
let result = wzp_client::echo_test::run_echo_test(&*transport, secs, 5.0).await?; let result = wzp_client::echo_test::run_echo_test(&*transport, secs, 5.0).await?;
@@ -399,7 +421,13 @@ async fn main() -> anyhow::Result<()> {
transport.close().await?; transport.close().await?;
Ok(()) Ok(())
} else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() { } else if cli.send_tone_secs.is_some() || cli.send_file.is_some() || cli.record_file.is_some() {
run_file_mode(transport, cli.send_tone_secs, cli.send_file, cli.record_file).await run_file_mode(
transport,
cli.send_tone_secs,
cli.send_file,
cli.record_file,
)
.await
} else { } else {
run_silence(transport).await run_silence(transport).await
} }
@@ -420,7 +448,7 @@ async fn run_silence(transport: Arc<wzp_transport::QuinnTransport>) -> anyhow::R
for i in 0..250u32 { for i in 0..250u32 {
let packets = encoder.encode_frame(&pcm)?; let packets = encoder.encode_frame(&pcm)?;
for pkt in &packets { for pkt in &packets {
if pkt.header.is_repair { if pkt.header.is_repair() {
total_repair += 1; total_repair += 1;
} else { } else {
total_source += 1; total_source += 1;
@@ -470,21 +498,28 @@ async fn run_file_mode(
// Read raw PCM file (48kHz mono s16le) // Read raw PCM file (48kHz mono s16le)
let bytes = match std::fs::read(path) { let bytes = match std::fs::read(path) {
Ok(b) => b, Ok(b) => b,
Err(e) => { error!("read {path}: {e}"); return; } Err(e) => {
error!("read {path}: {e}");
return;
}
}; };
let samples: Vec<i16> = bytes.chunks_exact(2) let samples: Vec<i16> = bytes
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]])) .map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect(); .collect();
let duration = samples.len() as f64 / 48_000.0; let duration = samples.len() as f64 / 48_000.0;
info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file"); info!(file = %path, duration = format!("{:.1}s", duration), "sending audio file");
samples.chunks(FRAME_SAMPLES) samples
.chunks(FRAME_SAMPLES)
.filter(|c| c.len() == FRAME_SAMPLES) .filter(|c| c.len() == FRAME_SAMPLES)
.map(|c| c.to_vec()) .map(|c| c.to_vec())
.collect() .collect()
} else if let Some(secs) = send_tone_secs { } else if let Some(secs) = send_tone_secs {
let total = (secs as u64) * 50; let total = (secs as u64) * 50;
info!(seconds = secs, frames = total, "sending 440Hz tone"); info!(seconds = secs, frames = total, "sending 440Hz tone");
(0..total).map(|i| generate_sine_frame(440.0, 48_000, i)).collect() (0..total)
.map(|i| generate_sine_frame(440.0, 48_000, i))
.collect()
} else { } else {
// No sending, just wait // No sending, just wait
tokio::signal::ctrl_c().await.ok(); tokio::signal::ctrl_c().await.ok();
@@ -508,7 +543,7 @@ async fn run_file_mode(
} }
}; };
for pkt in &packets { for pkt in &packets {
if pkt.header.is_repair { if pkt.header.is_repair() {
total_repair += 1; total_repair += 1;
} else { } else {
total_source += 1; total_source += 1;
@@ -556,7 +591,7 @@ async fn run_file_mode(
result = recv_transport.recv_media() => { result = recv_transport.recv_media() => {
match result { match result {
Ok(Some(pkt)) => { Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair();
decoder.ingest(pkt); decoder.ingest(pkt);
if !is_repair { if !is_repair {
if let Some(n) = decoder.decode_next(&mut pcm_buf) { if let Some(n) = decoder.decode_next(&mut pcm_buf) {
@@ -756,22 +791,30 @@ async fn run_signal_mode(
// Auth if token provided // Auth if token provided
if let Some(ref tok) = token { if let Some(ref tok) = token {
transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await?; transport
.send_signal(&SignalMessage::AuthToken { token: tok.clone() })
.await?;
} }
// Register presence (signature not verified in Phase 1) // Register presence (signature not verified in Phase 1)
transport.send_signal(&SignalMessage::RegisterPresence { transport
identity_pub, .send_signal(&SignalMessage::RegisterPresence {
signature: vec![], // Phase 1: not verified identity_pub,
alias: None, signature: vec![], // Phase 1: not verified
}).await?; alias: None,
})
.await?;
// Wait for ack // Wait for ack
match transport.recv_signal().await? { match transport.recv_signal().await? {
Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => { Some(SignalMessage::RegisterPresenceAck { success: true, .. }) => {
info!(fingerprint = %fp, "registered on relay — waiting for calls"); info!(fingerprint = %fp, "registered on relay — waiting for calls");
} }
Some(SignalMessage::RegisterPresenceAck { success: false, error, .. }) => { Some(SignalMessage::RegisterPresenceAck {
success: false,
error,
..
}) => {
anyhow::bail!("registration failed: {}", error.unwrap_or_default()); anyhow::bail!("registration failed: {}", error.unwrap_or_default());
} }
other => { other => {
@@ -782,25 +825,32 @@ async fn run_signal_mode(
// If --call specified, place the call // If --call specified, place the call
if let Some(ref target) = call_target { if let Some(ref target) = call_target {
info!(target = %target, "placing direct call..."); info!(target = %target, "placing direct call...");
let call_id = format!("{:016x}", std::time::SystemTime::now() let call_id = format!(
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()); "{:016x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
transport.send_signal(&SignalMessage::DirectCallOffer { transport
caller_fingerprint: fp.clone(), .send_signal(&SignalMessage::DirectCallOffer {
caller_alias: None, caller_fingerprint: fp.clone(),
target_fingerprint: target.clone(), caller_alias: None,
call_id: call_id.clone(), target_fingerprint: target.clone(),
identity_pub, call_id: call_id.clone(),
ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange identity_pub,
signature: vec![], ephemeral_pub: [0u8; 32], // Phase 1: not used for key exchange
supported_profiles: vec![wzp_proto::QualityProfile::GOOD], signature: vec![],
// CLI client doesn't attempt hole-punching; always supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
// relay-path. // CLI client doesn't attempt hole-punching; always
caller_reflexive_addr: None, // relay-path.
caller_local_addrs: Vec::new(), caller_reflexive_addr: None,
caller_mapped_addr: None, caller_local_addrs: Vec::new(),
caller_build_version: None, caller_mapped_addr: None,
}).await?; caller_build_version: None,
})
.await?;
} }
// Signal recv loop — handle incoming signals // Signal recv loop — handle incoming signals
@@ -814,7 +864,12 @@ async fn run_signal_mode(
SignalMessage::CallRinging { call_id } => { SignalMessage::CallRinging { call_id } => {
info!(call_id = %call_id, "ringing..."); info!(call_id = %call_id, "ringing...");
} }
SignalMessage::DirectCallOffer { caller_fingerprint, caller_alias, call_id, .. } => { SignalMessage::DirectCallOffer {
caller_fingerprint,
caller_alias,
call_id,
..
} => {
info!( info!(
from = %caller_fingerprint, from = %caller_fingerprint,
alias = ?caller_alias, alias = ?caller_alias,
@@ -822,25 +877,38 @@ async fn run_signal_mode(
"incoming call — auto-accepting (generic)" "incoming call — auto-accepting (generic)"
); );
// Auto-accept for CLI testing // Auto-accept for CLI testing
let _ = signal_transport.send_signal(&SignalMessage::DirectCallAnswer { let _ = signal_transport
call_id, .send_signal(&SignalMessage::DirectCallAnswer {
accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric, call_id,
identity_pub: Some(identity_pub), accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric,
ephemeral_pub: None, identity_pub: Some(identity_pub),
signature: None, ephemeral_pub: None,
chosen_profile: Some(wzp_proto::QualityProfile::GOOD), signature: None,
// CLI auto-accept uses generic (privacy) mode, chosen_profile: Some(wzp_proto::QualityProfile::GOOD),
// so callee addr stays hidden from the caller. // CLI auto-accept uses generic (privacy) mode,
callee_reflexive_addr: None, // so callee addr stays hidden from the caller.
callee_local_addrs: Vec::new(), callee_reflexive_addr: None,
callee_mapped_addr: None, callee_local_addrs: Vec::new(),
callee_build_version: None, callee_mapped_addr: None,
}).await; callee_build_version: None,
})
.await;
} }
SignalMessage::DirectCallAnswer { call_id, accept_mode, .. } => { SignalMessage::DirectCallAnswer {
call_id,
accept_mode,
..
} => {
info!(call_id = %call_id, mode = ?accept_mode, "call answered"); info!(call_id = %call_id, mode = ?accept_mode, "call answered");
} }
SignalMessage::CallSetup { call_id, room, relay_addr: setup_relay, peer_direct_addr: _, peer_local_addrs: _, peer_mapped_addr: _ } => { SignalMessage::CallSetup {
call_id,
room,
relay_addr: setup_relay,
peer_direct_addr: _,
peer_local_addrs: _,
peer_mapped_addr: _,
} => {
info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room"); info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room");
// Connect to the media room // Connect to the media room
@@ -848,18 +916,28 @@ async fn run_signal_mode(
let media_cfg = wzp_transport::client_config(); let media_cfg = wzp_transport::client_config();
match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await { match wzp_transport::connect(&endpoint, media_relay, &room, media_cfg).await {
Ok(media_conn) => { Ok(media_conn) => {
let media_transport = Arc::new(wzp_transport::QuinnTransport::new(media_conn)); let media_transport =
Arc::new(wzp_transport::QuinnTransport::new(media_conn));
// Crypto handshake // Crypto handshake
match wzp_client::handshake::perform_handshake(&*media_transport, &my_seed, None).await { match wzp_client::handshake::perform_handshake(
&*media_transport,
&my_seed,
None,
)
.await
{
Ok(_session) => { Ok(_session) => {
info!("media connected — sending tone (press Ctrl+C to hang up)"); info!(
"media connected — sending tone (press Ctrl+C to hang up)"
);
// Simple tone sender for testing // Simple tone sender for testing
let mt = media_transport.clone(); let mt = media_transport.clone();
let send_task = tokio::spawn(async move { let send_task = tokio::spawn(async move {
let config = wzp_client::call::CallConfig::default(); let config = wzp_client::call::CallConfig::default();
let mut encoder = wzp_client::call::CallEncoder::new(&config); let mut encoder =
wzp_client::call::CallEncoder::new(&config);
let duration = tokio::time::Duration::from_millis(20); let duration = tokio::time::Duration::from_millis(20);
loop { loop {
let pcm: Vec<i16> = (0..FRAME_SAMPLES) let pcm: Vec<i16> = (0..FRAME_SAMPLES)
@@ -867,7 +945,9 @@ async fn run_signal_mode(
.collect(); .collect();
if let Ok(pkts) = encoder.encode_frame(&pcm) { if let Ok(pkts) = encoder.encode_frame(&pcm) {
for pkt in &pkts { for pkt in &pkts {
if mt.send_media(pkt).await.is_err() { return; } if mt.send_media(pkt).await.is_err() {
return;
}
} }
} }
tokio::time::sleep(duration).await; tokio::time::sleep(duration).await;

View File

@@ -144,7 +144,7 @@ pub async fn run_drift_test(
} }
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await { match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => { Ok(Ok(Some(pkt))) => {
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair();
decoder.ingest(pkt); decoder.ingest(pkt);
if !is_repair { if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) { if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
@@ -180,7 +180,7 @@ pub async fn run_drift_test(
while Instant::now() < drain_deadline { while Instant::now() < drain_deadline {
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await { match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => { Ok(Ok(Some(pkt))) => {
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair();
decoder.ingest(pkt); decoder.ingest(pkt);
if !is_repair { if !is_repair {
if let Some(_n) = decoder.decode_next(&mut pcm_buf) { if let Some(_n) = decoder.decode_next(&mut pcm_buf) {
@@ -234,7 +234,10 @@ pub fn print_drift_report(result: &DriftResult) {
println!(); println!();
println!("Expected duration: {} ms", result.expected_duration_ms); println!("Expected duration: {} ms", result.expected_duration_ms);
println!("Actual duration: {} ms", result.actual_duration_ms); println!("Actual duration: {} ms", result.actual_duration_ms);
println!("Drift: {} ms ({:+.4}%)", result.drift_ms, result.drift_pct); println!(
"Drift: {} ms ({:+.4}%)",
result.drift_ms, result.drift_pct
);
println!(); println!();
// Interpretation // Interpretation
@@ -246,9 +249,15 @@ pub fn print_drift_report(result: &DriftResult) {
} else if abs_drift < 20 { } else if abs_drift < 20 {
println!("Result: GOOD -- drift is within acceptable bounds (<20 ms)."); println!("Result: GOOD -- drift is within acceptable bounds (<20 ms).");
} else if abs_drift < 100 { } else if abs_drift < 100 {
println!("Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.", abs_drift); println!(
"Result: FAIR -- noticeable drift ({} ms). Clock sync may be needed.",
abs_drift
);
} else { } else {
println!("Result: POOR -- significant drift ({} ms). Investigate clock sources.", abs_drift); println!(
"Result: POOR -- significant drift ({} ms). Investigate clock sources.",
abs_drift
);
} }
println!(); println!();
} }

View File

@@ -43,7 +43,7 @@ pub enum WinningPath {
pub struct CandidateDiag { pub struct CandidateDiag {
pub index: usize, pub index: usize,
pub addr: String, pub addr: String,
pub result: String, // "ok", "skipped:ipv6", "error:..." pub result: String, // "ok", "skipped:ipv6", "error:..."
pub elapsed_ms: Option<u32>, pub elapsed_ms: Option<u32>,
} }
@@ -299,10 +299,16 @@ pub async fn race(
socket2::Domain::IPV4, socket2::Domain::IPV4,
socket2::Type::DGRAM, socket2::Type::DGRAM,
Some(socket2::Protocol::UDP), Some(socket2::Protocol::UDP),
).map_err(|e| format!("socket: {e}"))?; )
sock.set_reuse_address(true).map_err(|e| format!("reuseaddr: {e}"))?; .map_err(|e| format!("socket: {e}"))?;
sock.set_reuse_address(true)
.map_err(|e| format!("reuseaddr: {e}"))?;
// macOS/BSD/Linux also need SO_REUSEPORT // macOS/BSD/Linux also need SO_REUSEPORT
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] #[cfg(any(
target_os = "macos",
target_os = "linux",
target_os = "android"
))]
{ {
// socket2 exposes set_reuse_port on unix // socket2 exposes set_reuse_port on unix
unsafe { unsafe {
@@ -316,12 +322,14 @@ pub async fn race(
); );
} }
} }
sock.set_nonblocking(true).map_err(|e| format!("nonblock: {e}"))?; sock.set_nonblocking(true)
.map_err(|e| format!("nonblock: {e}"))?;
let bind_addr: SocketAddr = SocketAddr::new( let bind_addr: SocketAddr = SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
local_addr.port(), local_addr.port(),
); );
sock.bind(&bind_addr.into()).map_err(|e| format!("bind :{}: {e}", local_addr.port()))?; sock.bind(&bind_addr.into())
.map_err(|e| format!("bind :{}: {e}", local_addr.port()))?;
let std_sock: StdUdpSocket = sock.into(); let std_sock: StdUdpSocket = sock.into();
for addr in &tickle_addrs { for addr in &tickle_addrs {
let _ = std_sock.send_to(&[0u8; 1], addr); let _ = std_sock.send_to(&[0u8; 1], addr);
@@ -469,13 +477,8 @@ pub async fn race(
candidate_idx = idx, candidate_idx = idx,
"dual_path: dialing candidate" "dual_path: dialing candidate"
); );
let result = wzp_transport::connect( let result =
&ep, wzp_transport::connect(&ep, candidate, &sni, client_cfg).await;
candidate,
&sni,
client_cfg,
)
.await;
let elapsed = start.elapsed().as_millis() as u32; let elapsed = start.elapsed().as_millis() as u32;
let diag_result = match &result { let diag_result = match &result {
Ok(_) => "ok".to_string(), Ok(_) => "ok".to_string(),
@@ -604,9 +607,7 @@ pub async fn race(
"dual_path: racing direct vs relay" "dual_path: racing direct vs relay"
); );
let mut direct_task = tokio::spawn( let mut direct_task = tokio::spawn(tokio::time::timeout(Duration::from_secs(4), direct_fut));
tokio::time::timeout(Duration::from_secs(4), direct_fut),
);
let mut relay_task = tokio::spawn(async move { let mut relay_task = tokio::spawn(async move {
// Keep the 500ms head start so direct has a chance // Keep the 500ms head start so direct has a chance
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
@@ -695,8 +696,12 @@ pub async fn race(
// If it doesn't, we still proceed with just the winner. // If it doesn't, we still proceed with just the winner.
if direct_result.is_none() { if direct_result.is_none() {
match tokio::time::timeout(Duration::from_secs(1), direct_task).await { match tokio::time::timeout(Duration::from_secs(1), direct_task).await {
Ok(Ok(Ok(Ok(t)))) => { direct_result = Some(Ok(t)); } Ok(Ok(Ok(Ok(t)))) => {
Ok(Ok(Ok(Err(e)))) => { direct_result = Some(Err(anyhow::anyhow!("{e}"))); } direct_result = Some(Ok(t));
}
Ok(Ok(Ok(Err(e)))) => {
direct_result = Some(Err(anyhow::anyhow!("{e}")));
}
_ => { _ => {
direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period"))); direct_result = Some(Err(anyhow::anyhow!("direct: no result in grace period")));
// Fill timeout diags for candidates that never reported. // Fill timeout diags for candidates that never reported.
@@ -719,9 +724,15 @@ pub async fn race(
} }
if relay_result.is_none() { if relay_result.is_none() {
match tokio::time::timeout(Duration::from_secs(1), relay_task).await { match tokio::time::timeout(Duration::from_secs(1), relay_task).await {
Ok(Ok(Ok(Ok(t)))) => { relay_result = Some(Ok(t)); } Ok(Ok(Ok(Ok(t)))) => {
Ok(Ok(Ok(Err(e)))) => { relay_result = Some(Err(anyhow::anyhow!("{e}"))); } relay_result = Some(Ok(t));
_ => { relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period"))); } }
Ok(Ok(Ok(Err(e)))) => {
relay_result = Some(Err(anyhow::anyhow!("{e}")));
}
_ => {
relay_result = Some(Err(anyhow::anyhow!("relay: no result in grace period")));
}
} }
} }
@@ -736,22 +747,21 @@ pub async fn race(
); );
if !direct_ok && !relay_ok { if !direct_ok && !relay_ok {
return Err(anyhow::anyhow!("both paths failed: no media transport available")); return Err(anyhow::anyhow!(
"both paths failed: no media transport available"
));
} }
let _ = (direct_ep, relay_ep, ipv6_endpoint); let _ = (direct_ep, relay_ep, ipv6_endpoint);
let candidate_diags = diags_collector.lock() let candidate_diags = diags_collector
.lock()
.map(|d| d.clone()) .map(|d| d.clone())
.unwrap_or_default(); .unwrap_or_default();
Ok(RaceResult { Ok(RaceResult {
direct_transport: direct_result direct_transport: direct_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
.and_then(|r| r.ok()) relay_transport: relay_result.and_then(|r| r.ok()).map(|t| Arc::new(t)),
.map(|t| Arc::new(t)),
relay_transport: relay_result
.and_then(|r| r.ok())
.map(|t| Arc::new(t)),
local_winner, local_winner,
candidate_diags, candidate_diags,
}) })
@@ -777,7 +787,10 @@ mod tests {
assert_eq!(order.len(), 4); assert_eq!(order.len(), 4);
assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap()); assert_eq!(order[0], "192.168.1.10:4433".parse::<SocketAddr>().unwrap());
assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap()); assert_eq!(order[1], "10.0.0.5:4433".parse::<SocketAddr>().unwrap());
assert_eq!(order[2], "198.51.100.42:12345".parse::<SocketAddr>().unwrap()); assert_eq!(
order[2],
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
);
assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap()); assert_eq!(order[3], "203.0.113.5:4433".parse::<SocketAddr>().unwrap());
} }
@@ -805,7 +818,10 @@ mod tests {
let order = candidates.dial_order(); let order = candidates.dial_order();
assert_eq!(order.len(), 1); assert_eq!(order.len(), 1);
assert_eq!(order[0], "198.51.100.42:12345".parse::<SocketAddr>().unwrap()); assert_eq!(
order[0],
"198.51.100.42:12345".parse::<SocketAddr>().unwrap()
);
} }
#[test] #[test]

View File

@@ -166,7 +166,7 @@ pub async fn run_echo_test(
match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await { match tokio::time::timeout(Duration::from_millis(2), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => { Ok(Ok(Some(pkt))) => {
total_packets_received += 1; total_packets_received += 1;
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair();
decoder.ingest(pkt); decoder.ingest(pkt);
if !is_repair { if !is_repair {
if let Some(n) = decoder.decode_next(&mut pcm_buf) { if let Some(n) = decoder.decode_next(&mut pcm_buf) {
@@ -184,7 +184,8 @@ pub async fn run_echo_test(
let time_offset = start.elapsed().as_secs_f64(); let time_offset = start.elapsed().as_secs_f64();
// Compare sent vs received for this window // Compare sent vs received for this window
let sent_start = (window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize; let sent_start =
(window_idx as u64 * frames_per_window * FRAME_SAMPLES as u64) as usize;
let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES); let sent_end = sent_start + (window_frames_sent as usize * FRAME_SAMPLES);
let sent_window = if sent_end <= sent_pcm.len() { let sent_window = if sent_end <= sent_pcm.len() {
&sent_pcm[sent_start..sent_end] &sent_pcm[sent_start..sent_end]
@@ -192,7 +193,9 @@ pub async fn run_echo_test(
&sent_pcm[sent_start..] &sent_pcm[sent_start..]
}; };
let recv_start = recv_pcm.len().saturating_sub(window_frames_received as usize * FRAME_SAMPLES); let recv_start = recv_pcm
.len()
.saturating_sub(window_frames_received as usize * FRAME_SAMPLES);
let recv_window = &recv_pcm[recv_start..]; let recv_window = &recv_pcm[recv_start..];
let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0); let peak = recv_window.iter().map(|s| s.abs()).max().unwrap_or(0);
@@ -256,7 +259,7 @@ pub async fn run_echo_test(
match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await { match tokio::time::timeout(Duration::from_millis(100), transport.recv_media()).await {
Ok(Ok(Some(pkt))) => { Ok(Ok(Some(pkt))) => {
total_packets_received += 1; total_packets_received += 1;
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair();
decoder.ingest(pkt); decoder.ingest(pkt);
if !is_repair { if !is_repair {
decoder.decode_next(&mut pcm_buf); decoder.decode_next(&mut pcm_buf);
@@ -310,8 +313,14 @@ pub fn print_report(result: &EchoTestResult) {
let status = if w.is_silent { " !" } else { " " }; let status = if w.is_silent { " !" } else { " " };
println!( println!(
"{:>3}{}{:>5.1}s │ {:>4}{:>4}{:>5.1}% │ {:>5.1}{:.3}", "{:>3}{}{:>5.1}s │ {:>4}{:>4}{:>5.1}% │ {:>5.1}{:.3}",
w.index, status, w.time_offset_secs, w.frames_sent, w.frames_received, w.index,
w.loss_pct, w.snr_db, w.correlation status,
w.time_offset_secs,
w.frames_sent,
w.frames_received,
w.loss_pct,
w.snr_db,
w.correlation
); );
} }
println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘"); println!("└───────┴─────────┴──────┴──────┴─────────┴───────┴───────┘");
@@ -321,18 +330,28 @@ pub fn print_report(result: &EchoTestResult) {
let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec(); let first_half: Vec<_> = result.windows[..result.windows.len() / 2].to_vec();
let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec(); let second_half: Vec<_> = result.windows[result.windows.len() / 2..].to_vec();
let avg_loss_first = first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32; let avg_loss_first =
let avg_loss_second = second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32; first_half.iter().map(|w| w.loss_pct).sum::<f32>() / first_half.len() as f32;
let avg_corr_first = first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32; let avg_loss_second =
let avg_corr_second = second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32; second_half.iter().map(|w| w.loss_pct).sum::<f32>() / second_half.len() as f32;
let avg_corr_first =
first_half.iter().map(|w| w.correlation).sum::<f32>() / first_half.len() as f32;
let avg_corr_second =
second_half.iter().map(|w| w.correlation).sum::<f32>() / second_half.len() as f32;
println!(); println!();
if avg_loss_second > avg_loss_first + 5.0 { if avg_loss_second > avg_loss_first + 5.0 {
println!("WARNING: Quality degradation detected!"); println!("WARNING: Quality degradation detected!");
println!(" Loss increased from {:.1}% to {:.1}% over time", avg_loss_first, avg_loss_second); println!(
" Loss increased from {:.1}% to {:.1}% over time",
avg_loss_first, avg_loss_second
);
} }
if avg_corr_second < avg_corr_first - 0.1 { if avg_corr_second < avg_corr_first - 0.1 {
println!("WARNING: Signal correlation dropped from {:.3} to {:.3}", avg_corr_first, avg_corr_second); println!(
"WARNING: Signal correlation dropped from {:.3} to {:.3}",
avg_corr_first, avg_corr_second
);
} }
if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 { if avg_loss_second <= avg_loss_first + 5.0 && avg_corr_second >= avg_corr_first - 0.1 {
println!("Quality is STABLE over the test duration."); println!("Quality is STABLE over the test duration.");

View File

@@ -118,14 +118,14 @@ pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType {
SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer, SignalMessage::DirectCallAnswer { .. } => CallSignalType::Answer,
SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only SignalMessage::CallSetup { .. } => CallSignalType::Offer, // relay-only
SignalMessage::CallRinging { .. } => CallSignalType::Ringing, SignalMessage::CallRinging { .. } => CallSignalType::Ringing,
SignalMessage::RegisterPresence { .. } SignalMessage::RegisterPresence { .. } | SignalMessage::RegisterPresenceAck { .. } => {
| SignalMessage::RegisterPresenceAck { .. } => CallSignalType::Offer, // relay-only CallSignalType::Offer
} // relay-only
// NAT reflection is a client↔relay control exchange that // NAT reflection is a client↔relay control exchange that
// never crosses the featherChat bridge — if it ever reaches // never crosses the featherChat bridge — if it ever reaches
// this mapper something is wrong, but we still have to give // this mapper something is wrong, but we still have to give
// an answer. "Offer" is the generic catch-all. // an answer. "Offer" is the generic catch-all.
SignalMessage::Reflect SignalMessage::Reflect | SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
| SignalMessage::ReflectResponse { .. } => CallSignalType::Offer, // control-plane
// Phase 4 cross-relay forwarding envelope — strictly a // Phase 4 cross-relay forwarding envelope — strictly a
// relay-to-relay message, never rides the featherChat // relay-to-relay message, never rides the featherChat
// bridge. Catch-all mapping for completeness. // bridge. Catch-all mapping for completeness.
@@ -181,17 +181,35 @@ mod tests {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: None, call_id: None,
}; };
assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup)); assert!(matches!(
signal_to_call_type(&hangup),
CallSignalType::Hangup
));
assert!(matches!(signal_to_call_type(&SignalMessage::Hold), CallSignalType::Hold)); assert!(matches!(
assert!(matches!(signal_to_call_type(&SignalMessage::Unhold), CallSignalType::Unhold)); signal_to_call_type(&SignalMessage::Hold),
assert!(matches!(signal_to_call_type(&SignalMessage::Mute), CallSignalType::Mute)); CallSignalType::Hold
assert!(matches!(signal_to_call_type(&SignalMessage::Unmute), CallSignalType::Unmute)); ));
assert!(matches!(
signal_to_call_type(&SignalMessage::Unhold),
CallSignalType::Unhold
));
assert!(matches!(
signal_to_call_type(&SignalMessage::Mute),
CallSignalType::Mute
));
assert!(matches!(
signal_to_call_type(&SignalMessage::Unmute),
CallSignalType::Unmute
));
let transfer = SignalMessage::Transfer { let transfer = SignalMessage::Transfer {
target_fingerprint: "abc".to_string(), target_fingerprint: "abc".to_string(),
relay_addr: None, relay_addr: None,
}; };
assert!(matches!(signal_to_call_type(&transfer), CallSignalType::Transfer)); assert!(matches!(
signal_to_call_type(&transfer),
CallSignalType::Transfer
));
} }
} }

View File

@@ -55,21 +55,21 @@ pub async fn perform_handshake(
.await? .await?
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallAnswer"))?; .ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallAnswer"))?;
let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) = match answer let (callee_identity_pub, callee_ephemeral_pub, callee_signature, _chosen_profile) =
{ match answer {
SignalMessage::CallAnswer { SignalMessage::CallAnswer {
identity_pub, identity_pub,
ephemeral_pub, ephemeral_pub,
signature, signature,
chosen_profile, chosen_profile,
} => (identity_pub, ephemeral_pub, signature, chosen_profile), } => (identity_pub, ephemeral_pub, signature, chosen_profile),
other => { other => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"expected CallAnswer, got {:?}", "expected CallAnswer, got {:?}",
std::mem::discriminant(&other) std::mem::discriminant(&other)
)) ));
} }
}; };
// 6. Verify callee's signature over (ephemeral_pub || "call-answer") // 6. Verify callee's signature over (ephemeral_pub || "call-answer")
let mut verify_data = Vec::with_capacity(32 + 11); let mut verify_data = Vec::with_capacity(32 + 11);

View File

@@ -106,14 +106,9 @@ impl IceAgent {
); );
let reflexive = stun_result.ok().and_then(|r| r.ok()); let reflexive = stun_result.ok().and_then(|r| r.ok());
let mapped = portmap_result let mapped = portmap_result.ok().flatten().map(|m| m.external_addr);
.ok() let local =
.flatten() reflect::local_host_candidates(self.config.local_v4_port, self.config.local_v6_port);
.map(|m| m.external_addr);
let local = reflect::local_host_candidates(
self.config.local_v4_port,
self.config.local_v6_port,
);
tracing::info!( tracing::info!(
generation, generation,
@@ -151,10 +146,7 @@ impl IceAgent {
/// Process a peer's candidate update. Returns `Some(PeerCandidates)` /// Process a peer's candidate update. Returns `Some(PeerCandidates)`
/// if the update is newer than the last-seen generation, `None` /// if the update is newer than the last-seen generation, `None`
/// if it's stale. /// if it's stale.
pub fn apply_peer_update( pub fn apply_peer_update(&self, update: &SignalMessage) -> Option<PeerCandidates> {
&self,
update: &SignalMessage,
) -> Option<PeerCandidates> {
let (reflexive_addr, local_addrs, mapped_addr, generation) = match update { let (reflexive_addr, local_addrs, mapped_addr, generation) = match update {
SignalMessage::CandidateUpdate { SignalMessage::CandidateUpdate {
reflexive_addr, reflexive_addr,
@@ -177,16 +169,9 @@ impl IceAgent {
return None; return None;
} }
let reflexive = reflexive_addr let reflexive = reflexive_addr.as_deref().and_then(|s| s.parse().ok());
.as_deref() let local: Vec<SocketAddr> = local_addrs.iter().filter_map(|s| s.parse().ok()).collect();
.and_then(|s| s.parse().ok()); let mapped = mapped_addr.as_deref().and_then(|s| s.parse().ok());
let local: Vec<SocketAddr> = local_addrs
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let mapped = mapped_addr
.as_deref()
.and_then(|s| s.parse().ok());
tracing::info!( tracing::info!(
generation, generation,
@@ -304,10 +289,7 @@ mod tests {
let update = SignalMessage::CandidateUpdate { let update = SignalMessage::CandidateUpdate {
call_id: "test-call".into(), call_id: "test-call".into(),
reflexive_addr: Some("203.0.113.5:4433".into()), reflexive_addr: Some("203.0.113.5:4433".into()),
local_addrs: vec![ local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()],
"192.168.1.10:4433".into(),
"10.0.0.5:4433".into(),
],
mapped_addr: Some("198.51.100.42:12345".into()), mapped_addr: Some("198.51.100.42:12345".into()),
generation: 1, generation: 1,
}; };
@@ -382,16 +364,19 @@ mod tests {
async fn gather_returns_candidates_even_with_no_stun() { async fn gather_returns_candidates_even_with_no_stun() {
// With default config (port 0 = no portmap, STUN will timeout // With default config (port 0 = no portmap, STUN will timeout
// quickly on loopback), gather should still return host candidates. // quickly on loopback), gather should still return host candidates.
let agent = IceAgent::new("test".into(), IceAgentConfig { let agent = IceAgent::new(
stun_config: stun::StunConfig { "test".into(),
servers: vec![], // no servers = quick failure IceAgentConfig {
timeout: Duration::from_millis(100), stun_config: stun::StunConfig {
servers: vec![], // no servers = quick failure
timeout: Duration::from_millis(100),
},
enable_portmap: false,
gather_timeout: Duration::from_millis(200),
local_v4_port: 12345,
local_v6_port: None,
}, },
enable_portmap: false, );
gather_timeout: Duration::from_millis(200),
local_v4_port: 12345,
local_v6_port: None,
});
let candidates = agent.gather().await; let candidates = agent.gather().await;
assert_eq!(candidates.generation, 0); assert_eq!(candidates.generation, 0);
@@ -405,16 +390,19 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn re_gather_produces_signal_message() { async fn re_gather_produces_signal_message() {
let agent = IceAgent::new("call-42".into(), IceAgentConfig { let agent = IceAgent::new(
stun_config: stun::StunConfig { "call-42".into(),
servers: vec![], IceAgentConfig {
timeout: Duration::from_millis(50), stun_config: stun::StunConfig {
servers: vec![],
timeout: Duration::from_millis(50),
},
enable_portmap: false,
gather_timeout: Duration::from_millis(100),
local_v4_port: 4433,
local_v6_port: None,
}, },
enable_portmap: false, );
gather_timeout: Duration::from_millis(100),
local_v4_port: 4433,
local_v6_port: None,
});
let (candidates, signal) = agent.re_gather().await; let (candidates, signal) = agent.re_gather().await;
assert_eq!(candidates.generation, 0); assert_eq!(candidates.generation, 0);

View File

@@ -27,15 +27,15 @@ pub mod audio_wasapi;
#[cfg(all(feature = "linux-aec", target_os = "linux"))] #[cfg(all(feature = "linux-aec", target_os = "linux"))]
pub mod audio_linux_aec; pub mod audio_linux_aec;
pub mod bench; pub mod bench;
pub mod birthday;
pub mod call; pub mod call;
pub mod drift_test; pub mod drift_test;
pub mod dual_path;
pub mod echo_test; pub mod echo_test;
pub mod featherchat; pub mod featherchat;
pub mod handshake; pub mod handshake;
pub mod dual_path;
pub mod metrics;
pub mod birthday;
pub mod ice_agent; pub mod ice_agent;
pub mod metrics;
pub mod netcheck; pub mod netcheck;
pub mod portmap; pub mod portmap;
pub mod reflect; pub mod reflect;

View File

@@ -178,7 +178,10 @@ mod tests {
// Immediate second write should be skipped (60s interval). // Immediate second write should be skipped (60s interval).
let second = writer.maybe_write(&snap).unwrap(); let second = writer.maybe_write(&snap).unwrap();
assert!(!second, "second write should be skipped — interval not elapsed"); assert!(
!second,
"second write should be skipped — interval not elapsed"
);
// Clean up. // Clean up.
let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&path);

View File

@@ -112,22 +112,30 @@ pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout); let ipv6_fut = test_ipv6(config.test_ipv6, config.timeout);
let port_alloc_fut = stun::detect_port_allocation(&config.stun_config); let port_alloc_fut = stun::detect_port_allocation(&config.stun_config);
let (stun_probes, relay_latencies, portmap_result, gateway_result, ipv6_reachable, port_alloc_result) = let (
tokio::join!(stun_fut, relay_fut, portmap_fut, gateway_result_fut(gateway_fut), ipv6_fut, port_alloc_fut); stun_probes,
relay_latencies,
portmap_result,
gateway_result,
ipv6_reachable,
port_alloc_result,
) = tokio::join!(
stun_fut,
relay_fut,
portmap_fut,
gateway_result_fut(gateway_fut),
ipv6_fut,
port_alloc_fut
);
// Classify NAT from STUN probes. // Classify NAT from STUN probes.
let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes); let (nat_type, consensus_addr) = reflect::classify_nat(&stun_probes);
// Determine STUN latency (first successful probe). // Determine STUN latency (first successful probe).
let stun_latency_ms = stun_probes let stun_latency_ms = stun_probes.iter().filter_map(|p| p.latency_ms).min();
.iter()
.filter_map(|p| p.latency_ms)
.min();
// IPv4 reachable if any STUN probe succeeded. // IPv4 reachable if any STUN probe succeeded.
let ipv4_reachable = stun_probes let ipv4_reachable = stun_probes.iter().any(|p| p.observed_addr.is_some());
.iter()
.any(|p| p.observed_addr.is_some());
// Preferred relay = lowest RTT. // Preferred relay = lowest RTT.
let preferred_relay = relay_latencies let preferred_relay = relay_latencies
@@ -176,10 +184,7 @@ pub async fn run_netcheck(config: &NetcheckConfig) -> NetcheckReport {
} }
/// Probe relay latencies via reflect. /// Probe relay latencies via reflect.
async fn probe_relays( async fn probe_relays(relays: &[(String, SocketAddr)], timeout: Duration) -> Vec<RelayLatency> {
relays: &[(String, SocketAddr)],
timeout: Duration,
) -> Vec<RelayLatency> {
if relays.is_empty() { if relays.is_empty() {
return Vec::new(); return Vec::new();
} }
@@ -223,10 +228,7 @@ async fn probe_relays(
} }
/// Attempt port mapping and return the mapping if successful. /// Attempt port mapping and return the mapping if successful.
async fn probe_portmap( async fn probe_portmap(enabled: bool, local_port: u16) -> Option<portmap::PortMapping> {
enabled: bool,
local_port: u16,
) -> Option<portmap::PortMapping> {
if !enabled || local_port == 0 { if !enabled || local_port == 0 {
return None; return None;
} }
@@ -251,7 +253,9 @@ async fn test_ipv6(enabled: bool, timeout: Duration) -> bool {
let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?; let sock = tokio::net::UdpSocket::bind("[::]:0").await.ok()?;
// Try Google's IPv6 STUN — if DNS resolves to an AAAA record // Try Google's IPv6 STUN — if DNS resolves to an AAAA record
// and we can send a packet, IPv6 is working. // and we can send a packet, IPv6 is working.
let addr = stun::resolve_stun_server("stun.l.google.com:19302").await.ok()?; let addr = stun::resolve_stun_server("stun.l.google.com:19302")
.await
.ok()?;
if addr.is_ipv6() { if addr.is_ipv6() {
sock.send_to(&[0u8; 1], addr).await.ok()?; sock.send_to(&[0u8; 1], addr).await.ok()?;
Some(true) Some(true)
@@ -276,10 +280,7 @@ pub fn format_report(report: &NetcheckReport) -> String {
let mut out = String::new(); let mut out = String::new();
out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n")); out.push_str(&format!("=== WarzonePhone Netcheck ===\n\n"));
out.push_str(&format!( out.push_str(&format!("NAT Type: {:?}\n", report.nat_type));
"NAT Type: {:?}\n",
report.nat_type
));
out.push_str(&format!( out.push_str(&format!(
"Reflexive Addr: {}\n", "Reflexive Addr: {}\n",
report.reflexive_addr.as_deref().unwrap_or("(unknown)") report.reflexive_addr.as_deref().unwrap_or("(unknown)")
@@ -298,15 +299,17 @@ pub fn format_report(report: &NetcheckReport) -> String {
)); ));
if let Some(ref alloc) = report.port_allocation { if let Some(ref alloc) = report.port_allocation {
out.push_str(&format!( out.push_str(&format!("Port Alloc: {alloc}\n"));
"Port Alloc: {alloc}\n"
));
} }
out.push_str(&format!("\n--- Port Mapping ---\n")); out.push_str(&format!("\n--- Port Mapping ---\n"));
out.push_str(&format!( out.push_str(&format!(
"NAT-PMP: {} PCP: {} UPnP: {}\n", "NAT-PMP: {} PCP: {} UPnP: {}\n",
if report.nat_pmp_available { "yes" } else { "no" }, if report.nat_pmp_available {
"yes"
} else {
"no"
},
if report.pcp_available { "yes" } else { "no" }, if report.pcp_available { "yes" } else { "no" },
if report.upnp_available { "yes" } else { "no" }, if report.upnp_available { "yes" } else { "no" },
)); ));
@@ -321,8 +324,13 @@ pub fn format_report(report: &NetcheckReport) -> String {
" {}{} ({}ms){}\n", " {}{} ({}ms){}\n",
p.relay_name, p.relay_name,
p.observed_addr.as_deref().unwrap_or("failed"), p.observed_addr.as_deref().unwrap_or("failed"),
p.latency_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()), p.latency_ms
p.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(), .map(|ms| ms.to_string())
.unwrap_or_else(|| "-".into()),
p.error
.as_ref()
.map(|e| format!(" [{e}]"))
.unwrap_or_default(),
)); ));
} }
} }
@@ -334,8 +342,13 @@ pub fn format_report(report: &NetcheckReport) -> String {
" {} ({}) → {}ms{}\n", " {} ({}) → {}ms{}\n",
r.name, r.name,
r.addr, r.addr,
r.rtt_ms.map(|ms| ms.to_string()).unwrap_or_else(|| "-".into()), r.rtt_ms
r.error.as_ref().map(|e| format!(" [{e}]")).unwrap_or_default(), .map(|ms| ms.to_string())
.unwrap_or_else(|| "-".into()),
r.error
.as_ref()
.map(|e| format!(" [{e}]"))
.unwrap_or_default(),
)); ));
} }
if let Some(ref pref) = report.preferred_relay { if let Some(ref pref) = report.preferred_relay {

View File

@@ -279,8 +279,15 @@ async fn try_natpmp(
// Step 2: request port mapping // Step 2: request port mapping
// Request same port as internal (preferred); 7200s lifetime (standard) // Request same port as internal (preferred); 7200s lifetime (standard)
let (mapped_port, lifetime) = let (mapped_port, lifetime) = natpmp_map_udp(
natpmp_map_udp(&socket, gw_addr, internal_port, internal_port, 7200, timeout).await?; &socket,
gw_addr,
internal_port,
internal_port,
7200,
timeout,
)
.await?;
let lifetime_dur = Duration::from_secs(lifetime as u64); let lifetime_dur = Duration::from_secs(lifetime as u64);
Ok(PortMapping { Ok(PortMapping {
@@ -533,17 +540,12 @@ async fn fetch_url_simple(url: &str, timeout: Duration) -> Result<String, PortMa
.map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))? .map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))?
}; };
let mut stream = tokio::time::timeout( let mut stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr))
timeout, .await
tokio::net::TcpStream::connect(addr), .map_err(|_| PortMapError::Timeout)?
) .map_err(|e| PortMapError::Io(e.to_string()))?;
.await
.map_err(|_| PortMapError::Timeout)?
.map_err(|e| PortMapError::Io(e.to_string()))?;
let request = format!( let request = format!("GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n");
"GET {path} HTTP/1.1\r\nHost: {host_port}\r\nConnection: close\r\n\r\n"
);
stream stream
.write_all(request.as_bytes()) .write_all(request.as_bytes())
.await .await
@@ -593,13 +595,10 @@ async fn soap_post(
.map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))? .map_err(|e| PortMapError::Protocol(format!("parse {host_port}:80: {e}")))?
}; };
let mut stream = tokio::time::timeout( let mut stream = tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr))
timeout, .await
tokio::net::TcpStream::connect(addr), .map_err(|_| PortMapError::Timeout)?
) .map_err(|e| PortMapError::Io(e.to_string()))?;
.await
.map_err(|_| PortMapError::Timeout)?
.map_err(|e| PortMapError::Io(e.to_string()))?;
let soap_body = format!( let soap_body = format!(
"<?xml version=\"1.0\"?>\ "<?xml version=\"1.0\"?>\
@@ -662,9 +661,7 @@ fn extract_control_url(xml: &str, base_url: &str) -> Result<String, PortMapError
return Ok(control_path.to_string()); return Ok(control_path.to_string());
} }
// Build absolute URL from base // Build absolute URL from base
let base = base_url let base = base_url.strip_prefix("http://").unwrap_or(base_url);
.strip_prefix("http://")
.unwrap_or(base_url);
let host_port = base.split('/').next().unwrap_or(base); let host_port = base.split('/').next().unwrap_or(base);
return Ok(format!("http://{host_port}{control_path}")); return Ok(format!("http://{host_port}{control_path}"));
} }
@@ -681,7 +678,8 @@ async fn upnp_get_external_ip(
control_url: &str, control_url: &str,
timeout: Duration, timeout: Duration,
) -> Result<Ipv4Addr, PortMapError> { ) -> Result<Ipv4Addr, PortMapError> {
let body = "<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"/>"; let body =
"<u:GetExternalIPAddress xmlns:u=\"urn:schemas-upnp-org:service:WANIPConnection:1\"/>";
let action = "urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress"; let action = "urn:schemas-upnp-org:service:WANIPConnection:1#GetExternalIPAddress";
let response = soap_post(control_url, action, body, timeout).await?; let response = soap_post(control_url, action, body, timeout).await?;
@@ -933,7 +931,10 @@ mod tests {
assert_eq!(request[0], 0); assert_eq!(request[0], 0);
assert_eq!(request[1], 1); assert_eq!(request[1], 1);
assert_eq!(u16::from_be_bytes([request[4], request[5]]), 12345); assert_eq!(u16::from_be_bytes([request[4], request[5]]), 12345);
assert_eq!(u32::from_be_bytes([request[8], request[9], request[10], request[11]]), 7200); assert_eq!(
u32::from_be_bytes([request[8], request[9], request[10], request[11]]),
7200
);
} }
#[test] #[test]

View File

@@ -31,7 +31,7 @@ use std::time::{Duration, Instant};
use serde::Serialize; use serde::Serialize;
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{client_config, create_endpoint, QuinnTransport}; use wzp_transport::{QuinnTransport, client_config, create_endpoint};
/// Result of one probe against one relay. Always returned so the /// Result of one probe against one relay. Always returned so the
/// UI can render per-relay status even when some fail. /// UI can render per-relay status even when some fail.
@@ -110,10 +110,9 @@ pub async fn probe_reflect_addr(
let start = Instant::now(); let start = Instant::now();
let probe = async { let probe = async {
// Open the signal connection. // Open the signal connection.
let conn = let conn = wzp_transport::connect(&endpoint, relay, "_signal", client_config())
wzp_transport::connect(&endpoint, relay, "_signal", client_config()) .await
.await .map_err(|e| format!("connect: {e}"))?;
.map_err(|e| format!("connect: {e}"))?;
let transport = QuinnTransport::new(conn); let transport = QuinnTransport::new(conn);
// The relay signal handler waits for a RegisterPresence // The relay signal handler waits for a RegisterPresence
@@ -540,10 +539,7 @@ mod tests {
#[test] #[test]
fn classify_two_identical_is_cone() { fn classify_two_identical_is_cone() {
let probes = vec![ let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:4433"))];
mk(Some("192.0.2.1:4433")),
mk(Some("192.0.2.1:4433")),
];
let (nt, addr) = classify_nat(&probes); let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::Cone); assert_eq!(nt, NatType::Cone);
assert_eq!(addr.as_deref(), Some("192.0.2.1:4433")); assert_eq!(addr.as_deref(), Some("192.0.2.1:4433"));
@@ -551,10 +547,7 @@ mod tests {
#[test] #[test]
fn classify_same_ip_different_ports_is_symmetric() { fn classify_same_ip_different_ports_is_symmetric() {
let probes = vec![ let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("192.0.2.1:51234"))];
mk(Some("192.0.2.1:4433")),
mk(Some("192.0.2.1:51234")),
];
let (nt, addr) = classify_nat(&probes); let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::SymmetricPort); assert_eq!(nt, NatType::SymmetricPort);
assert!(addr.is_none()); assert!(addr.is_none());
@@ -562,10 +555,7 @@ mod tests {
#[test] #[test]
fn classify_different_ips_is_multiple() { fn classify_different_ips_is_multiple() {
let probes = vec![ let probes = vec![mk(Some("192.0.2.1:4433")), mk(Some("198.51.100.9:4433"))];
mk(Some("192.0.2.1:4433")),
mk(Some("198.51.100.9:4433")),
];
let (nt, addr) = classify_nat(&probes); let (nt, addr) = classify_nat(&probes);
assert_eq!(nt, NatType::Multiple); assert_eq!(nt, NatType::Multiple);
assert!(addr.is_none()); assert!(addr.is_none());
@@ -591,9 +581,9 @@ mod tests {
#[test] #[test]
fn classify_drops_loopback_probes() { fn classify_drops_loopback_probes() {
let probes = vec![ let probes = vec![
mk(Some("127.0.0.1:4433")), // loopback — must be dropped mk(Some("127.0.0.1:4433")), // loopback — must be dropped
mk(Some("203.0.113.5:4433")), // public mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:4433")), // public, same addr mk(Some("203.0.113.5:4433")), // public, same addr
]; ];
let (nt, addr) = classify_nat(&probes); let (nt, addr) = classify_nat(&probes);
// Two public probes with identical addrs → Cone. // Two public probes with identical addrs → Cone.
@@ -608,9 +598,9 @@ mod tests {
// client with a 100.64/10 addr is on the same CGNAT // client with a 100.64/10 addr is on the same CGNAT
// network and can't contribute to public NAT classification. // network and can't contribute to public NAT classification.
let probes = vec![ let probes = vec![
mk(Some("100.64.0.42:4433")), // CGNAT — dropped mk(Some("100.64.0.42:4433")), // CGNAT — dropped
mk(Some("203.0.113.5:4433")), // public mk(Some("203.0.113.5:4433")), // public
mk(Some("203.0.113.5:12345")), // public, different port mk(Some("203.0.113.5:12345")), // public, different port
]; ];
let (nt, _) = classify_nat(&probes); let (nt, _) = classify_nat(&probes);
// Two public probes same IP different port → SymmetricPort. // Two public probes same IP different port → SymmetricPort.

View File

@@ -109,11 +109,9 @@ impl RelayMap {
/// Check if any entry has a stale probe (older than `max_age`). /// Check if any entry has a stale probe (older than `max_age`).
pub fn needs_reprobe(&self, max_age: Duration) -> bool { pub fn needs_reprobe(&self, max_age: Duration) -> bool {
self.entries.iter().any(|e| { self.entries.iter().any(|e| match e.last_probed {
match e.last_probed { None => true,
None => true, Some(t) => t.elapsed() > max_age,
Some(t) => t.elapsed() > max_age,
}
}) })
} }

View File

@@ -223,9 +223,7 @@ pub fn parse_binding_response(
pos = value_end + ((4 - (attr_len % 4)) % 4); pos = value_end + ((4 - (attr_len % 4)) % 4);
} }
xor_mapped xor_mapped.or(mapped).ok_or(StunError::NoMappedAddress)
.or(mapped)
.ok_or(StunError::NoMappedAddress)
} }
/// Parse a MAPPED-ADDRESS attribute value (RFC 5389 §15.1). /// Parse a MAPPED-ADDRESS attribute value (RFC 5389 §15.1).
@@ -279,10 +277,7 @@ fn parse_mapped_address(value: &[u8]) -> Result<SocketAddr, StunError> {
/// - Port: XOR with top 16 bits of magic cookie /// - Port: XOR with top 16 bits of magic cookie
/// - IPv4 address: XOR with magic cookie /// - IPv4 address: XOR with magic cookie
/// - IPv6 address: XOR with magic cookie || transaction ID /// - IPv6 address: XOR with magic cookie || transaction ID
fn parse_xor_mapped_address( fn parse_xor_mapped_address(value: &[u8], txn_id: &[u8; 12]) -> Result<SocketAddr, StunError> {
value: &[u8],
txn_id: &[u8; 12],
) -> Result<SocketAddr, StunError> {
if value.len() < 4 { if value.len() < 4 {
return Err(StunError::Malformed("XOR-MAPPED-ADDRESS too short".into())); return Err(StunError::Malformed("XOR-MAPPED-ADDRESS too short".into()));
} }
@@ -471,9 +466,7 @@ pub async fn discover_reflexive(config: &StunConfig) -> Result<SocketAddr, StunE
/// Unlike `discover_reflexive` (which returns on first success), this /// Unlike `discover_reflexive` (which returns on first success), this
/// waits for ALL servers and returns individual results — needed for /// waits for ALL servers and returns individual results — needed for
/// NAT type classification which requires 2+ observations. /// NAT type classification which requires 2+ observations.
pub async fn probe_stun_servers( pub async fn probe_stun_servers(config: &StunConfig) -> Vec<crate::reflect::NatProbeResult> {
config: &StunConfig,
) -> Vec<crate::reflect::NatProbeResult> {
use std::time::Instant; use std::time::Instant;
let mut set = tokio::task::JoinSet::new(); let mut set = tokio::task::JoinSet::new();
@@ -596,9 +589,7 @@ pub struct PortAllocationResult {
/// - No pattern → `Random` /// - No pattern → `Random`
/// ///
/// Requires at least 3 servers for reliable classification. /// Requires at least 3 servers for reliable classification.
pub async fn detect_port_allocation( pub async fn detect_port_allocation(config: &StunConfig) -> PortAllocationResult {
config: &StunConfig,
) -> PortAllocationResult {
if config.servers.len() < 2 { if config.servers.len() < 2 {
return PortAllocationResult { return PortAllocationResult {
allocation: PortAllocation::Unknown, allocation: PortAllocation::Unknown,
@@ -696,11 +687,15 @@ pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation {
// Allow small jitter: if all deltas are within ±1 of each other, // Allow small jitter: if all deltas are within ±1 of each other,
// consider it sequential with the median delta. // consider it sequential with the median delta.
let all_close = deltas.iter().all(|&d| (d - first_delta).unsigned_abs() <= 1); let all_close = deltas
.iter()
.all(|&d| (d - first_delta).unsigned_abs() <= 1);
if all_close { if all_close {
// Use the most common delta (mode). // Use the most common delta (mode).
let median_delta = first_delta; let median_delta = first_delta;
return PortAllocation::Sequential { delta: median_delta }; return PortAllocation::Sequential {
delta: median_delta,
};
} }
// Check for consistent delta with occasional skip (some NATs // Check for consistent delta with occasional skip (some NATs
@@ -727,12 +722,7 @@ pub fn classify_port_allocation(ports: &[u16]) -> PortAllocation {
/// predicted ports centered around the most likely next value. /// predicted ports centered around the most likely next value.
/// The `offset` parameter accounts for additional flows that may /// The `offset` parameter accounts for additional flows that may
/// open between the probe and the actual connection attempt. /// open between the probe and the actual connection attempt.
pub fn predict_ports( pub fn predict_ports(last_port: u16, delta: i16, offset: u16, spread: u16) -> Vec<u16> {
last_port: u16,
delta: i16,
offset: u16,
spread: u16,
) -> Vec<u16> {
let base = last_port as i32 + (delta as i32 * (offset as i32 + 1)); let base = last_port as i32 + (delta as i32 * (offset as i32 + 1));
let mut ports = Vec::with_capacity((spread * 2 + 1) as usize); let mut ports = Vec::with_capacity((spread * 2 + 1) as usize);
for i in -(spread as i32)..=(spread as i32) { for i in -(spread as i32)..=(spread as i32) {
@@ -1217,7 +1207,11 @@ mod tests {
assert!(StunError::TxnMismatch.to_string().contains("mismatch")); assert!(StunError::TxnMismatch.to_string().contains("mismatch"));
assert!(StunError::NoMappedAddress.to_string().contains("MAPPED")); assert!(StunError::NoMappedAddress.to_string().contains("MAPPED"));
assert!(StunError::Io("test".into()).to_string().contains("test")); assert!(StunError::Io("test".into()).to_string().contains("test"));
assert!(StunError::DnsError("bad".into()).to_string().contains("bad")); assert!(
StunError::DnsError("bad".into())
.to_string()
.contains("bad")
);
assert!(StunError::ErrorResponse(420).to_string().contains("420")); assert!(StunError::ErrorResponse(420).to_string().contains("420"));
assert!(StunError::Malformed("x".into()).to_string().contains("x")); assert!(StunError::Malformed("x".into()).to_string().contains("x"));
} }
@@ -1244,7 +1238,10 @@ mod tests {
#[test] #[test]
fn classify_port_preserving() { fn classify_port_preserving() {
let ports = vec![4433, 4433, 4433, 4433, 4433]; let ports = vec![4433, 4433, 4433, 4433, 4433];
assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving); assert_eq!(
classify_port_allocation(&ports),
PortAllocation::PortPreserving
);
} }
#[test] #[test]
@@ -1290,7 +1287,10 @@ mod tests {
#[test] #[test]
fn classify_two_same_is_preserving() { fn classify_two_same_is_preserving() {
let ports = vec![4433, 4433]; let ports = vec![4433, 4433];
assert_eq!(classify_port_allocation(&ports), PortAllocation::PortPreserving); assert_eq!(
classify_port_allocation(&ports),
PortAllocation::PortPreserving
);
} }
#[test] #[test]
@@ -1359,8 +1359,14 @@ mod tests {
#[test] #[test]
fn port_allocation_display() { fn port_allocation_display() {
assert_eq!(PortAllocation::PortPreserving.to_string(), "port-preserving"); assert_eq!(
assert_eq!(PortAllocation::Sequential { delta: 1 }.to_string(), "sequential(delta=1)"); PortAllocation::PortPreserving.to_string(),
"port-preserving"
);
assert_eq!(
PortAllocation::Sequential { delta: 1 }.to_string(),
"sequential(delta=1)"
);
assert_eq!(PortAllocation::Random.to_string(), "random"); assert_eq!(PortAllocation::Random.to_string(), "random");
assert_eq!(PortAllocation::Unknown.to_string(), "unknown"); assert_eq!(PortAllocation::Unknown.to_string(), "unknown");
} }
@@ -1421,7 +1427,10 @@ mod tests {
let config = StunConfig::default(); let config = StunConfig::default();
let probes = probe_stun_servers(&config).await; let probes = probe_stun_servers(&config).await;
assert!(!probes.is_empty()); assert!(!probes.is_empty());
let successes: Vec<_> = probes.iter().filter(|p| p.observed_addr.is_some()).collect(); let successes: Vec<_> = probes
.iter()
.filter(|p| p.observed_addr.is_some())
.collect();
assert!( assert!(
!successes.is_empty(), !successes.is_empty(),
"at least one STUN server should respond" "at least one STUN server should respond"

View File

@@ -72,8 +72,7 @@ fn sine_frame(freq_hz: f32, frame_offset: u64) -> Vec<i16> {
/// decoder, pushes frames through the pipeline, and collects statistics. /// decoder, pushes frames through the pipeline, and collects statistics.
/// Combinations where `target_depth > max_depth` are skipped. /// Combinations where `target_depth > max_depth` are skipped.
pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> { pub fn run_local_sweep(config: &SweepConfig) -> Vec<SweepResult> {
let frames_per_config = let frames_per_config = (config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
(config.test_duration_secs as u64) * (1000 / FRAME_DURATION_MS as u64);
let mut results = Vec::new(); let mut results = Vec::new();

View File

@@ -19,7 +19,7 @@
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration; use std::time::Duration;
use wzp_client::dual_path::{race, PeerCandidates, WinningPath}; use wzp_client::dual_path::{PeerCandidates, WinningPath, race};
use wzp_client::reflect::Role; use wzp_client::reflect::Role;
use wzp_transport::{create_endpoint, server_config}; use wzp_transport::{create_endpoint, server_config};
@@ -125,8 +125,15 @@ async fn dual_path_direct_wins_on_loopback() {
.await .await
.expect("race must succeed"); .expect("race must succeed");
assert!(result.direct_transport.is_some(), "direct transport should be available"); assert!(
assert_eq!(result.local_winner, WinningPath::Direct, "direct should win on loopback"); result.direct_transport.is_some(),
"direct transport should be available"
);
assert_eq!(
result.local_winner,
WinningPath::Direct,
"direct should win on loopback"
);
// Cancel the acceptor accept task so the test finishes. // Cancel the acceptor accept task so the test finishes.
acceptor_accept_task.abort(); acceptor_accept_task.abort();
@@ -170,7 +177,10 @@ async fn dual_path_relay_wins_when_direct_is_dead() {
.await .await
.expect("race must succeed via relay fallback"); .expect("race must succeed via relay fallback");
assert!(result.relay_transport.is_some(), "relay transport should be available"); assert!(
result.relay_transport.is_some(),
"relay transport should be available"
);
assert_eq!( assert_eq!(
result.local_winner, result.local_winner,
WinningPath::Relay, WinningPath::Relay,

View File

@@ -6,8 +6,8 @@
use std::sync::Arc; use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::sync::mpsc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::sync::mpsc;
use wzp_proto::packet::MediaPacket; use wzp_proto::packet::MediaPacket;
use wzp_proto::traits::{MediaTransport, PathQuality}; use wzp_proto::traits::{MediaTransport, PathQuality};
@@ -83,7 +83,11 @@ async fn full_handshake_both_sides_derive_same_session() {
// Run client and relay handshakes concurrently. // Run client and relay handshakes concurrently.
let (client_result, relay_result) = tokio::join!( let (client_result, relay_result) = tokio::join!(
wzp_client::handshake::perform_handshake(client_transport_clone.as_ref(), &client_seed, None), wzp_client::handshake::perform_handshake(
client_transport_clone.as_ref(),
&client_seed,
None
),
wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed), wzp_relay::handshake::accept_handshake(relay_transport_clone.as_ref(), &relay_seed),
); );

View File

@@ -83,8 +83,12 @@ fn long_session_no_drift() {
println!( println!(
"long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \ "long_session_no_drift: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}", underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen, stats.underruns,
stats.packets_late, stats.packets_lost, stats.overruns,
stats.current_depth,
stats.max_depth_seen,
stats.packets_late,
stats.packets_lost,
); );
// With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames // With 1 decode per tick over 3000 ticks, we expect ~3000 decoded frames
@@ -123,7 +127,7 @@ fn long_session_with_simulated_loss() {
for (j, pkt) in batch.into_iter().enumerate() { for (j, pkt) in batch.into_iter().enumerate() {
// Drop every 20th *source* (non-repair) packet to simulate ~5% loss. // Drop every 20th *source* (non-repair) packet to simulate ~5% loss.
if !pkt.header.is_repair && i % 20 == 0 && j == 0 { if !pkt.header.is_repair() && i % 20 == 0 && j == 0 {
continue; // drop this packet continue; // drop this packet
} }
decoder.ingest(pkt); decoder.ingest(pkt);
@@ -139,8 +143,12 @@ fn long_session_with_simulated_loss() {
println!( println!(
"long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \ "long_session_with_simulated_loss: decoded={frames_decoded}/{TOTAL_FRAMES}, \
underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}", underruns={}, overruns={}, depth={}, max_depth={}, late={}, lost={}",
stats.underruns, stats.overruns, stats.current_depth, stats.max_depth_seen, stats.underruns,
stats.packets_late, stats.packets_lost, stats.overruns,
stats.current_depth,
stats.max_depth_seen,
stats.packets_late,
stats.packets_lost,
); );
// With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded. // With 5% artificial loss + FEC recovery + PLC, we should still get >90% decoded.

View File

@@ -325,7 +325,10 @@ mod tests {
// Feed 960 samples (= delay amount). No samples released yet. // Feed 960 samples (= delay amount). No samples released yet.
aec.feed_farend(&vec![1i16; 960]); aec.feed_farend(&vec![1i16; 960]);
// far_buf should still be all zeros (nothing released). // far_buf should still be all zeros (nothing released).
assert!(aec.far_buf.iter().all(|&s| s == 0.0), "nothing should be released yet"); assert!(
aec.far_buf.iter().all(|&s| s == 0.0),
"nothing should be released yet"
);
// Feed 480 more. 480 should be released to far_buf. // Feed 480 more. 480 should be released to far_buf.
aec.feed_farend(&vec![2i16; 480]); aec.feed_farend(&vec![2i16; 480]);

View File

@@ -24,12 +24,12 @@ impl AutoGainControl {
/// Create a new AGC with sensible VoIP defaults. /// Create a new AGC with sensible VoIP defaults.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
target_rms: 3000.0, // ~-20 dBFS for i16 target_rms: 3000.0, // ~-20 dBFS for i16
current_gain: 1.0, current_gain: 1.0,
min_gain: 0.5, min_gain: 0.5,
max_gain: 32.0, max_gain: 32.0,
attack_alpha: 0.3, // fast attack attack_alpha: 0.3, // fast attack
release_alpha: 0.02, // slow release release_alpha: 0.02, // slow release
enabled: true, enabled: true,
} }
} }
@@ -211,9 +211,6 @@ mod tests {
fn agc_gain_db_at_unity() { fn agc_gain_db_at_unity() {
let agc = AutoGainControl::new(); let agc = AutoGainControl::new();
let db = agc.current_gain_db(); let db = agc.current_gain_db();
assert!( assert!(db.abs() < 0.01, "expected ~0 dB at unity gain, got {db}");
db.abs() < 0.01,
"expected ~0 dB at unity gain, got {db}"
);
} }
} }

View File

@@ -99,7 +99,11 @@ mod tests {
} }
let original_len = pcm.len(); let original_len = pcm.len();
ns.process(&mut pcm); ns.process(&mut pcm);
assert_eq!(pcm.len(), original_len, "output length must match input length"); assert_eq!(
pcm.len(),
original_len,
"output length must match input length"
);
} }
#[test] #[test]

View File

@@ -71,9 +71,8 @@ impl DecoderHandle {
"opus_decoder_create failed: err={error}" "opus_decoder_create failed: err={error}"
))); )));
} }
let inner = NonNull::new(ptr).ok_or_else(|| { let inner = NonNull::new(ptr)
CodecError::DecodeFailed("opus_decoder_create returned null".into()) .ok_or_else(|| CodecError::DecodeFailed("opus_decoder_create returned null".into()))?;
})?;
Ok(Self { inner }) Ok(Self { inner })
} }
@@ -257,11 +256,7 @@ impl DredDecoderHandle {
/// The `dred_end` output is the silence gap at the tail of the DRED /// The `dred_end` output is the silence gap at the tail of the DRED
/// window; we subtract it from the total offset to give callers the /// window; we subtract it from the total offset to give callers the
/// truly usable sample count. /// truly usable sample count.
pub fn parse_into( pub fn parse_into(&mut self, state: &mut DredState, packet: &[u8]) -> Result<i32, CodecError> {
&mut self,
state: &mut DredState,
packet: &[u8],
) -> Result<i32, CodecError> {
if packet.is_empty() { if packet.is_empty() {
state.samples_available = 0; state.samples_available = 0;
return Ok(0); return Ok(0);
@@ -545,7 +540,10 @@ mod tests {
// to our sine wave because we fed a cold decoder only one warmup // to our sine wave because we fed a cold decoder only one warmup
// frame, but it should still produce non-silent speech-like output // frame, but it should still produce non-silent speech-like output
// since the DRED state was parsed from real speech content. // since the DRED state was parsed from real speech content.
let energy: u64 = recon_pcm.iter().map(|&s| (s as i32).unsigned_abs() as u64).sum(); let energy: u64 = recon_pcm
.iter()
.map(|&s| (s as i32).unsigned_abs() as u64)
.sum();
assert!( assert!(
energy > 0, energy > 0,
"reconstructed audio has zero total energy — DRED reconstruction produced silence" "reconstructed audio has zero total energy — DRED reconstruction produced silence"

View File

@@ -53,10 +53,7 @@ pub fn set_dred_verbose_logs(enabled: bool) {
/// The returned encoder accepts 48 kHz mono PCM regardless of the active /// The returned encoder accepts 48 kHz mono PCM regardless of the active
/// codec; resampling is handled internally when Codec2 is selected. /// codec; resampling is handled internally when Codec2 is selected.
pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> { pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
Box::new( Box::new(AdaptiveEncoder::new(profile).expect("failed to create adaptive encoder"))
AdaptiveEncoder::new(profile)
.expect("failed to create adaptive encoder"),
)
} }
/// Create an adaptive decoder starting at the given quality profile. /// Create an adaptive decoder starting at the given quality profile.
@@ -64,10 +61,7 @@ pub fn create_encoder(profile: QualityProfile) -> Box<dyn AudioEncoder> {
/// The returned decoder always produces 48 kHz mono PCM; upsampling from /// The returned decoder always produces 48 kHz mono PCM; upsampling from
/// Codec2's native 8 kHz is handled internally. /// Codec2's native 8 kHz is handled internally.
pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> { pub fn create_decoder(profile: QualityProfile) -> Box<dyn AudioDecoder> {
Box::new( Box::new(AdaptiveDecoder::new(profile).expect("failed to create adaptive decoder"))
AdaptiveDecoder::new(profile)
.expect("failed to create adaptive decoder"),
)
} }
#[cfg(test)] #[cfg(test)]
@@ -210,7 +204,10 @@ mod codec2_tests {
let mut pcm_out_c2 = vec![0i16; 1920]; let mut pcm_out_c2 = vec![0i16; 1920];
let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap(); let samples_c2 = dec.decode(&encoded_c2[..n_c2], &mut pcm_out_c2).unwrap();
assert_eq!(samples_c2, 1920, "should get 1920 samples at 48kHz after upsample"); assert_eq!(
samples_c2, 1920,
"should get 1920 samples at 48kHz after upsample"
);
// Step 3: Switch back to Opus. // Step 3: Switch back to Opus.
enc.set_profile(QualityProfile::GOOD).unwrap(); enc.set_profile(QualityProfile::GOOD).unwrap();

View File

@@ -332,7 +332,11 @@ impl AudioEncoder for OpusEncoder {
); );
return; return;
} }
let mode = if enabled { InbandFec::Mode1 } else { InbandFec::Off }; let mode = if enabled {
InbandFec::Mode1
} else {
InbandFec::Off
};
let _ = self.inner.set_inband_fec(mode); let _ = self.inner.set_inband_fec(mode);
} }

View File

@@ -129,8 +129,7 @@ impl Downsampler48to8 {
// Update history: keep the last (FIR_TAPS - 1) samples from work. // Update history: keep the last (FIR_TAPS - 1) samples from work.
if work.len() >= hist_len { if work.len() >= hist_len {
self.history self.history.copy_from_slice(&work[work.len() - hist_len..]);
.copy_from_slice(&work[work.len() - hist_len..]);
} else { } else {
// Input was shorter than history — shift. // Input was shorter than history — shift.
let shift = hist_len - work.len(); let shift = hist_len - work.len();
@@ -209,8 +208,7 @@ impl Upsampler8to48 {
// Update history. // Update history.
if work.len() >= hist_len { if work.len() >= hist_len {
self.history self.history.copy_from_slice(&work[work.len() - hist_len..]);
.copy_from_slice(&work[work.len() - hist_len..]);
} else { } else {
let shift = hist_len - work.len(); let shift = hist_len - work.len();
self.history.copy_within(shift.., 0); self.history.copy_within(shift.., 0);

View File

@@ -151,7 +151,10 @@ mod tests {
for _ in 0..4 { for _ in 0..4 {
det.is_silent(&silence); det.is_silent(&silence);
} }
assert!(det.is_silent(&silence), "should be suppressing after hangover"); assert!(
det.is_silent(&silence),
"should be suppressing after hangover"
);
// Speech arrives — should immediately stop suppressing. // Speech arrives — should immediately stop suppressing.
assert!(!det.is_silent(&speech)); assert!(!det.is_silent(&speech));
@@ -165,10 +168,16 @@ mod tests {
cn.generate(&mut pcm); cn.generate(&mut pcm);
// At least some samples should be non-zero. // At least some samples should be non-zero.
assert!(pcm.iter().any(|&s| s != 0), "CN output should not be all zeros"); assert!(
pcm.iter().any(|&s| s != 0),
"CN output should not be all zeros"
);
// All samples should be within [-50, 50]. // All samples should be within [-50, 50].
assert!(pcm.iter().all(|&s| s.abs() <= 50), "CN samples out of range"); assert!(
pcm.iter().all(|&s| s.abs() <= 50),
"CN samples out of range"
);
} }
#[test] #[test]
@@ -179,11 +188,17 @@ mod tests {
// Constant value: RMS of [v, v, v, ...] = |v|. // Constant value: RMS of [v, v, v, ...] = |v|.
let pcm = vec![100i16; 100]; let pcm = vec![100i16; 100];
let rms = SilenceDetector::rms(&pcm); let rms = SilenceDetector::rms(&pcm);
assert!((rms - 100.0).abs() < 0.01, "RMS of constant 100 should be 100, got {rms}"); assert!(
(rms - 100.0).abs() < 0.01,
"RMS of constant 100 should be 100, got {rms}"
);
// Known pattern: [3, 4] → sqrt((9+16)/2) = sqrt(12.5) ≈ 3.5355 // Known pattern: [3, 4] → sqrt((9+16)/2) = sqrt(12.5) ≈ 3.5355
let rms2 = SilenceDetector::rms(&[3, 4]); let rms2 = SilenceDetector::rms(&[3, 4]);
assert!((rms2 - 3.5355).abs() < 0.01, "RMS of [3,4] should be ~3.5355, got {rms2}"); assert!(
(rms2 - 3.5355).abs() < 0.01,
"RMS of [3,4] should be ~3.5355, got {rms2}"
);
// Empty buffer → 0. // Empty buffer → 0.
assert_eq!(SilenceDetector::rms(&[]), 0.0); assert_eq!(SilenceDetector::rms(&[]), 0.0);

View File

@@ -156,7 +156,11 @@ mod tests {
fn sequential_accepted() { fn sequential_accepted() {
let mut w = AntiReplayWindow::new(); let mut w = AntiReplayWindow::new();
for i in 0..200 { for i in 0..200 {
assert!(w.check_and_update(i).is_ok(), "seq {} should be accepted", i); assert!(
w.check_and_update(i).is_ok(),
"seq {} should be accepted",
i
);
} }
} }

View File

@@ -9,8 +9,8 @@ use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use hkdf::Hkdf; use hkdf::Hkdf;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use wzp_proto::{CryptoError, CryptoSession, KeyExchange}; use wzp_proto::{CryptoError, CryptoSession, KeyExchange};
use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret};
use crate::session::ChaChaSession; use crate::session::ChaChaSession;
@@ -95,12 +95,11 @@ impl KeyExchange for WarzoneKeyExchange {
&self, &self,
peer_ephemeral_pub: &[u8; 32], peer_ephemeral_pub: &[u8; 32],
) -> Result<Box<dyn CryptoSession>, CryptoError> { ) -> Result<Box<dyn CryptoSession>, CryptoError> {
let secret = self let secret = self.ephemeral_secret.as_ref().ok_or_else(|| {
.ephemeral_secret CryptoError::Internal(
.as_ref() "no ephemeral key generated; call generate_ephemeral first".into(),
.ok_or_else(|| { )
CryptoError::Internal("no ephemeral key generated; call generate_ephemeral first".into()) })?;
})?;
let peer_public = X25519PublicKey::from(*peer_ephemeral_pub); let peer_public = X25519PublicKey::from(*peer_ephemeral_pub);
// Use diffie_hellman with a clone of the StaticSecret // Use diffie_hellman with a clone of the StaticSecret

View File

@@ -79,7 +79,9 @@ impl Seed {
/// ///
/// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed` /// Mirrors: `warzone-protocol::mnemonic::mnemonic_to_seed`
pub fn from_mnemonic(words: &str) -> Result<Self, String> { pub fn from_mnemonic(words: &str) -> Result<Self, String> {
let mnemonic: bip39::Mnemonic = words.parse().map_err(|e| format!("invalid mnemonic: {e}"))?; let mnemonic: bip39::Mnemonic = words
.parse()
.map_err(|e| format!("invalid mnemonic: {e}"))?;
let entropy = mnemonic.to_entropy(); let entropy = mnemonic.to_entropy();
if entropy.len() != 32 { if entropy.len() != 32 {
return Err(format!("expected 32 bytes entropy, got {}", entropy.len())); return Err(format!("expected 32 bytes entropy, got {}", entropy.len()));

View File

@@ -16,8 +16,8 @@ pub mod session;
pub use anti_replay::AntiReplayWindow; pub use anti_replay::AntiReplayWindow;
pub use handshake::WarzoneKeyExchange; pub use handshake::WarzoneKeyExchange;
pub use identity::{hash_room_name, Fingerprint, IdentityKeyPair, PublicIdentity, Seed}; pub use identity::{Fingerprint, IdentityKeyPair, PublicIdentity, Seed, hash_room_name};
pub use nonce::{build_nonce, Direction}; pub use nonce::{Direction, build_nonce};
pub use rekey::RekeyManager; pub use rekey::RekeyManager;
pub use session::ChaChaSession; pub use session::ChaChaSession;

View File

@@ -5,9 +5,9 @@
use chacha20poly1305::aead::Aead; use chacha20poly1305::aead::Aead;
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce}; use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce};
use x25519_dalek::{PublicKey, StaticSecret};
use rand::rngs::OsRng; use rand::rngs::OsRng;
use wzp_proto::{CryptoError, CryptoSession}; use wzp_proto::{CryptoError, CryptoSession};
use x25519_dalek::{PublicKey, StaticSecret};
use crate::nonce::{self, Direction}; use crate::nonce::{self, Direction};
use crate::rekey::RekeyManager; use crate::rekey::RekeyManager;
@@ -135,7 +135,9 @@ impl CryptoSession for ChaChaSession {
.ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?; .ok_or_else(|| CryptoError::RekeyFailed("no pending rekey".into()))?;
let total_packets = self.send_seq as u64 + self.recv_seq as u64; let total_packets = self.send_seq as u64 + self.recv_seq as u64;
let new_key = self.rekey_mgr.perform_rekey(peer_ephemeral_pub, secret, total_packets); let new_key = self
.rekey_mgr
.perform_rekey(peer_ephemeral_pub, secret, total_packets);
self.install_key(new_key); self.install_key(new_key);
// Reset sequence counters after rekey for nonce uniqueness // Reset sequence counters after rekey for nonce uniqueness

View File

@@ -52,7 +52,10 @@ fn wzp_identity_module_matches_featherchat() {
assert_eq!(wzp_pub.signing.as_bytes(), fc_pub.signing.as_bytes()); assert_eq!(wzp_pub.signing.as_bytes(), fc_pub.signing.as_bytes());
assert_eq!(wzp_pub.encryption.as_bytes(), fc_pub.encryption.as_bytes()); assert_eq!(wzp_pub.encryption.as_bytes(), fc_pub.encryption.as_bytes());
assert_eq!(wzp_pub.fingerprint.0, fc_pub.fingerprint.0); assert_eq!(wzp_pub.fingerprint.0, fc_pub.fingerprint.0);
assert_eq!(wzp_pub.fingerprint.to_string(), fc_pub.fingerprint.to_string()); assert_eq!(
wzp_pub.fingerprint.to_string(),
fc_pub.fingerprint.to_string()
);
} }
#[test] #[test]
@@ -148,16 +151,25 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() {
// And deserializes back // And deserializes back
let decoded: warzone_protocol::message::WireMessage = bincode::deserialize(&encoded).unwrap(); let decoded: warzone_protocol::message::WireMessage = bincode::deserialize(&encoded).unwrap();
if let warzone_protocol::message::WireMessage::CallSignal { if let warzone_protocol::message::WireMessage::CallSignal {
id, payload: p, signal_type, .. id,
payload: p,
signal_type,
..
} = decoded } = decoded
{ {
assert_eq!(id, "call-123"); assert_eq!(id, "call-123");
assert!(matches!(signal_type, warzone_protocol::message::CallSignalType::Offer)); assert!(matches!(
signal_type,
warzone_protocol::message::CallSignalType::Offer
));
// Decode the WZP payload back // Decode the WZP payload back
let wzp_payload = wzp_client::featherchat::decode_call_payload(&p).unwrap(); let wzp_payload = wzp_client::featherchat::decode_call_payload(&p).unwrap();
assert_eq!(wzp_payload.relay_addr.unwrap(), "relay.example.com:4433"); assert_eq!(wzp_payload.relay_addr.unwrap(), "relay.example.com:4433");
assert!(matches!(wzp_payload.signal, wzp_proto::SignalMessage::CallOffer { .. })); assert!(matches!(
wzp_payload.signal,
wzp_proto::SignalMessage::CallOffer { .. }
));
} else { } else {
panic!("expected CallSignal"); panic!("expected CallSignal");
} }
@@ -204,7 +216,10 @@ fn wzp_hangup_round_trips_through_fc_callsignal() {
let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None); let payload = wzp_client::featherchat::encode_call_payload(&hangup, None, None);
let signal_type = wzp_client::featherchat::signal_to_call_type(&hangup); let signal_type = wzp_client::featherchat::signal_to_call_type(&hangup);
assert!(matches!(signal_type, wzp_client::featherchat::CallSignalType::Hangup)); assert!(matches!(
signal_type,
wzp_client::featherchat::CallSignalType::Hangup
));
let fc_msg = warzone_protocol::message::WireMessage::CallSignal { let fc_msg = warzone_protocol::message::WireMessage::CallSignal {
id: "call-789".to_string(), id: "call-789".to_string(),
@@ -219,7 +234,10 @@ fn wzp_hangup_round_trips_through_fc_callsignal() {
if let warzone_protocol::message::WireMessage::CallSignal { payload, .. } = decoded { if let warzone_protocol::message::WireMessage::CallSignal { payload, .. } = decoded {
let wzp = wzp_client::featherchat::decode_call_payload(&payload).unwrap(); let wzp = wzp_client::featherchat::decode_call_payload(&payload).unwrap();
assert!(matches!(wzp.signal, wzp_proto::SignalMessage::Hangup { .. })); assert!(matches!(
wzp.signal,
wzp_proto::SignalMessage::Hangup { .. }
));
} }
} }
@@ -252,8 +270,7 @@ fn auth_validate_response_matches_wzp_expectations() {
"eth_address": null "eth_address": null
}); });
let wzp_resp: wzp_relay::auth::ValidateResponse = let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
serde_json::from_value(fc_response).unwrap();
assert!(wzp_resp.valid); assert!(wzp_resp.valid);
assert_eq!( assert_eq!(
wzp_resp.fingerprint.unwrap(), wzp_resp.fingerprint.unwrap(),
@@ -265,8 +282,7 @@ fn auth_validate_response_matches_wzp_expectations() {
#[test] #[test]
fn auth_invalid_response_matches() { fn auth_invalid_response_matches() {
let fc_response = serde_json::json!({ "valid": false }); let fc_response = serde_json::json!({ "valid": false });
let wzp_resp: wzp_relay::auth::ValidateResponse = let wzp_resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(fc_response).unwrap();
serde_json::from_value(fc_response).unwrap();
assert!(!wzp_resp.valid); assert!(!wzp_resp.valid);
assert!(wzp_resp.fingerprint.is_none()); assert!(wzp_resp.fingerprint.is_none());
} }
@@ -280,15 +296,18 @@ fn all_signal_types_map_correctly() {
let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![ let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![
( (
wzp_proto::SignalMessage::CallOffer { wzp_proto::SignalMessage::CallOffer {
identity_pub: [0; 32], ephemeral_pub: [0; 32], identity_pub: [0; 32],
signature: vec![], supported_profiles: vec![], ephemeral_pub: [0; 32],
signature: vec![],
supported_profiles: vec![],
alias: None, alias: None,
}, },
"Offer", "Offer",
), ),
( (
wzp_proto::SignalMessage::CallAnswer { wzp_proto::SignalMessage::CallAnswer {
identity_pub: [0; 32], ephemeral_pub: [0; 32], identity_pub: [0; 32],
ephemeral_pub: [0; 32],
signature: vec![], signature: vec![],
chosen_profile: wzp_proto::QualityProfile::GOOD, chosen_profile: wzp_proto::QualityProfile::GOOD,
}, },
@@ -312,7 +331,10 @@ fn all_signal_types_map_correctly() {
for (signal, expected_name) in cases { for (signal, expected_name) in cases {
let ct = signal_to_call_type(&signal); let ct = signal_to_call_type(&signal);
let name = format!("{ct:?}"); let name = format!("{ct:?}");
assert_eq!(name, expected_name, "signal type mapping for {expected_name}"); assert_eq!(
name, expected_name,
"signal type mapping for {expected_name}"
);
} }
} }
@@ -426,8 +448,7 @@ fn auth_response_with_eth_address() {
"alias": "vitalik", "alias": "vitalik",
"eth_address": "0x1234567890abcdef1234567890abcdef12345678" "eth_address": "0x1234567890abcdef1234567890abcdef12345678"
}); });
let resp: wzp_relay::auth::ValidateResponse = let resp: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_eth).unwrap();
serde_json::from_value(with_eth).unwrap();
assert!(resp.valid); assert!(resp.valid);
assert_eq!( assert_eq!(
resp.fingerprint.unwrap(), resp.fingerprint.unwrap(),
@@ -442,8 +463,7 @@ fn auth_response_with_eth_address() {
"alias": "anon", "alias": "anon",
"eth_address": null "eth_address": null
}); });
let resp2: wzp_relay::auth::ValidateResponse = let resp2: wzp_relay::auth::ValidateResponse = serde_json::from_value(with_null_eth).unwrap();
serde_json::from_value(with_null_eth).unwrap();
assert!(resp2.valid); assert!(resp2.valid);
assert_eq!( assert_eq!(
resp2.fingerprint.unwrap(), resp2.fingerprint.unwrap(),
@@ -454,8 +474,7 @@ fn auth_response_with_eth_address() {
let without_eth = serde_json::json!({ let without_eth = serde_json::json!({
"valid": false "valid": false
}); });
let resp3: wzp_relay::auth::ValidateResponse = let resp3: wzp_relay::auth::ValidateResponse = serde_json::from_value(without_eth).unwrap();
serde_json::from_value(without_eth).unwrap();
assert!(!resp3.valid); assert!(!resp3.valid);
} }
@@ -496,7 +515,11 @@ fn all_fc_call_signal_types_representable() {
(CallSignalType::Busy, "Busy"), (CallSignalType::Busy, "Busy"),
]; ];
assert_eq!(variants.len(), 7, "featherChat defines exactly 7 call signal types"); assert_eq!(
variants.len(),
7,
"featherChat defines exactly 7 call signal types"
);
for (variant, expected_name) in &variants { for (variant, expected_name) in &variants {
let name = format!("{variant:?}"); let name = format!("{variant:?}");
@@ -550,10 +573,7 @@ fn hash_room_name_used_as_sni_is_valid() {
#[test] #[test]
fn wzp_proto_cargo_toml_is_standalone() { fn wzp_proto_cargo_toml_is_standalone() {
// Try both paths (run from workspace root or from crate directory) // Try both paths (run from workspace root or from crate directory)
let candidates = [ let candidates = ["crates/wzp-proto/Cargo.toml", "../wzp-proto/Cargo.toml"];
"crates/wzp-proto/Cargo.toml",
"../wzp-proto/Cargo.toml",
];
let contents = candidates let contents = candidates
.iter() .iter()

View File

@@ -4,8 +4,8 @@ use std::collections::HashMap;
use std::time::Instant; use std::time::Instant;
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder}; use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockDecoder};
use wzp_proto::error::FecError;
use wzp_proto::FecDecoder; use wzp_proto::FecDecoder;
use wzp_proto::error::FecError;
/// Length prefix size (u16 little-endian), must match encoder. /// Length prefix size (u16 little-endian), must match encoder.
const LEN_PREFIX: usize = 2; const LEN_PREFIX: usize = 2;
@@ -140,10 +140,7 @@ impl FecDecoder for RaptorQFecDecoder {
frames.push(Vec::new()); frames.push(Vec::new());
continue; continue;
} }
let payload_len = u16::from_le_bytes([ let payload_len = u16::from_le_bytes([data[offset], data[offset + 1]]) as usize;
data[offset],
data[offset + 1],
]) as usize;
let payload_start = offset + LEN_PREFIX; let payload_start = offset + LEN_PREFIX;
let payload_end = (payload_start + payload_len).min(data.len()); let payload_end = (payload_start + payload_len).min(data.len());
frames.push(data[payload_start..payload_end].to_vec()); frames.push(data[payload_start..payload_end].to_vec());
@@ -198,9 +195,7 @@ mod tests {
// Feed all source symbols (using the length-prefixed padded data). // Feed all source symbols (using the length-prefixed padded data).
for (i, pkt) in source_pkts.iter().enumerate() { for (i, pkt) in source_pkts.iter().enumerate() {
decoder decoder.add_symbol(0, i as u8, false, pkt.data()).unwrap();
.add_symbol(0, i as u8, false, pkt.data())
.unwrap();
} }
let result = decoder.try_decode(0).unwrap(); let result = decoder.try_decode(0).unwrap();
@@ -233,7 +228,11 @@ mod tests {
let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1); let config = ObjectTransmissionInformation::new(block_len, SYMBOL_SIZE, 1, 1, 1);
let mut dec = SourceBlockDecoder::new(0, &config, block_len); let mut dec = SourceBlockDecoder::new(0, &config, block_len);
let decoded = dec.decode(all); let decoded = dec.decode(all);
assert!(decoded.is_some(), "Should recover with {:.0}% loss", drop_fraction * 100.0); assert!(
decoded.is_some(),
"Should recover with {:.0}% loss",
drop_fraction * 100.0
);
let data = decoded.unwrap(); let data = decoded.unwrap();
let ss = SYMBOL_SIZE as usize; let ss = SYMBOL_SIZE as usize;
@@ -245,13 +244,19 @@ mod tests {
} }
#[test] #[test]
fn decode_with_30pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3); } fn decode_with_30pct_loss() {
run_loss_test(FRAMES_PER_BLOCK, 0.5, 0.3);
}
#[test] #[test]
fn decode_with_50pct_loss() { run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5); } fn decode_with_50pct_loss() {
run_loss_test(FRAMES_PER_BLOCK, 1.0, 0.5);
}
#[test] #[test]
fn decode_with_70pct_source_loss_heavy_repair() { run_loss_test(8, 2.0, 0.5); } fn decode_with_70pct_source_loss_heavy_repair() {
run_loss_test(8, 2.0, 0.5);
}
#[test] #[test]
fn expire_removes_old_blocks() { fn expire_removes_old_blocks() {

View File

@@ -1,8 +1,8 @@
//! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols. //! RaptorQ FEC encoder — accumulates source symbols into blocks and generates repair symbols.
use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder}; use raptorq::{EncodingPacket, ObjectTransmissionInformation, PayloadId, SourceBlockEncoder};
use wzp_proto::error::FecError;
use wzp_proto::FecEncoder; use wzp_proto::FecEncoder;
use wzp_proto::error::FecError;
/// Maximum symbol size in bytes. Audio frames are typically < 200 bytes, /// Maximum symbol size in bytes. Audio frames are typically < 200 bytes,
/// but we pad to a uniform size within a block. /// but we pad to a uniform size within a block.
@@ -54,8 +54,7 @@ impl RaptorQFecEncoder {
let payload_len = sym.len().min(max_payload); let payload_len = sym.len().min(max_payload);
let offset = i * ss; let offset = i * ss;
// Write 2-byte little-endian length prefix. // Write 2-byte little-endian length prefix.
data[offset..offset + LEN_PREFIX] data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
.copy_from_slice(&(payload_len as u16).to_le_bytes());
// Write payload after prefix. // Write payload after prefix.
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len] data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
.copy_from_slice(&sym[..payload_len]); .copy_from_slice(&sym[..payload_len]);
@@ -81,7 +80,8 @@ impl FecEncoder for RaptorQFecEncoder {
} }
let block_data = self.build_block_data(); let block_data = self.build_block_data();
let config = ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size); let config =
ObjectTransmissionInformation::with_defaults(block_data.len() as u64, self.symbol_size);
let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data); let encoder = SourceBlockEncoder::new(self.block_id, &config, &block_data);
let num_source = self.source_symbols.len() as u32; let num_source = self.source_symbols.len() as u32;
@@ -130,8 +130,7 @@ fn build_prefixed_block_data(symbols: &[Vec<u8>], symbol_size: u16) -> Vec<u8> {
let max_payload = ss - LEN_PREFIX; let max_payload = ss - LEN_PREFIX;
let payload_len = sym.len().min(max_payload); let payload_len = sym.len().min(max_payload);
let offset = i * ss; let offset = i * ss;
data[offset..offset + LEN_PREFIX] data[offset..offset + LEN_PREFIX].copy_from_slice(&(payload_len as u16).to_le_bytes());
.copy_from_slice(&(payload_len as u16).to_le_bytes());
data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len] data[offset + LEN_PREFIX..offset + LEN_PREFIX + payload_len]
.copy_from_slice(&sym[..payload_len]); .copy_from_slice(&sym[..payload_len]);
} }

View File

@@ -146,7 +146,10 @@ mod tests {
// Each block should lose exactly 2 (6 losses / 3 blocks) // Each block should lose exactly 2 (6 losses / 3 blocks)
for &loss in &losses_per_block { for &loss in &losses_per_block {
assert_eq!(loss, 2, "Each block should lose at most 2 symbols from a burst of 6"); assert_eq!(
loss, 2,
"Each block should lose at most 2 symbols from a burst of 6"
);
} }
} }
} }

View File

@@ -16,7 +16,9 @@ pub mod encoder;
pub mod interleave; pub mod interleave;
pub use adaptive::AdaptiveFec; pub use adaptive::AdaptiveFec;
pub use block_manager::{DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState}; pub use block_manager::{
DecoderBlockManager, DecoderBlockState, EncoderBlockManager, EncoderBlockState,
};
pub use decoder::RaptorQFecDecoder; pub use decoder::RaptorQFecDecoder;
pub use encoder::RaptorQFecEncoder; pub use encoder::RaptorQFecEncoder;
pub use interleave::Interleaver; pub use interleave::Interleaver;
@@ -24,9 +26,7 @@ pub use interleave::Interleaver;
pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile}; pub use wzp_proto::{FecDecoder, FecEncoder, QualityProfile};
/// Create an encoder/decoder pair configured for the given quality profile. /// Create an encoder/decoder pair configured for the given quality profile.
pub fn create_fec_pair( pub fn create_fec_pair(profile: &QualityProfile) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
profile: &QualityProfile,
) -> (RaptorQFecEncoder, RaptorQFecDecoder) {
let cfg = AdaptiveFec::from_profile(profile); let cfg = AdaptiveFec::from_profile(profile);
let encoder = cfg.build_encoder(); let encoder = cfg.build_encoder();
let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size); let decoder = RaptorQFecDecoder::new(cfg.frames_per_block, cfg.symbol_size);

View File

@@ -24,7 +24,10 @@ fn main() {
let oboe_dir = fetch_oboe(); let oboe_dir = fetch_oboe();
match oboe_dir { match oboe_dir {
Some(oboe_path) => { Some(oboe_path) => {
println!("cargo:warning=wzp-native: building with Oboe from {:?}", oboe_path); println!(
"cargo:warning=wzp-native: building with Oboe from {:?}",
oboe_path
);
let mut build = cc::Build::new(); let mut build = cc::Build::new();
build build
.cpp(true) .cpp(true)
@@ -96,7 +99,12 @@ fn fetch_oboe() -> Option<PathBuf> {
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let oboe_dir = out_dir.join("oboe"); let oboe_dir = out_dir.join("oboe");
if oboe_dir.join("include").join("oboe").join("Oboe.h").exists() { if oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists()
{
return Some(oboe_dir); return Some(oboe_dir);
} }
@@ -111,7 +119,14 @@ fn fetch_oboe() -> Option<PathBuf> {
.status(); .status();
match status { match status {
Ok(s) if s.success() && oboe_dir.join("include").join("oboe").join("Oboe.h").exists() => { Ok(s)
if s.success()
&& oboe_dir
.join("include")
.join("oboe")
.join("Oboe.h")
.exists() =>
{
Some(oboe_dir) Some(oboe_dir)
} }
_ => None, _ => None,

View File

@@ -116,7 +116,11 @@ impl RingBuffer {
let w = self.write_idx.load(Ordering::Acquire); let w = self.write_idx.load(Ordering::Acquire);
let r = self.read_idx.load(Ordering::Relaxed); let r = self.read_idx.load(Ordering::Relaxed);
let avail = w - r; let avail = w - r;
if avail < 0 { (avail + self.capacity as i32) as usize } else { avail as usize } if avail < 0 {
(avail + self.capacity as i32) as usize
} else {
avail as usize
}
} }
fn available_write(&self) -> usize { fn available_write(&self) -> usize {
@@ -132,9 +136,13 @@ impl RingBuffer {
let cap = self.capacity; let cap = self.capacity;
let buf_ptr = self.buf.as_ptr() as *mut i16; let buf_ptr = self.buf.as_ptr() as *mut i16;
for sample in &data[..count] { for sample in &data[..count] {
unsafe { *buf_ptr.add(w) = *sample; } unsafe {
*buf_ptr.add(w) = *sample;
}
w += 1; w += 1;
if w >= cap { w = 0; } if w >= cap {
w = 0;
}
} }
self.write_idx.store(w as i32, Ordering::Release); self.write_idx.store(w as i32, Ordering::Release);
count count
@@ -149,9 +157,13 @@ impl RingBuffer {
let cap = self.capacity; let cap = self.capacity;
let buf_ptr = self.buf.as_ptr(); let buf_ptr = self.buf.as_ptr();
for slot in &mut out[..count] { for slot in &mut out[..count] {
unsafe { *slot = *buf_ptr.add(r); } unsafe {
*slot = *buf_ptr.add(r);
}
r += 1; r += 1;
if r >= cap { r = 0; } if r >= cap {
r = 0;
}
} }
self.read_idx.store(r as i32, Ordering::Release); self.read_idx.store(r as i32, Ordering::Release);
count count
@@ -316,17 +328,27 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
// has stopped firing → restart the streams. This is the // has stopped firing → restart the streams. This is the
// self-healing behavior that makes rejoin work: teardown + // self-healing behavior that makes rejoin work: teardown +
// rebuild clears whatever HAL state locked up the callback. // rebuild clears whatever HAL state locked up the callback.
let current_read_idx = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed); let current_read_idx = b
let last_read_idx = b.playout_last_read_idx.load(std::sync::atomic::Ordering::Relaxed); .playout
.read_idx
.load(std::sync::atomic::Ordering::Relaxed);
let last_read_idx = b
.playout_last_read_idx
.load(std::sync::atomic::Ordering::Relaxed);
if current_read_idx == last_read_idx { if current_read_idx == last_read_idx {
let stall = b.playout_stall_writes.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let stall = b
.playout_stall_writes
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if stall >= 50 { if stall >= 50 {
// Callback hasn't drained anything in ~1 second. // Callback hasn't drained anything in ~1 second.
// Force a stream restart. // Force a stream restart.
unsafe { unsafe {
android_log("playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams"); android_log(
"playout STALL detected (50 writes, read_idx unchanged) — restarting Oboe streams",
);
} }
b.playout_stall_writes.store(0, std::sync::atomic::Ordering::Relaxed); b.playout_stall_writes
.store(0, std::sync::atomic::Ordering::Relaxed);
// Release the started lock, stop, re-start. // Release the started lock, stop, re-start.
// This is the same logic as the Rust-side // This is the same logic as the Rust-side
// audio_stop() + audio_start() but done inline // audio_stop() + audio_start() but done inline
@@ -341,10 +363,18 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
} }
} }
// Clear the rings so the restart doesn't read stale data // Clear the rings so the restart doesn't read stale data
b.playout.write_idx.store(0, std::sync::atomic::Ordering::Relaxed); b.playout
b.playout.read_idx.store(0, std::sync::atomic::Ordering::Relaxed); .write_idx
b.capture.write_idx.store(0, std::sync::atomic::Ordering::Relaxed); .store(0, std::sync::atomic::Ordering::Relaxed);
b.capture.read_idx.store(0, std::sync::atomic::Ordering::Relaxed); b.playout
.read_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture
.write_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
b.capture
.read_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
// Re-start (stall detector — always non-BT mode) // Re-start (stall detector — always non-BT mode)
let config = WzpOboeConfig { let config = WzpOboeConfig {
sample_rate: 48_000, sample_rate: 48_000,
@@ -367,30 +397,49 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
if let Ok(mut started) = b.started.lock() { if let Ok(mut started) = b.started.lock() {
*started = true; *started = true;
} }
unsafe { android_log("playout restart OK — Oboe streams rebuilt"); } unsafe {
android_log("playout restart OK — Oboe streams rebuilt");
}
} else { } else {
unsafe { android_log(&format!("playout restart FAILED: {ret}")); } unsafe {
android_log(&format!("playout restart FAILED: {ret}"));
}
} }
b.playout_last_read_idx.store(0, std::sync::atomic::Ordering::Relaxed); b.playout_last_read_idx
.store(0, std::sync::atomic::Ordering::Relaxed);
return 0; // caller will retry on next frame return 0; // caller will retry on next frame
} }
} else { } else {
// read_idx advanced — callback is alive, reset counter // read_idx advanced — callback is alive, reset counter
b.playout_stall_writes.store(0, std::sync::atomic::Ordering::Relaxed); b.playout_stall_writes
b.playout_last_read_idx.store(current_read_idx, std::sync::atomic::Ordering::Relaxed); .store(0, std::sync::atomic::Ordering::Relaxed);
b.playout_last_read_idx
.store(current_read_idx, std::sync::atomic::Ordering::Relaxed);
} }
let before_w = b.playout.write_idx.load(std::sync::atomic::Ordering::Relaxed); let before_w = b
let before_r = b.playout.read_idx.load(std::sync::atomic::Ordering::Relaxed); .playout
.write_idx
.load(std::sync::atomic::Ordering::Relaxed);
let before_r = b
.playout
.read_idx
.load(std::sync::atomic::Ordering::Relaxed);
let written = b.playout.write(slice); let written = b.playout.write(slice);
// First few writes: log ring state + sample range so we can compare what // First few writes: log ring state + sample range so we can compare what
// engine.rs hands us to what the C++ playout callback reads. // engine.rs hands us to what the C++ playout callback reads.
let first_writes = b.playout_write_log_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let first_writes = b
.playout_write_log_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if first_writes < 3 || first_writes % 50 == 0 { if first_writes < 3 || first_writes % 50 == 0 {
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64); let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64);
for &s in slice.iter() { for &s in slice.iter() {
if s < lo { lo = s; } if s < lo {
if s > hi { hi = s; } lo = s;
}
if s > hi {
hi = s;
}
sumsq += (s as i64) * (s as i64); sumsq += (s as i64) * (s as i64);
} }
let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32; let rms = (sumsq as f64 / slice.len() as f64).sqrt() as i32;
@@ -398,7 +447,8 @@ pub unsafe extern "C" fn wzp_native_audio_write_playout(input: *const i16, in_le
let avail_r_after = b.playout.available_read(); let avail_r_after = b.playout.available_read();
let msg = format!( let msg = format!(
"playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}", "playout WRITE #{first_writes}: in_len={} written={} range=[{lo}..{hi}] rms={rms} before_w={before_w} before_r={before_r} avail_read_after={avail_r_after} avail_write_after={avail_w_after}",
slice.len(), written slice.len(),
written
); );
unsafe { unsafe {
android_log(msg.as_str()); android_log(msg.as_str());
@@ -422,7 +472,9 @@ unsafe fn android_log(msg: &str) {
let mut buf = Vec::with_capacity(msg.len() + 1); let mut buf = Vec::with_capacity(msg.len() + 1);
buf.extend_from_slice(msg.as_bytes()); buf.extend_from_slice(msg.as_bytes());
buf.push(0); buf.push(0);
unsafe { __android_log_write(4, tag.as_ptr(), buf.as_ptr()); } unsafe {
__android_log_write(4, tag.as_ptr(), buf.as_ptr());
}
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]

View File

@@ -9,8 +9,8 @@
use std::collections::VecDeque; use std::collections::VecDeque;
use std::time::Instant; use std::time::Instant;
use crate::packet::QualityReport;
use crate::QualityProfile; use crate::QualityProfile;
use crate::packet::QualityReport;
/// Network congestion state derived from delay and loss signals. /// Network congestion state derived from delay and loss signals.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -396,10 +396,7 @@ mod tests {
// Below 8 => CATASTROPHIC // Below 8 => CATASTROPHIC
let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0); let bwe_cat = BandwidthEstimator::new(7.9, 2.0, 100.0);
assert_eq!( assert_eq!(bwe_cat.recommended_profile(), QualityProfile::CATASTROPHIC);
bwe_cat.recommended_profile(),
QualityProfile::CATASTROPHIC
);
// High bandwidth // High bandwidth
let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0); let bwe_high = BandwidthEstimator::new(80.0, 2.0, 100.0);
@@ -413,7 +410,7 @@ mod tests {
// Build a QualityReport with moderate loss and RTT. // Build a QualityReport with moderate loss and RTT.
let report = QualityReport { let report = QualityReport {
loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss loss_pct: (10.0_f32 / 100.0 * 255.0) as u8, // ~10% loss
rtt_4ms: 25, // 100ms RTT rtt_4ms: 25, // 100ms RTT
jitter_ms: 10, jitter_ms: 10,
bitrate_cap_kbps: 200, bitrate_cap_kbps: 200,
}; };

View File

@@ -49,7 +49,7 @@ fn baseline_dred_frames(codec: CodecId) -> u8 {
match codec { match codec {
CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms CodecId::Opus32k | CodecId::Opus48k | CodecId::Opus64k => 10, // 100 ms
CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms CodecId::Opus16k | CodecId::Opus24k => 20, // 200 ms
CodecId::Opus6k => 50, // 500 ms CodecId::Opus6k => 50, // 500 ms
_ => 0, _ => 0,
} }
} }
@@ -128,7 +128,11 @@ impl DredTuner {
self.initialized = true; self.initialized = true;
} else { } else {
// Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA // Fast-up (alpha=0.3), slow-down (alpha=0.05) asymmetric EWMA
let alpha = if jitter_f > self.jitter_ewma { 0.3 } else { 0.05 }; let alpha = if jitter_f > self.jitter_ewma {
0.3
} else {
0.05
};
self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma; self.jitter_ewma = alpha * jitter_f + (1.0 - alpha) * self.jitter_ewma;
} }

View File

@@ -81,9 +81,7 @@ impl AdaptivePlayoutDelay {
let jitter = (actual_delta - expected_delta).abs(); let jitter = (actual_delta - expected_delta).abs();
// Spike detection: check before EMA update // Spike detection: check before EMA update
if self.jitter_ema > 0.0 if self.jitter_ema > 0.0 && jitter > self.jitter_ema * self.spike_threshold_multiplier {
&& jitter > self.jitter_ema * self.spike_threshold_multiplier
{
self.spike_detected_at = Some(Instant::now()); self.spike_detected_at = Some(Instant::now());
} }
@@ -107,10 +105,8 @@ impl AdaptivePlayoutDelay {
self.target_delay = self.max_delay; self.target_delay = self.max_delay;
} else { } else {
// Convert jitter estimate to target delay in packets // Convert jitter estimate to target delay in packets
let raw_target = let raw_target = (self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin;
(self.jitter_ema / FRAME_DURATION_MS).ceil() + self.safety_margin; self.target_delay = (raw_target as usize).clamp(self.min_delay, self.max_delay);
self.target_delay =
(raw_target as usize).clamp(self.min_delay, self.max_delay);
} }
} }
@@ -162,9 +158,9 @@ impl AdaptivePlayoutDelay {
/// Manages packet reordering, gap detection, and signals when PLC is needed. /// Manages packet reordering, gap detection, and signals when PLC is needed.
pub struct JitterBuffer { pub struct JitterBuffer {
/// Packets waiting to be consumed, ordered by sequence number. /// Packets waiting to be consumed, ordered by sequence number.
buffer: BTreeMap<u16, MediaPacket>, buffer: BTreeMap<u32, MediaPacket>,
/// Next sequence number expected for playout. /// Next sequence number expected for playout.
next_playout_seq: u16, next_playout_seq: u32,
/// Maximum buffer depth in number of packets. /// Maximum buffer depth in number of packets.
max_depth: usize, max_depth: usize,
/// Target buffer depth (adaptive, based on jitter). /// Target buffer depth (adaptive, based on jitter).
@@ -204,7 +200,7 @@ pub enum PlayoutResult {
/// A packet is available for playout. /// A packet is available for playout.
Packet(MediaPacket), Packet(MediaPacket),
/// The expected packet is missing — decoder should generate PLC. /// The expected packet is missing — decoder should generate PLC.
Missing { seq: u16 }, Missing { seq: u32 },
/// Buffer is empty or not yet filled to target depth. /// Buffer is empty or not yet filled to target depth.
NotReady, NotReady,
} }
@@ -278,9 +274,18 @@ impl JitterBuffer {
// federation room — reset instead of dropping. // federation room — reset instead of dropping.
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) { if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
let backward_distance = self.next_playout_seq.wrapping_sub(seq); let backward_distance = self.next_playout_seq.wrapping_sub(seq);
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected"); tracing::warn!(
seq,
next = self.next_playout_seq,
backward_distance,
"jitter: backward seq detected"
);
if backward_distance > 100 { if backward_distance > 100 {
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected"); tracing::info!(
seq,
next = self.next_playout_seq,
"jitter: RESET — new sender detected"
);
self.buffer.clear(); self.buffer.clear();
self.next_playout_seq = seq; self.next_playout_seq = seq;
self.stats.packets_late = 0; self.stats.packets_late = 0;
@@ -428,9 +433,18 @@ impl JitterBuffer {
// federation room — reset instead of dropping. // federation room — reset instead of dropping.
if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) { if self.stats.packets_played > 0 && seq_before(seq, self.next_playout_seq) {
let backward_distance = self.next_playout_seq.wrapping_sub(seq); let backward_distance = self.next_playout_seq.wrapping_sub(seq);
tracing::warn!(seq, next = self.next_playout_seq, backward_distance, "jitter: backward seq detected"); tracing::warn!(
seq,
next = self.next_playout_seq,
backward_distance,
"jitter: backward seq detected"
);
if backward_distance > 100 { if backward_distance > 100 {
tracing::info!(seq, next = self.next_playout_seq, "jitter: RESET — new sender detected"); tracing::info!(
seq,
next = self.next_playout_seq,
"jitter: RESET — new sender detected"
);
self.buffer.clear(); self.buffer.clear();
self.next_playout_seq = seq; self.next_playout_seq = seq;
self.stats.packets_late = 0; self.stats.packets_late = 0;
@@ -489,7 +503,7 @@ impl JitterBuffer {
/// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic). /// Sequence number comparison with wrapping (RFC 1982 serial number arithmetic).
/// Returns true if `a` comes before `b` in sequence space. /// Returns true if `a` comes before `b` in sequence space.
fn seq_before(a: u16, b: u16) -> bool { fn seq_before(a: u32, b: u32) -> bool {
let diff = b.wrapping_sub(a); let diff = b.wrapping_sub(a);
diff > 0 && diff < 0x8000 diff > 0 && diff < 0x8000
} }
@@ -497,24 +511,23 @@ fn seq_before(a: u16, b: u16) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::CodecId;
use crate::MediaType;
use crate::packet::{MediaHeader, MediaPacket}; use crate::packet::{MediaHeader, MediaPacket};
use bytes::Bytes; use bytes::Bytes;
use crate::CodecId;
fn make_packet(seq: u16) -> MediaPacket { fn make_packet(seq: u32) -> MediaPacket {
MediaPacket { MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq, seq,
timestamp: seq as u32 * 20, timestamp: seq * 20,
fec_block: 0, fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(vec![0u8; 60]), payload: Bytes::from(vec![0u8; 60]),
quality_report: None, quality_report: None,
@@ -598,7 +611,7 @@ mod tests {
fn seq_before_wrapping() { fn seq_before_wrapping() {
assert!(seq_before(0, 1)); assert!(seq_before(0, 1));
assert!(seq_before(65534, 65535)); assert!(seq_before(65534, 65535));
assert!(seq_before(65535, 0)); // wrap assert!(seq_before(u32::MAX, 0)); // wrap
assert!(!seq_before(1, 0)); assert!(!seq_before(1, 0));
assert!(!seq_before(5, 5)); // equal assert!(!seq_before(5, 5)); // equal
} }
@@ -800,7 +813,7 @@ mod tests {
let mut jb = JitterBuffer::new_adaptive(3, 50); let mut jb = JitterBuffer::new_adaptive(3, 50);
// Push packets with consistent timing // Push packets with consistent timing
for i in 0u16..20 { for i in 0u32..20 {
let pkt = make_packet(i); let pkt = make_packet(i);
let arrival_ms = i as u64 * 20; let arrival_ms = i as u64 * 20;
jb.push_with_arrival(pkt, arrival_ms); jb.push_with_arrival(pkt, arrival_ms);

View File

@@ -30,10 +30,9 @@ pub use dred_tuner::{DredTuner, DredTuning};
pub use error::*; pub use error::*;
pub use media_type::MediaType; pub use media_type::MediaType;
pub use packet::{ pub use packet::{
CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV1, CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV2,
MediaHeaderV2, MediaPacket, MiniFrameContext, MiniFrameContextV1, MiniFrameContextV2, MediaPacket, MiniFrameContext, MiniFrameContextV2, MiniHeader, MiniHeaderV2, PresenceUser,
MiniHeader, MiniHeaderV1, MiniHeaderV2, PresenceUser, QualityReport, RoomParticipant, QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame,
SignalMessage, TrunkEntry, TrunkFrame,
}; };
pub use quality::{AdaptiveQualityController, NetworkContext, Tier}; pub use quality::{AdaptiveQualityController, NetworkContext, Tier};
pub use session::{Session, SessionEvent, SessionState}; pub use session::{Session, SessionEvent, SessionState};

View File

@@ -3,162 +3,8 @@ use serde::{Deserialize, Serialize};
use crate::{CodecId, MediaType}; use crate::{CodecId, MediaType};
/// 12-byte v1 media packet header for the lossy link. /// v2 media header alias. All production code uses this type.
/// pub type MediaHeader = MediaHeaderV2;
/// Wire layout:
/// ```text
/// Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
/// Byte 1: [FecRatioLo:6][unused:2]
/// Byte 2-3: Sequence number (big-endian u16)
/// Byte 4-7: Timestamp in ms since session start (big-endian u32)
/// Byte 8: FEC block ID
/// Byte 9: FEC symbol index within block
/// Byte 10: Reserved / flags
/// Byte 11: CSRC count
/// ```
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MediaHeaderV1 {
/// Protocol version (0 = v1).
pub version: u8,
/// true = FEC repair packet, false = source media.
pub is_repair: bool,
/// Codec identifier.
pub codec_id: CodecId,
/// Whether a QualityReport trailer is appended.
pub has_quality_report: bool,
/// FEC ratio as 7-bit value (0-127 maps to 0.0-1.0).
pub fec_ratio_encoded: u8,
/// Wrapping packet sequence number.
pub seq: u16,
/// Milliseconds since session start.
pub timestamp: u32,
/// FEC source block ID (wrapping).
pub fec_block: u8,
/// Symbol index within the FEC block.
pub fec_symbol: u8,
/// Reserved flags byte.
pub reserved: u8,
/// Number of contributing sources (for future mixing).
pub csrc_count: u8,
}
impl MediaHeaderV1 {
/// Header size in bytes on the wire.
pub const WIRE_SIZE: usize = 12;
/// Create a default header for raw PCM relay (used by WebSocket bridge).
pub fn default_pcm() -> Self {
Self {
version: 0,
is_repair: false,
codec_id: CodecId::Opus24k,
has_quality_report: false,
fec_ratio_encoded: 0,
seq: 0,
timestamp: 0,
fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}
}
/// Encode the FEC ratio float (0.0-2.0+) to a 7-bit value (0-127).
pub fn encode_fec_ratio(ratio: f32) -> u8 {
// Map 0.0-2.0 to 0-127, clamping at 127
let scaled = (ratio * 63.5).round() as u8;
scaled.min(127)
}
/// Decode the 7-bit FEC ratio value back to a float.
pub fn decode_fec_ratio(encoded: u8) -> f32 {
(encoded & 0x7F) as f32 / 63.5
}
/// Serialize to a 12-byte buffer.
pub fn write_to(&self, buf: &mut impl BufMut) {
// Byte 0: V(1) | T(1) | CodecID(4) | Q(1) | FecRatioHi(1)
let byte0 = ((self.version & 0x01) << 7)
| ((self.is_repair as u8) << 6)
| ((self.codec_id.to_wire() & 0x0F) << 2)
| ((self.has_quality_report as u8) << 1)
| ((self.fec_ratio_encoded >> 6) & 0x01);
buf.put_u8(byte0);
// Byte 1: FecRatioLo(6) | unused(2)
let byte1 = (self.fec_ratio_encoded & 0x3F) << 2;
buf.put_u8(byte1);
// Bytes 2-3: sequence number
buf.put_u16(self.seq);
// Bytes 4-7: timestamp
buf.put_u32(self.timestamp);
// Byte 8: FEC block
buf.put_u8(self.fec_block);
// Byte 9: FEC symbol
buf.put_u8(self.fec_symbol);
// Byte 10: reserved
buf.put_u8(self.reserved);
// Byte 11: CSRC count
buf.put_u8(self.csrc_count);
}
/// Deserialize from a buffer. Returns None if insufficient data.
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
let byte0 = buf.get_u8();
let byte1 = buf.get_u8();
let version = (byte0 >> 7) & 0x01;
let is_repair = ((byte0 >> 6) & 0x01) != 0;
let codec_wire = (byte0 >> 2) & 0x0F;
let has_quality_report = ((byte0 >> 1) & 0x01) != 0;
let fec_ratio_hi = byte0 & 0x01;
let fec_ratio_lo = (byte1 >> 2) & 0x3F;
let fec_ratio_encoded = (fec_ratio_hi << 6) | fec_ratio_lo;
let codec_id = CodecId::from_wire(codec_wire)?;
let seq = buf.get_u16();
let timestamp = buf.get_u32();
let fec_block = buf.get_u8();
let fec_symbol = buf.get_u8();
let reserved = buf.get_u8();
let csrc_count = buf.get_u8();
Some(Self {
version,
is_repair,
codec_id,
has_quality_report,
fec_ratio_encoded,
seq,
timestamp,
fec_block,
fec_symbol,
reserved,
csrc_count,
})
}
/// Serialize header to a new Bytes value.
pub fn to_bytes(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(Self::WIRE_SIZE);
self.write_to(&mut buf);
buf.freeze()
}
}
/// Temporary alias so existing code continues to compile.
/// Removed in T1.5 once all emit/parse sites migrate to v2.
pub type MediaHeader = MediaHeaderV1;
/// 16-byte v2 media header. See docs/PRD/PRD-wire-format-v2.md. /// 16-byte v2 media header. See docs/PRD/PRD-wire-format-v2.md.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -258,6 +104,23 @@ impl MediaHeaderV2 {
pub fn is_frame_end(&self) -> bool { pub fn is_frame_end(&self) -> bool {
self.flags & Self::FLAG_FRAME_END != 0 self.flags & Self::FLAG_FRAME_END != 0
} }
/// Encode the FEC ratio float (0.0-2.0) to an 8-bit value (0-200).
pub fn encode_fec_ratio(ratio: f32) -> u8 {
(ratio * 100.0).round() as u8
}
/// Decode the 8-bit FEC ratio value back to a float.
pub fn decode_fec_ratio(encoded: u8) -> f32 {
encoded as f32 / 100.0
}
/// Serialize header to a new Bytes value.
pub fn to_bytes(&self) -> Bytes {
let mut buf = BytesMut::with_capacity(Self::WIRE_SIZE);
self.write_to(&mut buf);
buf.freeze()
}
} }
/// A user visible in the signal presence list. /// A user visible in the signal presence list.
@@ -363,7 +226,7 @@ impl MediaPacket {
let header = MediaHeader::read_from(&mut cursor)?; let header = MediaHeader::read_from(&mut cursor)?;
let remaining = data.len() - MediaHeader::WIRE_SIZE; let remaining = data.len() - MediaHeader::WIRE_SIZE;
let (payload_len, quality_report) = if header.has_quality_report { let (payload_len, quality_report) = if header.has_quality() {
if remaining < QualityReport::WIRE_SIZE { if remaining < QualityReport::WIRE_SIZE {
return None; return None;
} }
@@ -393,11 +256,12 @@ impl MediaPacket {
pub fn encode_compact(&self, ctx: &mut MiniFrameContext, frames_since_full: &mut u32) -> Bytes { pub fn encode_compact(&self, ctx: &mut MiniFrameContext, frames_since_full: &mut u32) -> Bytes {
if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL { if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL {
// --- mini frame --- // --- mini frame ---
let ts_delta = self let ts_delta =
.header self.header
.timestamp .timestamp
.wrapping_sub(ctx.last_header.unwrap().timestamp) as u16; .wrapping_sub(ctx.last_header().unwrap().timestamp) as u16;
let mini = MiniHeader { let mini = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: ts_delta, timestamp_delta_ms: ts_delta,
payload_len: self.payload.len() as u16, payload_len: self.payload.len() as u16,
}; };
@@ -599,42 +463,8 @@ pub const FRAME_TYPE_FULL: u8 = 0x00;
/// Frame type tag: MiniHeader follows (requires prior baseline). /// Frame type tag: MiniHeader follows (requires prior baseline).
pub const FRAME_TYPE_MINI: u8 = 0x01; pub const FRAME_TYPE_MINI: u8 = 0x01;
/// Compact 4-byte v1 header used after a full MediaHeader baseline has been /// v2 mini header alias. All production code uses this type.
/// established. Only the timestamp delta and payload length are transmitted; pub type MiniHeader = MiniHeaderV2;
/// all other fields are inherited from the last full header.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MiniHeaderV1 {
/// Milliseconds elapsed since the last header's timestamp.
pub timestamp_delta_ms: u16,
/// Length of the payload that follows this header.
pub payload_len: u16,
}
impl MiniHeaderV1 {
/// Header size in bytes on the wire.
pub const WIRE_SIZE: usize = 4;
/// Serialize to a 4-byte buffer.
pub fn write_to(&self, buf: &mut impl BufMut) {
buf.put_u16(self.timestamp_delta_ms);
buf.put_u16(self.payload_len);
}
/// Deserialize from a buffer. Returns `None` if insufficient data.
pub fn read_from(buf: &mut impl Buf) -> Option<Self> {
if buf.remaining() < Self::WIRE_SIZE {
return None;
}
Some(Self {
timestamp_delta_ms: buf.get_u16(),
payload_len: buf.get_u16(),
})
}
}
/// Temporary alias so existing code continues to compile.
/// Removed in T1.5 once all emit/parse sites migrate to v2.
pub type MiniHeader = MiniHeaderV1;
/// Compact 5-byte v2 mini header with explicit `seq_delta`. /// Compact 5-byte v2 mini header with explicit `seq_delta`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -672,34 +502,8 @@ impl MiniHeaderV2 {
} }
} }
/// Stateful v1 context that expands [`MiniHeaderV1`]s back into full /// v2 mini frame context alias. All production code uses this type.
/// [`MediaHeader`]s by tracking the last baseline header. pub type MiniFrameContext = MiniFrameContextV2;
#[derive(Clone, Debug, Default)]
pub struct MiniFrameContextV1 {
last_header: Option<MediaHeader>,
}
impl MiniFrameContextV1 {
/// Record a full header as the new baseline for subsequent mini-frames.
pub fn update(&mut self, header: &MediaHeader) {
self.last_header = Some(*header);
}
/// Expand a mini-header into a full [`MediaHeader`] using the stored
/// baseline. Returns `None` if no baseline has been set yet.
pub fn expand(&mut self, mini: &MiniHeader) -> Option<MediaHeader> {
let base = self.last_header.as_ref()?;
let mut expanded = *base;
expanded.seq = base.seq.wrapping_add(1);
expanded.timestamp = base.timestamp.wrapping_add(mini.timestamp_delta_ms as u32);
self.last_header = Some(expanded);
Some(expanded)
}
}
/// Temporary alias so existing code continues to compile.
/// Removed in T1.5 once all emit/parse sites migrate to v2.
pub type MiniFrameContext = MiniFrameContextV1;
/// Stateful v2 context that expands [`MiniHeaderV2`]s back into full /// Stateful v2 context that expands [`MiniHeaderV2`]s back into full
/// [`MediaHeaderV2`]s by tracking the last baseline header. /// [`MediaHeaderV2`]s by tracking the last baseline header.
@@ -724,6 +528,11 @@ impl MiniFrameContextV2 {
self.last = Some(e); self.last = Some(e);
Some(e) Some(e)
} }
/// Return a reference to the last baseline header, if any.
pub fn last_header(&self) -> Option<&MediaHeaderV2> {
self.last.as_ref()
}
} }
/// Signaling messages sent over the reliable QUIC stream. /// Signaling messages sent over the reliable QUIC stream.
@@ -1332,17 +1141,15 @@ mod tests {
#[test] #[test]
fn header_roundtrip() { fn header_roundtrip() {
let header = MediaHeader { let header = MediaHeader {
version: 0, version: 2,
is_repair: false, flags: MediaHeader::FLAG_QUALITY,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: true, stream_id: 0,
fec_ratio_encoded: 42, fec_ratio: 42,
seq: 12345, seq: 12345,
timestamp: 987654, timestamp: 987654,
fec_block: 7, fec_block: 7,
fec_symbol: 3,
reserved: 0,
csrc_count: 0,
}; };
let bytes = header.to_bytes(); let bytes = header.to_bytes();
@@ -1356,17 +1163,15 @@ mod tests {
#[test] #[test]
fn header_repair_flag() { fn header_repair_flag() {
let header = MediaHeader { let header = MediaHeader {
version: 0, version: 2,
is_repair: true, flags: MediaHeader::FLAG_REPAIR,
media_type: MediaType::Audio,
codec_id: CodecId::Codec2_1200, codec_id: CodecId::Codec2_1200,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 127, fec_ratio: 127,
seq: 65535, seq: 0xDEAD_BEEF,
timestamp: u32::MAX, timestamp: u32::MAX,
fec_block: 255, fec_block: 0xABCD,
fec_symbol: 255,
reserved: 0xFF,
csrc_count: 0,
}; };
let bytes = header.to_bytes(); let bytes = header.to_bytes();
@@ -1418,17 +1223,15 @@ mod tests {
fn media_packet_roundtrip() { fn media_packet_roundtrip() {
let packet = MediaPacket { let packet = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: MediaHeader::FLAG_QUALITY,
media_type: MediaType::Audio,
codec_id: CodecId::Opus6k, codec_id: CodecId::Opus6k,
has_quality_report: true, stream_id: 0,
fec_ratio_encoded: 32, fec_ratio: 32,
seq: 100, seq: 100,
timestamp: 2000, timestamp: 2000,
fec_block: 1, fec_block: 1,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from_static(b"test audio data here"), payload: Bytes::from_static(b"test audio data here"),
quality_report: Some(QualityReport { quality_report: Some(QualityReport {
@@ -1859,11 +1662,11 @@ mod tests {
let ratio = 0.5; let ratio = 0.5;
let encoded = MediaHeader::encode_fec_ratio(ratio); let encoded = MediaHeader::encode_fec_ratio(ratio);
let decoded = MediaHeader::decode_fec_ratio(encoded); let decoded = MediaHeader::decode_fec_ratio(encoded);
assert!((decoded - ratio).abs() < 0.02); assert!((decoded - ratio).abs() < 0.01);
let ratio_max = 2.0; let ratio_max = 2.0;
let encoded_max = MediaHeader::encode_fec_ratio(ratio_max); let encoded_max = MediaHeader::encode_fec_ratio(ratio_max);
assert_eq!(encoded_max, 127); assert_eq!(encoded_max, 200);
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -1924,6 +1727,7 @@ mod tests {
#[test] #[test]
fn mini_header_encode_decode() { fn mini_header_encode_decode() {
let mini = MiniHeader { let mini = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20, timestamp_delta_ms: 20,
payload_len: 160, payload_len: 160,
}; };
@@ -1938,29 +1742,28 @@ mod tests {
#[test] #[test]
fn mini_header_wire_size() { fn mini_header_wire_size() {
let mini = MiniHeader { let mini = MiniHeader {
seq_delta: 0xFF,
timestamp_delta_ms: 0xFFFF, timestamp_delta_ms: 0xFFFF,
payload_len: 0xFFFF, payload_len: 0xFFFF,
}; };
let mut buf = BytesMut::new(); let mut buf = BytesMut::new();
mini.write_to(&mut buf); mini.write_to(&mut buf);
assert_eq!(buf.len(), 4); assert_eq!(buf.len(), 5);
assert_eq!(MiniHeader::WIRE_SIZE, 4); assert_eq!(MiniHeader::WIRE_SIZE, 5);
} }
#[test] #[test]
fn mini_frame_context_expand() { fn mini_frame_context_expand() {
let baseline = MediaHeader { let baseline = MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 10, fec_ratio: 10,
seq: 100, seq: 100,
timestamp: 1000, timestamp: 1000,
fec_block: 5, fec_block: 5,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}; };
let mut ctx = MiniFrameContext::default(); let mut ctx = MiniFrameContext::default();
@@ -1968,6 +1771,7 @@ mod tests {
// First expansion // First expansion
let mini1 = MiniHeader { let mini1 = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20, timestamp_delta_ms: 20,
payload_len: 80, payload_len: 80,
}; };
@@ -1979,6 +1783,7 @@ mod tests {
// Second expansion — builds on expanded h1 // Second expansion — builds on expanded h1
let mini2 = MiniHeader { let mini2 = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20, timestamp_delta_ms: 20,
payload_len: 80, payload_len: 80,
}; };
@@ -1991,6 +1796,7 @@ mod tests {
fn mini_frame_context_no_baseline() { fn mini_frame_context_no_baseline() {
let mut ctx = MiniFrameContext::default(); let mut ctx = MiniFrameContext::default();
let mini = MiniHeader { let mini = MiniHeader {
seq_delta: 1,
timestamp_delta_ms: 20, timestamp_delta_ms: 20,
payload_len: 80, payload_len: 80,
}; };
@@ -2065,13 +1871,13 @@ mod tests {
#[test] #[test]
fn full_vs_mini_size_comparison() { fn full_vs_mini_size_comparison() {
// Full frame on wire: 1 byte type tag + 12 byte MediaHeader = 13 // Full frame on wire: 1 byte type tag + 16 byte MediaHeader = 17
let full_size = 1 + MediaHeader::WIRE_SIZE; let full_size = 1 + MediaHeader::WIRE_SIZE;
assert_eq!(full_size, 13); assert_eq!(full_size, 17);
// Mini frame on wire: 1 byte type tag + 4 byte MiniHeader = 5 // Mini frame on wire: 1 byte type tag + 5 byte MiniHeader = 6
let mini_size = 1 + MiniHeader::WIRE_SIZE; let mini_size = 1 + MiniHeader::WIRE_SIZE;
assert_eq!(mini_size, 5); assert_eq!(mini_size, 6);
// Verify the constants match expectations // Verify the constants match expectations
assert_eq!(FRAME_TYPE_FULL, 0x00); assert_eq!(FRAME_TYPE_FULL, 0x00);
@@ -2082,20 +1888,18 @@ mod tests {
// encode_compact / decode_compact tests // encode_compact / decode_compact tests
// --------------------------------------------------------------- // ---------------------------------------------------------------
fn make_media_packet(seq: u16, ts: u32, payload: &[u8]) -> MediaPacket { fn make_media_packet(seq: u32, ts: u32, payload: &[u8]) -> MediaPacket {
MediaPacket { MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 10, fec_ratio: 10,
seq, seq,
timestamp: ts, timestamp: ts,
fec_block: 0, fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(payload.to_vec()), payload: Bytes::from(payload.to_vec()),
quality_report: None, quality_report: None,
@@ -2109,7 +1913,7 @@ mod tests {
let mut frames_since_full: u32 = 0; let mut frames_since_full: u32 = 0;
let packets: Vec<MediaPacket> = (0..5) let packets: Vec<MediaPacket> = (0..5)
.map(|i| make_media_packet(i, i as u32 * 20, b"audio")) .map(|i| make_media_packet(i, i * 20, b"audio"))
.collect(); .collect();
for (i, pkt) in packets.iter().enumerate() { for (i, pkt) in packets.iter().enumerate() {
@@ -2121,7 +1925,7 @@ mod tests {
} else { } else {
// Subsequent frames should be mini // Subsequent frames should be mini
assert_eq!(wire[0], FRAME_TYPE_MINI, "frame {i} should be MINI"); assert_eq!(wire[0], FRAME_TYPE_MINI, "frame {i} should be MINI");
// Mini wire: 1 (tag) + 4 (mini header) + payload // Mini wire: 1 (tag) + 5 (mini header) + payload
assert_eq!(wire.len(), 1 + MiniHeader::WIRE_SIZE + pkt.payload.len()); assert_eq!(wire.len(), 1 + MiniHeader::WIRE_SIZE + pkt.payload.len());
} }
@@ -2141,7 +1945,7 @@ mod tests {
// Encode MINI_FRAME_FULL_INTERVAL + 1 frames. Frame 0 and frame 50 // Encode MINI_FRAME_FULL_INTERVAL + 1 frames. Frame 0 and frame 50
// should be FULL, everything in between should be MINI. // should be FULL, everything in between should be MINI.
for i in 0..=MINI_FRAME_FULL_INTERVAL { for i in 0..=MINI_FRAME_FULL_INTERVAL {
let pkt = make_media_packet(i as u16, i * 20, b"data"); let pkt = make_media_packet(i, i * 20, b"data");
let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full); let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full);
if i == 0 || i == MINI_FRAME_FULL_INTERVAL { if i == 0 || i == MINI_FRAME_FULL_INTERVAL {
@@ -2196,8 +2000,8 @@ mod tests {
// (which is what the encoder does when the feature is off). // (which is what the encoder does when the feature is off).
let mut ctx = MiniFrameContext::default(); let mut ctx = MiniFrameContext::default();
for i in 0..10u16 { for i in 0..10u32 {
let pkt = make_media_packet(i, i as u32 * 20, b"payload"); let pkt = make_media_packet(i, i * 20, b"payload");
// When mini-frames are disabled, the encoder always passes // When mini-frames are disabled, the encoder always passes
// frames_since_full = 0 equivalent by never using encode_compact. // frames_since_full = 0 equivalent by never using encode_compact.
// We test the raw path: frames_since_full forced to 0 every time. // We test the raw path: frames_since_full forced to 0 every time.

View File

@@ -7,9 +7,7 @@ fn main() {
.output(); .output();
let hash = match output { let hash = match output {
Ok(o) if o.status.success() => { Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => "unknown".to_string(), _ => "unknown".to_string(),
}; };

View File

@@ -32,10 +32,7 @@ pub struct AuthenticatedClient {
/// ///
/// Calls `POST {auth_url}` with `{ "token": "..." }`. /// Calls `POST {auth_url}` with `{ "token": "..." }`.
/// Returns the client identity if valid, or an error string. /// Returns the client identity if valid, or an error string.
pub async fn validate_token( pub async fn validate_token(auth_url: &str, token: &str) -> Result<AuthenticatedClient, String> {
auth_url: &str,
token: &str,
) -> Result<AuthenticatedClient, String> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(5))
.build() .build()

View File

@@ -83,7 +83,12 @@ impl CallRegistry {
} }
/// Create a new pending call. Returns the call_id. /// Create a new pending call. Returns the call_id.
pub fn create_call(&mut self, call_id: String, caller_fp: String, callee_fp: String) -> &DirectCall { pub fn create_call(
&mut self,
call_id: String,
caller_fp: String,
callee_fp: String,
) -> &DirectCall {
let call = DirectCall { let call = DirectCall {
call_id: call_id.clone(), call_id: call_id.clone(),
caller_fingerprint: caller_fp, caller_fingerprint: caller_fp,
@@ -189,7 +194,12 @@ impl CallRegistry {
} }
/// Transition to Active state. /// Transition to Active state.
pub fn set_active(&mut self, call_id: &str, mode: wzp_proto::CallAcceptMode, room: String) -> bool { pub fn set_active(
&mut self,
call_id: &str,
mode: wzp_proto::CallAcceptMode,
room: String,
) -> bool {
if let Some(call) = self.calls.get_mut(call_id) { if let Some(call) = self.calls.get_mut(call_id) {
if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing { if call.state == DirectCallState::Pending || call.state == DirectCallState::Ringing {
call.state = DirectCallState::Active; call.state = DirectCallState::Active;
@@ -213,7 +223,8 @@ impl CallRegistry {
/// Find active/pending calls involving a fingerprint. /// Find active/pending calls involving a fingerprint.
pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> { pub fn calls_for_fingerprint(&self, fp: &str) -> Vec<&DirectCall> {
self.calls.values() self.calls
.values()
.filter(|c| { .filter(|c| {
c.state != DirectCallState::Ended c.state != DirectCallState::Ended
&& (c.caller_fingerprint == fp || c.callee_fingerprint == fp) && (c.caller_fingerprint == fp || c.callee_fingerprint == fp)
@@ -236,22 +247,25 @@ impl CallRegistry {
/// Returns call IDs of expired calls. /// Returns call IDs of expired calls.
pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> { pub fn expire_stale(&mut self, timeout: Duration) -> Vec<DirectCall> {
let now = Instant::now(); let now = Instant::now();
let expired: Vec<String> = self.calls.iter() let expired: Vec<String> = self
.calls
.iter()
.filter(|(_, c)| { .filter(|(_, c)| {
c.state == DirectCallState::Pending c.state == DirectCallState::Pending && now.duration_since(c.created_at) > timeout
&& now.duration_since(c.created_at) > timeout
}) })
.map(|(id, _)| id.clone()) .map(|(id, _)| id.clone())
.collect(); .collect();
expired.into_iter() expired
.into_iter()
.filter_map(|id| self.calls.remove(&id)) .filter_map(|id| self.calls.remove(&id))
.collect() .collect()
} }
/// Number of active (non-ended) calls. /// Number of active (non-ended) calls.
pub fn active_count(&self) -> usize { pub fn active_count(&self) -> usize {
self.calls.values() self.calls
.values()
.filter(|c| c.state != DirectCallState::Ended) .filter(|c| c.state != DirectCallState::Ended)
.count() .count()
} }
@@ -270,9 +284,16 @@ mod tests {
assert!(reg.set_ringing("c1")); assert!(reg.set_ringing("c1"));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing); assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Ringing);
assert!(reg.set_active("c1", wzp_proto::CallAcceptMode::AcceptGeneric, "_call:c1".into())); assert!(reg.set_active(
"c1",
wzp_proto::CallAcceptMode::AcceptGeneric,
"_call:c1".into()
));
assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active); assert_eq!(reg.get("c1").unwrap().state, DirectCallState::Active);
assert_eq!(reg.get("c1").unwrap().room_name.as_deref(), Some("_call:c1")); assert_eq!(
reg.get("c1").unwrap().room_name.as_deref(),
Some("_call:c1")
);
let ended = reg.end_call("c1").unwrap(); let ended = reg.end_call("c1").unwrap();
assert_eq!(ended.state, DirectCallState::Ended); assert_eq!(ended.state, DirectCallState::Ended);
@@ -329,10 +350,7 @@ mod tests {
// Both addrs are independently readable — the relay uses // Both addrs are independently readable — the relay uses
// them to cross-wire peer_direct_addr in CallSetup. // them to cross-wire peer_direct_addr in CallSetup.
let c = reg.get("c1").unwrap(); let c = reg.get("c1").unwrap();
assert_eq!( assert_eq!(c.caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433"));
c.caller_reflexive_addr.as_deref(),
Some("192.0.2.1:4433")
);
assert_eq!( assert_eq!(
c.callee_reflexive_addr.as_deref(), c.callee_reflexive_addr.as_deref(),
Some("198.51.100.9:4433") Some("198.51.100.9:4433")

View File

@@ -145,7 +145,10 @@ pub struct RelayInfo {
} }
/// Load config from path, or create a personalized example config if it doesn't exist. /// Load config from path, or create a personalized example config if it doesn't exist.
pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<RelayConfig, anyhow::Error> { pub fn load_or_create_config(
path: &str,
info: Option<&RelayInfo>,
) -> Result<RelayConfig, anyhow::Error> {
let p = std::path::Path::new(path); let p = std::path::Path::new(path);
if p.exists() { if p.exists() {
return load_config(path); return load_config(path);
@@ -164,7 +167,9 @@ pub fn load_or_create_config(path: &str, info: Option<&RelayInfo>) -> Result<Rel
/// Generate an example TOML config, personalized with this relay's info if available. /// Generate an example TOML config, personalized with this relay's info if available.
fn generate_example_config(info: Option<&RelayInfo>) -> String { fn generate_example_config(info: Option<&RelayInfo>) -> String {
let listen = info.map(|i| i.listen_addr.as_str()).unwrap_or("0.0.0.0:4433"); let listen = info
.map(|i| i.listen_addr.as_str())
.unwrap_or("0.0.0.0:4433");
let peer_example = if let Some(i) = info { let peer_example = if let Some(i) = info {
let ip = i.public_ip.as_deref().unwrap_or("this-relay-ip"); let ip = i.public_ip.as_deref().unwrap_or("this-relay-ip");
format!( format!(

View File

@@ -25,16 +25,13 @@ pub struct Event {
pub src: Option<String>, pub src: Option<String>,
/// Packet sequence number. /// Packet sequence number.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<u16>, pub seq: Option<u32>,
/// Codec identifier. /// Codec identifier.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub codec: Option<String>, pub codec: Option<String>,
/// FEC block ID. /// FEC block ID (low byte) and symbol index (high byte).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub fec_block: Option<u8>, pub fec_block: Option<u16>,
/// FEC symbol index.
#[serde(skip_serializing_if = "Option::is_none")]
pub fec_sym: Option<u8>,
/// Is FEC repair packet. /// Is FEC repair packet.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub repair: Option<bool>, pub repair: Option<bool>,
@@ -60,7 +57,9 @@ pub struct Event {
impl Event { impl Event {
fn now() -> String { fn now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.6fZ").to_string() chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.6fZ")
.to_string()
} }
/// Create a minimal event with just type and timestamp. /// Create a minimal event with just type and timestamp.
@@ -73,7 +72,6 @@ impl Event {
seq: None, seq: None,
codec: None, codec: None,
fec_block: None, fec_block: None,
fec_sym: None,
repair: None, repair: None,
len: None, len: None,
to_count: None, to_count: None,
@@ -85,33 +83,59 @@ impl Event {
} }
/// Set room. /// Set room.
pub fn room(mut self, room: &str) -> Self { self.room = Some(room.to_string()); self } pub fn room(mut self, room: &str) -> Self {
self.room = Some(room.to_string());
self
}
/// Set source. /// Set source.
pub fn src(mut self, src: &str) -> Self { self.src = Some(src.to_string()); self } pub fn src(mut self, src: &str) -> Self {
self.src = Some(src.to_string());
self
}
/// Set packet header fields from a MediaPacket. /// Set packet header fields from a MediaPacket.
pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self { pub fn packet(mut self, pkt: &wzp_proto::MediaPacket) -> Self {
self.seq = Some(pkt.header.seq); self.seq = Some(pkt.header.seq);
self.codec = Some(format!("{:?}", pkt.header.codec_id)); self.codec = Some(format!("{:?}", pkt.header.codec_id));
self.fec_block = Some(pkt.header.fec_block); self.fec_block = Some(pkt.header.fec_block);
self.fec_sym = Some(pkt.header.fec_symbol); self.repair = Some(pkt.header.is_repair());
self.repair = Some(pkt.header.is_repair);
self.len = Some(pkt.payload.len()); self.len = Some(pkt.payload.len());
self self
} }
/// Set seq only (when full packet not available). /// Set seq only (when full packet not available).
pub fn seq(mut self, seq: u16) -> Self { self.seq = Some(seq); self } pub fn seq(mut self, seq: u32) -> Self {
self.seq = Some(seq);
self
}
/// Set payload length. /// Set payload length.
pub fn len(mut self, len: usize) -> Self { self.len = Some(len); self } pub fn len(mut self, len: usize) -> Self {
self.len = Some(len);
self
}
/// Set recipient count. /// Set recipient count.
pub fn to_count(mut self, n: usize) -> Self { self.to_count = Some(n); self } pub fn to_count(mut self, n: usize) -> Self {
self.to_count = Some(n);
self
}
/// Set peer label. /// Set peer label.
pub fn peer(mut self, peer: &str) -> Self { self.peer = Some(peer.to_string()); self } pub fn peer(mut self, peer: &str) -> Self {
self.peer = Some(peer.to_string());
self
}
/// Set drop reason. /// Set drop reason.
pub fn reason(mut self, reason: &str) -> Self { self.reason = Some(reason.to_string()); self } pub fn reason(mut self, reason: &str) -> Self {
self.reason = Some(reason.to_string());
self
}
/// Set presence action. /// Set presence action.
pub fn action(mut self, action: &str) -> Self { self.action = Some(action.to_string()); self } pub fn action(mut self, action: &str) -> Self {
self.action = Some(action.to_string());
self
}
/// Set participant count. /// Set participant count.
pub fn participants(mut self, n: usize) -> Self { self.participants = Some(n); self } pub fn participants(mut self, n: usize) -> Self {
self.participants = Some(n);
self
}
} }
/// Handle for emitting events. Cheap to clone. /// Handle for emitting events. Cheap to clone.
@@ -181,8 +205,12 @@ async fn writer_task(path: PathBuf, mut rx: mpsc::UnboundedReceiver<Event>) {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
match serde_json::to_string(&event) { match serde_json::to_string(&event) {
Ok(json) => { Ok(json) => {
if writer.write_all(json.as_bytes()).await.is_err() { break; } if writer.write_all(json.as_bytes()).await.is_err() {
if writer.write_all(b"\n").await.is_err() { break; } break;
}
if writer.write_all(b"\n").await.is_err() {
break;
}
count += 1; count += 1;
// Flush every 100 events // Flush every 100 events
if count % 100 == 0 { if count % 100 == 0 {

View File

@@ -11,7 +11,7 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use bytes::Bytes; use bytes::Bytes;
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -56,13 +56,14 @@ impl Deduplicator {
} }
/// Returns true if this packet is a duplicate (already seen within TTL). /// Returns true if this packet is a duplicate (already seen within TTL).
fn is_dup(&mut self, room_hash: &[u8; 8], seq: u16, extra: u64) -> bool { fn is_dup(&mut self, room_hash: &[u8; 8], seq: u32, extra: u64) -> bool {
let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra; let key = u64::from_be_bytes(*room_hash) ^ (seq as u64) ^ extra;
let now = Instant::now(); let now = Instant::now();
// Periodic cleanup (every ~256 packets) // Periodic cleanup (every ~256 packets)
if self.entries.len() > 256 { if self.entries.len() > 256 {
self.entries.retain(|_, ts| now.duration_since(*ts) < self.ttl); self.entries
.retain(|_, ts| now.duration_since(*ts) < self.ttl);
} }
if let Some(ts) = self.entries.get(&key) { if let Some(ts) = self.entries.get(&key) {
@@ -215,8 +216,11 @@ impl FederationManager {
pub async fn broadcast_signal(&self, msg: &wzp_proto::SignalMessage) -> usize { pub async fn broadcast_signal(&self, msg: &wzp_proto::SignalMessage) -> usize {
let peers: Vec<(String, String, Arc<QuinnTransport>)> = { let peers: Vec<(String, String, Arc<QuinnTransport>)> = {
let links = self.peer_links.lock().await; let links = self.peer_links.lock().await;
links.iter().map(|(fp, l)| (fp.clone(), l.label.clone(), l.transport.clone())).collect() links
}; // lock released .iter()
.map(|(fp, l)| (fp.clone(), l.label.clone(), l.transport.clone()))
.collect()
}; // lock released
let mut count = 0; let mut count = 0;
for (fp, label, transport) in &peers { for (fp, label, transport) in &peers {
match transport.send_signal(msg).await { match transport.send_signal(msg).await {
@@ -249,7 +253,7 @@ impl FederationManager {
let transport = { let transport = {
let links = self.peer_links.lock().await; let links = self.peer_links.lock().await;
links.get(&normalized).map(|l| l.transport.clone()) links.get(&normalized).map(|l| l.transport.clone())
}; // lock released }; // lock released
match transport { match transport {
Some(t) => t Some(t) => t
.send_signal(msg) .send_signal(msg)
@@ -300,9 +304,10 @@ impl FederationManager {
return Some(room.to_string()); return Some(room.to_string());
} }
// Hashed match (desktop clients hash room names for SNI privacy) // Hashed match (desktop clients hash room names for SNI privacy)
self.global_rooms.iter().find(|name| { self.global_rooms
wzp_crypto::hash_room_name(name) == room .iter()
}).map(|s| s.to_string()) .find(|name| wzp_crypto::hash_room_name(name) == room)
.map(|s| s.to_string())
} }
/// Get the canonical federation room hash for a room. /// Get the canonical federation room hash for a room.
@@ -371,7 +376,10 @@ impl FederationManager {
/// Get all remote participants for a room from all peer links. /// Get all remote participants for a room from all peer links.
/// Deduplicates by fingerprint (same participant may appear via multiple links). /// Deduplicates by fingerprint (same participant may appear via multiple links).
pub async fn get_remote_participants(&self, room: &str) -> Vec<wzp_proto::packet::RoomParticipant> { pub async fn get_remote_participants(
&self,
room: &str,
) -> Vec<wzp_proto::packet::RoomParticipant> {
let canonical = self.resolve_global_room(room); let canonical = self.resolve_global_room(room);
let links = self.peer_links.lock().await; let links = self.peer_links.lock().await;
let mut result = Vec::new(); let mut result = Vec::new();
@@ -407,12 +415,22 @@ impl FederationManager {
/// the other room-tagged helpers and for future per-room-name logging /// the other room-tagged helpers and for future per-room-name logging
/// or rate limiting; the body currently forwards on `room_hash` alone /// or rate limiting; the body currently forwards on `room_hash` alone
/// because that's what the wire format carries. /// because that's what the wire format carries.
pub async fn forward_to_peers(&self, _room_name: &str, room_hash: &[u8; 8], media_data: &Bytes) { pub async fn forward_to_peers(
&self,
_room_name: &str,
room_hash: &[u8; 8],
media_data: &Bytes,
) {
let peers: Vec<(String, Arc<QuinnTransport>)> = { let peers: Vec<(String, Arc<QuinnTransport>)> = {
let links = self.peer_links.lock().await; let links = self.peer_links.lock().await;
if links.is_empty() { return; } if links.is_empty() {
links.values().map(|l| (l.label.clone(), l.transport.clone())).collect() return;
}; // lock released }
links
.values()
.map(|l| (l.label.clone(), l.transport.clone()))
.collect()
}; // lock released
for (label, transport) in &peers { for (label, transport) in &peers {
let mut tagged = Vec::with_capacity(8 + media_data.len()); let mut tagged = Vec::with_capacity(8 + media_data.len());
@@ -420,8 +438,10 @@ impl FederationManager {
tagged.extend_from_slice(media_data); tagged.extend_from_slice(media_data);
match transport.send_raw_datagram(&tagged) { match transport.send_raw_datagram(&tagged) {
Ok(()) => { Ok(()) => {
self.metrics.federation_packets_forwarded self.metrics
.with_label_values(&[label, "out"]).inc(); .federation_packets_forwarded
.with_label_values(&[label, "out"])
.inc();
} }
Err(e) => warn!(peer = %label, "federation send error: {e}"), Err(e) => warn!(peer = %label, "federation send error: {e}"),
} }
@@ -431,20 +451,25 @@ impl FederationManager {
// ── Trust verification (kept from previous implementation) ── // ── Trust verification (kept from previous implementation) ──
pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> { pub fn find_peer_by_fingerprint(&self, fp: &str) -> Option<&PeerConfig> {
self.peers.iter().find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp)) self.peers
.iter()
.find(|p| normalize_fp(&p.fingerprint) == normalize_fp(fp))
} }
pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> { pub fn find_peer_by_addr(&self, addr: SocketAddr) -> Option<&PeerConfig> {
let addr_ip = addr.ip(); let addr_ip = addr.ip();
self.peers.iter().find(|p| { self.peers.iter().find(|p| {
p.url.parse::<SocketAddr>() p.url
.parse::<SocketAddr>()
.map(|sa| sa.ip() == addr_ip) .map(|sa| sa.ip() == addr_ip)
.unwrap_or(false) .unwrap_or(false)
}) })
} }
pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> { pub fn find_trusted_by_fingerprint(&self, fp: &str) -> Option<&TrustedConfig> {
self.trusted.iter().find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp)) self.trusted
.iter()
.find(|t| normalize_fp(&t.fingerprint) == normalize_fp(fp))
} }
pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> { pub fn check_inbound_trust(&self, addr: SocketAddr, hello_fp: &str) -> Option<String> {
@@ -452,7 +477,12 @@ impl FederationManager {
return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone())); return Some(peer.label.clone().unwrap_or_else(|| peer.url.clone()));
} }
if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) { if let Some(trusted) = self.find_trusted_by_fingerprint(hello_fp) {
return Some(trusted.label.clone().unwrap_or_else(|| hello_fp[..16].to_string())); return Some(
trusted
.label
.clone()
.unwrap_or_else(|| hello_fp[..16].to_string()),
);
} }
None None
} }
@@ -471,7 +501,8 @@ pub async fn run_federation_media_egress(
if count == 1 || count % 250 == 0 { if count == 1 || count % 250 == 0 {
info!(room = %out.room_name, count, "federation egress: forwarding media"); info!(room = %out.room_name, count, "federation egress: forwarding media");
} }
fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data).await; fm.forward_to_peers(&out.room_name, &out.room_hash, &out.data)
.await;
} }
info!(total = count, "federation egress task ended"); info!(total = count, "federation egress task ended");
} }
@@ -536,7 +567,9 @@ async fn run_stale_presence_sweeper(fm: Arc<FederationManager>) {
let links = fm.peer_links.lock().await; let links = fm.peer_links.lock().await;
let mut stale = Vec::new(); let mut stale = Vec::new();
for (fp, link) in links.iter() { for (fp, link) in links.iter() {
if link.last_seen.elapsed() > stale_threshold && !link.remote_participants.is_empty() { if link.last_seen.elapsed() > stale_threshold
&& !link.remote_participants.is_empty()
{
for room in link.remote_participants.keys() { for room in link.remote_participants.keys() {
stale.push((fp.clone(), room.clone())); stale.push((fp.clone(), room.clone()));
} }
@@ -615,7 +648,10 @@ async fn run_peer_loop(fm: Arc<FederationManager>, peer: PeerConfig) {
} }
/// Connect to a peer relay and send hello. /// Connect to a peer relay and send hello.
async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Arc<QuinnTransport>, anyhow::Error> { async fn connect_to_peer(
fm: &FederationManager,
peer: &PeerConfig,
) -> Result<Arc<QuinnTransport>, anyhow::Error> {
let addr: SocketAddr = peer.url.parse()?; let addr: SocketAddr = peer.url.parse()?;
let client_cfg = wzp_transport::client_config(); let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?; let conn = wzp_transport::connect(&fm.endpoint, addr, "_federation", client_cfg).await?;
@@ -625,7 +661,9 @@ async fn connect_to_peer(fm: &FederationManager, peer: &PeerConfig) -> Result<Ar
let hello = SignalMessage::FederationHello { let hello = SignalMessage::FederationHello {
tls_fingerprint: fm.local_tls_fp.clone(), tls_fingerprint: fm.local_tls_fp.clone(),
}; };
transport.send_signal(&hello).await transport
.send_signal(&hello)
.await
.map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?; .map_err(|e| anyhow::anyhow!("federation hello send failed: {e}"))?;
info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)"); info!(peer_url = %peer.url, label = ?peer.label, "federation: connected (hello sent)");
@@ -642,16 +680,22 @@ async fn run_federation_link(
peer_label: String, peer_label: String,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
// Register peer link + metrics // Register peer link + metrics
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(1); fm.metrics
.federation_peer_status
.with_label_values(&[&peer_label])
.set(1);
{ {
let mut links = fm.peer_links.lock().await; let mut links = fm.peer_links.lock().await;
links.insert(peer_fp.clone(), PeerLink { links.insert(
transport: transport.clone(), peer_fp.clone(),
label: peer_label.clone(), PeerLink {
active_rooms: HashSet::new(), transport: transport.clone(),
remote_participants: HashMap::new(), label: peer_label.clone(),
last_seen: Instant::now(), active_rooms: HashSet::new(),
}); remote_participants: HashMap::new(),
last_seen: Instant::now(),
},
);
} }
// Announce our currently active global rooms to this new peer // Announce our currently active global rooms to this new peer
@@ -665,7 +709,10 @@ async fn run_federation_link(
if fm.is_global_room(room_name) { if fm.is_global_room(room_name) {
let participants = fm.room_mgr.local_participant_list(room_name); let participants = fm.room_mgr.local_participant_list(room_name);
info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer"); info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer");
msgs.push(SignalMessage::GlobalRoomActive { room: room_name.clone(), participants }); msgs.push(SignalMessage::GlobalRoomActive {
room: room_name.clone(),
participants,
});
} }
} }
@@ -761,7 +808,10 @@ async fn run_federation_link(
} }
// Cleanup: remove peer link + metrics // Cleanup: remove peer link + metrics
fm.metrics.federation_peer_status.with_label_values(&[&peer_label]).set(0); fm.metrics
.federation_peer_status
.with_label_values(&[&peer_label])
.set(0);
{ {
let mut links = fm.peer_links.lock().await; let mut links = fm.peer_links.lock().await;
links.remove(&peer_fp); links.remove(&peer_fp);
@@ -799,34 +849,43 @@ async fn handle_signal(
fm.metrics.federation_active_rooms.set(total as i64); fm.metrics.federation_active_rooms.set(total as i64);
if let Some(link) = links.get_mut(peer_fp) { if let Some(link) = links.get_mut(peer_fp) {
// Tag remote participants with their relay label // Tag remote participants with their relay label
let tagged: Vec<_> = participants.iter().map(|p| { let tagged: Vec<_> = participants
let mut tagged = p.clone(); .iter()
if tagged.relay_label.is_none() { .map(|p| {
tagged.relay_label = Some(link.label.clone()); let mut tagged = p.clone();
} if tagged.relay_label.is_none() {
tagged tagged.relay_label = Some(link.label.clone());
}).collect(); }
tagged
})
.collect();
link.remote_participants.insert(room.clone(), tagged); link.remote_participants.insert(room.clone(), tagged);
} }
// Propagate to other peers (with relay labels preserved) // Propagate to other peers (with relay labels preserved)
let tagged_for_propagation = if let Some(link) = links.get(peer_fp) { let tagged_for_propagation = if let Some(link) = links.get(peer_fp) {
let label = link.label.clone(); let label = link.label.clone();
participants.iter().map(|p| { participants
let mut t = p.clone(); .iter()
if t.relay_label.is_none() { .map(|p| {
t.relay_label = Some(label.clone()); let mut t = p.clone();
} if t.relay_label.is_none() {
t t.relay_label = Some(label.clone());
}).collect::<Vec<_>>() }
t
})
.collect::<Vec<_>>()
} else { } else {
participants.clone() participants.clone()
}; };
for (fp, link) in links.iter() { for (fp, link) in links.iter() {
if fp != peer_fp { if fp != peer_fp {
let _ = link.transport.send_signal(&SignalMessage::GlobalRoomActive { let _ = link
room: room.clone(), .transport
participants: tagged_for_propagation.clone(), .send_signal(&SignalMessage::GlobalRoomActive {
}).await; room: room.clone(),
participants: tagged_for_propagation.clone(),
})
.await;
} }
} }
drop(links); drop(links);
@@ -835,19 +894,25 @@ async fn handle_signal(
// Find the local room name (may be hashed or raw) // Find the local room name (may be hashed or raw)
let active = fm.room_mgr.active_rooms(); let active = fm.room_mgr.active_rooms();
for local_room in &active { for local_room in &active {
if fm.is_global_room(local_room) && fm.resolve_global_room(local_room) == fm.resolve_global_room(&room) { if fm.is_global_room(local_room)
&& fm.resolve_global_room(local_room) == fm.resolve_global_room(&room)
{
// Build merged participant list: local + all remote (deduped) // Build merged participant list: local + all remote (deduped)
let mut all_participants = fm.room_mgr.local_participant_list(local_room); let mut all_participants = fm.room_mgr.local_participant_list(local_room);
{ {
let links = fm.peer_links.lock().await; let links = fm.peer_links.lock().await;
for link in links.values() { for link in links.values() {
if let Some(ref canonical) = fm.resolve_global_room(local_room) { if let Some(ref canonical) = fm.resolve_global_room(local_room) {
if let Some(remote) = link.remote_participants.get(canonical.as_str()) { if let Some(remote) =
link.remote_participants.get(canonical.as_str())
{
all_participants.extend(remote.iter().cloned()); all_participants.extend(remote.iter().cloned());
} }
// Also check raw room name, but only if different from canonical // Also check raw room name, but only if different from canonical
if canonical != local_room { if canonical != local_room {
if let Some(remote) = link.remote_participants.get(local_room) { if let Some(remote) =
link.remote_participants.get(local_room)
{
all_participants.extend(remote.iter().cloned()); all_participants.extend(remote.iter().cloned());
} }
} }
@@ -890,7 +955,9 @@ async fn handle_signal(
let canonical = fm.resolve_global_room(&room); let canonical = fm.resolve_global_room(&room);
let mut result = Vec::new(); let mut result = Vec::new();
for (fp, link) in links.iter() { for (fp, link) in links.iter() {
if fp == peer_fp { continue; } if fp == peer_fp {
continue;
}
if let Some(ref c) = canonical { if let Some(ref c) = canonical {
if let Some(remote) = link.remote_participants.get(c.as_str()) { if let Some(remote) = link.remote_participants.get(c.as_str()) {
result.extend(remote.iter().cloned()); result.extend(remote.iter().cloned());
@@ -904,11 +971,16 @@ async fn handle_signal(
// Propagate to other peers: send updated GlobalRoomActive with revised list, // Propagate to other peers: send updated GlobalRoomActive with revised list,
// or GlobalRoomInactive if no participants remain anywhere // or GlobalRoomInactive if no participants remain anywhere
let local_active = fm.room_mgr.active_rooms().iter().any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room)); let local_active = fm
.room_mgr
.active_rooms()
.iter()
.any(|r| fm.resolve_global_room(r) == fm.resolve_global_room(&room));
let has_remaining = !remaining_remote.is_empty() || local_active; let has_remaining = !remaining_remote.is_empty() || local_active;
// Collect peer transports to send to (avoid holding lock across await) // Collect peer transports to send to (avoid holding lock across await)
let peer_sends: Vec<_> = links.iter() let peer_sends: Vec<_> = links
.iter()
.filter(|(fp, _)| *fp != peer_fp) .filter(|(fp, _)| *fp != peer_fp)
.map(|(_, link)| link.transport.clone()) .map(|(_, link)| link.transport.clone())
.collect(); .collect();
@@ -920,7 +992,8 @@ async fn handle_signal(
if local_active { if local_active {
for local_room in fm.room_mgr.active_rooms() { for local_room in fm.room_mgr.active_rooms() {
if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) { if fm.resolve_global_room(&local_room) == fm.resolve_global_room(&room) {
updated_participants.extend(fm.room_mgr.local_participant_list(&local_room)); updated_participants
.extend(fm.room_mgr.local_participant_list(&local_room));
break; break;
} }
} }
@@ -943,7 +1016,9 @@ async fn handle_signal(
// Broadcast updated RoomUpdate to local clients (remote participant removed) // Broadcast updated RoomUpdate to local clients (remote participant removed)
let active = fm.room_mgr.active_rooms(); let active = fm.room_mgr.active_rooms();
for local_room in &active { for local_room in &active {
if fm.is_global_room(local_room) && fm.resolve_global_room(local_room) == fm.resolve_global_room(&room) { if fm.is_global_room(local_room)
&& fm.resolve_global_room(local_room) == fm.resolve_global_room(&room)
{
let mut all_participants = fm.room_mgr.local_participant_list(local_room); let mut all_participants = fm.room_mgr.local_participant_list(local_room);
all_participants.extend(remaining_remote.iter().cloned()); all_participants.extend(remaining_remote.iter().cloned());
// Deduplicate by fingerprint // Deduplicate by fingerprint
@@ -972,7 +1047,10 @@ async fn handle_signal(
// Loop prevention: drop any forward whose origin matches // Loop prevention: drop any forward whose origin matches
// our own federation TLS fingerprint. With // our own federation TLS fingerprint. With
// broadcast-to-all-peers this prevents A→B→A echo loops. // broadcast-to-all-peers this prevents A→B→A echo loops.
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => { SignalMessage::FederatedSignalForward {
inner,
origin_relay_fp,
} => {
if origin_relay_fp == fm.local_tls_fp { if origin_relay_fp == fm.local_tls_fp {
tracing::debug!( tracing::debug!(
peer = %peer_label, peer = %peer_label,
@@ -1016,12 +1094,10 @@ async fn handle_signal(
} }
/// Handle an incoming federation datagram (room-hash-tagged media). /// Handle an incoming federation datagram (room-hash-tagged media).
async fn handle_datagram( async fn handle_datagram(fm: &Arc<FederationManager>, source_peer_fp: &str, data: Bytes) {
fm: &Arc<FederationManager>, if data.len() < 12 {
source_peer_fp: &str, return;
data: Bytes, } // 8-byte hash + min packet
) {
if data.len() < 12 { return; } // 8-byte hash + min packet
let mut rh = [0u8; 8]; let mut rh = [0u8; 8];
rh.copy_from_slice(&data[..8]); rh.copy_from_slice(&data[..8]);
@@ -1030,7 +1106,8 @@ async fn handle_datagram(
let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) { let pkt = match wzp_proto::MediaPacket::from_bytes(media_bytes.clone()) {
Some(pkt) => pkt, Some(pkt) => pkt,
None => { None => {
fm.event_log.emit(Event::new("federation_ingress_malformed").len(data.len())); fm.event_log
.emit(Event::new("federation_ingress_malformed").len(data.len()));
return; return;
} }
}; };
@@ -1038,13 +1115,22 @@ async fn handle_datagram(
// Event log: federation ingress // Event log: federation ingress
let peer_label = { let peer_label = {
let links = fm.peer_links.lock().await; let links = fm.peer_links.lock().await;
links.get(source_peer_fp).map(|l| l.label.clone()).unwrap_or_default() links
.get(source_peer_fp)
.map(|l| l.label.clone())
.unwrap_or_default()
}; };
fm.event_log.emit(Event::new("federation_ingress").packet(&pkt).peer(&peer_label)); fm.event_log.emit(
Event::new("federation_ingress")
.packet(&pkt)
.peer(&peer_label),
);
// Count inbound federation packet + update last_seen // Count inbound federation packet + update last_seen
fm.metrics.federation_packets_forwarded fm.metrics
.with_label_values(&[source_peer_fp, "in"]).inc(); .federation_packets_forwarded
.with_label_values(&[source_peer_fp, "in"])
.inc();
{ {
let mut links = fm.peer_links.lock().await; let mut links = fm.peer_links.lock().await;
if let Some(link) = links.get_mut(source_peer_fp) { if let Some(link) = links.get_mut(source_peer_fp) {
@@ -1065,7 +1151,11 @@ async fn handle_datagram(
{ {
let mut dedup = fm.dedup.lock().await; let mut dedup = fm.dedup.lock().await;
if dedup.is_dup(&rh, pkt.header.seq, payload_hash) { if dedup.is_dup(&rh, pkt.header.seq, payload_hash) {
fm.event_log.emit(Event::new("dedup_drop").seq(pkt.header.seq).peer(&peer_label)); fm.event_log.emit(
Event::new("dedup_drop")
.seq(pkt.header.seq)
.peer(&peer_label),
);
return; return;
} }
} }
@@ -1074,18 +1164,33 @@ async fn handle_datagram(
let room_name = { let room_name = {
let active = fm.room_mgr.active_rooms(); let active = fm.room_mgr.active_rooms();
// First: check local rooms (has participants) // First: check local rooms (has participants)
active.iter().find(|r| room_hash(r) == rh).cloned() active
.or_else(|| active.iter().find(|r| fm.global_room_hash(r) == rh).cloned()) .iter()
.find(|r| room_hash(r) == rh)
.cloned()
.or_else(|| {
active
.iter()
.find(|r| fm.global_room_hash(r) == rh)
.cloned()
})
// Second: check static global room config (hub relay may have no local participants) // Second: check static global room config (hub relay may have no local participants)
.or_else(|| { .or_else(|| {
fm.global_rooms.iter().find(|name| room_hash(name) == rh).cloned() fm.global_rooms
.iter()
.find(|name| room_hash(name) == rh)
.cloned()
}) })
}; };
let room_name = match room_name { let room_name = match room_name {
Some(r) => r, Some(r) => r,
None => { None => {
fm.event_log.emit(Event::new("room_not_found").seq(pkt.header.seq).peer(&peer_label)); fm.event_log.emit(
Event::new("room_not_found")
.seq(pkt.header.seq)
.peer(&peer_label),
);
// Phase 4.1 diagnostic: log the hash + active rooms // Phase 4.1 diagnostic: log the hash + active rooms
// so we can diagnose cross-relay call-* media routing // so we can diagnose cross-relay call-* media routing
// failures. This fires when a peer relay sends media // failures. This fires when a peer relay sends media
@@ -1107,10 +1212,15 @@ async fn handle_datagram(
// Rate limit per room // Rate limit per room
if FEDERATION_RATE_LIMIT_PPS > 0 { if FEDERATION_RATE_LIMIT_PPS > 0 {
let mut limiters = fm.rate_limiters.lock().await; let mut limiters = fm.rate_limiters.lock().await;
let limiter = limiters.entry(room_name.clone()) let limiter = limiters
.entry(room_name.clone())
.or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS)); .or_insert_with(|| RateLimiter::new(FEDERATION_RATE_LIMIT_PPS));
if !limiter.allow() { if !limiter.allow() {
fm.event_log.emit(Event::new("rate_limit_drop").room(&room_name).seq(pkt.header.seq)); fm.event_log.emit(
Event::new("rate_limit_drop")
.room(&room_name)
.seq(pkt.header.seq),
);
return; return;
} }
} }
@@ -1122,14 +1232,26 @@ async fn handle_datagram(
match sender { match sender {
room::ParticipantSender::Quic(t) => { room::ParticipantSender::Quic(t) => {
if let Err(e) = t.send_raw_datagram(&media_bytes) { if let Err(e) = t.send_raw_datagram(&media_bytes) {
fm.event_log.emit(Event::new("local_deliver_error").room(&room_name).seq(pkt.header.seq).reason(&e.to_string())); fm.event_log.emit(
Event::new("local_deliver_error")
.room(&room_name)
.seq(pkt.header.seq)
.reason(&e.to_string()),
);
warn!("federation local delivery error: {e}"); warn!("federation local delivery error: {e}");
} }
} }
room::ParticipantSender::WebSocket(_) => { let _ = sender.send_raw(&pkt.payload).await; } room::ParticipantSender::WebSocket(_) => {
let _ = sender.send_raw(&pkt.payload).await;
}
} }
} }
fm.event_log.emit(Event::new("local_deliver").room(&room_name).seq(pkt.header.seq).to_count(locals.len())); fm.event_log.emit(
Event::new("local_deliver")
.room(&room_name)
.seq(pkt.header.seq)
.to_count(locals.len()),
);
// Multi-hop: forward to ALL other connected peers (not the source) // Multi-hop: forward to ALL other connected peers (not the source)
// Don't filter by active_rooms — the receiving peer decides whether to deliver // Don't filter by active_rooms — the receiving peer decides whether to deliver

View File

@@ -20,29 +20,48 @@ use wzp_proto::{MediaTransport, QualityProfile, SignalMessage};
pub async fn accept_handshake( pub async fn accept_handshake(
transport: &dyn MediaTransport, transport: &dyn MediaTransport,
seed: &[u8; 32], seed: &[u8; 32],
) -> Result<(Box<dyn CryptoSession>, QualityProfile, String, Option<String>), anyhow::Error> { ) -> Result<
(
Box<dyn CryptoSession>,
QualityProfile,
String,
Option<String>,
),
anyhow::Error,
> {
// 1. Receive CallOffer // 1. Receive CallOffer
let offer = transport let offer = transport
.recv_signal() .recv_signal()
.await? .await?
.ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?; .ok_or_else(|| anyhow::anyhow!("connection closed before receiving CallOffer"))?;
let (caller_identity_pub, caller_ephemeral_pub, caller_signature, supported_profiles, caller_alias) = let (
match offer { caller_identity_pub,
SignalMessage::CallOffer { caller_ephemeral_pub,
identity_pub, caller_signature,
ephemeral_pub, supported_profiles,
signature, caller_alias,
supported_profiles, ) = match offer {
alias, SignalMessage::CallOffer {
} => (identity_pub, ephemeral_pub, signature, supported_profiles, alias), identity_pub,
other => { ephemeral_pub,
return Err(anyhow::anyhow!( signature,
"expected CallOffer, got {:?}", supported_profiles,
std::mem::discriminant(&other) alias,
)) } => (
} identity_pub,
}; ephemeral_pub,
signature,
supported_profiles,
alias,
),
other => {
return Err(anyhow::anyhow!(
"expected CallOffer, got {:?}",
std::mem::discriminant(&other)
));
}
};
// 2. Verify caller's signature over (ephemeral_pub || "call-offer") // 2. Verify caller's signature over (ephemeral_pub || "call-offer")
let mut verify_data = Vec::with_capacity(32 + 10); let mut verify_data = Vec::with_capacity(32 + 10);
@@ -81,11 +100,11 @@ pub async fn accept_handshake(
// Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:... // Derive caller fingerprint: SHA-256(Ed25519 pub)[:16], formatted as xxxx:xxxx:...
// Must match the format used in signal registration and presence. // Must match the format used in signal registration and presence.
let caller_fp = { let caller_fp = {
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
let hash = Sha256::digest(&caller_identity_pub); let hash = Sha256::digest(&caller_identity_pub);
let fp = wzp_crypto::Fingerprint([ let fp = wzp_crypto::Fingerprint([
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8],
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15],
]); ]);
fp.to_string() fp.to_string()
}; };

View File

@@ -12,7 +12,6 @@ pub mod call_registry;
pub mod config; pub mod config;
pub mod event_log; pub mod event_log;
pub mod federation; pub mod federation;
pub mod signal_hub;
pub mod handshake; pub mod handshake;
pub mod metrics; pub mod metrics;
pub mod pipeline; pub mod pipeline;
@@ -22,6 +21,7 @@ pub mod relay_link;
pub mod room; pub mod room;
pub mod route; pub mod route;
pub mod session_mgr; pub mod session_mgr;
pub mod signal_hub;
pub mod trunk; pub mod trunk;
pub mod ws; pub mod ws;

View File

@@ -8,8 +8,8 @@
//! The web bridge connects with room name as SNI. //! The web bridge connects with room name as SNI.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration; use std::time::Duration;
use clap::Parser; use clap::Parser;
@@ -116,7 +116,9 @@ fn parse_args() -> CliResult {
} }
// Track if we need to create the config after identity is known // Track if we need to create the config after identity is known
let config_needs_create = args.config_file.as_ref() let config_needs_create = args
.config_file
.as_ref()
.map(|p| !std::path::Path::new(p).exists()) .map(|p| !std::path::Path::new(p).exists())
.unwrap_or(false); .unwrap_or(false);
@@ -125,11 +127,10 @@ fn parse_args() -> CliResult {
// Will be re-created with personalized info after identity is loaded // Will be re-created with personalized info after identity is loaded
RelayConfig::default() RelayConfig::default()
} else { } else {
wzp_relay::config::load_config(path) wzp_relay::config::load_config(path).unwrap_or_else(|e| {
.unwrap_or_else(|e| { eprintln!("failed to load config from {path}: {e}");
eprintln!("failed to load config from {path}: {e}"); std::process::exit(1);
std::process::exit(1); })
})
} }
} else { } else {
RelayConfig::default() RelayConfig::default()
@@ -164,7 +165,9 @@ fn parse_args() -> CliResult {
config.static_dir = Some(dir); config.static_dir = Some(dir);
} }
for name in args.global_room { for name in args.global_room {
config.global_rooms.push(wzp_relay::config::GlobalRoomConfig { name }); config
.global_rooms
.push(wzp_relay::config::GlobalRoomConfig { name });
} }
if let Some(tap) = args.debug_tap { if let Some(tap) = args.debug_tap {
config.debug_tap = Some(tap); config.debug_tap = Some(tap);
@@ -199,7 +202,9 @@ async fn run_upstream(
let mut pipe = pipeline.lock().await; let mut pipe = pipeline.lock().await;
let decoded = pipe.ingest(pkt); let decoded = pipe.ingest(pkt);
let mut out = Vec::new(); let mut out = Vec::new();
for p in decoded { out.extend(pipe.prepare_outbound(p)); } for p in decoded {
out.extend(pipe.prepare_outbound(p));
}
out out
}; };
for p in &outbound { for p in &outbound {
@@ -208,10 +213,18 @@ async fn run_upstream(
return; return;
} }
} }
stats.upstream_packets.fetch_add(outbound.len() as u64, Ordering::Relaxed); stats
.upstream_packets
.fetch_add(outbound.len() as u64, Ordering::Relaxed);
}
Ok(None) => {
info!("client disconnected (upstream)");
break;
}
Err(e) => {
error!("upstream recv: {e}");
break;
} }
Ok(None) => { info!("client disconnected (upstream)"); break; }
Err(e) => { error!("upstream recv: {e}"); break; }
} }
} }
} }
@@ -229,7 +242,9 @@ async fn run_downstream(
let mut pipe = pipeline.lock().await; let mut pipe = pipeline.lock().await;
let decoded = pipe.ingest(pkt); let decoded = pipe.ingest(pkt);
let mut out = Vec::new(); let mut out = Vec::new();
for p in decoded { out.extend(pipe.prepare_outbound(p)); } for p in decoded {
out.extend(pipe.prepare_outbound(p));
}
out out
}; };
for p in &outbound { for p in &outbound {
@@ -238,10 +253,18 @@ async fn run_downstream(
return; return;
} }
} }
stats.downstream_packets.fetch_add(outbound.len() as u64, Ordering::Relaxed); stats
.downstream_packets
.fetch_add(outbound.len() as u64, Ordering::Relaxed);
}
Ok(None) => {
info!("remote disconnected (downstream)");
break;
}
Err(e) => {
error!("downstream recv: {e}");
break;
} }
Ok(None) => { info!("remote disconnected (downstream)"); break; }
Err(e) => { error!("downstream recv: {e}"); break; }
} }
} }
} }
@@ -266,7 +289,12 @@ const BUILD_GIT_HASH: &str = env!("WZP_BUILD_HASH");
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let CliResult { config, identity_path, config_file, config_needs_create } = parse_args(); let CliResult {
config,
identity_path,
config_file,
config_needs_create,
} = parse_args();
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
info!(version = BUILD_GIT_HASH, "wzp-relay build"); info!(version = BUILD_GIT_HASH, "wzp-relay build");
rustls::crypto::ring::default_provider() rustls::crypto::ring::default_provider()
@@ -303,7 +331,10 @@ async fn main() -> anyhow::Result<()> {
info!("loaded relay identity from {}", id_path.display()); info!("loaded relay identity from {}", id_path.display());
s s
} else { } else {
warn!("corrupt identity file {}, generating new", id_path.display()); warn!(
"corrupt identity file {}, generating new",
id_path.display()
);
let s = wzp_crypto::Seed::generate(); let s = wzp_crypto::Seed::generate();
let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect(); let hex: String = s.0.iter().map(|b| format!("{b:02x}")).collect();
let _ = std::fs::write(&id_path, &hex); let _ = std::fs::write(&id_path, &hex);
@@ -386,7 +417,7 @@ async fn main() -> anyhow::Result<()> {
} else { } else {
// Probe via a dummy "connected" UDP socket. Never actually sends. // Probe via a dummy "connected" UDP socket. Never actually sends.
match std::net::UdpSocket::bind("0.0.0.0:0") match std::net::UdpSocket::bind("0.0.0.0:0")
.and_then(|s| { s.connect("8.8.8.8:80").map(|_| s) }) .and_then(|s| s.connect("8.8.8.8:80").map(|_| s))
.and_then(|s| s.local_addr()) .and_then(|s| s.local_addr())
{ {
Ok(a) if !a.ip().is_loopback() => a.ip(), Ok(a) if !a.ip().is_loopback() => a.ip(),
@@ -398,47 +429,48 @@ async fn main() -> anyhow::Result<()> {
info!(%advertised_addr_str, "relay advertised address for CallSetup"); info!(%advertised_addr_str, "relay advertised address for CallSetup");
// Forward mode // Forward mode
let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> = let remote_transport: Option<Arc<wzp_transport::QuinnTransport>> = if let Some(remote_addr) =
if let Some(remote_addr) = config.remote_relay { config.remote_relay
info!(%remote_addr, "forward mode → remote relay"); {
let client_cfg = wzp_transport::client_config(); info!(%remote_addr, "forward mode → remote relay");
let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?; let client_cfg = wzp_transport::client_config();
Some(Arc::new(wzp_transport::QuinnTransport::new(conn))) let conn = wzp_transport::connect(&endpoint, remote_addr, "localhost", client_cfg).await?;
} else { Some(Arc::new(wzp_transport::QuinnTransport::new(conn)))
info!("room mode — clients join named rooms (SFU)"); } else {
None info!("room mode — clients join named rooms (SFU)");
}; None
};
// Room manager (room mode only) // Room manager (room mode only)
let room_mgr = Arc::new(RoomManager::new()); let room_mgr = Arc::new(RoomManager::new());
// Event log for protocol analysis // Event log for protocol analysis
let event_log = wzp_relay::event_log::start_event_log( let event_log = wzp_relay::event_log::start_event_log(
config.event_log.as_ref().map(std::path::PathBuf::from) config.event_log.as_ref().map(std::path::PathBuf::from),
); );
// Federation manager // Federation manager
let global_room_set: std::collections::HashSet<String> = config.global_rooms.iter() let global_room_set: std::collections::HashSet<String> =
.map(|g| g.name.clone()) config.global_rooms.iter().map(|g| g.name.clone()).collect();
.collect();
let federation_mgr = if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() { let federation_mgr =
let fm = Arc::new(wzp_relay::federation::FederationManager::new( if !config.peers.is_empty() || !config.trusted.is_empty() || !global_room_set.is_empty() {
config.peers.clone(), let fm = Arc::new(wzp_relay::federation::FederationManager::new(
config.trusted.clone(), config.peers.clone(),
global_room_set.clone(), config.trusted.clone(),
room_mgr.clone(), global_room_set.clone(),
endpoint.clone(), room_mgr.clone(),
tls_fp.clone(), endpoint.clone(),
metrics.clone(), tls_fp.clone(),
event_log.clone(), metrics.clone(),
)); event_log.clone(),
let fm_run = fm.clone(); ));
tokio::spawn(async move { fm_run.run().await }); let fm_run = fm.clone();
Some(fm) tokio::spawn(async move { fm_run.run().await });
} else { Some(fm)
None } else {
}; None
};
// Session manager — enforces max concurrent sessions // Session manager — enforces max concurrent sessions
let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions))); let session_mgr = Arc::new(Mutex::new(SessionManager::new(config.max_sessions)));
@@ -624,14 +656,15 @@ async fn main() -> anyhow::Result<()> {
// active, then read back everything needed to // active, then read back everything needed to
// cross-wire into the local CallSetup. // cross-wire into the local CallSetup.
let room_name = format!("call-{call_id}"); let room_name = format!("call-{call_id}");
let (callee_addr_for_setup, callee_local_for_setup, callee_mapped_for_setup) = { let (
callee_addr_for_setup,
callee_local_for_setup,
callee_mapped_for_setup,
) = {
let mut reg = call_registry_d.lock().await; let mut reg = call_registry_d.lock().await;
reg.set_active(call_id, accept_mode, room_name.clone()); reg.set_active(call_id, accept_mode, room_name.clone());
reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone())); reg.set_peer_relay_fp(call_id, Some(origin_relay_fp.clone()));
reg.set_callee_reflexive_addr( reg.set_callee_reflexive_addr(call_id, callee_reflexive_addr.clone());
call_id,
callee_reflexive_addr.clone(),
);
reg.set_callee_local_addrs(call_id, callee_local_addrs.clone()); reg.set_callee_local_addrs(call_id, callee_local_addrs.clone());
reg.set_callee_mapped_addr(call_id, callee_mapped_addr.clone()); reg.set_callee_mapped_addr(call_id, callee_mapped_addr.clone());
let c = reg.get(call_id); let c = reg.get(call_id);
@@ -762,7 +795,9 @@ async fn main() -> anyhow::Result<()> {
let relay_seed_bytes = relay_seed.0; let relay_seed_bytes = relay_seed.0;
let metrics = metrics.clone(); let metrics = metrics.clone();
let trunking_enabled = config.trunking_enabled; let trunking_enabled = config.trunking_enabled;
let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap { room_filter: filter.clone() }); let debug_tap = config.debug_tap.as_ref().map(|filter| room::DebugTap {
room_filter: filter.clone(),
});
let presence = presence.clone(); let presence = presence.clone();
let route_resolver = route_resolver.clone(); let route_resolver = route_resolver.clone();
let federation_mgr = federation_mgr.clone(); let federation_mgr = federation_mgr.clone();
@@ -771,7 +806,9 @@ async fn main() -> anyhow::Result<()> {
let advertised_addr_str = advertised_addr_str.clone(); let advertised_addr_str = advertised_addr_str.clone();
// Phase 8: relay region + peer addresses for RegisterPresenceAck // Phase 8: relay region + peer addresses for RegisterPresenceAck
let relay_region = config.region.clone(); let relay_region = config.region.clone();
let relay_peers_for_ack: Vec<String> = config.peers.iter() let relay_peers_for_ack: Vec<String> = config
.peers
.iter()
.filter_map(|p| { .filter_map(|p| {
let label = p.label.as_deref().unwrap_or("peer"); let label = p.label.as_deref().unwrap_or("peer");
Some(format!("{label}|{}", p.url)) Some(format!("{label}|{}", p.url))
@@ -800,9 +837,7 @@ async fn main() -> anyhow::Result<()> {
let room_name = connection let room_name = connection
.handshake_data() .handshake_data()
.and_then(|hd| { .and_then(|hd| hd.downcast::<quinn::crypto::rustls::HandshakeData>().ok())
hd.downcast::<quinn::crypto::rustls::HandshakeData>().ok()
})
.and_then(|hd| hd.server_name.clone()) .and_then(|hd| hd.server_name.clone())
.unwrap_or_else(|| "default".to_string()); .unwrap_or_else(|| "default".to_string());
@@ -832,17 +867,23 @@ async fn main() -> anyhow::Result<()> {
loop { loop {
match transport.recv_signal().await { match transport.recv_signal().await {
Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => { Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => {
if let Err(e) = transport.send_signal( if let Err(e) = transport
&wzp_proto::SignalMessage::Pong { timestamp_ms }, .send_signal(&wzp_proto::SignalMessage::Pong { timestamp_ms })
).await { .await
{
error!(%addr, "probe pong send error: {e}"); error!(%addr, "probe pong send error: {e}");
break; break;
} }
} }
Ok(Some(wzp_proto::SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => { Ok(Some(wzp_proto::SignalMessage::PresenceUpdate {
fingerprints,
relay_addr,
})) => {
// A peer relay is telling us which fingerprints it has // A peer relay is telling us which fingerprints it has
let peer_addr: std::net::SocketAddr = relay_addr.parse().unwrap_or(addr); let peer_addr: std::net::SocketAddr =
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect(); relay_addr.parse().unwrap_or(addr);
let fps: std::collections::HashSet<String> =
fingerprints.into_iter().collect();
{ {
let mut reg = presence.lock().await; let mut reg = presence.lock().await;
reg.update_peer(peer_addr, fps); reg.update_peer(peer_addr, fps);
@@ -871,9 +912,13 @@ async fn main() -> anyhow::Result<()> {
wzp_relay::route::Route::Local => { wzp_relay::route::Route::Local => {
(true, vec![route_resolver.local_addr().to_string()]) (true, vec![route_resolver.local_addr().to_string()])
} }
wzp_relay::route::Route::DirectPeer(peer_addr) => { wzp_relay::route::Route::DirectPeer(peer_addr) => (
(true, vec![route_resolver.local_addr().to_string(), peer_addr.to_string()]) true,
} vec![
route_resolver.local_addr().to_string(),
peer_addr.to_string(),
],
),
_ => { _ => {
// Not found locally; if ttl > 0 we could forward // Not found locally; if ttl > 0 we could forward
// to other peers (future multi-hop). For now, reply not found. // to other peers (future multi-hop). For now, reply not found.
@@ -918,8 +963,12 @@ async fn main() -> anyhow::Result<()> {
let hello_fp = match tokio::time::timeout( let hello_fp = match tokio::time::timeout(
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
transport.recv_signal(), transport.recv_signal(),
).await { )
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint }))) => tls_fingerprint, .await
{
Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello {
tls_fingerprint,
}))) => tls_fingerprint,
_ => { _ => {
warn!(%addr, "federation: no hello received, closing"); warn!(%addr, "federation: no hello received, closing");
return; return;
@@ -964,7 +1013,10 @@ async fn main() -> anyhow::Result<()> {
} }
} }
} }
_ => { warn!(%addr, "signal: expected AuthToken"); return; } _ => {
warn!(%addr, "signal: expected AuthToken");
return;
}
} }
} else { } else {
None None
@@ -974,15 +1026,22 @@ async fn main() -> anyhow::Result<()> {
let (client_fp, client_alias) = match tokio::time::timeout( let (client_fp, client_alias) = match tokio::time::timeout(
std::time::Duration::from_secs(10), std::time::Duration::from_secs(10),
transport.recv_signal(), transport.recv_signal(),
).await { )
Ok(Ok(Some(SignalMessage::RegisterPresence { identity_pub, signature: _, alias }))) => { .await
{
Ok(Ok(Some(SignalMessage::RegisterPresence {
identity_pub,
signature: _,
alias,
}))) => {
// Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type // Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type
let fp = { let fp = {
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
let hash = Sha256::digest(&identity_pub); let hash = Sha256::digest(&identity_pub);
let fingerprint = wzp_crypto::Fingerprint([ let fingerprint = wzp_crypto::Fingerprint([
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6],
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15], hash[7], hash[8], hash[9], hash[10], hash[11], hash[12], hash[13],
hash[14], hash[15],
]); ]);
fingerprint.to_string() fingerprint.to_string()
}; };
@@ -1006,13 +1065,15 @@ async fn main() -> anyhow::Result<()> {
} }
// Send ack // Send ack
let _ = transport.send_signal(&SignalMessage::RegisterPresenceAck { let _ = transport
success: true, .send_signal(&SignalMessage::RegisterPresenceAck {
error: None, success: true,
relay_build: Some(BUILD_GIT_HASH.to_string()), error: None,
relay_region: relay_region.clone(), relay_build: Some(BUILD_GIT_HASH.to_string()),
available_relays: relay_peers_for_ack.clone(), relay_region: relay_region.clone(),
}).await; available_relays: relay_peers_for_ack.clone(),
})
.await;
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered"); info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
@@ -1086,10 +1147,12 @@ async fn main() -> anyhow::Result<()> {
if !forwarded { if !forwarded {
info!(%addr, target = %target_fp, "call target not online (no federation route)"); info!(%addr, target = %target_fp, "call target not online (no federation route)");
let _ = transport.send_signal(&SignalMessage::Hangup { let _ = transport
reason: wzp_proto::HangupReason::Normal, .send_signal(&SignalMessage::Hangup {
call_id: None, reason: wzp_proto::HangupReason::Normal,
}).await; call_id: None,
})
.await;
continue; continue;
} }
@@ -1128,9 +1191,11 @@ async fn main() -> anyhow::Result<()> {
// Send ringing to caller immediately // Send ringing to caller immediately
// so the UI shows feedback while the // so the UI shows feedback while the
// federated delivery is in flight. // federated delivery is in flight.
let _ = transport.send_signal(&SignalMessage::CallRinging { let _ = transport
call_id: call_id.clone(), .send_signal(&SignalMessage::CallRinging {
}).await; call_id: call_id.clone(),
})
.await;
continue; continue;
} }
@@ -1141,10 +1206,23 @@ async fn main() -> anyhow::Result<()> {
// injected later into the callee's CallSetup. // injected later into the callee's CallSetup.
{ {
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;
reg.create_call(call_id.clone(), client_fp.clone(), target_fp.clone()); reg.create_call(
reg.set_caller_reflexive_addr(&call_id, caller_addr_for_registry); call_id.clone(),
reg.set_caller_local_addrs(&call_id, caller_local_for_registry); client_fp.clone(),
reg.set_caller_mapped_addr(&call_id, caller_mapped_for_registry); target_fp.clone(),
);
reg.set_caller_reflexive_addr(
&call_id,
caller_addr_for_registry,
);
reg.set_caller_local_addrs(
&call_id,
caller_local_for_registry,
);
reg.set_caller_mapped_addr(
&call_id,
caller_mapped_for_registry,
);
} }
// Forward offer to callee // Forward offer to callee
@@ -1156,9 +1234,11 @@ async fn main() -> anyhow::Result<()> {
// Send ringing to caller // Send ringing to caller
drop(hub); drop(hub);
let _ = transport.send_signal(&SignalMessage::CallRinging { let _ = transport
call_id: call_id.clone(), .send_signal(&SignalMessage::CallRinging {
}).await; call_id: call_id.clone(),
})
.await;
} }
SignalMessage::DirectCallAnswer { SignalMessage::DirectCallAnswer {
@@ -1186,7 +1266,10 @@ async fn main() -> anyhow::Result<()> {
let reg = call_registry.lock().await; let reg = call_registry.lock().await;
match reg.get(&call_id) { match reg.get(&call_id) {
Some(c) => ( Some(c) => (
Some(reg.peer_fingerprint(&call_id, &client_fp).map(|s| s.to_string())), Some(
reg.peer_fingerprint(&call_id, &client_fp)
.map(|s| s.to_string()),
),
c.peer_relay_fp.clone(), c.peer_relay_fp.clone(),
), ),
None => (None, None), None => (None, None),
@@ -1213,20 +1296,29 @@ async fn main() -> anyhow::Result<()> {
reason: wzp_proto::HangupReason::Normal, reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()), call_id: Some(call_id.clone()),
}; };
let forward = SignalMessage::FederatedSignalForward { let forward =
inner: Box::new(hangup), SignalMessage::FederatedSignalForward {
origin_relay_fp: tls_fp.clone(), inner: Box::new(hangup),
}; origin_relay_fp: tls_fp.clone(),
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await { };
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!(%call_id, %origin_fp, error = %e, "cross-relay reject forward failed"); warn!(%call_id, %origin_fp, error = %e, "cross-relay reject forward failed");
} }
} }
} else { } else {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &SignalMessage::Hangup { let _ = hub
reason: wzp_proto::HangupReason::Normal, .send_to(
call_id: Some(call_id.clone()), &peer_fp,
}).await; &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
},
)
.await;
} }
} else { } else {
// Accept — create private room + stash the // Accept — create private room + stash the
@@ -1236,18 +1328,36 @@ async fn main() -> anyhow::Result<()> {
// BOTH parties' addrs so we can cross-wire // BOTH parties' addrs so we can cross-wire
// peer_direct_addr on the CallSetups below. // peer_direct_addr on the CallSetups below.
let room = format!("call-{call_id}"); let room = format!("call-{call_id}");
let (caller_addr, callee_addr, caller_local, callee_local, caller_mapped, callee_mapped) = { let (
caller_addr,
callee_addr,
caller_local,
callee_local,
caller_mapped,
callee_mapped,
) = {
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;
reg.set_active(&call_id, mode, room.clone()); reg.set_active(&call_id, mode, room.clone());
reg.set_callee_reflexive_addr(&call_id, callee_addr_for_registry); reg.set_callee_reflexive_addr(
reg.set_callee_local_addrs(&call_id, callee_local_for_registry.clone()); &call_id,
reg.set_callee_mapped_addr(&call_id, callee_mapped_for_registry); callee_addr_for_registry,
);
reg.set_callee_local_addrs(
&call_id,
callee_local_for_registry.clone(),
);
reg.set_callee_mapped_addr(
&call_id,
callee_mapped_for_registry,
);
let call = reg.get(&call_id); let call = reg.get(&call_id);
( (
call.and_then(|c| c.caller_reflexive_addr.clone()), call.and_then(|c| c.caller_reflexive_addr.clone()),
call.and_then(|c| c.callee_reflexive_addr.clone()), call.and_then(|c| c.callee_reflexive_addr.clone()),
call.map(|c| c.caller_local_addrs.clone()).unwrap_or_default(), call.map(|c| c.caller_local_addrs.clone())
call.map(|c| c.callee_local_addrs.clone()).unwrap_or_default(), .unwrap_or_default(),
call.map(|c| c.callee_local_addrs.clone())
.unwrap_or_default(),
call.and_then(|c| c.caller_mapped_addr.clone()), call.and_then(|c| c.caller_mapped_addr.clone()),
call.and_then(|c| c.callee_mapped_addr.clone()), call.and_then(|c| c.callee_mapped_addr.clone()),
) )
@@ -1278,11 +1388,15 @@ async fn main() -> anyhow::Result<()> {
// CallSetup (to our callee) with // CallSetup (to our callee) with
// peer_direct_addr = caller_addr. // peer_direct_addr = caller_addr.
if let Some(ref fm) = federation_mgr { if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward { let forward =
inner: Box::new(msg.clone()), SignalMessage::FederatedSignalForward {
origin_relay_fp: tls_fp.clone(), inner: Box::new(msg.clone()),
}; origin_relay_fp: tls_fp.clone(),
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await { };
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!( warn!(
%call_id, %call_id,
%origin_fp, %origin_fp,
@@ -1301,7 +1415,8 @@ async fn main() -> anyhow::Result<()> {
peer_mapped_addr: caller_mapped.clone(), peer_mapped_addr: caller_mapped.clone(),
}; };
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(&client_fp, &setup_for_callee).await; let _ =
hub.send_to(&client_fp, &setup_for_callee).await;
} else { } else {
// Local call (existing Phase 3 path). // Local call (existing Phase 3 path).
// Forward answer to caller // Forward answer to caller
@@ -1331,7 +1446,8 @@ async fn main() -> anyhow::Result<()> {
}; };
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(&peer_fp, &setup_for_caller).await; let _ = hub.send_to(&peer_fp, &setup_for_caller).await;
let _ = hub.send_to(&client_fp, &setup_for_callee).await; let _ =
hub.send_to(&client_fp, &setup_for_callee).await;
} }
} }
} }
@@ -1346,21 +1462,31 @@ async fn main() -> anyhow::Result<()> {
if let Some(cid) = call_id { if let Some(cid) = call_id {
// Targeted hangup: only the named call // Targeted hangup: only the named call
reg.get(cid) reg.get(cid)
.map(|c| vec![(c.call_id.clone(), if c.caller_fingerprint == client_fp { .map(|c| {
c.callee_fingerprint.clone() vec![(
} else { c.call_id.clone(),
c.caller_fingerprint.clone() if c.caller_fingerprint == client_fp {
})]) c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
},
)]
})
.unwrap_or_default() .unwrap_or_default()
} else { } else {
// Legacy: end all calls for this user // Legacy: end all calls for this user
reg.calls_for_fingerprint(&client_fp) reg.calls_for_fingerprint(&client_fp)
.iter() .iter()
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp { .map(|c| {
c.callee_fingerprint.clone() (
} else { c.call_id.clone(),
c.caller_fingerprint.clone() if c.caller_fingerprint == client_fp {
})) c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
},
)
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
}; };
@@ -1396,11 +1522,15 @@ async fn main() -> anyhow::Result<()> {
if let Some(ref origin_fp) = peer_relay_fp { if let Some(ref origin_fp) = peer_relay_fp {
// Cross-relay: wrap and forward // Cross-relay: wrap and forward
if let Some(ref fm) = federation_mgr { if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward { let forward =
inner: Box::new(msg.clone()), SignalMessage::FederatedSignalForward {
origin_relay_fp: tls_fp.clone(), inner: Box::new(msg.clone()),
}; origin_relay_fp: tls_fp.clone(),
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await { };
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!( warn!(
%call_id, %call_id,
%origin_fp, %origin_fp,
@@ -1436,11 +1566,15 @@ async fn main() -> anyhow::Result<()> {
if let Some(fp) = peer_fp { if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp { if let Some(ref origin_fp) = peer_relay_fp {
if let Some(ref fm) = federation_mgr { if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward { let forward =
inner: Box::new(msg.clone()), SignalMessage::FederatedSignalForward {
origin_relay_fp: tls_fp.clone(), inner: Box::new(msg.clone()),
}; origin_relay_fp: tls_fp.clone(),
if let Err(e) = fm.send_signal_to_peer(origin_fp, &forward).await { };
if let Err(e) = fm
.send_signal_to_peer(origin_fp, &forward)
.await
{
warn!( warn!(
%call_id, %call_id,
%origin_fp, %origin_fp,
@@ -1458,12 +1592,12 @@ async fn main() -> anyhow::Result<()> {
// Hard NAT: forward HardNatProbe + HardNatBirthdayStart // Hard NAT: forward HardNatProbe + HardNatBirthdayStart
// to call peer (same pattern as CandidateUpdate). // to call peer (same pattern as CandidateUpdate).
SignalMessage::HardNatBirthdayStart { ref call_id, .. } | SignalMessage::HardNatBirthdayStart { ref call_id, .. }
SignalMessage::HardNatProbe { ref call_id, .. } | | SignalMessage::HardNatProbe { ref call_id, .. }
SignalMessage::UpgradeProposal { ref call_id, .. } | | SignalMessage::UpgradeProposal { ref call_id, .. }
SignalMessage::UpgradeResponse { ref call_id, .. } | | SignalMessage::UpgradeResponse { ref call_id, .. }
SignalMessage::UpgradeConfirm { ref call_id, .. } | | SignalMessage::UpgradeConfirm { ref call_id, .. }
SignalMessage::QualityCapability { ref call_id, .. } => { | SignalMessage::QualityCapability { ref call_id, .. } => {
let (peer_fp, peer_relay_fp) = { let (peer_fp, peer_relay_fp) = {
let reg = call_registry.lock().await; let reg = call_registry.lock().await;
match reg.get(call_id) { match reg.get(call_id) {
@@ -1479,11 +1613,14 @@ async fn main() -> anyhow::Result<()> {
if let Some(fp) = peer_fp { if let Some(fp) = peer_fp {
if let Some(ref origin_fp) = peer_relay_fp { if let Some(ref origin_fp) = peer_relay_fp {
if let Some(ref fm) = federation_mgr { if let Some(ref fm) = federation_mgr {
let forward = SignalMessage::FederatedSignalForward { let forward =
inner: Box::new(msg.clone()), SignalMessage::FederatedSignalForward {
origin_relay_fp: tls_fp.clone(), inner: Box::new(msg.clone()),
}; origin_relay_fp: tls_fp.clone(),
let _ = fm.send_signal_to_peer(origin_fp, &forward).await; };
let _ = fm
.send_signal_to_peer(origin_fp, &forward)
.await;
} }
} else { } else {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
@@ -1493,7 +1630,9 @@ async fn main() -> anyhow::Result<()> {
} }
SignalMessage::Ping { timestamp_ms } => { SignalMessage::Ping { timestamp_ms } => {
let _ = transport.send_signal(&SignalMessage::Pong { timestamp_ms }).await; let _ = transport
.send_signal(&SignalMessage::Pong { timestamp_ms })
.await;
} }
// QUIC-native NAT reflection ("STUN for QUIC"). // QUIC-native NAT reflection ("STUN for QUIC").
@@ -1510,11 +1649,12 @@ async fn main() -> anyhow::Result<()> {
// reaches this match arm. // reaches this match arm.
SignalMessage::Reflect => { SignalMessage::Reflect => {
let observed_addr = addr.to_string(); let observed_addr = addr.to_string();
if let Err(e) = transport.send_signal( if let Err(e) = transport
&SignalMessage::ReflectResponse { .send_signal(&SignalMessage::ReflectResponse {
observed_addr: observed_addr.clone(), observed_addr: observed_addr.clone(),
}, })
).await { .await
{
warn!(%addr, error = %e, "reflect: failed to send response"); warn!(%addr, error = %e, "reflect: failed to send response");
} else { } else {
debug!(%addr, %observed_addr, "reflect: responded"); debug!(%addr, %observed_addr, "reflect: responded");
@@ -1552,19 +1692,29 @@ async fn main() -> anyhow::Result<()> {
let reg = call_registry.lock().await; let reg = call_registry.lock().await;
reg.calls_for_fingerprint(&client_fp) reg.calls_for_fingerprint(&client_fp)
.iter() .iter()
.map(|c| (c.call_id.clone(), if c.caller_fingerprint == client_fp { .map(|c| {
c.callee_fingerprint.clone() (
} else { c.call_id.clone(),
c.caller_fingerprint.clone() if c.caller_fingerprint == client_fp {
})) c.callee_fingerprint.clone()
} else {
c.caller_fingerprint.clone()
},
)
})
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
for (call_id, peer_fp) in &active_calls { for (call_id, peer_fp) in &active_calls {
let hub = signal_hub.lock().await; let hub = signal_hub.lock().await;
let _ = hub.send_to(peer_fp, &SignalMessage::Hangup { let _ = hub
reason: wzp_proto::HangupReason::Normal, .send_to(
call_id: Some(call_id.clone()), peer_fp,
}).await; &SignalMessage::Hangup {
reason: wzp_proto::HangupReason::Normal,
call_id: Some(call_id.clone()),
},
)
.await;
drop(hub); drop(hub);
let mut reg = call_registry.lock().await; let mut reg = call_registry.lock().await;
reg.end_call(call_id); reg.end_call(call_id);
@@ -1632,22 +1782,20 @@ async fn main() -> anyhow::Result<()> {
// Crypto handshake: verify client identity + negotiate quality profile // Crypto handshake: verify client identity + negotiate quality profile
let handshake_start = std::time::Instant::now(); let handshake_start = std::time::Instant::now();
let (_crypto_session, _chosen_profile, caller_fp, caller_alias) = match wzp_relay::handshake::accept_handshake( let (_crypto_session, _chosen_profile, caller_fp, caller_alias) =
&*transport, match wzp_relay::handshake::accept_handshake(&*transport, &relay_seed_bytes).await {
&relay_seed_bytes, Ok(result) => {
).await { let elapsed = handshake_start.elapsed().as_secs_f64();
Ok(result) => { metrics.handshake_duration.observe(elapsed);
let elapsed = handshake_start.elapsed().as_secs_f64(); info!(%addr, elapsed_ms = %(elapsed * 1000.0), "crypto handshake complete");
metrics.handshake_duration.observe(elapsed); result
info!(%addr, elapsed_ms = %(elapsed * 1000.0), "crypto handshake complete"); }
result Err(e) => {
} error!(%addr, "handshake failed: {e}");
Err(e) => { close_transport(&*transport, "cleanup").await;
error!(%addr, "handshake failed: {e}"); return;
close_transport(&*transport, "cleanup").await; }
return; };
}
};
// Use the caller's identity fingerprint from the handshake // Use the caller's identity fingerprint from the handshake
let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp); let participant_fp = authenticated_fp.clone().unwrap_or(caller_fp);
@@ -1704,8 +1852,18 @@ async fn main() -> anyhow::Result<()> {
} }
}); });
let up = tokio::spawn(run_upstream(transport.clone(), remote.clone(), up_pipe, stats.clone())); let up = tokio::spawn(run_upstream(
let dn = tokio::spawn(run_downstream(transport.clone(), remote.clone(), dn_pipe, stats)); transport.clone(),
remote.clone(),
up_pipe,
stats.clone(),
));
let dn = tokio::spawn(run_downstream(
transport.clone(),
remote.clone(),
dn_pipe,
stats,
));
tokio::select! { _ = up => {} _ = dn => {} } tokio::select! { _ = up => {} _ = dn => {} }
stats_handle.abort(); stats_handle.abort();
@@ -1752,7 +1910,11 @@ async fn main() -> anyhow::Result<()> {
// Merge federated participants into RoomUpdate if this is a global room // Merge federated participants into RoomUpdate if this is a global room
let merged_update = if let Some(ref fm) = federation_mgr { let merged_update = if let Some(ref fm) = federation_mgr {
if fm.is_global_room(&room_name) { if fm.is_global_room(&room_name) {
if let SignalMessage::RoomUpdate { count: _, participants: mut local_parts } = update { if let SignalMessage::RoomUpdate {
count: _,
participants: mut local_parts,
} = update
{
let remote = fm.get_remote_participants(&room_name).await; let remote = fm.get_remote_participants(&room_name).await;
local_parts.extend(remote); local_parts.extend(remote);
// Deduplicate by fingerprint // Deduplicate by fingerprint
@@ -1762,17 +1924,27 @@ async fn main() -> anyhow::Result<()> {
count: local_parts.len() as u32, count: local_parts.len() as u32,
participants: local_parts, participants: local_parts,
} }
} else { update } } else {
} else { update } update
} else { update }; }
} else {
update
}
} else {
update
};
if let Some(ref tap) = debug_tap { if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) { if tap.matches(&room_name) {
tap.log_signal(&room_name, &merged_update); tap.log_signal(&room_name, &merged_update);
tap.log_event(&room_name, "join", &format!( tap.log_event(
"participant={id} addr={addr} alias={}", &room_name,
caller_alias.as_deref().unwrap_or("?") "join",
)); &format!(
"participant={id} addr={addr} alias={}",
caller_alias.as_deref().unwrap_or("?")
),
);
} }
} }
room::broadcast_signal(&senders, &merged_update).await; room::broadcast_signal(&senders, &merged_update).await;
@@ -1789,10 +1961,8 @@ async fn main() -> anyhow::Result<()> {
} }
}; };
let session_id_str: String = session_id let session_id_str: String =
.iter() session_id.iter().map(|b| format!("{b:02x}")).collect();
.map(|b| format!("{b:02x}"))
.collect();
// Set up federation media channel if this is a global room // Set up federation media channel if this is a global room
let (federation_tx, federation_room_hash) = if let Some(ref fm) = federation_mgr { let (federation_tx, federation_room_hash) = if let Some(ref fm) = federation_mgr {
let is_global = fm.is_global_room(&room_name); let is_global = fm.is_global_room(&room_name);
@@ -1823,7 +1993,8 @@ async fn main() -> anyhow::Result<()> {
debug_tap, debug_tap,
federation_tx, federation_tx,
federation_room_hash, federation_room_hash,
).await; )
.await;
// Participant disconnected — clean up presence + per-session metrics // Participant disconnected — clean up presence + per-session metrics
if let Some(ref fp) = authenticated_fp { if let Some(ref fp) = authenticated_fp {

View File

@@ -4,8 +4,8 @@ use prometheus::{
Encoder, GaugeVec, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Encoder, GaugeVec, Histogram, HistogramOpts, IntCounter, IntCounterVec, IntGauge, IntGaugeVec,
Opts, Registry, TextEncoder, Opts, Registry, TextEncoder,
}; };
use wzp_proto::packet::QualityReport;
use std::sync::Arc; use std::sync::Arc;
use wzp_proto::packet::QualityReport;
/// All relay-level Prometheus metrics. /// All relay-level Prometheus metrics.
#[derive(Clone)] #[derive(Clone)]
@@ -40,21 +40,23 @@ impl RelayMetrics {
pub fn new() -> Self { pub fn new() -> Self {
let registry = Registry::new(); let registry = Registry::new();
let active_sessions = IntGauge::with_opts( let active_sessions = IntGauge::with_opts(Opts::new(
Opts::new("wzp_relay_active_sessions", "Current active sessions"), "wzp_relay_active_sessions",
) "Current active sessions",
))
.expect("metric"); .expect("metric");
let active_rooms = IntGauge::with_opts( let active_rooms =
Opts::new("wzp_relay_active_rooms", "Current active rooms"), IntGauge::with_opts(Opts::new("wzp_relay_active_rooms", "Current active rooms"))
) .expect("metric");
let packets_forwarded = IntCounter::with_opts(Opts::new(
"wzp_relay_packets_forwarded_total",
"Total packets forwarded",
))
.expect("metric"); .expect("metric");
let packets_forwarded = IntCounter::with_opts( let bytes_forwarded = IntCounter::with_opts(Opts::new(
Opts::new("wzp_relay_packets_forwarded_total", "Total packets forwarded"), "wzp_relay_bytes_forwarded_total",
) "Total bytes forwarded",
.expect("metric"); ))
let bytes_forwarded = IntCounter::with_opts(
Opts::new("wzp_relay_bytes_forwarded_total", "Total bytes forwarded"),
)
.expect("metric"); .expect("metric");
let auth_attempts = IntCounterVec::new( let auth_attempts = IntCounterVec::new(
Opts::new("wzp_relay_auth_attempts_total", "Auth validation attempts"), Opts::new("wzp_relay_auth_attempts_total", "Auth validation attempts"),
@@ -66,31 +68,51 @@ impl RelayMetrics {
"wzp_relay_handshake_duration_seconds", "wzp_relay_handshake_duration_seconds",
"Crypto handshake time", "Crypto handshake time",
) )
.buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5]), .buckets(vec![
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5,
]),
) )
.expect("metric"); .expect("metric");
let federation_peer_status = IntGaugeVec::new( let federation_peer_status = IntGaugeVec::new(
Opts::new("wzp_federation_peer_status", "Peer connection status (0=disconnected, 1=connected)"), Opts::new(
"wzp_federation_peer_status",
"Peer connection status (0=disconnected, 1=connected)",
),
&["peer"], &["peer"],
).expect("metric"); )
.expect("metric");
let federation_peer_rtt_ms = GaugeVec::new( let federation_peer_rtt_ms = GaugeVec::new(
Opts::new("wzp_federation_peer_rtt_ms", "QUIC RTT to federated peer in milliseconds"), Opts::new(
"wzp_federation_peer_rtt_ms",
"QUIC RTT to federated peer in milliseconds",
),
&["peer"], &["peer"],
).expect("metric"); )
.expect("metric");
let federation_packets_forwarded = IntCounterVec::new( let federation_packets_forwarded = IntCounterVec::new(
Opts::new("wzp_federation_packets_forwarded_total", "Packets forwarded to/from federated peers"), Opts::new(
"wzp_federation_packets_forwarded_total",
"Packets forwarded to/from federated peers",
),
&["peer", "direction"], &["peer", "direction"],
).expect("metric"); )
let federation_packets_deduped = IntCounter::with_opts( .expect("metric");
Opts::new("wzp_federation_packets_deduped_total", "Duplicate federation packets dropped"), let federation_packets_deduped = IntCounter::with_opts(Opts::new(
).expect("metric"); "wzp_federation_packets_deduped_total",
let federation_packets_rate_limited = IntCounter::with_opts( "Duplicate federation packets dropped",
Opts::new("wzp_federation_packets_rate_limited_total", "Federation packets dropped by rate limiter"), ))
).expect("metric"); .expect("metric");
let federation_active_rooms = IntGauge::with_opts( let federation_packets_rate_limited = IntCounter::with_opts(Opts::new(
Opts::new("wzp_federation_active_rooms", "Number of federated rooms currently active"), "wzp_federation_packets_rate_limited_total",
).expect("metric"); "Federation packets dropped by rate limiter",
))
.expect("metric");
let federation_active_rooms = IntGauge::with_opts(Opts::new(
"wzp_federation_active_rooms",
"Number of federated rooms currently active",
))
.expect("metric");
let session_buffer_depth = IntGaugeVec::new( let session_buffer_depth = IntGaugeVec::new(
Opts::new( Opts::new(
@@ -109,10 +131,7 @@ impl RelayMetrics {
) )
.expect("metric"); .expect("metric");
let session_rtt_ms = GaugeVec::new( let session_rtt_ms = GaugeVec::new(
Opts::new( Opts::new("wzp_relay_session_rtt_ms", "Round-trip time per session"),
"wzp_relay_session_rtt_ms",
"Round-trip time per session",
),
&["session_id"], &["session_id"],
) )
.expect("metric"); .expect("metric");
@@ -150,25 +169,63 @@ impl RelayMetrics {
) )
.expect("metric"); .expect("metric");
registry.register(Box::new(active_sessions.clone())).expect("register"); registry
registry.register(Box::new(active_rooms.clone())).expect("register"); .register(Box::new(active_sessions.clone()))
registry.register(Box::new(packets_forwarded.clone())).expect("register"); .expect("register");
registry.register(Box::new(bytes_forwarded.clone())).expect("register"); registry
registry.register(Box::new(auth_attempts.clone())).expect("register"); .register(Box::new(active_rooms.clone()))
registry.register(Box::new(handshake_duration.clone())).expect("register"); .expect("register");
registry.register(Box::new(federation_peer_status.clone())).expect("register"); registry
registry.register(Box::new(federation_peer_rtt_ms.clone())).expect("register"); .register(Box::new(packets_forwarded.clone()))
registry.register(Box::new(federation_packets_forwarded.clone())).expect("register"); .expect("register");
registry.register(Box::new(federation_packets_deduped.clone())).expect("register"); registry
registry.register(Box::new(federation_packets_rate_limited.clone())).expect("register"); .register(Box::new(bytes_forwarded.clone()))
registry.register(Box::new(federation_active_rooms.clone())).expect("register"); .expect("register");
registry.register(Box::new(session_buffer_depth.clone())).expect("register"); registry
registry.register(Box::new(session_loss_pct.clone())).expect("register"); .register(Box::new(auth_attempts.clone()))
registry.register(Box::new(session_rtt_ms.clone())).expect("register"); .expect("register");
registry.register(Box::new(session_underruns.clone())).expect("register"); registry
registry.register(Box::new(session_overruns.clone())).expect("register"); .register(Box::new(handshake_duration.clone()))
registry.register(Box::new(session_dred_reconstructions.clone())).expect("register"); .expect("register");
registry.register(Box::new(session_classical_plc.clone())).expect("register"); registry
.register(Box::new(federation_peer_status.clone()))
.expect("register");
registry
.register(Box::new(federation_peer_rtt_ms.clone()))
.expect("register");
registry
.register(Box::new(federation_packets_forwarded.clone()))
.expect("register");
registry
.register(Box::new(federation_packets_deduped.clone()))
.expect("register");
registry
.register(Box::new(federation_packets_rate_limited.clone()))
.expect("register");
registry
.register(Box::new(federation_active_rooms.clone()))
.expect("register");
registry
.register(Box::new(session_buffer_depth.clone()))
.expect("register");
registry
.register(Box::new(session_loss_pct.clone()))
.expect("register");
registry
.register(Box::new(session_rtt_ms.clone()))
.expect("register");
registry
.register(Box::new(session_underruns.clone()))
.expect("register");
registry
.register(Box::new(session_overruns.clone()))
.expect("register");
registry
.register(Box::new(session_dred_reconstructions.clone()))
.expect("register");
registry
.register(Box::new(session_classical_plc.clone()))
.expect("register");
Self { Self {
active_sessions, active_sessions,
@@ -230,10 +287,7 @@ impl RelayMetrics {
.with_label_values(&[session_id]) .with_label_values(&[session_id])
.inc_by(underruns - cur_underruns as u64); .inc_by(underruns - cur_underruns as u64);
} }
let cur_overruns = self let cur_overruns = self.session_overruns.with_label_values(&[session_id]).get();
.session_overruns
.with_label_values(&[session_id])
.get();
if overruns > cur_overruns as u64 { if overruns > cur_overruns as u64 {
self.session_overruns self.session_overruns
.with_label_values(&[session_id]) .with_label_values(&[session_id])
@@ -284,7 +338,9 @@ impl RelayMetrics {
let _ = self let _ = self
.session_dred_reconstructions .session_dred_reconstructions
.remove_label_values(&[session_id]); .remove_label_values(&[session_id]);
let _ = self.session_classical_plc.remove_label_values(&[session_id]); let _ = self
.session_classical_plc
.remove_label_values(&[session_id]);
} }
/// Get a reference to the underlying Prometheus registry. /// Get a reference to the underlying Prometheus registry.
@@ -298,7 +354,9 @@ impl RelayMetrics {
let encoder = TextEncoder::new(); let encoder = TextEncoder::new();
let metric_families = self.registry.gather(); let metric_families = self.registry.gather();
let mut buffer = Vec::new(); let mut buffer = Vec::new();
encoder.encode(&metric_families, &mut buffer).expect("encode"); encoder
.encode(&metric_families, &mut buffer)
.expect("encode");
String::from_utf8(buffer).expect("utf8") String::from_utf8(buffer).expect("utf8")
} }
} }
@@ -310,7 +368,7 @@ pub async fn serve_metrics(
presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>, presence: Option<Arc<tokio::sync::Mutex<crate::presence::PresenceRegistry>>>,
route_resolver: Option<Arc<crate::route::RouteResolver>>, route_resolver: Option<Arc<crate::route::RouteResolver>>,
) { ) {
use axum::{extract::Path, routing::get, Router}; use axum::{Router, extract::Path, routing::get};
let metrics_clone = metrics.clone(); let metrics_clone = metrics.clone();
let presence_all = presence.clone(); let presence_all = presence.clone();
@@ -454,8 +512,8 @@ mod tests {
fn session_quality_update() { fn session_quality_update() {
let m = RelayMetrics::new(); let m = RelayMetrics::new();
let report = QualityReport { let report = QualityReport {
loss_pct: 128, // ~50% loss_pct: 128, // ~50%
rtt_4ms: 25, // 100ms rtt_4ms: 25, // 100ms
jitter_ms: 10, jitter_ms: 10,
bitrate_cap_kbps: 200, bitrate_cap_kbps: 200,
}; };

View File

@@ -11,11 +11,11 @@
use tracing::{debug, info}; use tracing::{debug, info};
use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder}; use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder};
use wzp_proto::QualityProfile;
use wzp_proto::jitter::{JitterBuffer, PlayoutResult}; use wzp_proto::jitter::{JitterBuffer, PlayoutResult};
use wzp_proto::packet::{MediaHeader, MediaPacket}; use wzp_proto::packet::{MediaHeader, MediaPacket};
use wzp_proto::quality::AdaptiveQualityController; use wzp_proto::quality::AdaptiveQualityController;
use wzp_proto::traits::{FecDecoder, FecEncoder, QualityController}; use wzp_proto::traits::{FecDecoder, FecEncoder, QualityController};
use wzp_proto::QualityProfile;
/// Configuration for a relay pipeline instance. /// Configuration for a relay pipeline instance.
pub struct PipelineConfig { pub struct PipelineConfig {
@@ -51,7 +51,7 @@ pub struct RelayPipeline {
/// Current quality profile. /// Current quality profile.
profile: QualityProfile, profile: QualityProfile,
/// Outbound sequence counter. /// Outbound sequence counter.
out_seq: u16, out_seq: u32,
/// Packets processed count. /// Packets processed count.
stats: PipelineStats, stats: PipelineStats,
} }
@@ -110,15 +110,15 @@ impl RelayPipeline {
// Feed packet into FEC decoder // Feed packet into FEC decoder
let header = &packet.header; let header = &packet.header;
let _ = self.fec_decoder.add_symbol( let _ = self.fec_decoder.add_symbol(
header.fec_block, (header.fec_block & 0xFF) as u8,
header.fec_symbol, (header.fec_block >> 8) as u8,
header.is_repair, header.is_repair(),
&packet.payload, &packet.payload,
); );
// Try to decode the FEC block // Try to decode the FEC block
let mut output = Vec::new(); let mut output = Vec::new();
if let Ok(Some(frames)) = self.fec_decoder.try_decode(header.fec_block) { if let Ok(Some(frames)) = self.fec_decoder.try_decode((header.fec_block & 0xFF) as u8) {
debug!( debug!(
block = header.fec_block, block = header.fec_block,
frames = frames.len(), frames = frames.len(),
@@ -128,22 +128,21 @@ impl RelayPipeline {
for (i, frame) in frames.into_iter().enumerate() { for (i, frame) in frames.into_iter().enumerate() {
let reconstructed = MediaPacket { let reconstructed = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: header.codec_id, codec_id: header.codec_id,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: header.fec_ratio_encoded, fec_ratio: header.fec_ratio,
// Reconstruct seq from block + symbol index // Reconstruct seq from block + symbol index
seq: (header.fec_block as u16) seq: (header.fec_block as u32)
.wrapping_mul(self.profile.frames_per_block as u16) .wrapping_mul(self.profile.frames_per_block as u32)
.wrapping_add(i as u16), .wrapping_add(i as u32),
timestamp: header timestamp: header.timestamp.wrapping_add(
.timestamp (i as u32) * (header.codec_id.frame_duration_ms() as u32),
.wrapping_add((i as u32) * (header.codec_id.frame_duration_ms() as u32)), ),
fec_block: header.fec_block, fec_block: u16::from((header.fec_block & 0xFF) as u8)
fec_symbol: i as u8, | (u16::from(i as u8) << 8),
reserved: 0,
csrc_count: 0,
}, },
payload: bytes::Bytes::from(frame), payload: bytes::Bytes::from(frame),
quality_report: None, quality_report: None,
@@ -191,19 +190,16 @@ impl RelayPipeline {
for (sym_idx, repair_data) in repairs { for (sym_idx, repair_data) in repairs {
let repair_packet = MediaPacket { let repair_packet = MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: true, flags: MediaHeader::FLAG_REPAIR,
media_type: wzp_proto::MediaType::Audio,
codec_id: packet.header.codec_id, codec_id: packet.header.codec_id,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: MediaHeader::encode_fec_ratio( fec_ratio: MediaHeader::encode_fec_ratio(self.profile.fec_ratio),
self.profile.fec_ratio,
),
seq: self.out_seq, seq: self.out_seq,
timestamp: packet.header.timestamp, timestamp: packet.header.timestamp,
fec_block: self.fec_encoder.current_block_id(), fec_block: u16::from(self.fec_encoder.current_block_id())
fec_symbol: sym_idx, | (u16::from(sym_idx) << 8),
reserved: 0,
csrc_count: 0,
}, },
payload: bytes::Bytes::from(repair_data), payload: bytes::Bytes::from(repair_data),
quality_report: None, quality_report: None,
@@ -232,23 +228,21 @@ impl RelayPipeline {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use wzp_proto::CodecId;
use bytes::Bytes; use bytes::Bytes;
use wzp_proto::CodecId;
fn make_media_packet(seq: u16, block: u8, symbol: u8) -> MediaPacket { fn make_media_packet(seq: u32, block: u8, symbol: u8) -> MediaPacket {
MediaPacket { MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: CodecId::Opus24k, codec_id: CodecId::Opus24k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq, seq,
timestamp: seq as u32 * 20, timestamp: seq * 20,
fec_block: block, fec_block: u16::from(block) | (u16::from(symbol) << 8),
fec_symbol: symbol,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(vec![seq as u8; 60]), payload: Bytes::from(vec![seq as u8; 60]),
quality_report: None, quality_report: None,
@@ -283,7 +277,7 @@ mod tests {
// Feed 5 packets (one full block) // Feed 5 packets (one full block)
let mut total_out = 0; let mut total_out = 0;
for i in 0..5u16 { for i in 0..5u32 {
let pkt = make_media_packet(i, 0, i as u8); let pkt = make_media_packet(i, 0, i as u8);
let out = pipeline.prepare_outbound(pkt); let out = pipeline.prepare_outbound(pkt);
total_out += out.len(); total_out += out.len();

View File

@@ -74,13 +74,21 @@ impl PresenceRegistry {
} }
/// Register a fingerprint as locally connected (called after auth + handshake). /// Register a fingerprint as locally connected (called after auth + handshake).
pub fn register_local(&mut self, fingerprint: &str, alias: Option<String>, room: Option<String>) { pub fn register_local(
self.local.insert(fingerprint.to_string(), LocalPresence { &mut self,
fingerprint: fingerprint.to_string(), fingerprint: &str,
alias, alias: Option<String>,
connected_at: Instant::now(), room: Option<String>,
room, ) {
}); self.local.insert(
fingerprint.to_string(),
LocalPresence {
fingerprint: fingerprint.to_string(),
alias,
connected_at: Instant::now(),
room,
},
);
} }
/// Unregister a locally connected fingerprint (called on disconnect). /// Unregister a locally connected fingerprint (called on disconnect).
@@ -98,11 +106,14 @@ impl PresenceRegistry {
// Insert new remote entries // Insert new remote entries
for fp in &fingerprints { for fp in &fingerprints {
self.remote.insert(fp.clone(), RemotePresence { self.remote.insert(
fingerprint: fp.clone(), fp.clone(),
relay_addr: addr, RemotePresence {
last_seen: now, fingerprint: fp.clone(),
}); relay_addr: addr,
last_seen: now,
},
);
} }
// Update the peer record // Update the peer record
@@ -156,7 +167,8 @@ impl PresenceRegistry {
self.remote.retain(|_, rp| rp.last_seen > cutoff); self.remote.retain(|_, rp| rp.last_seen > cutoff);
// Expire peer relay records and their fingerprint sets // Expire peer relay records and their fingerprint sets
let stale_peers: Vec<SocketAddr> = self.peers let stale_peers: Vec<SocketAddr> = self
.peers
.iter() .iter()
.filter(|(_, p)| p.last_update <= cutoff) .filter(|(_, p)| p.last_update <= cutoff)
.map(|(addr, _)| *addr) .map(|(addr, _)| *addr)
@@ -280,13 +292,15 @@ mod tests {
let all = reg.all_known(); let all = reg.all_known();
assert_eq!(all.len(), 2); assert_eq!(all.len(), 2);
let local_entries: Vec<_> = all.iter() let local_entries: Vec<_> = all
.iter()
.filter(|(_, loc)| *loc == PresenceLocation::Local) .filter(|(_, loc)| *loc == PresenceLocation::Local)
.collect(); .collect();
assert_eq!(local_entries.len(), 1); assert_eq!(local_entries.len(), 1);
assert_eq!(local_entries[0].0, "local1"); assert_eq!(local_entries[0].0, "local1");
let remote_entries: Vec<_> = all.iter() let remote_entries: Vec<_> = all
.iter()
.filter(|(_, loc)| matches!(loc, PresenceLocation::Remote(_))) .filter(|(_, loc)| matches!(loc, PresenceLocation::Remote(_)))
.collect(); .collect();
assert_eq!(remote_entries.len(), 1); assert_eq!(remote_entries.len(), 1);

View File

@@ -43,8 +43,7 @@ impl ProbeMetrics {
/// Register probe metrics with the given `target` label value. /// Register probe metrics with the given `target` label value.
pub fn register(target: &str, registry: &Registry) -> Self { pub fn register(target: &str, registry: &Registry) -> Self {
let rtt_ms = Gauge::with_opts( let rtt_ms = Gauge::with_opts(
Opts::new("wzp_probe_rtt_ms", "RTT to peer relay in ms") Opts::new("wzp_probe_rtt_ms", "RTT to peer relay in ms").const_label("target", target),
.const_label("target", target),
) )
.expect("probe metric"); .expect("probe metric");
@@ -66,9 +65,15 @@ impl ProbeMetrics {
) )
.expect("probe metric"); .expect("probe metric");
registry.register(Box::new(rtt_ms.clone())).expect("register"); registry
registry.register(Box::new(loss_pct.clone())).expect("register"); .register(Box::new(rtt_ms.clone()))
registry.register(Box::new(jitter_ms.clone())).expect("register"); .expect("register");
registry
.register(Box::new(loss_pct.clone()))
.expect("register");
registry
.register(Box::new(jitter_ms.clone()))
.expect("register");
registry.register(Box::new(up.clone())).expect("register"); registry.register(Box::new(up.clone())).expect("register");
Self { Self {
@@ -168,7 +173,11 @@ impl ProbeRunner {
) -> Self { ) -> Self {
let target_str = config.target.to_string(); let target_str = config.target.to_string();
let metrics = ProbeMetrics::register(&target_str, registry); let metrics = ProbeMetrics::register(&target_str, registry);
Self { config, metrics, presence } Self {
config,
metrics,
presence,
}
} }
/// Run the probe forever. This function never returns under normal operation. /// Run the probe forever. This function never returns under normal operation.
@@ -198,13 +207,8 @@ impl ProbeRunner {
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap(); let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?; let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
let client_cfg = wzp_transport::client_config(); let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect( let conn =
&endpoint, wzp_transport::connect(&endpoint, self.config.target, "_probe", client_cfg).await?;
self.config.target,
"_probe",
client_cfg,
)
.await?;
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn)); let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
self.metrics.up.set(1); self.metrics.up.set(1);
@@ -237,11 +241,15 @@ impl ProbeRunner {
loss_gauge.set(w.loss_pct()); loss_gauge.set(w.loss_pct());
jitter_gauge.set(w.jitter_ms()); jitter_gauge.set(w.jitter_ms());
} }
Ok(Some(SignalMessage::PresenceUpdate { fingerprints, relay_addr })) => { Ok(Some(SignalMessage::PresenceUpdate {
fingerprints,
relay_addr,
})) => {
if let Some(ref reg) = recv_presence { if let Some(ref reg) = recv_presence {
// Parse the relay_addr; fall back to the connection target // Parse the relay_addr; fall back to the connection target
let addr = relay_addr.parse().unwrap_or(recv_target); let addr = relay_addr.parse().unwrap_or(recv_target);
let fps: std::collections::HashSet<String> = fingerprints.into_iter().collect(); let fps: std::collections::HashSet<String> =
fingerprints.into_iter().collect();
let mut r = reg.lock().await; let mut r = reg.lock().await;
r.update_peer(addr, fps); r.update_peer(addr, fps);
} }
@@ -374,10 +382,7 @@ pub fn mesh_summary(registry: &Registry) -> String {
let name = family.get_name(); let name = family.get_name();
for metric in family.get_metric() { for metric in family.get_metric() {
// Find the "target" label // Find the "target" label
let target_label = metric let target_label = metric.get_label().iter().find(|l| l.get_name() == "target");
.get_label()
.iter()
.find(|l| l.get_name() == "target");
let target = match target_label { let target = match target_label {
Some(l) => l.get_value().to_string(), Some(l) => l.get_value().to_string(),
None => continue, None => continue,
@@ -420,10 +425,7 @@ pub fn mesh_summary(registry: &Registry) -> String {
/// Handle an incoming Ping signal by replying with a Pong carrying the same timestamp. /// Handle an incoming Ping signal by replying with a Pong carrying the same timestamp.
/// Returns true if the message was a Ping and was handled, false otherwise. /// Returns true if the message was a Ping and was handled, false otherwise.
pub async fn handle_ping( pub async fn handle_ping(transport: &wzp_transport::QuinnTransport, msg: &SignalMessage) -> bool {
transport: &wzp_transport::QuinnTransport,
msg: &SignalMessage,
) -> bool {
if let SignalMessage::Ping { timestamp_ms } = msg { if let SignalMessage::Ping { timestamp_ms } = msg {
if let Err(e) = transport if let Err(e) = transport
.send_signal(&SignalMessage::Pong { .send_signal(&SignalMessage::Pong {
@@ -456,9 +458,18 @@ mod tests {
encoder.encode(&families, &mut buf).unwrap(); encoder.encode(&families, &mut buf).unwrap();
let output = String::from_utf8(buf).unwrap(); let output = String::from_utf8(buf).unwrap();
assert!(output.contains("wzp_probe_rtt_ms"), "missing wzp_probe_rtt_ms"); assert!(
assert!(output.contains("wzp_probe_loss_pct"), "missing wzp_probe_loss_pct"); output.contains("wzp_probe_rtt_ms"),
assert!(output.contains("wzp_probe_jitter_ms"), "missing wzp_probe_jitter_ms"); "missing wzp_probe_rtt_ms"
);
assert!(
output.contains("wzp_probe_loss_pct"),
"missing wzp_probe_loss_pct"
);
assert!(
output.contains("wzp_probe_jitter_ms"),
"missing wzp_probe_jitter_ms"
);
assert!(output.contains("wzp_probe_up"), "missing wzp_probe_up"); assert!(output.contains("wzp_probe_up"), "missing wzp_probe_up");
assert!( assert!(
output.contains("target=\"127.0.0.1:4433\""), output.contains("target=\"127.0.0.1:4433\""),

View File

@@ -40,10 +40,7 @@ impl RelayLink {
/// should skip normal client auth/handshake for relay-SNI connections. /// should skip normal client auth/handshake for relay-SNI connections.
pub async fn connect(target: SocketAddr) -> Result<Self, anyhow::Error> { pub async fn connect(target: SocketAddr) -> Result<Self, anyhow::Error> {
// Create a client-only endpoint on an OS-assigned port. // Create a client-only endpoint on an OS-assigned port.
let endpoint = wzp_transport::create_endpoint( let endpoint = wzp_transport::create_endpoint("0.0.0.0:0".parse().unwrap(), None)?;
"0.0.0.0:0".parse().unwrap(),
None,
)?;
let client_cfg = wzp_transport::client_config(); let client_cfg = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, target, "_relay", client_cfg).await?; let conn = wzp_transport::connect(&endpoint, target, "_relay", client_cfg).await?;
@@ -457,17 +454,15 @@ mod tests {
let pkt = MediaPacket { let pkt = MediaPacket {
header: wzp_proto::packet::MediaHeader { header: wzp_proto::packet::MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: wzp_proto::CodecId::Opus16k, codec_id: wzp_proto::CodecId::Opus16k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq: 1, seq: 1,
timestamp: 100, timestamp: 100,
fec_block: 0, fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: bytes::Bytes::from_static(b"test"), payload: bytes::Bytes::from_static(b"test"),
quality_report: None, quality_report: None,

View File

@@ -4,18 +4,18 @@
//! the relay forwards it to all other participants in the room (SFU model). //! the relay forwards it to all other participants in the room (SFU model).
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration; use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use dashmap::DashMap; use dashmap::DashMap;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use wzp_proto::MediaTransport;
use wzp_proto::packet::TrunkFrame; use wzp_proto::packet::TrunkFrame;
use wzp_proto::quality::{AdaptiveQualityController, Tier}; use wzp_proto::quality::{AdaptiveQualityController, Tier};
use wzp_proto::traits::QualityController; use wzp_proto::traits::QualityController;
use wzp_proto::MediaTransport;
use crate::metrics::RelayMetrics; use crate::metrics::RelayMetrics;
use crate::trunk::TrunkBatcher; use crate::trunk::TrunkBatcher;
@@ -32,7 +32,14 @@ impl DebugTap {
self.room_filter == "*" || self.room_filter == room_name self.room_filter == "*" || self.room_filter == room_name
} }
pub fn log_packet(&self, room: &str, dir: &str, addr: &std::net::SocketAddr, pkt: &wzp_proto::MediaPacket, fan_out: usize) { pub fn log_packet(
&self,
room: &str,
dir: &str,
addr: &std::net::SocketAddr,
pkt: &wzp_proto::MediaPacket,
fan_out: usize,
) {
let h = &pkt.header; let h = &pkt.header;
info!( info!(
target: "debug_tap", target: "debug_tap",
@@ -43,8 +50,7 @@ impl DebugTap {
codec = ?h.codec_id, codec = ?h.codec_id,
ts = h.timestamp, ts = h.timestamp,
fec_block = h.fec_block, fec_block = h.fec_block,
fec_sym = h.fec_symbol, repair = h.is_repair(),
repair = h.is_repair,
len = pkt.payload.len(), len = pkt.payload.len(),
fan_out, fan_out,
"TAP" "TAP"
@@ -53,8 +59,12 @@ impl DebugTap {
pub fn log_signal(&self, room: &str, signal: &wzp_proto::SignalMessage) { pub fn log_signal(&self, room: &str, signal: &wzp_proto::SignalMessage) {
match signal { match signal {
wzp_proto::SignalMessage::RoomUpdate { count, participants } => { wzp_proto::SignalMessage::RoomUpdate {
let names: Vec<&str> = participants.iter() count,
participants,
} => {
let names: Vec<&str> = participants
.iter()
.map(|p| p.alias.as_deref().unwrap_or("?")) .map(|p| p.alias.as_deref().unwrap_or("?"))
.collect(); .collect();
info!( info!(
@@ -66,7 +76,10 @@ impl DebugTap {
"TAP SIGNAL" "TAP SIGNAL"
); );
} }
wzp_proto::SignalMessage::QualityDirective { recommended_profile, reason } => { wzp_proto::SignalMessage::QualityDirective {
recommended_profile,
reason,
} => {
info!( info!(
target: "debug_tap", target: "debug_tap",
room = %room, room = %room,
@@ -119,7 +132,7 @@ pub struct TapStats {
pub out_pkts: u64, pub out_pkts: u64,
pub seq_gaps: u64, pub seq_gaps: u64,
pub codecs_seen: std::collections::HashSet<wzp_proto::CodecId>, pub codecs_seen: std::collections::HashSet<wzp_proto::CodecId>,
last_seq: Option<u16>, last_seq: Option<u32>,
} }
impl TapStats { impl TapStats {
@@ -225,17 +238,29 @@ impl ParticipantSender {
/// Send raw bytes to this participant. /// Send raw bytes to this participant.
pub async fn send_raw(&self, data: &[u8]) -> Result<(), String> { pub async fn send_raw(&self, data: &[u8]) -> Result<(), String> {
match self { match self {
ParticipantSender::WebSocket(tx) => { ParticipantSender::WebSocket(tx) => tx
tx.try_send(Bytes::copy_from_slice(data)) .try_send(Bytes::copy_from_slice(data))
.map_err(|e| format!("ws send: {e}")) .map_err(|e| format!("ws send: {e}")),
}
ParticipantSender::Quic(transport) => { ParticipantSender::Quic(transport) => {
let pkt = wzp_proto::MediaPacket { let pkt = wzp_proto::MediaPacket {
header: wzp_proto::packet::MediaHeader::default_pcm(), header: wzp_proto::packet::MediaHeader {
version: 2,
flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: wzp_proto::CodecId::Opus24k,
stream_id: 0,
fec_ratio: 0,
seq: 0,
timestamp: 0,
fec_block: 0,
},
payload: Bytes::copy_from_slice(data), payload: Bytes::copy_from_slice(data),
quality_report: None, quality_report: None,
}; };
transport.send_media(&pkt).await.map_err(|e| format!("quic send: {e}")) transport
.send_media(&pkt)
.await
.map_err(|e| format!("quic send: {e}"))
} }
} }
} }
@@ -301,13 +326,23 @@ impl Room {
) -> ParticipantId { ) -> ParticipantId {
let id = next_id(); let id = next_id();
info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room"); info!(room_size = self.participants.len() + 1, participant = id, %addr, "joined room");
self.participants.push(Participant { id, _addr: addr, sender, fingerprint, alias }); self.participants.push(Participant {
id,
_addr: addr,
sender,
fingerprint,
alias,
});
id id
} }
fn remove(&mut self, id: ParticipantId) { fn remove(&mut self, id: ParticipantId) {
self.participants.retain(|p| p.id != id); self.participants.retain(|p| p.id != id);
info!(room_size = self.participants.len(), participant = id, "left room"); info!(
room_size = self.participants.len(),
participant = id,
"left room"
);
} }
fn others(&self, exclude_id: ParticipantId) -> Vec<ParticipantSender> { fn others(&self, exclude_id: ParticipantId) -> Vec<ParticipantSender> {
@@ -387,7 +422,8 @@ impl RoomManager {
/// Grant a fingerprint access to a room. /// Grant a fingerprint access to a room.
pub fn allow(&self, room_name: &str, fingerprint: &str) { pub fn allow(&self, room_name: &str, fingerprint: &str) {
if let Some(ref acl) = self.acl { if let Some(ref acl) = self.acl {
acl.lock().unwrap() acl.lock()
.unwrap()
.entry(room_name.to_string()) .entry(room_name.to_string())
.or_default() .or_default()
.insert(fingerprint.to_string()); .insert(fingerprint.to_string());
@@ -398,7 +434,7 @@ impl RoomManager {
/// Returns true if ACL is disabled (open mode) or the fingerprint is in the allow list. /// Returns true if ACL is disabled (open mode) or the fingerprint is in the allow list.
pub fn is_authorized(&self, room_name: &str, fingerprint: Option<&str>) -> bool { pub fn is_authorized(&self, room_name: &str, fingerprint: Option<&str>) -> bool {
match (&self.acl, fingerprint) { match (&self.acl, fingerprint) {
(None, _) => true, // no ACL = open (None, _) => true, // no ACL = open
(Some(_), None) => false, // ACL enabled but no fingerprint (Some(_), None) => false, // ACL enabled but no fingerprint
(Some(acl), Some(fp)) => { (Some(acl), Some(fp)) => {
let acl = acl.lock().unwrap(); let acl = acl.lock().unwrap();
@@ -419,14 +455,29 @@ impl RoomManager {
sender: ParticipantSender, sender: ParticipantSender,
fingerprint: Option<&str>, fingerprint: Option<&str>,
alias: Option<&str>, alias: Option<&str>,
) -> Result<(ParticipantId, wzp_proto::SignalMessage, Vec<ParticipantSender>), String> { ) -> Result<
(
ParticipantId,
wzp_proto::SignalMessage,
Vec<ParticipantSender>,
),
String,
> {
if !self.is_authorized(room_name, fingerprint) { if !self.is_authorized(room_name, fingerprint) {
warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt"); warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt");
return Err("not authorized for this room".to_string()); return Err("not authorized for this room".to_string());
} }
let was_empty = self.rooms.get(room_name).map_or(true, |r| r.is_empty()); let was_empty = self.rooms.get(room_name).map_or(true, |r| r.is_empty());
let mut room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new); let mut room = self
let id = room.add(addr, sender, fingerprint.map(|s| s.to_string()), alias.map(|s| s.to_string())); .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()),
);
room.qualities.insert(id, ParticipantQuality::new()); room.qualities.insert(id, ParticipantQuality::new());
let update = wzp_proto::SignalMessage::RoomUpdate { let update = wzp_proto::SignalMessage::RoomUpdate {
count: room.len() as u32, count: room.len() as u32,
@@ -435,7 +486,9 @@ impl RoomManager {
let senders = room.all_senders(); let senders = room.all_senders();
drop(room); // release DashMap guard before event_tx send (not async, but good practice) drop(room); // release DashMap guard before event_tx send (not async, but good practice)
if was_empty { if was_empty {
let _ = self.event_tx.send(RoomEvent::LocalJoin { room: room_name.to_string() }); let _ = self.event_tx.send(RoomEvent::LocalJoin {
room: room_name.to_string(),
});
} }
Ok((id, update, senders)) Ok((id, update, senders))
} }
@@ -448,7 +501,13 @@ impl RoomManager {
sender: tokio::sync::mpsc::Sender<Bytes>, sender: tokio::sync::mpsc::Sender<Bytes>,
fingerprint: Option<&str>, fingerprint: Option<&str>,
) -> Result<ParticipantId, String> { ) -> Result<ParticipantId, String> {
let (id, _update, _senders) = self.join(room_name, addr, ParticipantSender::WebSocket(sender), fingerprint, None)?; let (id, _update, _senders) = self.join(
room_name,
addr,
ParticipantSender::WebSocket(sender),
fingerprint,
None,
)?;
Ok(id) Ok(id)
} }
@@ -458,23 +517,30 @@ impl RoomManager {
} }
/// Get participant list for a room (fingerprint + alias). /// Get participant list for a room (fingerprint + alias).
pub fn local_participant_list(&self, room_name: &str) -> Vec<wzp_proto::packet::RoomParticipant> { pub fn local_participant_list(
self.rooms.get(room_name) &self,
room_name: &str,
) -> Vec<wzp_proto::packet::RoomParticipant> {
self.rooms
.get(room_name)
.map(|room| room.participant_list()) .map(|room| room.participant_list())
.unwrap_or_default() .unwrap_or_default()
} }
/// Get all senders for participants in a room (for federation inbound media delivery). /// Get all senders for participants in a room (for federation inbound media delivery).
pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> { pub fn local_senders(&self, room_name: &str) -> Vec<ParticipantSender> {
self.rooms.get(room_name) self.rooms
.map(|room| room.participants.iter() .get(room_name)
.map(|p| p.sender.clone()) .map(|room| room.participants.iter().map(|p| p.sender.clone()).collect())
.collect())
.unwrap_or_default() .unwrap_or_default()
} }
/// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty. /// Leave a room. Returns (room_update_msg, remaining_senders) for broadcasting, or None if room is now empty.
pub fn leave(&self, room_name: &str, participant_id: ParticipantId) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> { pub fn leave(
&self,
room_name: &str,
participant_id: ParticipantId,
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
let result = { let result = {
if let Some(mut room) = self.rooms.get_mut(room_name) { if let Some(mut room) = self.rooms.get_mut(room_name) {
room.qualities.remove(&participant_id); room.qualities.remove(&participant_id);
@@ -482,7 +548,9 @@ impl RoomManager {
if room.is_empty() { if room.is_empty() {
drop(room); // release write guard before remove drop(room); // release write guard before remove
self.rooms.remove(room_name); self.rooms.remove(room_name);
let _ = self.event_tx.send(RoomEvent::LocalLeave { room: room_name.to_string() }); let _ = self.event_tx.send(RoomEvent::LocalLeave {
room: room_name.to_string(),
});
info!(room = room_name, "room closed (empty)"); info!(room = room_name, "room closed (empty)");
return None; return None;
} }
@@ -500,11 +568,7 @@ impl RoomManager {
} }
/// Get senders for all OTHER participants in a room. /// Get senders for all OTHER participants in a room.
pub fn others( pub fn others(&self, room_name: &str, participant_id: ParticipantId) -> Vec<ParticipantSender> {
&self,
room_name: &str,
participant_id: ParticipantId,
) -> Vec<ParticipantSender> {
self.rooms self.rooms
.get(room_name) .get(room_name)
.map(|r| r.others(participant_id)) .map(|r| r.others(participant_id))
@@ -523,7 +587,10 @@ impl RoomManager {
/// List all rooms with their sizes. /// List all rooms with their sizes.
pub fn list(&self) -> Vec<(String, usize)> { pub fn list(&self) -> Vec<(String, usize)> {
self.rooms.iter().map(|r| (r.key().clone(), r.len())).collect() self.rooms
.iter()
.map(|r| (r.key().clone(), r.len()))
.collect()
} }
/// Feed a quality report from a participant. If the room-wide weakest /// Feed a quality report from a participant. If the room-wide weakest
@@ -537,7 +604,8 @@ impl RoomManager {
) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> { ) -> Option<(wzp_proto::SignalMessage, Vec<ParticipantSender>)> {
let mut room = self.rooms.get_mut(room_name)?; let mut room = self.rooms.get_mut(room_name)?;
let tier_changed = room.qualities let tier_changed = room
.qualities
.get_mut(&participant_id) .get_mut(&participant_id)
.and_then(|pq| pq.observe(report)) .and_then(|pq| pq.observe(report))
.is_some(); .is_some();
@@ -639,7 +707,9 @@ impl TrunkedForwarder {
} }
fn send_frame(&self, frame: &TrunkFrame) -> anyhow::Result<()> { fn send_frame(&self, frame: &TrunkFrame) -> anyhow::Result<()> {
self.transport.send_trunk(frame).map_err(|e| anyhow::anyhow!(e)) self.transport
.send_trunk(frame)
.map_err(|e| anyhow::anyhow!(e))
} }
} }
@@ -667,12 +737,25 @@ pub async fn run_participant(
) { ) {
if trunking_enabled { if trunking_enabled {
run_participant_trunked( run_participant_trunked(
room_mgr, room_name, participant_id, transport, metrics, session_id, room_mgr,
room_name,
participant_id,
transport,
metrics,
session_id,
) )
.await; .await;
} else { } else {
run_participant_plain( run_participant_plain(
room_mgr, room_name, participant_id, transport, metrics, session_id, debug_tap, federation_tx, federation_room_hash, room_mgr,
room_name,
participant_id,
transport,
metrics,
session_id,
debug_tap,
federation_tx,
federation_room_hash,
) )
.await; .await;
} }
@@ -822,7 +905,8 @@ async fn run_participant_plain(
let data = pkt.to_bytes(); let data = pkt.to_bytes();
let _ = fed_tx.try_send(FederationMediaOut { let _ = fed_tx.try_send(FederationMediaOut {
room_name: room_name.clone(), room_name: room_name.clone(),
room_hash: federation_room_hash.unwrap_or_else(|| crate::federation::room_hash(&room_name)), room_hash: federation_room_hash
.unwrap_or_else(|| crate::federation::room_hash(&room_name)),
data, data,
}); });
} }
@@ -874,18 +958,24 @@ async fn run_participant_plain(
if let Some((update, senders)) = room_mgr.leave(&room_name, participant_id) { if let Some((update, senders)) = room_mgr.leave(&room_name, participant_id) {
if let Some(ref tap) = debug_tap { if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) { if tap.matches(&room_name) {
tap.log_event(&room_name, "leave", &format!( tap.log_event(
"participant={participant_id} addr={addr} forwarded={packets_forwarded}" &room_name,
)); "leave",
&format!(
"participant={participant_id} addr={addr} forwarded={packets_forwarded}"
),
);
tap.log_signal(&room_name, &update); tap.log_signal(&room_name, &update);
} }
} }
broadcast_signal(&senders, &update).await; broadcast_signal(&senders, &update).await;
} else if let Some(ref tap) = debug_tap { } else if let Some(ref tap) = debug_tap {
if tap.matches(&room_name) { if tap.matches(&room_name) {
tap.log_event(&room_name, "leave", &format!( tap.log_event(
"participant={participant_id} addr={addr} (room closed)" &room_name,
)); "leave",
&format!("participant={participant_id} addr={addr} (room closed)"),
);
} }
} }
} }
@@ -1146,17 +1236,15 @@ mod tests {
fn make_test_packet(payload: &[u8]) -> wzp_proto::MediaPacket { fn make_test_packet(payload: &[u8]) -> wzp_proto::MediaPacket {
wzp_proto::MediaPacket { wzp_proto::MediaPacket {
header: wzp_proto::packet::MediaHeader { header: wzp_proto::packet::MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: wzp_proto::MediaType::Audio,
codec_id: wzp_proto::CodecId::Opus16k, codec_id: wzp_proto::CodecId::Opus16k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 0, fec_ratio: 0,
seq: 1, seq: 1,
timestamp: 100, timestamp: 100,
fec_block: 0, fec_block: 0,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from(payload.to_vec()), payload: Bytes::from(payload.to_vec()),
quality_report: None, quality_report: None,
@@ -1266,6 +1354,10 @@ mod tests {
let participants = vec![good, bad]; let participants = vec![good, bad];
let weakest = weakest_tier(participants.iter()); let weakest = weakest_tier(participants.iter());
assert_ne!(weakest, Tier::Good, "weakest should not be Good when one participant is bad"); assert_ne!(
weakest,
Tier::Good,
"weakest should not be Good when one participant is bad"
);
} }
} }

View File

@@ -97,14 +97,13 @@ impl RouteResolver {
} }
/// Build a JSON-serializable route response for the HTTP API. /// Build a JSON-serializable route response for the HTTP API.
pub fn route_json( pub fn route_json(&self, fingerprint: &str, route: &Route) -> serde_json::Value {
&self,
fingerprint: &str,
route: &Route,
) -> serde_json::Value {
let (route_type, relay_chain) = match route { let (route_type, relay_chain) = match route {
Route::Local => ("local", vec![self.local_addr.to_string()]), Route::Local => ("local", vec![self.local_addr.to_string()]),
Route::DirectPeer(addr) => ("direct_peer", vec![self.local_addr.to_string(), addr.to_string()]), Route::DirectPeer(addr) => (
"direct_peer",
vec![self.local_addr.to_string(), addr.to_string()],
),
Route::Chain(chain) => { Route::Chain(chain) => {
let mut addrs = vec![self.local_addr.to_string()]; let mut addrs = vec![self.local_addr.to_string()];
addrs.extend(chain.iter().map(|a| a.to_string())); addrs.extend(chain.iter().map(|a| a.to_string()));
@@ -184,7 +183,10 @@ mod tests {
reg.update_peer(peer, fps); reg.update_peer(peer, fps);
// Local lookup works via multi-hop // Local lookup works via multi-hop
assert_eq!(resolver.resolve_multi_hop(&reg, "local_fp", 3), Route::Local); assert_eq!(
resolver.resolve_multi_hop(&reg, "local_fp", 3),
Route::Local
);
// Remote lookup works via multi-hop // Remote lookup works via multi-hop
assert_eq!( assert_eq!(
resolver.resolve_multi_hop(&reg, "remote_fp", 3), resolver.resolve_multi_hop(&reg, "remote_fp", 3),

View File

@@ -143,18 +143,18 @@ impl SessionManager {
fingerprint: Option<String>, fingerprint: Option<String>,
) -> Result<SessionId, String> { ) -> Result<SessionId, String> {
if self.total_count() >= self.max_sessions { if self.total_count() >= self.max_sessions {
return Err(format!( return Err(format!("max sessions ({}) exceeded", self.max_sessions));
"max sessions ({}) exceeded",
self.max_sessions
));
} }
let id = rand_session_id(); let id = rand_session_id();
self.tracked.insert(id, SessionInfo { self.tracked.insert(
room_name: room.to_string(), id,
fingerprint, SessionInfo {
connected_at: Instant::now(), room_name: room.to_string(),
state: SessionState::Active, fingerprint,
}); connected_at: Instant::now(),
state: SessionState::Active,
},
);
Ok(id) Ok(id)
} }
@@ -165,7 +165,10 @@ impl SessionManager {
/// Number of currently tracked (room-mode) sessions. /// Number of currently tracked (room-mode) sessions.
pub fn active_count(&self) -> usize { pub fn active_count(&self) -> usize {
self.tracked.values().filter(|s| s.state == SessionState::Active).count() self.tracked
.values()
.filter(|s| s.state == SessionState::Active)
.count()
} }
/// Return all session IDs that belong to a given room. /// Return all session IDs that belong to a given room.
@@ -278,7 +281,9 @@ mod tests {
#[test] #[test]
fn session_info_returns_correct_data() { fn session_info_returns_correct_data() {
let mut mgr = SessionManager::new(10); let mut mgr = SessionManager::new(10);
let id = mgr.create_session("room-x", Some("alice-fp".into())).unwrap(); let id = mgr
.create_session("room-x", Some("alice-fp".into()))
.unwrap();
let info = mgr.session_info(id).expect("session should exist"); let info = mgr.session_info(id).expect("session should exist");
assert_eq!(info.room_name, "room-x"); assert_eq!(info.room_name, "room-x");
@@ -297,6 +302,9 @@ mod tests {
mgr.create_session("room", None).unwrap(); mgr.create_session("room", None).unwrap();
// Both layers should now reject // Both layers should now reject
assert!(mgr.create_session("room", None).is_err()); assert!(mgr.create_session("room", None).is_err());
assert!(mgr.create_pipeline_session([2u8; 16], PipelineConfig::default()).is_none()); assert!(
mgr.create_pipeline_session([2u8; 16], PipelineConfig::default())
.is_none()
);
} }
} }

View File

@@ -34,12 +34,15 @@ impl SignalHub {
/// Register a new signaling client. /// Register a new signaling client.
pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) { pub fn register(&mut self, fp: String, transport: Arc<QuinnTransport>, alias: Option<String>) {
info!(fingerprint = %fp, alias = ?alias, "signal client registered"); info!(fingerprint = %fp, alias = ?alias, "signal client registered");
self.clients.insert(fp.clone(), SignalClient { self.clients.insert(
fingerprint: fp, fp.clone(),
alias, SignalClient {
transport, fingerprint: fp,
connected_at: Instant::now(), alias,
}); transport,
connected_at: Instant::now(),
},
);
} }
/// Unregister a signaling client. Returns the client if found. /// Unregister a signaling client. Returns the client if found.
@@ -64,10 +67,11 @@ impl SignalHub {
/// Send a signal message to a client by fingerprint. /// Send a signal message to a client by fingerprint.
pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> { pub async fn send_to(&self, fp: &str, msg: &SignalMessage) -> Result<(), String> {
match self.clients.get(fp) { match self.clients.get(fp) {
Some(client) => { Some(client) => client
client.transport.send_signal(msg).await .transport
.map_err(|e| format!("send to {fp}: {e}")) .send_signal(msg)
} .await
.map_err(|e| format!("send to {fp}: {e}")),
None => Err(format!("{fp} not online")), None => Err(format!("{fp} not online")),
} }
} }

View File

@@ -8,17 +8,17 @@ use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Router,
extract::{ extract::{
ws::{Message, WebSocket},
Path, State, WebSocketUpgrade, Path, State, WebSocketUpgrade,
ws::{Message, WebSocket},
}, },
response::IntoResponse, response::IntoResponse,
routing::get, routing::get,
Router,
}; };
use bytes::Bytes; use bytes::Bytes;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use tokio::sync::{mpsc, Mutex}; use tokio::sync::{Mutex, mpsc};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -143,9 +143,15 @@ async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
// 4. Join room with WS sender // 4. Join room with WS sender
let addr: SocketAddr = ([0, 0, 0, 0], 0).into(); let addr: SocketAddr = ([0, 0, 0, 0], 0).into();
let participant_id = { let participant_id = {
match state.room_mgr.join_ws(&room, addr, tx, fingerprint.as_deref()) { match state
.room_mgr
.join_ws(&room, addr, tx, fingerprint.as_deref())
{
Ok(id) => { Ok(id) => {
state.metrics.active_rooms.set(state.room_mgr.list().len() as i64); state
.metrics
.active_rooms
.set(state.room_mgr.list().len() as i64);
id id
} }
Err(e) => { Err(e) => {
@@ -187,10 +193,7 @@ async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
for other in &others { for other in &others {
let _ = other.send_raw(&data).await; let _ = other.send_raw(&data).await;
} }
state state.metrics.packets_forwarded.inc_by(others.len() as u64);
.metrics
.packets_forwarded
.inc_by(others.len() as u64);
state state
.metrics .metrics
.bytes_forwarded .bytes_forwarded
@@ -211,7 +214,10 @@ async fn handle_ws_connection(socket: WebSocket, room: String, state: WsState) {
} }
state.room_mgr.leave(&room, participant_id); state.room_mgr.leave(&room, participant_id);
state.metrics.active_rooms.set(state.room_mgr.list().len() as i64); state
.metrics
.active_rooms
.set(state.room_mgr.list().len() as i64);
let session_id_str: String = session_id.iter().map(|b| format!("{b:02x}")).collect(); let session_id_str: String = session_id.iter().map(|b| format!("{b:02x}")).collect();
state.metrics.remove_session_metrics(&session_id_str); state.metrics.remove_session_metrics(&session_id_str);

View File

@@ -94,9 +94,10 @@ fn relay_a_handle_offer(reg_a: &mut CallRegistry, offer: &SignalMessage) -> Sign
/// reproduced here for the test. /// reproduced here for the test.
fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMessage) { fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMessage) {
let (inner, origin_relay_fp) = match forward { let (inner, origin_relay_fp) = match forward {
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => { SignalMessage::FederatedSignalForward {
(inner.as_ref().clone(), origin_relay_fp.clone()) inner,
} origin_relay_fp,
} => (inner.as_ref().clone(), origin_relay_fp.clone()),
_ => panic!("not a forward"), _ => panic!("not a forward"),
}; };
// Loop-prevention: drop self-sourced. // Loop-prevention: drop self-sourced.
@@ -114,11 +115,7 @@ fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMess
}; };
// Simulated: target is local to B (Bob is registered here). // Simulated: target is local to B (Bob is registered here).
reg_b.create_call( reg_b.create_call(call_id.clone(), caller_fingerprint, target_fingerprint);
call_id.clone(),
caller_fingerprint,
target_fingerprint,
);
reg_b.set_caller_reflexive_addr(&call_id, caller_reflexive_addr); reg_b.set_caller_reflexive_addr(&call_id, caller_reflexive_addr);
reg_b.set_peer_relay_fp(&call_id, Some(origin_relay_fp)); reg_b.set_peer_relay_fp(&call_id, Some(origin_relay_fp));
} }
@@ -194,9 +191,10 @@ fn relay_a_handle_forwarded_answer(
forward: &SignalMessage, forward: &SignalMessage,
) -> SignalMessage { ) -> SignalMessage {
let (inner, origin_relay_fp) = match forward { let (inner, origin_relay_fp) = match forward {
SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => { SignalMessage::FederatedSignalForward {
(inner.as_ref().clone(), origin_relay_fp.clone()) inner,
} origin_relay_fp,
} => (inner.as_ref().clone(), origin_relay_fp.clone()),
_ => panic!("not a forward"), _ => panic!("not a forward"),
}; };
assert_ne!(origin_relay_fp, RELAY_A_TLS_FP); assert_ne!(origin_relay_fp, RELAY_A_TLS_FP);
@@ -270,12 +268,15 @@ fn cross_relay_answer_crosswires_peer_direct_addrs() {
// Bob answers on Relay B. // Bob answers on Relay B.
let answer = bob_answer("c-xrelay-2"); let answer = bob_answer("c-xrelay-2");
let (answer_forward, setup_for_bob) = let (answer_forward, setup_for_bob) = relay_b_handle_local_answer(&mut reg_b, &answer);
relay_b_handle_local_answer(&mut reg_b, &answer);
// Bob's CallSetup carries Alice's addr. // Bob's CallSetup carries Alice's addr.
match setup_for_bob { match setup_for_bob {
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr,
relay_addr,
..
} => {
assert_eq!(peer_direct_addr.as_deref(), Some(ALICE_ADDR)); assert_eq!(peer_direct_addr.as_deref(), Some(ALICE_ADDR));
assert_eq!(relay_addr, RELAY_B_ADDR); assert_eq!(relay_addr, RELAY_B_ADDR);
} }
@@ -286,7 +287,11 @@ fn cross_relay_answer_crosswires_peer_direct_addrs() {
// her CallSetup. // her CallSetup.
let setup_for_alice = relay_a_handle_forwarded_answer(&mut reg_a, &answer_forward); let setup_for_alice = relay_a_handle_forwarded_answer(&mut reg_a, &answer_forward);
match setup_for_alice { match setup_for_alice {
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr,
relay_addr,
..
} => {
assert_eq!(peer_direct_addr.as_deref(), Some(BOB_ADDR)); assert_eq!(peer_direct_addr.as_deref(), Some(BOB_ADDR));
assert_eq!(relay_addr, RELAY_A_ADDR); assert_eq!(relay_addr, RELAY_A_ADDR);
} }
@@ -313,9 +318,14 @@ fn cross_relay_loop_prevention_drops_self_sourced_forward() {
// The dispatcher in main.rs calls this explicit check before // The dispatcher in main.rs calls this explicit check before
// doing any work. Reproduce it inline. // doing any work. Reproduce it inline.
let origin = match &forward { let origin = match &forward {
SignalMessage::FederatedSignalForward { origin_relay_fp, .. } => origin_relay_fp.clone(), SignalMessage::FederatedSignalForward {
origin_relay_fp, ..
} => origin_relay_fp.clone(),
_ => unreachable!(), _ => unreachable!(),
}; };
// Relay B sees origin == its own fp → drop. // Relay B sees origin == its own fp → drop.
assert_eq!(origin, RELAY_B_TLS_FP, "loop-prevention triggers on self-fp"); assert_eq!(
origin, RELAY_B_TLS_FP,
"loop-prevention triggers on self-fp"
);
} }

View File

@@ -21,10 +21,10 @@ use bytes::Bytes;
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_relay::config::{PeerConfig, TrustedConfig}; use wzp_relay::config::{PeerConfig, TrustedConfig};
use wzp_relay::event_log::EventLogger; use wzp_relay::event_log::EventLogger;
use wzp_relay::federation::{room_hash, FederationManager}; use wzp_relay::federation::{FederationManager, room_hash};
use wzp_relay::metrics::RelayMetrics; use wzp_relay::metrics::RelayMetrics;
use wzp_relay::room::RoomManager; use wzp_relay::room::RoomManager;
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport}; use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config};
// ───────────────────────────── helpers ────────────────────────────── // ───────────────────────────── helpers ──────────────────────────────
@@ -41,8 +41,7 @@ fn create_test_fm_full(
) -> Arc<FederationManager> { ) -> Arc<FederationManager> {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert) = server_config(); let (sc, _cert) = server_config();
let ep = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), Some(sc)) let ep = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), Some(sc)).expect("test endpoint");
.expect("test endpoint");
let room_mgr = Arc::new(RoomManager::new()); let room_mgr = Arc::new(RoomManager::new());
let metrics = Arc::new(RelayMetrics::new()); let metrics = Arc::new(RelayMetrics::new());
let event_log = EventLogger::Noop; let event_log = EventLogger::Noop;
@@ -219,7 +218,10 @@ async fn forward_to_peers_empty_returns_immediately() {
fm.forward_to_peers("room", &hash, &data), fm.forward_to_peers("room", &hash, &data),
) )
.await; .await;
assert!(result.is_ok(), "forward_to_peers should return immediately with no peers"); assert!(
result.is_ok(),
"forward_to_peers should return immediately with no peers"
);
} }
// ─────────── 4. forward_to_peers with live QUIC peer links ────────── // ─────────── 4. forward_to_peers with live QUIC peer links ──────────
@@ -339,20 +341,20 @@ async fn broadcast_signal_sends_to_all_peers() {
.expect("FM should connect to mock peer within 5s"); .expect("FM should connect to mock peer within 5s");
// The FM sends FederationHello as the first signal. Read it. // The FM sends FederationHello as the first signal. Read it.
let hello = tokio::time::timeout( let hello = tokio::time::timeout(Duration::from_secs(2), peer_transport.recv_signal())
Duration::from_secs(2), .await
peer_transport.recv_signal(), .expect("hello timeout")
) .expect("recv ok")
.await .expect("some message");
.expect("hello timeout")
.expect("recv ok")
.expect("some message");
match hello { match hello {
SignalMessage::FederationHello { tls_fingerprint } => { SignalMessage::FederationHello { tls_fingerprint } => {
assert_eq!(tls_fingerprint, "test-relay-fp-abc123"); assert_eq!(tls_fingerprint, "test-relay-fp-abc123");
} }
other => panic!("expected FederationHello, got: {:?}", std::mem::discriminant(&other)), other => panic!(
"expected FederationHello, got: {:?}",
std::mem::discriminant(&other)
),
} }
// Now the FM's run_federation_link registered the peer in peer_links // Now the FM's run_federation_link registered the peer in peer_links
@@ -372,20 +374,22 @@ async fn broadcast_signal_sends_to_all_peers() {
assert_eq!(count, 1, "should have broadcast to exactly 1 peer"); assert_eq!(count, 1, "should have broadcast to exactly 1 peer");
// Read the signal on the peer side // Read the signal on the peer side
let received = tokio::time::timeout( let received = tokio::time::timeout(Duration::from_secs(2), peer_transport.recv_signal())
Duration::from_secs(2), .await
peer_transport.recv_signal(), .expect("broadcast signal timeout")
) .expect("recv ok")
.await .expect("some message");
.expect("broadcast signal timeout")
.expect("recv ok")
.expect("some message");
match received { match received {
SignalMessage::FederatedSignalForward { origin_relay_fp, .. } => { SignalMessage::FederatedSignalForward {
origin_relay_fp, ..
} => {
assert_eq!(origin_relay_fp, "other-relay-fp"); assert_eq!(origin_relay_fp, "other-relay-fp");
} }
other => panic!("expected FederatedSignalForward, got: {:?}", std::mem::discriminant(&other)), other => panic!(
"expected FederatedSignalForward, got: {:?}",
std::mem::discriminant(&other)
),
} }
drop(peer_transport); drop(peer_transport);
@@ -585,14 +589,11 @@ async fn federation_media_egress_forwards_to_peer() {
.expect("FM should connect within 5s"); .expect("FM should connect within 5s");
// Read the FederationHello // Read the FederationHello
let _hello = tokio::time::timeout( let _hello = tokio::time::timeout(Duration::from_secs(2), peer_transport.recv_signal())
Duration::from_secs(2), .await
peer_transport.recv_signal(), .expect("hello timeout")
) .expect("recv ok")
.await .expect("some message");
.expect("hello timeout")
.expect("recv ok")
.expect("some message");
// Wait for link setup // Wait for link setup
tokio::time::sleep(Duration::from_millis(100)).await; tokio::time::sleep(Duration::from_millis(100)).await;

View File

@@ -11,14 +11,18 @@ use wzp_client::perform_handshake;
use wzp_crypto::{KeyExchange, WarzoneKeyExchange}; use wzp_crypto::{KeyExchange, WarzoneKeyExchange};
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_relay::handshake::accept_handshake; use wzp_relay::handshake::accept_handshake;
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport}; use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config};
/// Establish a QUIC connection and wrap both sides in `QuinnTransport`. /// Establish a QUIC connection and wrap both sides in `QuinnTransport`.
/// ///
/// Returns (client_transport, server_transport, _endpoints) where the endpoint /// Returns (client_transport, server_transport, _endpoints) where the endpoint
/// tuple must be kept alive for the duration of the test to avoid premature /// tuple must be kept alive for the duration of the test to avoid premature
/// connection teardown. /// connection teardown.
async fn connected_pair() -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::Endpoint, quinn::Endpoint)) { async fn connected_pair() -> (
Arc<QuinnTransport>,
Arc<QuinnTransport>,
(quinn::Endpoint, quinn::Endpoint),
) {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert_der) = server_config(); let (sc, _cert_der) = server_config();
@@ -31,7 +35,9 @@ async fn connected_pair() -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::
let server_ep_clone = server_ep.clone(); let server_ep_clone = server_ep.clone();
let accept_fut = tokio::spawn(async move { let accept_fut = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept"); let conn = wzp_transport::accept(&server_ep_clone)
.await
.expect("accept");
Arc::new(QuinnTransport::new(conn)) Arc::new(QuinnTransport::new(conn))
}); });
@@ -59,9 +65,8 @@ async fn handshake_succeeds() {
// Clone Arc so the server transport stays alive in the main task too. // Clone Arc so the server transport stays alive in the main task too.
let server_t = Arc::clone(&server_transport); let server_t = Arc::clone(&server_transport);
let callee_handle = tokio::spawn(async move { let callee_handle =
accept_handshake(server_t.as_ref(), &callee_seed).await tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
});
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None) let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await .await
@@ -120,9 +125,8 @@ async fn handshake_verifies_identity() {
); );
let server_t = Arc::clone(&server_transport); let server_t = Arc::clone(&server_transport);
let callee_handle = tokio::spawn(async move { let callee_handle =
accept_handshake(server_t.as_ref(), &callee_seed).await tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
});
let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None) let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed, None)
.await .await
@@ -179,13 +183,17 @@ async fn auth_then_handshake() {
let token = match auth_msg { let token = match auth_msg {
SignalMessage::AuthToken { token } => token, SignalMessage::AuthToken { token } => token,
other => panic!("expected AuthToken, got {:?}", std::mem::discriminant(&other)), other => panic!(
"expected AuthToken, got {:?}",
std::mem::discriminant(&other)
),
}; };
// 2. Run the cryptographic handshake // 2. Run the cryptographic handshake
let (session, profile, _caller_fp, _caller_alias) = accept_handshake(server_t.as_ref(), &callee_seed) let (session, profile, _caller_fp, _caller_alias) =
.await accept_handshake(server_t.as_ref(), &callee_seed)
.expect("accept_handshake after auth"); .await
.expect("accept_handshake after auth");
(token, session, profile) (token, session, profile)
}); });
@@ -203,9 +211,7 @@ async fn auth_then_handshake() {
.await .await
.expect("perform_handshake after auth"); .expect("perform_handshake after auth");
let (received_token, callee_session, _profile) = callee_handle let (received_token, callee_session, _profile) = callee_handle.await.expect("join callee task");
.await
.expect("join callee task");
// Verify the auth token was received correctly. // Verify the auth token was received correctly.
assert_eq!(received_token, "bearer-test-token-12345"); assert_eq!(received_token, "bearer-test-token-12345");
@@ -246,9 +252,8 @@ async fn handshake_rejects_bad_signature() {
// Spawn callee -- it should reject the tampered CallOffer. // Spawn callee -- it should reject the tampered CallOffer.
let server_t = Arc::clone(&server_transport); let server_t = Arc::clone(&server_transport);
let callee_handle = tokio::spawn(async move { let callee_handle =
accept_handshake(server_t.as_ref(), &callee_seed).await tokio::spawn(async move { accept_handshake(server_t.as_ref(), &callee_seed).await });
});
// Manually build a CallOffer with a corrupted signature. // Manually build a CallOffer with a corrupted signature.
let mut kx = WarzoneKeyExchange::from_identity_seed(&caller_seed); let mut kx = WarzoneKeyExchange::from_identity_seed(&caller_seed);

View File

@@ -151,12 +151,13 @@ fn both_peers_advertise_reflex_addrs_cross_wire_in_setup() {
); );
let answer = mk_answer("c1", CallAcceptMode::AcceptTrusted, Some(callee_addr)); let answer = mk_answer("c1", CallAcceptMode::AcceptTrusted, Some(callee_addr));
let (setup_caller, setup_callee) = let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
handle_answer_and_build_setups(&mut reg, &answer);
// The CALLER's setup should carry the CALLEE's addr as peer_direct_addr. // The CALLER's setup should carry the CALLEE's addr as peer_direct_addr.
match setup_caller { match setup_caller {
SignalMessage::CallSetup { peer_direct_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert_eq!( assert_eq!(
peer_direct_addr.as_deref(), peer_direct_addr.as_deref(),
Some(callee_addr), Some(callee_addr),
@@ -168,7 +169,9 @@ fn both_peers_advertise_reflex_addrs_cross_wire_in_setup() {
// The CALLEE's setup should carry the CALLER's addr. // The CALLEE's setup should carry the CALLER's addr.
match setup_callee { match setup_callee {
SignalMessage::CallSetup { peer_direct_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert_eq!( assert_eq!(
peer_direct_addr.as_deref(), peer_direct_addr.as_deref(),
Some(caller_addr), Some(caller_addr),
@@ -193,12 +196,13 @@ fn privacy_mode_answer_omits_callee_addr_from_setup() {
// AcceptGeneric explicitly passes None for callee_reflexive_addr — // AcceptGeneric explicitly passes None for callee_reflexive_addr —
// the whole point is to hide the callee's IP from the caller. // the whole point is to hide the callee's IP from the caller.
let answer = mk_answer("c2", CallAcceptMode::AcceptGeneric, None); let answer = mk_answer("c2", CallAcceptMode::AcceptGeneric, None);
let (setup_caller, setup_callee) = let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
handle_answer_and_build_setups(&mut reg, &answer);
// CALLER should see peer_direct_addr = None (privacy preserved). // CALLER should see peer_direct_addr = None (privacy preserved).
match setup_caller { match setup_caller {
SignalMessage::CallSetup { peer_direct_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert!( assert!(
peer_direct_addr.is_none(), peer_direct_addr.is_none(),
"privacy mode must not leak callee addr to caller" "privacy mode must not leak callee addr to caller"
@@ -210,7 +214,9 @@ fn privacy_mode_answer_omits_callee_addr_from_setup() {
// CALLEE still gets the caller's addr — only the callee opted for // CALLEE still gets the caller's addr — only the callee opted for
// privacy, the caller already volunteered its addr in the offer. // privacy, the caller already volunteered its addr in the offer.
match setup_callee { match setup_callee {
SignalMessage::CallSetup { peer_direct_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert_eq!( assert_eq!(
peer_direct_addr.as_deref(), peer_direct_addr.as_deref(),
Some(caller_addr), Some(caller_addr),
@@ -242,11 +248,12 @@ fn pre_phase3_caller_leaves_both_setups_relay_only() {
CallAcceptMode::AcceptTrusted, CallAcceptMode::AcceptTrusted,
Some("198.51.100.9:4433"), Some("198.51.100.9:4433"),
); );
let (setup_caller, setup_callee) = let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
handle_answer_and_build_setups(&mut reg, &answer);
match setup_caller { match setup_caller {
SignalMessage::CallSetup { peer_direct_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
// Phase 3 relay behavior: we always inject whatever // Phase 3 relay behavior: we always inject whatever
// addrs are in the registry, regardless of who // addrs are in the registry, regardless of who
// advertised. The caller here gets the callee's addr // advertised. The caller here gets the callee's addr
@@ -258,7 +265,9 @@ fn pre_phase3_caller_leaves_both_setups_relay_only() {
// The callee's setup has no caller addr (pre-Phase-3 offer). // The callee's setup has no caller addr (pre-Phase-3 offer).
match setup_callee { match setup_callee {
SignalMessage::CallSetup { peer_direct_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr, ..
} => {
assert!( assert!(
peer_direct_addr.is_none(), peer_direct_addr.is_none(),
"callee should see no caller addr when offer was pre-Phase-3" "callee should see no caller addr when offer was pre-Phase-3"
@@ -278,12 +287,15 @@ fn neither_peer_advertises_both_setups_are_relay_only() {
handle_offer(&mut reg, &mk_offer("c4", None)); handle_offer(&mut reg, &mk_offer("c4", None));
let answer = mk_answer("c4", CallAcceptMode::AcceptTrusted, None); let answer = mk_answer("c4", CallAcceptMode::AcceptTrusted, None);
let (setup_caller, setup_callee) = let (setup_caller, setup_callee) = handle_answer_and_build_setups(&mut reg, &answer);
handle_answer_and_build_setups(&mut reg, &answer);
for (label, setup) in [("caller", setup_caller), ("callee", setup_callee)] { for (label, setup) in [("caller", setup_caller), ("callee", setup_callee)] {
match setup { match setup {
SignalMessage::CallSetup { peer_direct_addr, relay_addr, .. } => { SignalMessage::CallSetup {
peer_direct_addr,
relay_addr,
..
} => {
assert!( assert!(
peer_direct_addr.is_none(), peer_direct_addr.is_none(),
"{label}'s CallSetup must have no peer_direct_addr" "{label}'s CallSetup must have no peer_direct_addr"

View File

@@ -24,9 +24,9 @@ use std::net::{Ipv4Addr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use wzp_client::reflect::{detect_nat_type, probe_reflect_addr, NatType}; use wzp_client::reflect::{NatType, detect_nat_type, probe_reflect_addr};
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{create_endpoint, server_config, QuinnTransport}; use wzp_transport::{QuinnTransport, create_endpoint, server_config};
/// Minimal mock relay that loops accepting connections, handles /// Minimal mock relay that loops accepting connections, handles
/// RegisterPresence + Reflect, and responds correctly. Mirrors the /// RegisterPresence + Reflect, and responds correctly. Mirrors the
@@ -136,10 +136,7 @@ async fn detect_nat_type_two_loopback_relays_probes_work_but_classify_unknown()
let (addr_b, _h_b) = spawn_mock_relay().await; let (addr_b, _h_b) = spawn_mock_relay().await;
let detection = detect_nat_type( let detection = detect_nat_type(
vec![ vec![("RelayA".into(), addr_a), ("RelayB".into(), addr_b)],
("RelayA".into(), addr_a),
("RelayB".into(), addr_b),
],
2000, 2000,
None, None,
) )
@@ -194,10 +191,7 @@ async fn detect_nat_type_dead_relay_is_unknown() {
let dead_addr: SocketAddr = "127.0.0.1:1".parse().unwrap(); let dead_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
let detection = detect_nat_type( let detection = detect_nat_type(
vec![ vec![("Alive".into(), alive_addr), ("Dead".into(), dead_addr)],
("Alive".into(), alive_addr),
("Dead".into(), dead_addr),
],
600, // tight timeout so the dead probe fails fast 600, // tight timeout so the dead probe fails fast
None, None,
) )
@@ -207,8 +201,16 @@ async fn detect_nat_type_dead_relay_is_unknown() {
// Find the alive and dead probes by name (order of JoinSet // Find the alive and dead probes by name (order of JoinSet
// completions is not guaranteed). // completions is not guaranteed).
let alive = detection.probes.iter().find(|p| p.relay_name == "Alive").unwrap(); let alive = detection
let dead = detection.probes.iter().find(|p| p.relay_name == "Dead").unwrap(); .probes
.iter()
.find(|p| p.relay_name == "Alive")
.unwrap();
let dead = detection
.probes
.iter()
.find(|p| p.relay_name == "Dead")
.unwrap();
assert!( assert!(
alive.observed_addr.is_some(), alive.observed_addr.is_some(),

View File

@@ -31,7 +31,7 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use wzp_proto::{MediaTransport, SignalMessage}; use wzp_proto::{MediaTransport, SignalMessage};
use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport}; use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config};
/// Spawn a minimal mock relay that loops over `recv_signal`, /// Spawn a minimal mock relay that loops over `recv_signal`,
/// matches on `Reflect`, and responds with `ReflectResponse` using /// matches on `Reflect`, and responds with `ReflectResponse` using
@@ -94,7 +94,11 @@ async fn spawn_mock_relay_without_reflect(
/// distinct-ports test). /// distinct-ports test).
async fn connected_pair_with_port( async fn connected_pair_with_port(
_client_port_hint: u16, _client_port_hint: u16,
) -> (Arc<QuinnTransport>, Arc<QuinnTransport>, (quinn::Endpoint, quinn::Endpoint)) { ) -> (
Arc<QuinnTransport>,
Arc<QuinnTransport>,
(quinn::Endpoint, quinn::Endpoint),
) {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let (sc, _cert_der) = server_config(); let (sc, _cert_der) = server_config();
@@ -109,7 +113,9 @@ async fn connected_pair_with_port(
let server_ep_clone = server_ep.clone(); let server_ep_clone = server_ep.clone();
let accept_fut = tokio::spawn(async move { let accept_fut = tokio::spawn(async move {
let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept"); let conn = wzp_transport::accept(&server_ep_clone)
.await
.expect("accept");
Arc::new(QuinnTransport::new(conn)) Arc::new(QuinnTransport::new(conn))
}); });
@@ -134,10 +140,7 @@ async fn reflect_happy_path() {
// Grab the client's actual bound port so we can cross-check // Grab the client's actual bound port so we can cross-check
// against the reflected response. // against the reflected response.
let client_port = client_ep let client_port = client_ep.local_addr().expect("client local addr").port();
.local_addr()
.expect("client local addr")
.port();
assert_ne!(client_port, 0, "client must have a real bound port"); assert_ne!(client_port, 0, "client must have a real bound port");
// Start the mock relay's reflect handler. // Start the mock relay's reflect handler.
@@ -162,7 +165,10 @@ async fn reflect_happy_path() {
let observed_addr = match resp { let observed_addr = match resp {
SignalMessage::ReflectResponse { observed_addr } => observed_addr, SignalMessage::ReflectResponse { observed_addr } => observed_addr,
other => panic!("expected ReflectResponse, got {:?}", std::mem::discriminant(&other)), other => panic!(
"expected ReflectResponse, got {:?}",
std::mem::discriminant(&other)
),
}; };
let parsed: SocketAddr = observed_addr let parsed: SocketAddr = observed_addr
@@ -210,19 +216,17 @@ async fn reflect_two_clients_distinct_ports() {
// Client A // Client A
let client_ep_a = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep A"); let client_ep_a = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep A");
let conn_a = let conn_a = wzp_transport::connect(&client_ep_a, server_listen, "localhost", client_config())
wzp_transport::connect(&client_ep_a, server_listen, "localhost", client_config()) .await
.await .expect("connect A");
.expect("connect A");
let client_a = Arc::new(QuinnTransport::new(conn_a)); let client_a = Arc::new(QuinnTransport::new(conn_a));
let port_a = client_ep_a.local_addr().unwrap().port(); let port_a = client_ep_a.local_addr().unwrap().port();
// Client B // Client B
let client_ep_b = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep B"); let client_ep_b = create_endpoint((Ipv4Addr::LOCALHOST, 0).into(), None).expect("ep B");
let conn_b = let conn_b = wzp_transport::connect(&client_ep_b, server_listen, "localhost", client_config())
wzp_transport::connect(&client_ep_b, server_listen, "localhost", client_config()) .await
.await .expect("connect B");
.expect("connect B");
let client_b = Arc::new(QuinnTransport::new(conn_b)); let client_b = Arc::new(QuinnTransport::new(conn_b));
let port_b = client_ep_b.local_addr().unwrap().port(); let port_b = client_ep_b.local_addr().unwrap().port();
@@ -252,7 +256,8 @@ async fn reflect_two_clients_distinct_ports() {
} }
}; };
let (addr_a, addr_b) = tokio::join!(reflect_for(client_a.clone()), reflect_for(client_b.clone())); let (addr_a, addr_b) =
tokio::join!(reflect_for(client_a.clone()), reflect_for(client_b.clone()));
let parsed_a: SocketAddr = addr_a.parse().unwrap(); let parsed_a: SocketAddr = addr_a.parse().unwrap();
let parsed_b: SocketAddr = addr_b.parse().unwrap(); let parsed_b: SocketAddr = addr_b.parse().unwrap();
@@ -277,12 +282,10 @@ async fn reflect_two_clients_distinct_ports() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reflect_old_relay_times_out() { async fn reflect_old_relay_times_out() {
let (client_transport, server_transport, _endpoints) = let (client_transport, server_transport, _endpoints) = connected_pair_with_port(0).await;
connected_pair_with_port(0).await;
// Mock relay that ignores Reflect — simulates a pre-Phase-1 build. // Mock relay that ignores Reflect — simulates a pre-Phase-1 build.
let _relay_handle = let _relay_handle = spawn_mock_relay_without_reflect(Arc::clone(&server_transport)).await;
spawn_mock_relay_without_reflect(Arc::clone(&server_transport)).await;
client_transport client_transport
.send_signal(&SignalMessage::Reflect) .send_signal(&SignalMessage::Reflect)

View File

@@ -22,8 +22,8 @@ pub fn server_config() -> (quinn::ServerConfig, Vec<u8>) {
/// Create a server configuration with a deterministic self-signed certificate /// Create a server configuration with a deterministic self-signed certificate
/// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint. /// derived from a 32-byte seed. Same seed = same cert = same TLS fingerprint.
pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) { pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>) {
use ed25519_dalek::pkcs8::EncodePrivateKey;
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey;
use hkdf::Hkdf; use hkdf::Hkdf;
use sha2::Sha256; use sha2::Sha256;
@@ -35,22 +35,23 @@ pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>
// Create Ed25519 signing key and export as PKCS8 DER // Create Ed25519 signing key and export as PKCS8 DER
let signing_key = SigningKey::from_bytes(&ed_bytes); let signing_key = SigningKey::from_bytes(&ed_bytes);
let pkcs8_doc = signing_key.to_pkcs8_der() let pkcs8_doc = signing_key
.to_pkcs8_der()
.expect("failed to encode Ed25519 key as PKCS8"); .expect("failed to encode Ed25519 key as PKCS8");
let key_der_for_rcgen = rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec()) let key_der_for_rcgen =
.expect("failed to wrap PKCS8 DER"); rustls::pki_types::PrivateKeyDer::try_from(pkcs8_doc.as_bytes().to_vec())
.expect("failed to wrap PKCS8 DER");
// Create rcgen KeyPair from DER // Create rcgen KeyPair from DER
let key_pair = rcgen::KeyPair::from_der_and_sign_algo( let key_pair = rcgen::KeyPair::from_der_and_sign_algo(&key_der_for_rcgen, &rcgen::PKCS_ED25519)
&key_der_for_rcgen, .expect("failed to create KeyPair from seed-derived Ed25519 key");
&rcgen::PKCS_ED25519,
)
.expect("failed to create KeyPair from seed-derived Ed25519 key");
// Build self-signed cert with this deterministic keypair // Build self-signed cert with this deterministic keypair
let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]) let params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
.expect("failed to create CertificateParams"); .expect("failed to create CertificateParams");
let cert = params.self_signed(&key_pair).expect("failed to self-sign cert"); let cert = params
.self_signed(&key_pair)
.expect("failed to self-sign cert");
let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec()); let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec());
let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()) let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der())
.expect("failed to serialize key DER"); .expect("failed to serialize key DER");
@@ -62,7 +63,7 @@ pub fn server_config_from_seed(seed: &[u8; 32]) -> (quinn::ServerConfig, Vec<u8>
/// ///
/// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons). /// Format: `xx:xx:xx:xx:...` (32 bytes = 64 hex chars with colons).
pub fn tls_fingerprint(cert_der: &[u8]) -> String { pub fn tls_fingerprint(cert_der: &[u8]) -> String {
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
let hash = Sha256::digest(cert_der); let hash = Sha256::digest(cert_der);
hash.iter() hash.iter()
.map(|b| format!("{b:02x}")) .map(|b| format!("{b:02x}"))
@@ -148,7 +149,7 @@ fn transport_config() -> quinn::TransportConfig {
let mut mtu_config = quinn::MtuDiscoveryConfig::default(); let mut mtu_config = quinn::MtuDiscoveryConfig::default();
mtu_config mtu_config
.upper_bound(1452) .upper_bound(1452)
.interval(Duration::from_secs(300)) // re-probe every 5 min .interval(Duration::from_secs(300)) // re-probe every 5 min
.black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links .black_hole_cooldown(Duration::from_secs(30)); // retry faster on lossy links
config.mtu_discovery_config(Some(mtu_config)); config.mtu_discovery_config(Some(mtu_config));
config.initial_mtu(1200); // safe starting point config.initial_mtu(1200); // safe starting point

View File

@@ -28,13 +28,13 @@ pub async fn connect(
server_name: &str, server_name: &str,
config: quinn::ClientConfig, config: quinn::ClientConfig,
) -> Result<quinn::Connection, TransportError> { ) -> Result<quinn::Connection, TransportError> {
let connecting = endpoint.connect_with(config, addr, server_name).map_err(|e| { let connecting = endpoint
TransportError::Internal(format!("connect error: {e}")) .connect_with(config, addr, server_name)
})?; .map_err(|e| TransportError::Internal(format!("connect error: {e}")))?;
let connection = connecting.await.map_err(|e| { let connection = connecting
TransportError::Internal(format!("connection failed: {e}")) .await
})?; .map_err(|e| TransportError::Internal(format!("connection failed: {e}")))?;
Ok(connection) Ok(connection)
} }
@@ -111,9 +111,9 @@ pub async fn accept(endpoint: &quinn::Endpoint) -> Result<quinn::Connection, Tra
.await .await
.ok_or(TransportError::ConnectionLost)?; .ok_or(TransportError::ConnectionLost)?;
let connection = incoming.await.map_err(|e| { let connection = incoming
TransportError::Internal(format!("accept failed: {e}")) .await
})?; .map_err(|e| TransportError::Internal(format!("accept failed: {e}")))?;
Ok(connection) Ok(connection)
} }

View File

@@ -26,22 +26,20 @@ pub fn max_datagram_payload(connection: &quinn::Connection) -> Option<usize> {
mod tests { mod tests {
use super::*; use super::*;
use bytes::Bytes; use bytes::Bytes;
use wzp_proto::{CodecId, MediaHeader}; use wzp_proto::{CodecId, MediaHeader, MediaType};
fn test_packet() -> MediaPacket { fn test_packet() -> MediaPacket {
MediaPacket { MediaPacket {
header: MediaHeader { header: MediaHeader {
version: 0, version: 2,
is_repair: false, flags: 0,
media_type: MediaType::Audio,
codec_id: CodecId::Opus16k, codec_id: CodecId::Opus16k,
has_quality_report: false, stream_id: 0,
fec_ratio_encoded: 16, fec_ratio: 16,
seq: 42, seq: 42,
timestamp: 1000, timestamp: 1000,
fec_block: 1, fec_block: 1,
fec_symbol: 0,
reserved: 0,
csrc_count: 0,
}, },
payload: Bytes::from_static(b"fake opus frame data"), payload: Bytes::from_static(b"fake opus frame data"),
quality_report: None, quality_report: None,
@@ -61,7 +59,7 @@ mod tests {
#[test] #[test]
fn serialize_deserialize_with_quality_report() { fn serialize_deserialize_with_quality_report() {
let mut packet = test_packet(); let mut packet = test_packet();
packet.header.has_quality_report = true; packet.header.flags |= MediaHeader::FLAG_QUALITY;
packet.quality_report = Some(wzp_proto::QualityReport { packet.quality_report = Some(wzp_proto::QualityReport {
loss_pct: 50, loss_pct: 50,
rtt_4ms: 75, rtt_4ms: 75,

View File

@@ -30,7 +30,7 @@ pub struct PathMonitor {
first_recv_time_ms: Option<u64>, first_recv_time_ms: Option<u64>,
last_recv_time_ms: Option<u64>, last_recv_time_ms: Option<u64>,
/// Sequence tracking for loss detection. /// Sequence tracking for loss detection.
highest_sent_seq: Option<u16>, highest_sent_seq: Option<u32>,
total_sent: u64, total_sent: u64,
total_received: u64, total_received: u64,
/// Last observed RTT for jitter calculation. /// Last observed RTT for jitter calculation.
@@ -64,7 +64,7 @@ impl PathMonitor {
} }
/// Record that we sent a packet with the given sequence number and timestamp. /// Record that we sent a packet with the given sequence number and timestamp.
pub fn observe_sent(&mut self, seq: u16, timestamp_ms: u64) { pub fn observe_sent(&mut self, seq: u32, timestamp_ms: u64) {
self.total_sent += 1; self.total_sent += 1;
self.highest_sent_seq = Some(seq); self.highest_sent_seq = Some(seq);
@@ -78,7 +78,7 @@ impl PathMonitor {
} }
/// Record that we received a packet with the given sequence number and timestamp. /// Record that we received a packet with the given sequence number and timestamp.
pub fn observe_received(&mut self, seq: u16, timestamp_ms: u64) { pub fn observe_received(&mut self, seq: u32, timestamp_ms: u64) {
self.total_received += 1; self.total_received += 1;
if self.first_recv_time_ms.is_none() { if self.first_recv_time_ms.is_none() {
@@ -180,7 +180,12 @@ impl PathMonitor {
return 0.0; return 0.0;
} }
let mean = self.rtt_window.iter().sum::<f64>() / n as f64; let mean = self.rtt_window.iter().sum::<f64>() / n as f64;
let var = self.rtt_window.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n as f64; let var = self
.rtt_window
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ n as f64;
var.sqrt() var.sqrt()
} }
@@ -274,7 +279,7 @@ mod tests {
} }
// Receive only 7 of them (30% loss) // Receive only 7 of them (30% loss)
for i in [0u16, 1, 2, 3, 5, 7, 9] { for i in [0u32, 1, 2, 3, 5, 7, 9] {
monitor.observe_received(i, i as u64 * 20 + 50); monitor.observe_received(i, i as u64 * 20 + 50);
} }

View File

@@ -127,9 +127,9 @@ impl QuinnTransport {
} }
} }
self.connection.send_datagram(data).map_err(|e| { self.connection
TransportError::Internal(format!("send trunk datagram error: {e}")) .send_datagram(data)
})?; .map_err(|e| TransportError::Internal(format!("send trunk datagram error: {e}")))?;
Ok(()) Ok(())
} }
@@ -146,7 +146,7 @@ impl QuinnTransport {
Err(e) => { Err(e) => {
return Err(TransportError::Internal(format!( return Err(TransportError::Internal(format!(
"recv trunk datagram error: {e}" "recv trunk datagram error: {e}"
))) )));
} }
}; };
@@ -177,9 +177,9 @@ impl MediaTransport for QuinnTransport {
monitor.observe_sent(packet.header.seq, packet.header.timestamp as u64); monitor.observe_sent(packet.header.seq, packet.header.timestamp as u64);
} }
self.connection.send_datagram(data).map_err(|e| { self.connection
TransportError::Internal(format!("send datagram error: {e}")) .send_datagram(data)
})?; .map_err(|e| TransportError::Internal(format!("send datagram error: {e}")))?;
Ok(()) Ok(())
} }
@@ -192,7 +192,7 @@ impl MediaTransport for QuinnTransport {
Err(e) => { Err(e) => {
return Err(TransportError::Internal(format!( return Err(TransportError::Internal(format!(
"recv datagram error: {e}" "recv datagram error: {e}"
))) )));
} }
}; };
@@ -201,15 +201,15 @@ impl MediaTransport for QuinnTransport {
// Record receive observation // Record receive observation
{ {
let mut monitor = self.path_monitor.lock().unwrap(); let mut monitor = self.path_monitor.lock().unwrap();
monitor.observe_received( monitor.observe_received(packet.header.seq, packet.header.timestamp as u64);
packet.header.seq,
packet.header.timestamp as u64,
);
} }
Ok(Some(packet)) Ok(Some(packet))
} }
None => { None => {
tracing::warn!(len = data.len(), "skipping malformed media datagram, continuing"); tracing::warn!(
len = data.len(),
"skipping malformed media datagram, continuing"
);
// Don't return Ok(None) — that signals connection closed. // Don't return Ok(None) — that signals connection closed.
// Recurse to read the next datagram instead. // Recurse to read the next datagram instead.
Box::pin(self.recv_media()).await Box::pin(self.recv_media()).await
@@ -241,10 +241,8 @@ impl MediaTransport for QuinnTransport {
} }
async fn close(&self) -> Result<(), TransportError> { async fn close(&self) -> Result<(), TransportError> {
self.connection.close( self.connection
quinn::VarInt::from_u32(0), .close(quinn::VarInt::from_u32(0), b"normal close");
b"normal close",
);
Ok(()) Ok(())
} }
} }

View File

@@ -9,10 +9,14 @@ use wzp_proto::{SignalMessage, TransportError};
/// Send a signaling message over a new bidirectional QUIC stream. /// Send a signaling message over a new bidirectional QUIC stream.
/// ///
/// Opens a new bidi stream, writes a length-prefixed JSON frame, then finishes the send side. /// Opens a new bidi stream, writes a length-prefixed JSON frame, then finishes the send side.
pub async fn send_signal(connection: &Connection, msg: &SignalMessage) -> Result<(), TransportError> { pub async fn send_signal(
let (mut send, _recv) = connection.open_bi().await.map_err(|e| { connection: &Connection,
TransportError::Internal(format!("failed to open bidi stream: {e}")) msg: &SignalMessage,
})?; ) -> Result<(), TransportError> {
let (mut send, _recv) = connection
.open_bi()
.await
.map_err(|e| TransportError::Internal(format!("failed to open bidi stream: {e}")))?;
let json = serde_json::to_vec(msg) let json = serde_json::to_vec(msg)
.map_err(|e| TransportError::Internal(format!("signal serialize error: {e}")))?; .map_err(|e| TransportError::Internal(format!("signal serialize error: {e}")))?;

View File

@@ -10,13 +10,13 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use axum::Router;
use axum::extract::ws::{Message, WebSocket}; use axum::extract::ws::{Message, WebSocket};
use axum::extract::{Path, WebSocketUpgrade}; use axum::extract::{Path, WebSocketUpgrade};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::get; use axum::routing::get;
use axum::Router;
use futures::stream::StreamExt;
use futures::SinkExt; use futures::SinkExt;
use futures::stream::StreamExt;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -54,22 +54,45 @@ async fn main() -> anyhow::Result<()> {
let mut i = 1; let mut i = 1;
while i < args.len() { while i < args.len() {
match args[i].as_str() { match args[i].as_str() {
"--port" => { i += 1; port = args[i].parse().expect("invalid port"); } "--port" => {
"--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); } i += 1;
"--tls" => { use_tls = true; } port = args[i].parse().expect("invalid port");
"--auth-url" => { i += 1; auth_url = Some(args[i].clone()); } }
"--cert" => { i += 1; cert_path = Some(args[i].clone()); } "--relay" => {
"--key" => { i += 1; key_path = Some(args[i].clone()); } i += 1;
relay_addr = args[i].parse().expect("invalid relay address");
}
"--tls" => {
use_tls = true;
}
"--auth-url" => {
i += 1;
auth_url = Some(args[i].clone());
}
"--cert" => {
i += 1;
cert_path = Some(args[i].clone());
}
"--key" => {
i += 1;
key_path = Some(args[i].clone());
}
"--help" | "-h" => { "--help" | "-h" => {
eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls] [--auth-url <url>]"); eprintln!(
"Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls] [--auth-url <url>]"
);
eprintln!(); eprintln!();
eprintln!("Options:"); eprintln!("Options:");
eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)"); eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)");
eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)"); eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)");
eprintln!(" --tls Enable HTTPS (required for mic on Android)"); eprintln!(" --tls Enable HTTPS (required for mic on Android)");
eprintln!(" --auth-url <url> featherChat auth endpoint for token validation"); eprintln!(" --auth-url <url> featherChat auth endpoint for token validation");
eprintln!(" --cert <path> TLS certificate PEM file (optional, overrides self-signed)"); eprintln!(
eprintln!(" --key <path> TLS private key PEM file (optional, overrides self-signed)"); " --cert <path> TLS certificate PEM file (optional, overrides self-signed)"
);
eprintln!(
" --key <path> TLS private key PEM file (optional, overrides self-signed)"
);
eprintln!(); eprintln!();
eprintln!("Rooms: open https://host:port/<room-name> to join a room."); eprintln!("Rooms: open https://host:port/<room-name> to join a room.");
eprintln!("Browser sends auth JSON as first WS message when --auth-url is set."); eprintln!("Browser sends auth JSON as first WS message when --auth-url is set.");
@@ -81,7 +104,10 @@ async fn main() -> anyhow::Result<()> {
} }
if let Some(ref url) = auth_url { if let Some(ref url) = auth_url {
info!(url, "auth enabled — browsers must send token as first WS message"); info!(
url,
"auth enabled — browsers must send token as first WS message"
);
} }
let web_metrics = WebMetrics::new(); let web_metrics = WebMetrics::new();
@@ -101,10 +127,9 @@ async fn main() -> anyhow::Result<()> {
// Serve index.html for any path that isn't /ws/, /metrics, or a static file. // Serve index.html for any path that isn't /ws/, /metrics, or a static file.
// This lets URLs like /manwe load the SPA which reads the room from the path. // This lets URLs like /manwe load the SPA which reads the room from the path.
let static_service = ServeDir::new(static_dir) let static_service = ServeDir::new(static_dir).fallback(tower_http::services::ServeFile::new(
.fallback(tower_http::services::ServeFile::new( format!("{}/index.html", static_dir),
format!("{}/index.html", static_dir), ));
));
let app = Router::new() let app = Router::new()
.route("/ws/{room}", get(ws_handler)) .route("/ws/{room}", get(ws_handler))
@@ -130,7 +155,8 @@ async fn main() -> anyhow::Result<()> {
// Generate self-signed for development // Generate self-signed for development
info!("generating self-signed TLS certificate (use --cert/--key for production)"); info!("generating self-signed TLS certificate (use --cert/--key for production)");
let cert_key = rcgen::generate_simple_self_signed(vec![ let cert_key = rcgen::generate_simple_self_signed(vec![
"localhost".to_string(), "wzp".to_string(), "localhost".to_string(),
"wzp".to_string(),
])?; ])?;
let cert = rustls_pki_types::CertificateDer::from(cert_key.cert); let cert = rustls_pki_types::CertificateDer::from(cert_key.cert);
let key = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()) let key = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der())
@@ -186,7 +212,11 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
match serde_json::from_str::<serde_json::Value>(&text) { match serde_json::from_str::<serde_json::Value>(&text) {
Ok(v) if v.get("type").and_then(|t| t.as_str()) == Some("auth") => { Ok(v) if v.get("type").and_then(|t| t.as_str()) == Some("auth") => {
let token = v.get("token").and_then(|t| t.as_str()).unwrap_or("").to_string(); let token = v
.get("token")
.and_then(|t| t.as_str())
.unwrap_or("")
.to_string();
if token.is_empty() { if token.is_empty() {
error!(room = %room, "empty auth token"); error!(room = %room, "empty auth token");
state.metrics.auth_failures.inc(); state.metrics.auth_failures.inc();
@@ -239,7 +269,10 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
let client_config = wzp_transport::client_config(); let client_config = wzp_transport::client_config();
let endpoint = match wzp_transport::create_endpoint(bind_addr, None) { let endpoint = match wzp_transport::create_endpoint(bind_addr, None) {
Ok(e) => e, Ok(e) => e,
Err(e) => { error!("create endpoint: {e}"); return; } Err(e) => {
error!("create endpoint: {e}");
return;
}
}; };
// Hash room name for SNI privacy // Hash room name for SNI privacy
@@ -248,11 +281,14 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
} else { } else {
wzp_crypto::hash_room_name(&room) wzp_crypto::hash_room_name(&room)
}; };
let connection = let connection = match wzp_transport::connect(&endpoint, relay_addr, &sni, client_config).await
match wzp_transport::connect(&endpoint, relay_addr, &sni, client_config).await { {
Ok(c) => c, Ok(c) => c,
Err(e) => { error!("connect to relay: {e}"); return; } Err(e) => {
}; error!("connect to relay: {e}");
return;
}
};
info!(room = %room, "connected to relay"); info!(room = %room, "connected to relay");
@@ -290,9 +326,9 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
// (PTT handles silence at the browser level, no need to suppress here) // (PTT handles silence at the browser level, no need to suppress here)
let config = CallConfig { let config = CallConfig {
suppression_enabled: false, suppression_enabled: false,
jitter_target: 3, // 60ms instead of default (~1s) jitter_target: 3, // 60ms instead of default (~1s)
jitter_max: 20, // 400ms cap jitter_max: 20, // 400ms cap
jitter_min: 1, // start playing after 20ms jitter_min: 1, // start playing after 20ms
..CallConfig::default() ..CallConfig::default()
}; };
let encoder = Arc::new(Mutex::new(CallEncoder::new(&config))); let encoder = Arc::new(Mutex::new(CallEncoder::new(&config)));
@@ -308,8 +344,11 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
while let Some(Ok(msg)) = ws_receiver.next().await { while let Some(Ok(msg)) = ws_receiver.next().await {
match msg { match msg {
Message::Binary(data) => { Message::Binary(data) => {
if data.len() < FRAME_SAMPLES * 2 { continue; } if data.len() < FRAME_SAMPLES * 2 {
let pcm: Vec<i16> = data.chunks_exact(2) continue;
}
let pcm: Vec<i16> = data
.chunks_exact(2)
.take(FRAME_SAMPLES) .take(FRAME_SAMPLES)
.map(|c| i16::from_le_bytes([c[0], c[1]])) .map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect(); .collect();
@@ -318,7 +357,10 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
let mut enc = send_encoder.lock().await; let mut enc = send_encoder.lock().await;
match enc.encode_frame(&pcm) { match enc.encode_frame(&pcm) {
Ok(p) => p, Ok(p) => p,
Err(e) => { warn!("encode: {e}"); continue; } Err(e) => {
warn!("encode: {e}");
continue;
}
} }
}; };
@@ -352,19 +394,21 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
loop { loop {
match recv_transport.recv_media().await { match recv_transport.recv_media().await {
Ok(Some(pkt)) => { Ok(Some(pkt)) => {
let is_repair = pkt.header.is_repair; let is_repair = pkt.header.is_repair();
let mut dec = recv_decoder.lock().await; let mut dec = recv_decoder.lock().await;
dec.ingest(pkt); dec.ingest(pkt);
if !is_repair { if !is_repair {
if let Some(_n) = dec.decode_next(&mut pcm_buf) { if let Some(_n) = dec.decode_next(&mut pcm_buf) {
let bytes: Vec<u8> = pcm_buf.iter() let bytes: Vec<u8> =
.flat_map(|s| s.to_le_bytes()) pcm_buf.iter().flat_map(|s| s.to_le_bytes()).collect();
.collect();
if let Err(e) = ws_sender.send(Message::Binary(bytes.into())).await { if let Err(e) = ws_sender.send(Message::Binary(bytes.into())).await {
error!("ws send: {e}"); error!("ws send: {e}");
return; return;
} }
recv_metrics.frames_bridged.with_label_values(&["down"]).inc(); recv_metrics
.frames_bridged
.with_label_values(&["down"])
.inc();
frames_recv += 1; frames_recv += 1;
if frames_recv % 500 == 0 { if frames_recv % 500 == 0 {
info!(room = %recv_room, frames_recv, "relay → browser"); info!(room = %recv_room, frames_recv, "relay → browser");
@@ -372,8 +416,14 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
} }
} }
} }
Ok(None) => { info!(room = %recv_room, "relay closed"); break; } Ok(None) => {
Err(e) => { error!(room = %recv_room, "relay recv: {e}"); break; } info!(room = %recv_room, "relay closed");
break;
}
Err(e) => {
error!(room = %recv_room, "relay recv: {e}");
break;
}
} }
} }
info!(room = %recv_room, frames_recv, "recv ended"); info!(room = %recv_room, frames_recv, "recv ended");

View File

@@ -20,9 +20,10 @@ impl WebMetrics {
pub fn new() -> Self { pub fn new() -> Self {
let registry = Registry::new(); let registry = Registry::new();
let active_connections = IntGauge::with_opts( let active_connections = IntGauge::with_opts(Opts::new(
Opts::new("wzp_web_active_connections", "Current WebSocket connections"), "wzp_web_active_connections",
) "Current WebSocket connections",
))
.expect("metric"); .expect("metric");
registry registry
.register(Box::new(active_connections.clone())) .register(Box::new(active_connections.clone()))
@@ -37,20 +38,18 @@ impl WebMetrics {
.register(Box::new(frames_bridged.clone())) .register(Box::new(frames_bridged.clone()))
.expect("register"); .expect("register");
let auth_failures = IntCounter::with_opts( let auth_failures = IntCounter::with_opts(Opts::new(
Opts::new("wzp_web_auth_failures_total", "Browser auth failures"), "wzp_web_auth_failures_total",
) "Browser auth failures",
))
.expect("metric"); .expect("metric");
registry registry
.register(Box::new(auth_failures.clone())) .register(Box::new(auth_failures.clone()))
.expect("register"); .expect("register");
let handshake_latency = Histogram::with_opts( let handshake_latency = Histogram::with_opts(
HistogramOpts::new( HistogramOpts::new("wzp_web_handshake_latency_seconds", "Relay handshake time")
"wzp_web_handshake_latency_seconds", .buckets(vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]),
"Relay handshake time",
)
.buckets(vec![0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]),
) )
.expect("metric"); .expect("metric");
registry registry

View File

@@ -11,8 +11,8 @@
#![cfg(target_os = "android")] #![cfg(target_os = "android")]
use jni::objects::{JObject, JString, JValue};
use jni::JavaVM; use jni::JavaVM;
use jni::objects::{JObject, JString, JValue};
/// Grab the JavaVM + current Activity from the ndk_context that Tauri's /// Grab the JavaVM + current Activity from the ndk_context that Tauri's
/// mobile runtime sets up at process startup. /// mobile runtime sets up at process startup.
@@ -22,8 +22,7 @@ fn jvm_and_activity() -> Result<(JavaVM, JObject<'static>), String> {
if vm_ptr.is_null() { if vm_ptr.is_null() {
return Err("ndk_context: JavaVM pointer is null".into()); return Err("ndk_context: JavaVM pointer is null".into());
} }
let vm = unsafe { JavaVM::from_raw(vm_ptr) } let vm = unsafe { JavaVM::from_raw(vm_ptr) }.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
let activity_ptr = ctx.context() as jni::sys::jobject; let activity_ptr = ctx.context() as jni::sys::jobject;
if activity_ptr.is_null() { if activity_ptr.is_null() {
return Err("ndk_context: activity pointer is null".into()); return Err("ndk_context: activity pointer is null".into());
@@ -140,13 +139,8 @@ pub fn start_bluetooth_sco() -> Result<(), String> {
let am = audio_manager(&mut env, &activity)?; let am = audio_manager(&mut env, &activity)?;
// Ensure speaker is off — mutually exclusive with BT. // Ensure speaker is off — mutually exclusive with BT.
env.call_method( env.call_method(&am, "setSpeakerphoneOn", "(Z)V", &[JValue::Bool(0)])
&am, .map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
// Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo) // Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo)
// Find a BT SCO or BLE device from getAvailableCommunicationDevices() // Find a BT SCO or BLE device from getAvailableCommunicationDevices()
@@ -195,11 +189,7 @@ fn try_set_communication_device(
) -> Result<bool, String> { ) -> Result<bool, String> {
// Check SDK_INT >= 31 (Android 12) // Check SDK_INT >= 31 (Android 12)
let sdk_int = env let sdk_int = env
.get_static_field( .get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
"android/os/Build$VERSION",
"SDK_INT",
"I",
)
.and_then(|v| v.i()) .and_then(|v| v.i())
.unwrap_or(0); .unwrap_or(0);
@@ -261,11 +251,7 @@ fn try_set_communication_device(
.and_then(|v| v.z()) .and_then(|v| v.z())
.unwrap_or(false); .unwrap_or(false);
tracing::info!( tracing::info!(device_type, ok, "setCommunicationDevice: set BT device");
device_type,
ok,
"setCommunicationDevice: set BT device"
);
return Ok(ok); return Ok(ok);
} }
} }
@@ -293,7 +279,12 @@ pub fn is_bluetooth_sco_on() -> Result<bool, String> {
if sdk_int >= 31 { if sdk_int >= 31 {
// getCommunicationDevice() → AudioDeviceInfo (nullable) // getCommunicationDevice() → AudioDeviceInfo (nullable)
let device = env let device = env
.call_method(am, "getCommunicationDevice", "()Landroid/media/AudioDeviceInfo;", &[]) .call_method(
am,
"getCommunicationDevice",
"()Landroid/media/AudioDeviceInfo;",
&[],
)
.and_then(|v| v.l()) .and_then(|v| v.l())
.unwrap_or(JObject::null()); .unwrap_or(JObject::null());
if device.is_null() { if device.is_null() {
@@ -351,7 +342,11 @@ pub fn is_bluetooth_available() -> Result<bool, String> {
.unwrap_or(0); .unwrap_or(0);
// TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8 // TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8
if device_type == 7 || device_type == 8 { if device_type == 7 || device_type == 8 {
tracing::info!(device_type, idx = i, "is_bluetooth_available: found BT device"); tracing::info!(
device_type,
idx = i,
"is_bluetooth_available: found BT device"
);
return Ok(true); return Ok(true);
} }
} }

View File

@@ -9,8 +9,8 @@
//! still fails cleanly but the rest of the engine code links in. //! still fails cleanly but the rest of the engine code links in.
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::time::Instant; use std::time::Instant;
use tauri::Emitter; use tauri::Emitter;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -120,7 +120,10 @@ fn codec_to_profile(codec: CodecId) -> QualityProfile {
frame_duration_ms: 20, frame_duration_ms: 20,
frames_per_block: 5, frames_per_block: 5,
}, },
other => QualityProfile { codec: other, ..QualityProfile::GOOD }, other => QualityProfile {
codec: other,
..QualityProfile::GOOD
},
} }
} }
@@ -289,8 +292,7 @@ impl DredRecvState {
// user can see "DRED is on the wire" in logcat. After // user can see "DRED is on the wire" in logcat. After
// that, sample every 100th parse to confirm the window // that, sample every 100th parse to confirm the window
// is steady-state without drowning the log. // is steady-state without drowning the log.
let should_log = self.parses_with_data == 1 let should_log = self.parses_with_data == 1 || self.parses_with_data % 100 == 0;
|| self.parses_with_data % 100 == 0;
if should_log && wzp_codec::dred_verbose_logs() { if should_log && wzp_codec::dred_verbose_logs() {
info!( info!(
seq, seq,
@@ -467,8 +469,7 @@ impl CallEngine {
let relay_addr: SocketAddr = relay.parse()?; let relay_addr: SocketAddr = relay.parse()?;
info!(%relay_addr, "resolved relay addr"); info!(%relay_addr, "resolved relay addr");
let seed = crate::load_or_create_seed() let seed = crate::load_or_create_seed().map_err(|e| anyhow::anyhow!("identity: {e}"))?;
.map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let fp = seed.derive_identity().public_identity().fingerprint; let fp = seed.derive_identity().public_identity().fingerprint;
let fingerprint = fp.to_string(); let fingerprint = fp.to_string();
info!(%fp, "identity loaded"); info!(%fp, "identity loaded");
@@ -476,7 +477,10 @@ impl CallEngine {
// Transport source: either the pre-connected one from the // Transport source: either the pre-connected one from the
// dual-path race or build a fresh one here. // dual-path race or build a fresh one here.
let transport = if let Some(t) = pre_connected_transport { let transport = if let Some(t) = pre_connected_transport {
info!(t_ms = call_t0.elapsed().as_millis(), is_direct_p2p, "first-join diag: using pre-connected transport"); info!(
t_ms = call_t0.elapsed().as_millis(),
is_direct_p2p, "first-join diag: using pre-connected transport"
);
t t
} else { } else {
// QUIC transport + handshake (Phase 0 relay-only path). // QUIC transport + handshake (Phase 0 relay-only path).
@@ -492,8 +496,10 @@ impl CallEngine {
ep ep
} else { } else {
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap(); let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let ep = wzp_transport::create_endpoint(bind_addr, None) let ep = wzp_transport::create_endpoint(bind_addr, None).map_err(|e| {
.map_err(|e| { error!("create_endpoint failed: {e}"); e })?; error!("create_endpoint failed: {e}");
e
})?;
info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay"); info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay");
ep ep
}; };
@@ -501,18 +507,27 @@ impl CallEngine {
let conn = match tokio::time::timeout( let conn = match tokio::time::timeout(
std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS), std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
wzp_transport::connect(&endpoint, relay_addr, &room, client_config), wzp_transport::connect(&endpoint, relay_addr, &room, client_config),
).await { )
.await
{
Ok(Ok(c)) => c, Ok(Ok(c)) => c,
Ok(Err(e)) => { Ok(Err(e)) => {
error!("connect failed: {e}"); error!("connect failed: {e}");
return Err(e.into()); return Err(e.into());
} }
Err(_) => { Err(_) => {
error!("connect TIMED OUT after {CONNECT_TIMEOUT_SECS}s — QUIC handshake never completed. Relay may be unreachable from this endpoint."); error!(
return Err(anyhow::anyhow!("QUIC connect timeout ({CONNECT_TIMEOUT_SECS}s)")); "connect TIMED OUT after {CONNECT_TIMEOUT_SECS}s — QUIC handshake never completed. Relay may be unreachable from this endpoint."
);
return Err(anyhow::anyhow!(
"QUIC connect timeout ({CONNECT_TIMEOUT_SECS}s)"
));
} }
}; };
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: QUIC connection established, performing handshake"); info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: QUIC connection established, performing handshake"
);
Arc::new(wzp_transport::QuinnTransport::new(conn)) Arc::new(wzp_transport::QuinnTransport::new(conn))
}; };
@@ -526,16 +541,22 @@ impl CallEngine {
// through the signal channel (DirectCallOffer/Answer carry // through the signal channel (DirectCallOffer/Answer carry
// identity_pub + ephemeral_pub + signature). // identity_pub + ephemeral_pub + signature).
if !is_direct_p2p { if !is_direct_p2p {
let _session = wzp_client::handshake::perform_handshake( let _session =
&*transport, wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
&seed.0, .await
Some(&alias), .map_err(|e| {
) error!("perform_handshake failed: {e}");
.await e
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?; })?;
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: connected to relay, handshake complete"); info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: connected to relay, handshake complete"
);
} else { } else {
info!(t_ms = call_t0.elapsed().as_millis(), "first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"); info!(
t_ms = call_t0.elapsed().as_millis(),
"first-join diag: direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"
);
} }
event_cb("connected", &format!("joined room {room}")); event_cb("connected", &format!("joined room {room}"));
@@ -579,7 +600,9 @@ impl CallEngine {
let t_pre_audio = call_t0.elapsed().as_millis(); let t_pre_audio = call_t0.elapsed().as_millis();
if let Err(code) = crate::wzp_native::audio_start() { if let Err(code) = crate::wzp_native::audio_start() {
return Err(anyhow::anyhow!("wzp_native_audio_start failed: code {code}")); return Err(anyhow::anyhow!(
"wzp_native_audio_start failed: code {code}"
));
} }
// Fix C (task #36): prime the playout ring with 20ms of // Fix C (task #36): prime the playout ring with 20ms of
@@ -688,15 +711,17 @@ impl CallEngine {
} }
// RMS for UI meter // RMS for UI meter
let sum_sq: f64 = buf[..frame_samples].iter().map(|&s| (s as f64) * (s as f64)).sum(); let sum_sq: f64 = buf[..frame_samples]
.iter()
.map(|&s| (s as f64) * (s as f64))
.sum();
let rms = (sum_sq / frame_samples as f64).sqrt() as u32; let rms = (sum_sq / frame_samples as f64).sqrt() as u32;
send_level.store(rms, Ordering::Relaxed); send_level.store(rms, Ordering::Relaxed);
last_rms = rms; last_rms = rms;
if !first_nonzero_rms_logged && rms > 0 { if !first_nonzero_rms_logged && rms > 0 {
info!( info!(
t_ms = send_t0.elapsed().as_millis(), t_ms = send_t0.elapsed().as_millis(),
rms, rms, "first-join diag: send first non-zero capture RMS"
"first-join diag: send first non-zero capture RMS"
); );
first_nonzero_rms_logged = true; first_nonzero_rms_logged = true;
} }
@@ -763,11 +788,9 @@ impl CallEngine {
frames_since_dred_poll = 0; frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats(); let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality(); let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update( if let Some(tuning) =
snap.loss_pct, dred_tuner.update(snap.loss_pct, snap.rtt_ms, pq.jitter_ms)
snap.rtt_ms, {
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning); encoder.apply_dred_tuning(tuning);
if wzp_codec::dred_verbose_logs() { if wzp_codec::dred_verbose_logs() {
info!( info!(
@@ -874,9 +897,7 @@ impl CallEngine {
// independent of Oboe routing. Convert locally with e.g. // independent of Oboe routing. Convert locally with e.g.
// ffmpeg -f s16le -ar 48000 -ac 1 -i decoded.pcm decoded.wav // ffmpeg -f s16le -ar 48000 -ac 1 -i decoded.pcm decoded.wav
use std::io::Write; use std::io::Write;
let recorder_path = crate::APP_DATA_DIR let recorder_path = crate::APP_DATA_DIR.get().map(|p| p.join("decoded.pcm"));
.get()
.map(|p| p.join("decoded.pcm"));
let mut recorder = match recorder_path.as_ref() { let mut recorder = match recorder_path.as_ref() {
Some(p) => match std::fs::File::create(p) { Some(p) => match std::fs::File::create(p) {
Ok(f) => { Ok(f) => {
@@ -954,7 +975,9 @@ impl CallEngine {
{ {
let mut rx = recv_rx_codec.lock().await; let mut rx = recv_rx_codec.lock().await;
let codec_name = format!("{:?}", pkt.header.codec_id); let codec_name = format!("{:?}", pkt.header.codec_id);
if *rx != codec_name { *rx = codec_name; } if *rx != codec_name {
*rx = codec_name;
}
} }
if pkt.header.codec_id != current_codec { if pkt.header.codec_id != current_codec {
let new_profile = codec_to_profile(pkt.header.codec_id); let new_profile = codec_to_profile(pkt.header.codec_id);
@@ -980,9 +1003,8 @@ impl CallEngine {
// no-op. // no-op.
if pkt.header.codec_id.is_opus() { if pkt.header.codec_id.is_opus() {
dred_recv.ingest_opus(pkt.header.seq, &pkt.payload); dred_recv.ingest_opus(pkt.header.seq, &pkt.payload);
let frame_samples_now = (48_000 let frame_samples_now =
* current_profile.frame_duration_ms as usize) (48_000 * current_profile.frame_duration_ms as usize) / 1000;
/ 1000;
let spk_muted_flag = recv_spk.load(Ordering::Relaxed); let spk_muted_flag = recv_spk.load(Ordering::Relaxed);
dred_recv.fill_gap_to( dred_recv.fill_gap_to(
&mut decoder, &mut decoder,
@@ -1046,10 +1068,15 @@ impl CallEngine {
// Log sample range for the first few decoded frames and periodically // Log sample range for the first few decoded frames and periodically
if decoded_frames <= 3 || decoded_frames % 100 == 0 { if decoded_frames <= 3 || decoded_frames % 100 == 0 {
let slice = &pcm[..n]; let slice = &pcm[..n];
let (mut lo, mut hi, mut sumsq) = (i16::MAX, i16::MIN, 0i64); let (mut lo, mut hi, mut sumsq) =
(i16::MAX, i16::MIN, 0i64);
for &s in slice.iter() { for &s in slice.iter() {
if s < lo { lo = s; } if s < lo {
if s > hi { hi = s; } lo = s;
}
if s > hi {
hi = s;
}
sumsq += (s as i64) * (s as i64); sumsq += (s as i64) * (s as i64);
} }
let rms = (sumsq as f64 / n as f64).sqrt() as i32; let rms = (sumsq as f64 / n as f64).sqrt() as i32;
@@ -1086,7 +1113,10 @@ impl CallEngine {
.saturating_add(byte_slice.len() as u64); .saturating_add(byte_slice.len() as u64);
if recorder_bytes >= RECORDER_MAX_BYTES { if recorder_bytes >= RECORDER_MAX_BYTES {
let _ = rec.flush(); let _ = rec.flush();
info!(recorder_bytes, "decoded-pcm recorder: stopped after limit"); info!(
recorder_bytes,
"decoded-pcm recorder: stopped after limit"
);
} }
} }
} }
@@ -1105,11 +1135,18 @@ impl CallEngine {
last_written = w; last_written = w;
written_samples = written_samples.saturating_add(w as u64); written_samples = written_samples.saturating_add(w as u64);
if w < n && decoded_frames <= 10 { if w < n && decoded_frames <= 10 {
tracing::warn!(n, w, "recv: partial playout write (ring nearly full)"); tracing::warn!(
n,
w,
"recv: partial playout write (ring nearly full)"
);
} }
} else if decoded_frames <= 3 || decoded_frames % 100 == 0 { } else if decoded_frames <= 3 || decoded_frames % 100 == 0 {
// User clicked spk-mute — log it so we don't chase ghost bugs // User clicked spk-mute — log it so we don't chase ghost bugs
tracing::info!(decoded_frames, "recv: spk_muted=true, skipping playout write"); tracing::info!(
decoded_frames,
"recv: spk_muted=true, skipping playout write"
);
} }
} }
Err(e) => { Err(e) => {
@@ -1302,8 +1339,7 @@ impl CallEngine {
let relay_addr: SocketAddr = relay.parse()?; let relay_addr: SocketAddr = relay.parse()?;
let seed = crate::load_or_create_seed() let seed = crate::load_or_create_seed().map_err(|e| anyhow::anyhow!("identity: {e}"))?;
.map_err(|e| anyhow::anyhow!("identity: {e}"))?;
let fp = seed.derive_identity().public_identity().fingerprint; let fp = seed.derive_identity().public_identity().fingerprint;
let fingerprint = fp.to_string(); let fingerprint = fp.to_string();
info!(%fp, "identity loaded"); info!(%fp, "identity loaded");
@@ -1325,15 +1361,20 @@ impl CallEngine {
ep ep
} else { } else {
let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap(); let bind_addr: SocketAddr = "0.0.0.0:0".parse().unwrap();
let ep = wzp_transport::create_endpoint(bind_addr, None) let ep = wzp_transport::create_endpoint(bind_addr, None).map_err(|e| {
.map_err(|e| { error!("create_endpoint failed: {e}"); e })?; error!("create_endpoint failed: {e}");
e
})?;
info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay"); info!(local_addr = ?ep.local_addr().ok(), "created new endpoint, dialing relay");
ep ep
}; };
let client_config = wzp_transport::client_config(); let client_config = wzp_transport::client_config();
let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config) let conn = wzp_transport::connect(&endpoint, relay_addr, &room, client_config)
.await .await
.map_err(|e| { error!("connect failed: {e}"); e })?; .map_err(|e| {
error!("connect failed: {e}");
e
})?;
info!("QUIC connection established, performing handshake"); info!("QUIC connection established, performing handshake");
Arc::new(wzp_transport::QuinnTransport::new(conn)) Arc::new(wzp_transport::QuinnTransport::new(conn))
}; };
@@ -1343,13 +1384,13 @@ impl CallEngine {
// accept_handshake handler. See the android branch's // accept_handshake handler. See the android branch's
// comment for the full rationale. // comment for the full rationale.
if !is_direct_p2p { if !is_direct_p2p {
let _session = wzp_client::handshake::perform_handshake( let _session =
&*transport, wzp_client::handshake::perform_handshake(&*transport, &seed.0, Some(&alias))
&seed.0, .await
Some(&alias), .map_err(|e| {
) error!("perform_handshake failed: {e}");
.await e
.map_err(|e| { error!("perform_handshake failed: {e}"); e })?; })?;
} else { } else {
info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)"); info!("direct P2P — skipping relay handshake (QUIC TLS is the encryption layer)");
} }
@@ -1494,11 +1535,9 @@ impl CallEngine {
frames_since_dred_poll = 0; frames_since_dred_poll = 0;
let snap = send_t.quinn_path_stats(); let snap = send_t.quinn_path_stats();
let pq = send_t.path_quality(); let pq = send_t.path_quality();
if let Some(tuning) = dred_tuner.update( if let Some(tuning) =
snap.loss_pct, dred_tuner.update(snap.loss_pct, snap.rtt_ms, pq.jitter_ms)
snap.rtt_ms, {
pq.jitter_ms,
) {
encoder.apply_dred_tuning(tuning); encoder.apply_dred_tuning(tuning);
} }
} }
@@ -1558,7 +1597,9 @@ impl CallEngine {
{ {
let mut rx = recv_rx_codec.lock().await; let mut rx = recv_rx_codec.lock().await;
let codec_name = format!("{:?}", pkt.header.codec_id); let codec_name = format!("{:?}", pkt.header.codec_id);
if *rx != codec_name { *rx = codec_name; } if *rx != codec_name {
*rx = codec_name;
}
} }
// Auto-switch decoder if incoming codec differs // Auto-switch decoder if incoming codec differs
if pkt.header.codec_id != current_codec { if pkt.header.codec_id != current_codec {
@@ -1575,9 +1616,8 @@ impl CallEngine {
// start() recv task for full commentary. // start() recv task for full commentary.
if pkt.header.codec_id.is_opus() { if pkt.header.codec_id.is_opus() {
dred_recv.ingest_opus(pkt.header.seq, &pkt.payload); dred_recv.ingest_opus(pkt.header.seq, &pkt.payload);
let frame_samples_now = (48_000 let frame_samples_now =
* current_profile.frame_duration_ms as usize) (48_000 * current_profile.frame_duration_ms as usize) / 1000;
/ 1000;
let spk_muted_flag = recv_spk.load(Ordering::Relaxed); let spk_muted_flag = recv_spk.load(Ordering::Relaxed);
dred_recv.fill_gap_to( dred_recv.fill_gap_to(
&mut decoder, &mut decoder,

View File

@@ -74,7 +74,9 @@ fn save_to_disk(entries: &[CallHistoryEntry]) {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let Ok(json) = serde_json::to_vec_pretty(entries) else { return }; let Ok(json) = serde_json::to_vec_pretty(entries) else {
return;
};
// Atomic write via temp file + rename so a crash mid-write doesn't // Atomic write via temp file + rename so a crash mid-write doesn't
// leave us with a half-file on disk. // leave us with a half-file on disk.
let tmp = path.with_extension("json.tmp"); let tmp = path.with_extension("json.tmp");
@@ -94,12 +96,7 @@ fn now_unix() -> u64 {
/// Append a new entry to the store and persist to disk. Trims the store to /// Append a new entry to the store and persist to disk. Trims the store to
/// `MAX_ENTRIES` after insertion. /// `MAX_ENTRIES` after insertion.
pub fn log( pub fn log(call_id: String, peer_fp: String, peer_alias: Option<String>, direction: CallDirection) {
call_id: String,
peer_fp: String,
peer_alias: Option<String>,
direction: CallDirection,
) {
tracing::info!( tracing::info!(
%call_id, %peer_fp, ?direction, %call_id, %peer_fp, ?direction,
alias = ?peer_alias, alias = ?peer_alias,

File diff suppressed because it is too large Load Diff

View File

@@ -29,8 +29,10 @@ static AUDIO_START: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new(); static AUDIO_START_BT: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new(); static AUDIO_STOP: OnceLock<unsafe extern "C" fn()> = OnceLock::new();
static AUDIO_CAPTURE_AVAILABLE: OnceLock<extern "C" fn() -> usize> = OnceLock::new(); static AUDIO_CAPTURE_AVAILABLE: OnceLock<extern "C" fn() -> usize> = OnceLock::new();
static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> = OnceLock::new(); static AUDIO_READ_CAPTURE: OnceLock<unsafe extern "C" fn(*mut i16, usize) -> usize> =
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> = OnceLock::new(); OnceLock::new();
static AUDIO_WRITE_PLAYOUT: OnceLock<unsafe extern "C" fn(*const i16, usize) -> usize> =
OnceLock::new();
static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new(); static AUDIO_IS_RUNNING: OnceLock<unsafe extern "C" fn() -> i32> = OnceLock::new();
static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new(); static AUDIO_CAPTURE_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new(); static AUDIO_PLAYOUT_LATENCY: OnceLock<unsafe extern "C" fn() -> f32> = OnceLock::new();
@@ -56,25 +58,68 @@ pub fn init() -> Result<(), String> {
unsafe { unsafe {
macro_rules! resolve { macro_rules! resolve {
($cell:expr, $ty:ty, $name:expr) => {{ ($cell:expr, $ty:ty, $name:expr) => {{
let sym: libloading::Symbol<$ty> = lib_ref.get($name) let sym: libloading::Symbol<$ty> = lib_ref.get($name).map_err(|e| {
.map_err(|e| format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?")))?; format!("dlsym {}: {e}", core::str::from_utf8($name).unwrap_or("?"))
})?;
// Dereference the Symbol to extract the raw fn pointer; // Dereference the Symbol to extract the raw fn pointer;
// it stays valid because lib_ref is 'static. // it stays valid because lib_ref is 'static.
$cell.set(*sym).map_err(|_| format!("{} already set", core::str::from_utf8($name).unwrap_or("?")))?; $cell.set(*sym).map_err(|_| {
format!("{} already set", core::str::from_utf8($name).unwrap_or("?"))
})?;
}}; }};
} }
resolve!(VERSION, unsafe extern "C" fn() -> i32, b"wzp_native_version"); resolve!(
resolve!(HELLO, unsafe extern "C" fn(*mut u8, usize) -> usize, b"wzp_native_hello"); VERSION,
resolve!(AUDIO_START, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start"); unsafe extern "C" fn() -> i32,
resolve!(AUDIO_START_BT, unsafe extern "C" fn() -> i32, b"wzp_native_audio_start_bt"); b"wzp_native_version"
);
resolve!(
HELLO,
unsafe extern "C" fn(*mut u8, usize) -> usize,
b"wzp_native_hello"
);
resolve!(
AUDIO_START,
unsafe extern "C" fn() -> i32,
b"wzp_native_audio_start"
);
resolve!(
AUDIO_START_BT,
unsafe extern "C" fn() -> i32,
b"wzp_native_audio_start_bt"
);
resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop"); resolve!(AUDIO_STOP, unsafe extern "C" fn(), b"wzp_native_audio_stop");
resolve!(AUDIO_CAPTURE_AVAILABLE, extern "C" fn() -> usize, b"wzp_native_audio_capture_available"); resolve!(
resolve!(AUDIO_READ_CAPTURE, unsafe extern "C" fn(*mut i16, usize) -> usize, b"wzp_native_audio_read_capture"); AUDIO_CAPTURE_AVAILABLE,
resolve!(AUDIO_WRITE_PLAYOUT, unsafe extern "C" fn(*const i16, usize) -> usize, b"wzp_native_audio_write_playout"); extern "C" fn() -> usize,
resolve!(AUDIO_IS_RUNNING, unsafe extern "C" fn() -> i32, b"wzp_native_audio_is_running"); b"wzp_native_audio_capture_available"
resolve!(AUDIO_CAPTURE_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_capture_latency_ms"); );
resolve!(AUDIO_PLAYOUT_LATENCY, unsafe extern "C" fn() -> f32, b"wzp_native_audio_playout_latency_ms"); resolve!(
AUDIO_READ_CAPTURE,
unsafe extern "C" fn(*mut i16, usize) -> usize,
b"wzp_native_audio_read_capture"
);
resolve!(
AUDIO_WRITE_PLAYOUT,
unsafe extern "C" fn(*const i16, usize) -> usize,
b"wzp_native_audio_write_playout"
);
resolve!(
AUDIO_IS_RUNNING,
unsafe extern "C" fn() -> i32,
b"wzp_native_audio_is_running"
);
resolve!(
AUDIO_CAPTURE_LATENCY,
unsafe extern "C" fn() -> f32,
b"wzp_native_audio_capture_latency_ms"
);
resolve!(
AUDIO_PLAYOUT_LATENCY,
unsafe extern "C" fn() -> f32,
b"wzp_native_audio_playout_latency_ms"
);
} }
Ok(()) Ok(())
@@ -92,7 +137,9 @@ pub fn version() -> i32 {
} }
pub fn hello() -> String { pub fn hello() -> String {
let Some(f) = HELLO.get() else { return String::new(); }; let Some(f) = HELLO.get() else {
return String::new();
};
let mut buf = [0u8; 64]; let mut buf = [0u8; 64];
let n = unsafe { f(buf.as_mut_ptr(), buf.len()) }; let n = unsafe { f(buf.as_mut_ptr(), buf.len()) };
String::from_utf8_lossy(&buf[..n]).into_owned() String::from_utf8_lossy(&buf[..n]).into_owned()
@@ -125,32 +172,47 @@ pub fn audio_stop() {
/// Number of capture samples available to read without blocking. /// Number of capture samples available to read without blocking.
pub fn audio_capture_available() -> usize { pub fn audio_capture_available() -> usize {
let Some(f) = AUDIO_CAPTURE_AVAILABLE.get() else { return 0; }; let Some(f) = AUDIO_CAPTURE_AVAILABLE.get() else {
return 0;
};
f() f()
} }
/// Read captured i16 PCM into `out`. Returns bytes actually copied. /// Read captured i16 PCM into `out`. Returns bytes actually copied.
pub fn audio_read_capture(out: &mut [i16]) -> usize { pub fn audio_read_capture(out: &mut [i16]) -> usize {
let Some(f) = AUDIO_READ_CAPTURE.get() else { return 0; }; let Some(f) = AUDIO_READ_CAPTURE.get() else {
return 0;
};
unsafe { f(out.as_mut_ptr(), out.len()) } unsafe { f(out.as_mut_ptr(), out.len()) }
} }
/// Write i16 PCM into the playout ring. Returns samples enqueued. /// Write i16 PCM into the playout ring. Returns samples enqueued.
pub fn audio_write_playout(input: &[i16]) -> usize { pub fn audio_write_playout(input: &[i16]) -> usize {
let Some(f) = AUDIO_WRITE_PLAYOUT.get() else { return 0; }; let Some(f) = AUDIO_WRITE_PLAYOUT.get() else {
return 0;
};
unsafe { f(input.as_ptr(), input.len()) } unsafe { f(input.as_ptr(), input.len()) }
} }
pub fn audio_is_running() -> bool { pub fn audio_is_running() -> bool {
AUDIO_IS_RUNNING.get().map(|f| unsafe { f() } != 0).unwrap_or(false) AUDIO_IS_RUNNING
.get()
.map(|f| unsafe { f() } != 0)
.unwrap_or(false)
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn audio_capture_latency_ms() -> f32 { pub fn audio_capture_latency_ms() -> f32 {
AUDIO_CAPTURE_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0) AUDIO_CAPTURE_LATENCY
.get()
.map(|f| unsafe { f() })
.unwrap_or(0.0)
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn audio_playout_latency_ms() -> f32 { pub fn audio_playout_latency_ms() -> f32 {
AUDIO_PLAYOUT_LATENCY.get().map(|f| unsafe { f() }).unwrap_or(0.0) AUDIO_PLAYOUT_LATENCY
.get()
.map(|f| unsafe { f() })
.unwrap_or(0.0)
} }

View File

@@ -0,0 +1,228 @@
# Relay Abuse: Attack Surface & Mitigations
> WZP is end-to-end encrypted. The relay forwards ciphertext and cannot inspect payload content. This document enumerates the abuse vectors that survive E2E and the mitigations available without breaking it.
>
> Motivating threat: a PoC on another project (LiveKit) showed that an E2E SFU with no conformance enforcement can be repurposed as a free arbitrary-data tunnel. WZP must not be that.
## Threat model
### In scope
- **Bulk data tunneling.** Attacker uses a legitimate handshake, then pushes arbitrary bytes (file transfer, piracy, scraped traffic) through media datagrams.
- **Bandwidth parasitism.** Attacker uses the relay as a cheap forwarder for unrelated traffic at scale.
- **Quota / billing evasion.** Attacker disguises high-bandwidth use as low-bandwidth audio.
- **DoS via amplification.** Attacker sends one packet → SFU fans out to N peers, multiplying egress cost N×.
### Out of scope (cannot be solved without breaking E2E)
- **Steganography inside real audio.** Modulating Opus-encoded waveforms to encode a covert channel. Information-theoretic limit; ~tens to hundreds of bps achievable; economically uninteresting.
- **Modem-over-call.** Real audio whose semantic content is data. Same limit.
- **Slow exfiltration under all rate caps.** Attacker who stays within audio's natural bandwidth envelope, indefinitely.
### Threat actor profile
We are defending against **economically motivated abuse at scale**, not against a determined nation-state covert channel. The former needs bandwidth and is loud; the latter is impossible to stop and not worth the engineering cost.
## What the relay can observe
Despite E2E, the relay sees a lot. None of this is encrypted to the relay:
| Observable | Source | Bits available |
|---|---|---|
| `CodecID` (declared codec) | `MediaHeader`, AAD | 4 (today) / 6 (v2) |
| `MediaType` (audio / video / data / control) | `MediaHeader` v2 | 2 |
| `sequence`, `timestamp_ms` | `MediaHeader` | 32 + 32 |
| `fec_block_id`, `fec_symbol_idx`, `FecRatio`, `T` (repair) | `MediaHeader` | varies |
| `KeyFrame` bit | `MediaHeader` v2 | 1 |
| `Q` flag (QualityReport trailer present) | `MediaHeader` | 1 |
| Packet size | QUIC layer | — |
| Packet inter-arrival timing | QUIC layer | — |
| Aggregate bytes/sec per session | RelayMetrics | — |
| Source fingerprint, src IP | Session state | — |
This is enough surface for strong conformance enforcement without ever touching encrypted payload.
## Mitigation tiers
Listed in order of cost-to-implement vs. decisiveness. Tier A alone kills the gross-abuse threat. Higher tiers add defense in depth.
### Tier A — Codec-conformance bitrate caps
For each declared `CodecID`, the wire bitrate has a math-derivable hard ceiling:
```
ceiling_bps[CodecID] = nominal_bitrate * (1 + max_FEC_ratio) * (1 + overhead_pct)
= nominal * 3.0 * 1.15 // FEC max 2.0 → factor 3.0
```
| Codec | Nominal | Hard ceiling |
|---|---|---|
| Opus 64k | 64 kbps | ~221 kbps |
| Opus 24k | 24 kbps | ~83 kbps |
| Opus 6k | 6 kbps | ~21 kbps |
| Codec2 1200 | 1.2 kbps | ~4 kbps |
| ComfortNoise | 0 | ~2 kbps |
Sliding 1 s window per session. Sustained excess → hard violation, close session.
Decisive against bulk tunneling. False-positive rate negligible if ceilings set at math-derived max × 1.5.
### Tier B — Packet-rate conformance
Each codec has a fixed frame interval (20 ms or 40 ms), so legal `pps` is 25 or 50, plus FEC repair packets (max ~150 pps total at FEC ratio 2.0). Anything sustaining > 200 pps for an audio codec is not audio.
### Tier C — Timestamp-rate consistency
`timestamp_ms` advances at the declared frame interval. `Δtimestamp / Δseq` over a rolling window should match the codec's frame duration ±2×. Divergence catches abusers who send audio-rate small packets but burn fields for payload.
### Tier D — Per-codec packet-size sanity
EWMA of packet size per session, compared to per-codec typical:
| Codec | Typical | Reject above |
|---|---|---|
| Opus 24k 20 ms | 6080 B | 160 B |
| Opus 6k 40 ms | 3040 B | 90 B |
| Codec2 1200 40 ms | 6 B | 30 B |
| ComfortNoise | 04 B | 16 B |
### Tier E — Per-fingerprint / per-IP token bucket
Aggregate quota regardless of declared codec:
```
For each (fingerprint, src_ip):
monthly_bytes_quota authenticated = 50 GB (tune)
anonymous = 1 GB
per-session cap audio = 256 kbps
video = 5 Mbps
burst = 30 s at 2× cap
```
Won't stop a single rogue session under cap; bounds aggregate blast radius and makes relay economics predictable.
### Tier F — Behavioral entropy / statistical fingerprinting
The deeper layer. Computed continuously per session over 1030 s windows. Combined score flags streams that pass declared-codec checks but do not statistically look like real media.
**Why this works:** real audio and real video have very specific statistical signatures that tunneled data does not naturally produce, and that an attacker would have to deliberately and expensively mimic. The signatures differ wildly between audio and video — which is exactly why we separate them (see next section).
#### Audio fingerprint features
| Feature | Real Opus speech | Tunneled data |
|---|---|---|
| **IAT coefficient of variation** | 0.10.4 (clocked) | > 1.0 (bursty) |
| **Payload-size distribution** | Bimodal: speech 6080 B + silence/CN 010 B | Unimodal, large, MTU-skewed |
| **Silence fraction** | 1040 % (real conversation pauses) | < 2 % |
| **Bitrate over 30 s** | Tracks nominal codec ±20 % | Often saturates ceiling |
| **`Q` flag cadence** | Periodic, regular | Absent or random |
| **DRED / FEC ratio response** | Tracks `QualityReport` trend | Static or noise |
Single derived score: `audio_legitimacy ∈ [0, 1]`. Below threshold (e.g. 0.3) for 60 s → flag.
#### Video fingerprint features (post-V1)
| Feature | Real H.264 / AV1 video | Tunneled data |
|---|---|---|
| **Keyframe periodicity** | Regular (every 14 s, or on PLI) | Absent or uniform `KeyFrame=1` |
| **Frame-size ratio (I / P)** | 520× | ≈ 1× |
| **Burst structure** | One I-frame = N packets in < 5 ms, then quiet | Uniform spacing |
| **Bitrate response to BWE feedback** | Tracks `TransportFeedback::remb_bps` | Ignores it |
| **Resolution / FPS implied by bitrate** | Coherent (240 p ≠ 8 Mbps) | Incoherent |
| **NACK / PLI responsiveness** | Sender produces keyframe within 200 ms | No response |
Single derived score: `video_legitimacy ∈ [0, 1]`.
#### Implementation shape
```rust
pub struct LegitimacyScorer {
media_type: MediaType,
iat_ewma: ExponentialMovingAverage,
iat_variance: ExponentialMovingVariance,
size_histogram: SizeBuckets<8>,
silence_count: u32,
speech_count: u32,
quality_reports_seen: u32,
keyframe_intervals: RingBuffer<u32, 16>,
window_start: Instant,
}
impl LegitimacyScorer {
pub fn observe(&mut self, header: &MediaHeader, payload_len: usize, now: Instant);
pub fn score(&self) -> f32; // [0, 1]
pub fn verdict(&self) -> Verdict; // Legitimate | Suspect | Abusive
}
```
Cheap: a few floats and counters per session. Update on every packet, score every 1 s, escalate over 30+ s.
### Tier G — Reactive response
A scoring system needs a response policy:
| Verdict | Action |
|---|---|
| Legitimate | None |
| Suspect | Apply tighter Tier-E quota; emit `relay_conformance_suspect_total` |
| Abusive | Close session with `Hangup::PolicyViolation`; log to audit; cool-down fingerprint |
| Repeat-abusive | Lower-tier quota across the federation (gossip via federation channel) |
Never silent-drop. Always close with a typed reason so legitimate users hitting a bug get a clear error.
## Separating audio and video
**Yes — this is one of the strongest arguments for the v2 `MediaType` bit and should be a hard design rule.**
Audio and video have nothing in common statistically:
| Property | Audio | Video |
|---|---|---|
| Bitrate | 664 kbps | 100 kbps 5 Mbps |
| Packet rate | 2550 pps | 5002000 pps |
| Packet size | 6160 B | 2001450 B |
| Burst structure | Clocked, near-CBR | Bursty (I-frames) |
| Silence | Common (1040 %) | Meaningless |
| Loss tolerance | High (PLC, DRED) | Variable (keyframes critical) |
| Recovery primitive | FEC + DRED | NACK + PLI + keyframe cache |
A single scoring model trying to cover both would have to be so permissive at the union of envelopes that it would let tunnels through. **Separation is mandatory for Tier F to work.**
### What separation requires
1. **`MediaType:2` in `MediaHeader` v2** (already in `ROAD-TO-VIDEO.md` Phase V1). Without this, the relay must keep a `CodecID → MediaType` table and update it every time a codec is added — fragile.
2. **Per-`MediaType` conformance rules.** A and B and D have separate tables per type. Tier F has separate scorers.
3. **Per-`MediaType` quotas.** Tier E uses two buckets: `audio_bps_cap`, `video_bps_cap`. A session in audio-only mode never gets to spend the video budget. A video session has both, audio-priority.
4. **Per-`MediaType` keyframe/silence semantics.** `KeyFrame` bit is meaningless for audio; silence fraction is meaningless for video. The scorer needs to know which features apply.
### Bonus: separation also helps the SFU
Beyond abuse detection, the same separation makes graceful degradation cleaner: under congestion the relay can drop video packets first while preserving audio, because it knows which is which without parsing the codec table.
## Open questions for later decision
1. **Hard-close on first hard violation, or three-strikes?** Three-strikes is friendlier but lets twice the abuse through. Recommend hard-close + clear typed reason; legitimate users will reconnect, abusers won't try again at the same fingerprint.
2. **Where do verdicts persist?** In-memory per relay is simplest. Federated gossip is more powerful but a new attack surface (poisoning).
3. **Threshold tuning.** All thresholds in this doc are first-pass math. Real numbers come from a few weeks of Prometheus data on legitimate traffic before any enforcement turns on.
4. **Anonymous vs. authenticated split.** featherChat-authed users get generous quotas; anonymous users get tight ones. This makes the economics of mass abuse hostile (need many real identities) without locking out small legitimate use.
5. **What to log.** Conformance hits should be Prometheus counters + ringbuffer of recent violations; never log raw payload content (even encrypted) for privacy.
## Suggested implementation order (whenever this is picked up)
| Step | What | Why first |
|---|---|---|
| 1 | Land v2 wire format with `MediaType:2` | Prereq for separation; already on the road-to-video plan |
| 2 | Tier A + B + C as `wzp-relay/src/conformance.rs` | Kills bulk tunneling; cheap; no false positives if math is right |
| 3 | Prometheus metrics for violations + raw observables (IAT, size, silence frac) | Gather baseline of legitimate traffic before tightening |
| 4 | Tier D + E (size sanity + token bucket) | Defense in depth |
| 5 | Tier F scorer, audio-only first; tuned against the baseline from step 3 | Adds covert-tunnel pressure |
| 6 | Tier F video scorer once video is in production | Same shape, different features |
| 7 | Tier G response policy + audit log | Operationalize |
Steps 12 are decisive against the LiveKit-style PoC. The rest is steady tightening as real traffic accumulates.
## What this does NOT promise
- It does not stop a patient adversary running a slow covert channel inside real audio. Nothing E2E-preserving can.
- It does not detect content (no CSAM scan, no copyright fingerprint). Those would require breaking E2E and are out of scope by design.
- It does not eliminate abuse — it makes abuse loud, expensive, and detectable, which is the realistic goal for any E2E system.

View File

@@ -0,0 +1,109 @@
# PRD: Protocol Hardening Batch
> **Status:** proposed
> **Resolves:** Audit W2 (fec_block_id width), W3 (timestamp rebase doc), W5 (QualityReport AEAD binding), W11 (per-stream anti-replay), W12 (signal version byte), W13 (RoomManager lock).
> **Depends on:** PRD #1 (wire format v2 already widens block_id field).
## Problem
A handful of medium-priority audit findings that don't individually justify a PRD but together represent the long tail of protocol correctness and concurrency. Batching them avoids version churn.
## Items
### H1 — W5: `QualityReport` trailer must be inside AEAD
**Current risk.** If the 4-byte trailer sits *outside* the encrypted payload, anything stripping the last 4 bytes corrupts AEAD verification on legitimate packets and creates a quality-feedback downgrade vector. Even if it's correctly inside today, the v2 wire format change is the right moment to assert this explicitly.
**Action.**
- Audit `crates/wzp-proto/src/packet.rs` for `QualityReport` placement.
- Move inside AEAD payload if currently outside.
- Document: "QualityReport, when Q-flag set, is appended to plaintext payload before encryption."
- Test: tamper with trailer → AEAD decrypt fails.
**Severity.** Security correctness. Do this in Wave 1.
### H2 — W2: `fec_block_id` width
Resolved by v2 wire format (`u16` instead of `u8`). PRD #1 carries the wire change; this PRD just confirms semantics:
- Wraps at 2^16. At 5-frame blocks and 50 pps → ~22 min between collisions, vs. ~25 s in v1.
- Late-joining peers must still discard FEC blocks older than 2 s; widening is defense in depth.
**Action.** Update `wzp-fec` to operate on u16 block_id end-to-end. Test reconstruction across a synthetic 22-min session.
### H3 — W11: Per-stream, per-`MediaType` anti-replay window
**Current.** 64-packet sliding window globally.
**Problem.** Video keyframe burst (100+ packets) can stall the window behind one reordered prior packet.
**Action.**
- Anti-replay state is per (stream_id, media_type).
- Window size: 64 for audio, 1024 for video, 256 for data.
- Window size selected at session setup based on declared profile; tunable via `QualityProfile`.
**Severity.** Required before video. Wave 1.
### H4 — W12: `SignalMessage` versioning
**Current.** Bincode-serialized enum. `#[serde(default, skip_serializing_if)]` handles field additions; variant removals or semantic changes are unsafe.
**Action.**
- Every variant gains `version: u8` as its first field.
- Add `SignalMessage::Unknown { version, raw: Bytes }` to absorb future unknown variants gracefully.
- Decode path: unknown variant → log + drop, do not close session.
**Severity.** Future-proofing. Wave 3.
### H5 — W3: `timestamp_ms` rebase documentation
**Current.** Behavior at rekey (every 65,536 packets, ~22 min) is not documented.
**Decision (this PRD).** `timestamp_ms` is **monotonic across rekeys** — it does not reset. Rekey changes only the cryptographic key material; sequence and timestamp are session-scoped, not key-scoped.
**Action.**
- Document in `WZP-SPEC.md` and inline in `packet.rs` doc comments.
- Add a test that performs a rekey mid-session and asserts `timestamp_ms` continuity.
**Severity.** Doc + test. Wave 3.
### H6 — W13: `RoomManager` lock concurrency
**Current.** Single `Mutex<RoomManager>` acquired per packet by every participant for fan-out peer list. Serializes packet processing within a room.
**Problem.** At 1500 pps/sender for video, this is the dominant bottleneck.
**Action.**
- Migrate to `DashMap<RoomId, Arc<RwLock<Room>>>`.
- Per-room `RwLock` allows concurrent reads (fan-out peer list) and exclusive writes (join/leave/quality changes).
- Fan-out path holds read lock; participant churn holds write lock.
- Federation manager updated to match.
**Severity.** Required for video scale. Wave 3.
**Migration safety.**
- Integration test suite (40 + 4 relay tests) must pass.
- Federation tests must pass.
- Trunking tests must pass.
- Property-test: 100-participant room, 500 join/leave events, 10k packets — no panics, no missed forwards.
## Implementation order
| Wave | Item | Task |
|---|---|---|
| 1 | H1 (W5 AEAD binding) | T1.4 |
| 1 | H3 (W11 anti-replay per-stream) | T1.5 |
| 1 | H2 (W2 block_id widening) | folded into PRD #1 |
| 3 | H4 (W12 signal versioning) | T3.3 |
| 3 | H5 (W3 timestamp doc) | T3.2 |
| 3 | H6 (W13 RoomManager lock) | T3.4 |
## Acceptance criteria
- All current tests pass post-hardening.
- New tests: AEAD trailer tampering, rekey timestamp continuity, 100-participant property test, signal forward-compat decode.
- No Prometheus regression in fan-out latency p99 after H6.
## Effort
~4.5 engineer-days total (1.5 in Wave 1, 3 in Wave 3).

View File

@@ -0,0 +1,171 @@
# PRD: Relay Conformance Enforcement (Abuse Mitigation Tiers AG)
> **Status:** proposed
> **Resolves:** All in-scope vectors from `docs/ATTACK-SURFACE-RELAY-ABUSE.md`.
> **Depends on:** PRD #1 (wire format v2 — for `MediaType` separation in Tiers D/F).
## Problem
WZP relays forward E2E-encrypted ciphertext and cannot inspect payload content. A trivial PoC on another E2E SFU (LiveKit) showed that without conformance enforcement, the relay becomes a free arbitrary-data tunnel. WZP must enforce media-shape conformance against observable header and timing metadata, without breaking E2E.
## Goals
- Make bulk data tunneling through WZP infeasible.
- Bound aggregate per-user abuse blast radius.
- Make covert tunneling expensive (Tier F) without false-positiving real calls.
- Audio and video evaluated by **separate scorers** (statistical signatures don't overlap).
## Non-goals
- Content inspection (would break E2E).
- Detecting steganographic covert channels inside legitimate audio (information-theoretic limit; not worth chasing).
- CSAM / copyright detection (would require E2E break; explicit non-goal).
## Design — tiered enforcement
### Tier A — Codec-conformance bitrate caps
For each `CodecID`, compute math-derived ceiling and enforce sliding 1 s window per session:
```
ceiling_bps[CodecID] = nominal * (1 + max_FEC_ratio) * (1 + overhead_pct)
= nominal * 3.0 * 1.15
```
Hard violation (sustained > ceiling for 1 s) → close session with `Hangup::PolicyViolation { code: BITRATE }`.
### Tier B — Packet-rate cap
Per `CodecID`, max `pps` known (25 or 50 base × up to 3× for FEC = ~150 pps for audio). Sustained > 200 pps audio → hard violation.
### Tier C — Timestamp-rate consistency
`Δtimestamp_ms / Δsequence` over rolling 200-packet window must match codec frame duration ± 2×. Violation → hard.
### Tier D — Per-codec packet-size sanity
EWMA(`payload_len`) per session; reject sustained mean > 2× codec typical. Per-codec table in spec.
### Tier E — Per-fingerprint / per-IP token bucket
```
For each (fingerprint, src_ip):
monthly_bytes_quota authed = 50 GB (tunable)
anon = 1 GB
per-session bps cap audio = 256 kbps
video = 5 Mbps
burst = 30 s @ 2× cap
```
Anonymous quotas tight; authenticated (via featherChat) quotas generous. Soft enforcement: throttle, then close on persistent overage.
### Tier F — Behavioral entropy scoring (per `MediaType`)
Separate scorers for audio and video. Computed over 1030 s windows.
**Audio scorer features:**
| Feature | Legitimate | Abusive |
|---|---|---|
| IAT coefficient of variation | 0.10.4 | > 1.0 |
| Payload-size bimodality | Bimodal (speech + silence) | Unimodal |
| Silence fraction | 1040 % | < 2 % |
| 30 s bitrate vs. nominal | ± 20 % | Saturates ceiling |
| `Q` flag cadence | Periodic | Absent/random |
**Video scorer features (post-PRD #5):**
| Feature | Legitimate | Abusive |
|---|---|---|
| Keyframe periodicity | Regular (14 s or on PLI) | Absent / uniform KF=1 |
| I/P frame-size ratio | 520× | ~1× |
| Burst structure | I-frame in < 5 ms, then quiet | Uniform spacing |
| Bitrate response to BWE | Tracks `remb_bps` | Ignores |
| NACK/PLI responsiveness | Keyframe within 200 ms | No response |
Output: `legitimacy ∈ [0, 1]` per session per `MediaType`. < 0.3 for 60 s → Suspect; < 0.1 for 60 s → Abusive.
### Tier G — Reactive response
```
Verdict::Legitimate → no action
Verdict::Suspect → apply tighter Tier E quota; emit metric
Verdict::Abusive → close session with typed Hangup; cool-down fingerprint 1 h
Verdict::RepeatAbusive → relay-local block 24 h; (optional gossip)
```
Always typed close. No silent drops.
## Implementation outline
New module `wzp-relay/src/conformance.rs`:
```rust
pub struct ConformanceMeter {
media_type: MediaType,
declared_codec: AtomicU8,
bytes_window: SlidingWindow<1000>,
packet_window: SlidingWindow<1000>,
iat_ewma: ExponentialMovingAverage,
iat_variance: ExponentialMovingVariance,
size_histogram: SizeBuckets<8>,
silence_count: AtomicU32,
speech_count: AtomicU32,
quality_reports_seen: AtomicU32,
last_timestamp_ms: AtomicU32,
last_seq: AtomicU32,
keyframe_intervals: RingBuffer<u32, 16>,
violations: AtomicU32,
}
impl ConformanceMeter {
pub fn observe(&self, h: &MediaHeader, payload_len: usize, now: Instant) -> Result<(), Violation>;
pub fn legitimacy(&self) -> f32;
pub fn verdict(&self) -> Verdict;
}
```
Hooked into per-participant forwarding loop in `RoomManager`. Tier AD run synchronously (cheap). Tier F runs on a periodic task (every 1 s per session).
Prometheus exports:
```
wzp_relay_conformance_violations_total{tier,codec_id,media_type,verdict}
wzp_relay_conformance_legitimacy{media_type} histogram
wzp_relay_conformance_iat_cov{media_type} histogram
wzp_relay_conformance_silence_fraction histogram
```
## Rollout
1. Deploy with all tiers in **observe-only** mode (Prometheus only, no enforcement).
2. Collect 12 weeks of baseline traffic.
3. Set thresholds at observed 99.9th percentile of legitimate traffic + headroom.
4. Flip Tier A enforcement first (highest confidence, lowest false-positive risk).
5. Flip B, C, D over 2 weeks.
6. Tune Tier F thresholds against the baseline; flip Suspect first, then Abusive.
## Acceptance criteria
- Synthetic abuse test (5 Mbps random bytes declared as Opus 24 k) closed within 1 s.
- Synthetic abuse test (audio-rate small packets with stuffed payload) closed within 5 s by Tier D.
- Synthetic abuse test (audio-rate, audio-sized, but no silence and CoV=2.0 IAT) flagged Suspect within 60 s.
- Real-call false-positive rate < 0.1 % over a week of production baseline.
- All verdict transitions emit Prometheus counters.
## Risks
- **False positives on edge cases** (long lectures with little silence, ambient-music calls). Mitigation: Tier F floor at Suspect for 30 s minimum; manual review channel for repeat-flagged authed users.
- **Threshold drift** as codecs evolve. Mitigation: ceilings are math-derived from codec table; updated when codec table updates.
- **Federated abuse moving between relays.** Mitigation: Tier G optional gossip (post-Wave 5).
## Effort
- Tier A + B + C: 1.5 d (T2.4 + T2.5)
- Tier D: 0.5 d (T3.6)
- Tier E: 1.5 d (T3.5)
- Tier F audio: 3 d (T5.7)
- Tier F video: 3 d (T6.2)
- Tier G: 1 d (T5.8)
Total: ~10 engineer-days, spread across Waves 26.

Some files were not shown because too many files have changed in this diff Show More