diff --git a/README.md b/README.md index 8783f3c..245d1f2 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,31 @@ This is a slice of the code that powers [SIPcord](https://sipcord.net/) that you can use to self host something similar. It's not the full SIPcord package but rather the core functionality used in SIPcord with ways to build your own backend adapter. SIPcord itself uses this as a component of the full build so the code is the same that runs on the public bridges. -## Help! +This means you have to build the call routing backend yourself. I am including a `static-router` backend which you can use to map extensions in a TOML file like this +```toml +[extensions] +1000 = { guild = 123456789012345620, channel = 987654321012345620 } +2000 = { guild = 123456789012345620, channel = 111222333444555620 } +``` +but if you want more fancy routing you have to build it. You can easily use sipcord-bridge as a library and provide your own routers by implementing the `Backend` trait. -I am providing 0 support for this, my goal is to run [sipcord.net](https://sipcord.net/), not support self hosting. If you want to run this self hosted, feel free to use this code but do not ask me for support. +This was written a mix between myself and claude, sure, some of it's big slop but the parts I care about are not. -## I have a feature request! +### Can you help me set this up? + +**No.** I am not providing support for this as my goal is to run [sipcord.net](https://sipcord.net/), not support self hosting. If you want to run this self hosted, feel free to use this code but you are on your own here. + +### I have a feature request! **PR's welcome**. No really, feel free to implement it and contribute. -## Acknowledgements +### Acknowledgements - Thanks to [dusthillguy](https://www.youtube.com/watch?v=IK1ydvw3xkU) for letting me use the song *"Joona Kouvolalainen buttermilk"* as hold music and distribute it. -- Thanks to chrischrome for hosting bridge-use1 +- Thanks to [wberg](https://wberg.com/) for hosting `bridge-eu1` +- Thanks to [chrischrome](https://litenet.tel/) for hosting `bridge-use1` -## License +### License -GPLv3 \ No newline at end of file +Code is AGPLv3 +Dusthillguy track is whatever dusthillguy wishe \ No newline at end of file diff --git a/pjsua/build.rs b/pjsua/build.rs index 48fafe2..d5ea45d 100644 --- a/pjsua/build.rs +++ b/pjsua/build.rs @@ -7,7 +7,7 @@ //! cmake build (used in Docker to separate the slow C build into its own layer). use std::env; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; fn main() { @@ -214,7 +214,7 @@ fn main() { } /// Build pjproject from source and return include paths. -fn build_from_source(out_dir: &PathBuf) -> Vec { +fn build_from_source(out_dir: &Path) -> Vec { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let pjproject_src = manifest_dir.join("pjproject"); diff --git a/sipcord-bridge/src/call/mod.rs b/sipcord-bridge/src/call/mod.rs index 4faeee3..121ceb5 100644 --- a/sipcord-bridge/src/call/mod.rs +++ b/sipcord-bridge/src/call/mod.rs @@ -40,6 +40,9 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, trace, warn}; use udptl::AsyncUdptlSocket; +/// Type alias for fax session entries stored in the DashMap. +type FaxSessionEntry = (Arc>, CancellationToken); + /// Ring buffer capacity for Discord→SIP audio (i16 mono @ 16kHz). /// 3200 samples = 200ms of audio, enough for timing jitter. const DISCORD_TO_SIP_RING_BUFFER_SIZE: usize = 3200; @@ -107,7 +110,7 @@ struct BridgeContext { sip_calls: Arc>, /// Active fax sessions keyed by SIP call ID. /// Each entry holds the session and a cancellation token for the T.38 processing task. - fax_sessions: Arc>, CancellationToken)>>, + fax_sessions: Arc>, discord_event_tx: Sender, sip_cmd_tx: Sender, sound_manager: Arc, @@ -127,7 +130,7 @@ pub struct BridgeCoordinator { sip_calls: Arc>, /// Active fax sessions keyed by SIP call ID. /// Each entry holds the session and a cancellation token for the T.38 processing task. - fax_sessions: Arc>, CancellationToken)>>, + fax_sessions: Arc>, /// Stores outbound call requests by tracking_id so the answered handler can retrieve them. /// Entries are cleaned on answer/fail and periodically swept for stale entries. outbound_requests: Arc>, @@ -221,28 +224,28 @@ impl BridgeCoordinator { ); // Check for config-based extension sounds (easter eggs) - if let Ok(ext_num) = extension.parse::() { - if let Some(sound_name) = sound_manager.get_extension_sound(ext_num) { - info!( - "Extension {} maps to sound '{}' (call {})", - ext_num, sound_name, call_id - ); + if let Ok(ext_num) = extension.parse::() + && let Some(sound_name) = sound_manager.get_extension_sound(ext_num) + { + info!( + "Extension {} maps to sound '{}' (call {})", + ext_num, sound_name, call_id + ); - let sound_manager = sound_manager.clone(); - let sip_cmd_tx = sip_cmd_tx.clone(); - let sound_name = sound_name.to_string(); + let sound_manager = sound_manager.clone(); + let sip_cmd_tx = sip_cmd_tx.clone(); + let sound_name = sound_name.to_string(); - tokio::spawn(async move { - play_extension_sound_and_hangup( - call_id, - &sound_name, - &sound_manager, - &sip_cmd_tx, - ) - .await; - }); - continue; - } + tokio::spawn(async move { + play_extension_sound_and_hangup( + call_id, + &sound_name, + &sound_manager, + &sip_cmd_tx, + ) + .await; + }); + continue; } // Track this call @@ -325,34 +328,34 @@ impl BridgeCoordinator { backend.on_call_ended(&sip_call_id_str).await; }); - if let Some((_, call_info)) = sip_calls.remove(&call_id) { - if let Some(channel_id) = call_info.channel_id { - let should_destroy = { - if let Some(mut bridge) = bridges.get_mut(&channel_id) { - bridge.sip_calls.remove(&call_id); - info!( - "Removed call {} from bridge for channel {} ({} callers remaining)", - call_id, - channel_id, - bridge.sip_calls.len() - ); - bridge.sip_calls.is_empty() - } else { - false - } - }; - - if should_destroy { + if let Some((_, call_info)) = sip_calls.remove(&call_id) + && let Some(channel_id) = call_info.channel_id + { + let should_destroy = { + if let Some(mut bridge) = bridges.get_mut(&channel_id) { + bridge.sip_calls.remove(&call_id); info!( - "Last caller left, destroying bridge for channel {}", - channel_id + "Removed call {} from bridge for channel {} ({} callers remaining)", + call_id, + channel_id, + bridge.sip_calls.len() ); - cleanup_channel_port(channel_id); - teardown_channel_ring_buffers(channel_id); + bridge.sip_calls.is_empty() + } else { + false + } + }; - if let Some((_, bridge)) = bridges.remove(&channel_id) { - bridge.discord_connection.disconnect().await; - } + if should_destroy { + info!( + "Last caller left, destroying bridge for channel {}", + channel_id + ); + cleanup_channel_port(channel_id); + teardown_channel_ring_buffers(channel_id); + + if let Some((_, bridge)) = bridges.remove(&channel_id) { + bridge.discord_connection.disconnect().await; } } } @@ -366,16 +369,15 @@ impl BridgeCoordinator { // If no audio was ever received, report no_audio to the coordinator // so the Discord embed can show a diagnostic message - if rx_count == 0 { - if let Some(call_info) = sip_calls.get(&call_id) { - if let Some(ref tracking_id) = call_info.tracking_id { - info!( - "Call {} had zero RTP packets, reporting no_audio (tracking_id={})", - call_id, tracking_id - ); - backend_for_sip.report_call_status(tracking_id, "no_audio"); - } - } + if rx_count == 0 + && let Some(call_info) = sip_calls.get(&call_id) + && let Some(ref tracking_id) = call_info.tracking_id + { + info!( + "Call {} had zero RTP packets, reporting no_audio (tracking_id={})", + call_id, tracking_id + ); + backend_for_sip.report_call_status(tracking_id, "no_audio"); } let _ = sip_cmd_tx.send(SipCommand::Hangup { call_id }); @@ -1621,26 +1623,26 @@ async fn play_extension_sound_and_hangup( } // Check if this is a streaming sound (large file) - if sound_manager.is_streaming(sound_name) { - if let Some(config) = sound_manager.get_streaming(sound_name) { - info!( - "Starting streaming playback '{}' from {} for call {}", - sound_name, - config.path.display(), - call_id - ); + if sound_manager.is_streaming(sound_name) + && let Some(config) = sound_manager.get_streaming(sound_name) + { + info!( + "Starting streaming playback '{}' from {} for call {}", + sound_name, + config.path.display(), + call_id + ); - // Use the new port-based streaming approach - // The audio thread handles timing and the hangup happens automatically when done - let _ = sip_cmd_tx.send(SipCommand::StartStreaming { - call_id, - path: config.path.clone(), - }); + // Use the new port-based streaming approach + // The audio thread handles timing and the hangup happens automatically when done + let _ = sip_cmd_tx.send(SipCommand::StartStreaming { + call_id, + path: config.path.clone(), + }); - // Don't hangup here - the streaming player will hangup when done - // or when the call ends (detected via CALL_CONF_PORTS check) - return; - } + // Don't hangup here - the streaming player will hangup when done + // or when the call ends (detected via CALL_CONF_PORTS check) + return; } // Preloaded sound - play all at once @@ -1805,17 +1807,16 @@ async fn process_fax_audio( } let tx_available = tx_producer.slots(); let to_write = tx_generated.min(tx_available); - if to_write > 0 { - if let Ok(mut chunk) = tx_producer.write_chunk(to_write) { - let (first, second) = chunk.as_mut_slices(); - let first_len = first.len().min(to_write); - first[..first_len].copy_from_slice(&tx_buf[..first_len]); - if first_len < to_write { - second[..to_write - first_len] - .copy_from_slice(&tx_buf[first_len..to_write]); - } - chunk.commit_all(); + if to_write > 0 + && let Ok(mut chunk) = tx_producer.write_chunk(to_write) + { + let (first, second) = chunk.as_mut_slices(); + let first_len = first.len().min(to_write); + first[..first_len].copy_from_slice(&tx_buf[..first_len]); + if first_len < to_write { + second[..to_write - first_len].copy_from_slice(&tx_buf[first_len..to_write]); } + chunk.commit_all(); } } else { tx_silent_frames += 1; diff --git a/sipcord-bridge/src/fax/audio_port.rs b/sipcord-bridge/src/fax/audio_port.rs index 96638cc..f527d9f 100644 --- a/sipcord-bridge/src/fax/audio_port.rs +++ b/sipcord-bridge/src/fax/audio_port.rs @@ -256,44 +256,50 @@ unsafe extern "C" fn fax_port_get_frame( return pj_constants__PJ_SUCCESS as pj_status_t; } - let call_id_ldata = (*this_port).port_data.ldata; + let call_id_ldata = unsafe { (*this_port).port_data.ldata }; - if let Some(consumer_entry) = get_fax_tx_consumers().get(&call_id_ldata) { - if let Some(mut consumer) = consumer_entry.try_lock() { - let available = consumer.slots(); - if available >= SAMPLES_PER_FRAME { - if let Ok(chunk) = consumer.read_chunk(SAMPLES_PER_FRAME) { - let (first, second) = chunk.as_slices(); - let buf = (*frame).buf as *mut i16; - let out = std::slice::from_raw_parts_mut(buf, SAMPLES_PER_FRAME); - out[..first.len()].copy_from_slice(first); - if !second.is_empty() { - out[first.len()..first.len() + second.len()].copy_from_slice(second); - } - chunk.commit_all(); - (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; - (*frame).size = SAMPLES_PER_FRAME * 2; - return pj_constants__PJ_SUCCESS as pj_status_t; - } + if let Some(consumer_entry) = get_fax_tx_consumers().get(&call_id_ldata) + && let Some(mut consumer) = consumer_entry.try_lock() + { + let available = consumer.slots(); + if available >= SAMPLES_PER_FRAME + && let Ok(chunk) = consumer.read_chunk(SAMPLES_PER_FRAME) + { + let (first, second) = chunk.as_slices(); + let out = unsafe { + let buf = (*frame).buf as *mut i16; + std::slice::from_raw_parts_mut(buf, SAMPLES_PER_FRAME) + }; + out[..first.len()].copy_from_slice(first); + if !second.is_empty() { + out[first.len()..first.len() + second.len()].copy_from_slice(second); } + chunk.commit_all(); + unsafe { + (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; + (*frame).size = SAMPLES_PER_FRAME * 2; + } + return pj_constants__PJ_SUCCESS as pj_status_t; } } // No TX audio available — return silence audio frame (not NONE). // Returning FRAME_TYPE_NONE can cause PJSIP's conference bridge to // exclude this port from the audio mix, breaking the TX path. - let buf = (*frame).buf as *mut i16; - let out = std::slice::from_raw_parts_mut(buf, SAMPLES_PER_FRAME); - out.fill(0); - (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; - (*frame).size = SAMPLES_PER_FRAME * 2; + unsafe { + let buf = (*frame).buf as *mut i16; + let out = std::slice::from_raw_parts_mut(buf, SAMPLES_PER_FRAME); + out.fill(0); + (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; + (*frame).size = SAMPLES_PER_FRAME * 2; + } pj_constants__PJ_SUCCESS as pj_status_t } /// on_destroy callback — no-op since cleanup is done in remove_fax_audio_port(). /// Required by PJSIP to avoid "on_destroy() not found" warning. unsafe extern "C" fn fax_port_on_destroy(_this_port: *mut pjmedia_port) -> pj_status_t { - pj_constants__PJ_SUCCESS as pj_status_t + pj_constants__PJ_SUCCESS as pj_status_t // no unsafe ops needed } /// put_frame callback — captures SIP audio and pushes to RX ring buffer for SpanDSP. @@ -306,36 +312,40 @@ unsafe extern "C" fn fax_port_put_frame( } // Only process audio frames with data - if (*frame).type_ != pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO || (*frame).size == 0 { + if unsafe { + (*frame).type_ != pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO || (*frame).size == 0 + } { return pj_constants__PJ_SUCCESS as pj_status_t; } - let call_id_ldata = (*this_port).port_data.ldata; + let call_id_ldata = unsafe { (*this_port).port_data.ldata }; // View frame buffer as i16 slice - let num_samples = (*frame).size / 2; - let frame_buf = (*frame).buf as *const i16; - let samples = std::slice::from_raw_parts(frame_buf, num_samples); + let samples = unsafe { + let num_samples = (*frame).size / 2; + let frame_buf = (*frame).buf as *const i16; + std::slice::from_raw_parts(frame_buf, num_samples) + }; // Push to RX ring buffer - if let Some(producer_entry) = get_fax_rx_producers().get(&call_id_ldata) { - if let Some(mut producer) = producer_entry.try_lock() { - let available = producer.slots(); - if available >= samples.len() { - if let Ok(mut chunk) = producer.write_chunk(samples.len()) { - let (first, second) = chunk.as_mut_slices(); - let first_len = first.len().min(samples.len()); - first[..first_len].copy_from_slice(&samples[..first_len]); - if first_len < samples.len() { - second[..samples.len() - first_len].copy_from_slice(&samples[first_len..]); - } - chunk.commit_all(); - } - } else { - // Buffer full — fax processing is falling behind. Track the drop. - if let Some(counter) = get_fax_rx_drop_counts().get(&call_id_ldata) { - counter.fetch_add(1, Ordering::Relaxed); + if let Some(producer_entry) = get_fax_rx_producers().get(&call_id_ldata) + && let Some(mut producer) = producer_entry.try_lock() + { + let available = producer.slots(); + if available >= samples.len() { + if let Ok(mut chunk) = producer.write_chunk(samples.len()) { + let (first, second) = chunk.as_mut_slices(); + let first_len = first.len().min(samples.len()); + first[..first_len].copy_from_slice(&samples[..first_len]); + if first_len < samples.len() { + second[..samples.len() - first_len].copy_from_slice(&samples[first_len..]); } + chunk.commit_all(); + } + } else { + // Buffer full — fax processing is falling behind. Track the drop. + if let Some(counter) = get_fax_rx_drop_counts().get(&call_id_ldata) { + counter.fetch_add(1, Ordering::Relaxed); } } } diff --git a/sipcord-bridge/src/fax/spandsp.rs b/sipcord-bridge/src/fax/spandsp.rs index 23388d4..00a99d0 100644 --- a/sipcord-bridge/src/fax/spandsp.rs +++ b/sipcord-bridge/src/fax/spandsp.rs @@ -115,12 +115,14 @@ unsafe fn configure_log_state(log_state: *mut spandsp_sys::logging_state_t) { return; } let log_level = LogLevel::Flow as i32 | LogShowFlags::TAG.bits(); - spandsp_sys::span_log_set_level(log_state, log_level); - spandsp_sys::span_log_set_message_handler( - log_state, - Some(spandsp_log_handler), - std::ptr::null_mut(), - ); + unsafe { + spandsp_sys::span_log_set_level(log_state, log_level); + spandsp_sys::span_log_set_message_handler( + log_state, + Some(spandsp_log_handler), + std::ptr::null_mut(), + ); + } } /// Check fax reception completion status from callback state. @@ -467,8 +469,11 @@ unsafe extern "C" fn tx_packet_handler( if user_data.is_null() || buf.is_null() || len <= 0 { return -1; } - let state = &*(user_data as *const TxCallbackState); - let data = std::slice::from_raw_parts(buf, len as usize); + let (state, data) = unsafe { + let state = &*(user_data as *const TxCallbackState); + let data = std::slice::from_raw_parts(buf, len as usize); + (state, data) + }; debug!("SpanDSP TX IFP: {}B (count={})", len, count); // Send the packet `count` times as SpanDSP requests. // For indicator packets (CNG, CED, DIS), count is typically 3 — these @@ -491,7 +496,7 @@ unsafe extern "C" fn tx_packet_handler( /// Phase B handler: called when T.30 negotiation starts. unsafe extern "C" fn phase_b_handler(user_data: *mut std::ffi::c_void, result: i32) -> i32 { if !user_data.is_null() { - let state = &mut *(user_data as *mut FaxCallbackState); + let state = unsafe { &mut *(user_data as *mut FaxCallbackState) }; state.negotiation_started = true; info!( "SpanDSP phase B: fax negotiation started (result={})", @@ -504,7 +509,7 @@ unsafe extern "C" fn phase_b_handler(user_data: *mut std::ffi::c_void, result: i /// Phase D handler: called when a page is received. unsafe extern "C" fn phase_d_handler(user_data: *mut std::ffi::c_void, result: i32) -> i32 { if !user_data.is_null() { - let state = &mut *(user_data as *mut FaxCallbackState); + let state = unsafe { &mut *(user_data as *mut FaxCallbackState) }; state.pages_received += 1; info!( "SpanDSP phase D: page {} received (result={})", @@ -517,7 +522,7 @@ unsafe extern "C" fn phase_d_handler(user_data: *mut std::ffi::c_void, result: i /// Phase E handler: called when fax reception completes (success or failure). unsafe extern "C" fn phase_e_handler(user_data: *mut std::ffi::c_void, completion_code: i32) { if !user_data.is_null() { - let state = &mut *(user_data as *mut FaxCallbackState); + let state = unsafe { &mut *(user_data as *mut FaxCallbackState) }; state.completion_code = completion_code; state.completed = true; @@ -550,7 +555,7 @@ unsafe extern "C" fn spandsp_log_handler( if text.is_null() { return; } - let msg = std::ffi::CStr::from_ptr(text).to_string_lossy(); + let msg = unsafe { std::ffi::CStr::from_ptr(text) }.to_string_lossy(); let msg = msg.trim_end(); // SpanDSP messages often have trailing newlines match level { diff --git a/sipcord-bridge/src/fax/tiff_decoder.rs b/sipcord-bridge/src/fax/tiff_decoder.rs index 718c56f..310d7e7 100644 --- a/sipcord-bridge/src/fax/tiff_decoder.rs +++ b/sipcord-bridge/src/fax/tiff_decoder.rs @@ -941,11 +941,11 @@ fn decode_group4(data: &[u8], width: u32, height: u32) -> Result>> for _ in 0..height { // Check for EOFB (End Of Facsimile Block): two consecutive EOL codes - if let Some(v) = reader.peek(12) { - if v == 0x001 { - // Possible EOFB — check for second EOL - break; - } + if let Some(v) = reader.peek(12) + && v == 0x001 + { + // Possible EOFB — check for second EOL + break; } let line = match decode_line_2d(&mut reader, &reference, w) { diff --git a/sipcord-bridge/src/services/registrar.rs b/sipcord-bridge/src/services/registrar.rs index ab1a3b9..e19c423 100644 --- a/sipcord-bridge/src/services/registrar.rs +++ b/sipcord-bridge/src/services/registrar.rs @@ -46,6 +46,12 @@ pub struct Registrar { discord_to_sip: DashMap, } +impl Default for Registrar { + fn default() -> Self { + Self::new() + } +} + impl Registrar { pub fn new() -> Self { Self { @@ -68,10 +74,10 @@ impl Registrar { .find(|r| r.source_addr == reg.source_addr && r.contact_uri == reg.contact_uri) { // If discord_username changed, remove the old reverse mapping - if existing.discord_username != reg.discord_username { - if let Some(ref old_du) = existing.discord_username { - self.discord_to_sip.remove(old_du); - } + if existing.discord_username != reg.discord_username + && let Some(ref old_du) = existing.discord_username + { + self.discord_to_sip.remove(old_du); } existing.expires_at = reg.expires_at; diff --git a/sipcord-bridge/src/transport/discord/mod.rs b/sipcord-bridge/src/transport/discord/mod.rs index d815acd..c6255cd 100644 --- a/sipcord-bridge/src/transport/discord/mod.rs +++ b/sipcord-bridge/src/transport/discord/mod.rs @@ -1112,19 +1112,18 @@ impl VoiceEventHandler for VoiceReceiver { // CRITICAL: Skip our own SSRC to prevent feedback loop // When we send audio to Discord, it comes back in VoiceTick. // If we don't filter it out, we get: SIP -> Discord -> SIP -> Discord -> ... - if let Some(ref map) = ssrc_map { - if let Some(&user_id) = map.get(ssrc) { - if user_id == self.bot_user_id { - skipped_self = true; - if should_log { - trace!( - "VoiceTick: Skipping bot's own SSRC {} to prevent feedback", - ssrc - ); - } - continue; - } + if let Some(ref map) = ssrc_map + && let Some(&user_id) = map.get(ssrc) + && user_id == self.bot_user_id + { + skipped_self = true; + if should_log { + trace!( + "VoiceTick: Skipping bot's own SSRC {} to prevent feedback", + ssrc + ); } + continue; } if let Some(ref decoded) = voice_data.decoded_voice { diff --git a/sipcord-bridge/src/transport/sip/audio_thread.rs b/sipcord-bridge/src/transport/sip/audio_thread.rs index 8aae82f..88d3dbe 100644 --- a/sipcord-bridge/src/transport/sip/audio_thread.rs +++ b/sipcord-bridge/src/transport/sip/audio_thread.rs @@ -230,27 +230,27 @@ pub fn stop_audio_thread() { // If the thread is blocked on a conference bridge lock, we don't want // shutdown to hang indefinitely. The 2s force-exit timer in main.rs // is a final backstop, but this avoids relying on a hard process exit. - if let Some(handle_storage) = AUDIO_THREAD_HANDLE.get() { - if let Some(handle) = handle_storage.lock().take() { - tracing::debug!("Joining audio thread (2s timeout)..."); - let (done_tx, done_rx) = std::sync::mpsc::channel(); - let join_thread = std::thread::spawn(move || { - let result = handle.join(); - let _ = done_tx.send(result); - }); - match done_rx.recv_timeout(std::time::Duration::from_secs(2)) { - Ok(Ok(())) => { - tracing::debug!("Audio thread joined successfully"); - } - Ok(Err(e)) => { - tracing::error!("Audio thread panicked: {:?}", e); - } - Err(_) => { - tracing::warn!("Audio thread join timed out after 2s, detaching"); - // Detach the join thread — the audio thread will be - // cleaned up by process exit - drop(join_thread); - } + if let Some(handle_storage) = AUDIO_THREAD_HANDLE.get() + && let Some(handle) = handle_storage.lock().take() + { + tracing::debug!("Joining audio thread (2s timeout)..."); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let join_thread = std::thread::spawn(move || { + let result = handle.join(); + let _ = done_tx.send(result); + }); + match done_rx.recv_timeout(std::time::Duration::from_secs(2)) { + Ok(Ok(())) => { + tracing::debug!("Audio thread joined successfully"); + } + Ok(Err(e)) => { + tracing::error!("Audio thread panicked: {:?}", e); + } + Err(_) => { + tracing::warn!("Audio thread join timed out after 2s, detaching"); + // Detach the join thread — the audio thread will be + // cleaned up by process exit + drop(join_thread); } } } @@ -331,15 +331,15 @@ fn process_pending_pjsua_ops() { PendingPjsuaOp::Hangup { call_id } => Some(*call_id), PendingPjsuaOp::ConnectFaxPort { call_id, .. } => Some(*call_id), }; - if let Some(cid) = call_id { - if !is_call_valid(cid) { - tracing::warn!("Skipping stale op for dead call {}: {:?}", cid, op); - // For ConnectFaxPort, signal failure so the caller doesn't hang - if let PendingPjsuaOp::ConnectFaxPort { done_tx, .. } = op { - let _ = done_tx.send(false); - } - continue; + if let Some(cid) = call_id + && !is_call_valid(cid) + { + tracing::warn!("Skipping stale op for dead call {}: {:?}", cid, op); + // For ConnectFaxPort, signal failure so the caller doesn't hang + if let PendingPjsuaOp::ConnectFaxPort { done_tx, .. } = op { + let _ = done_tx.send(false); } + continue; } count += 1; match op { @@ -559,7 +559,7 @@ unsafe fn process_audio_frame( timestamp: pj_timestamp { u64_: *timestamp }, bit_info: 0, }; - pjmedia_port_get_frame(master_port, &mut clock_frame); + unsafe { pjmedia_port_get_frame(master_port, &mut clock_frame) }; // Now drain the SIP->Discord buffers that were filled by channel_port_put_frame callbacks // during the conference tick above. @@ -718,11 +718,11 @@ pub fn check_rtp_inactivity() { } } - if let Some(sender_lock) = TIMEOUT_EVENT_TX.get() { - if let Some(ref tx) = *sender_lock.lock() { - for (call_id, rx_count) in timed_out_calls { - let _ = tx.send(super::SipEvent::CallTimeout { call_id, rx_count }); - } + if let Some(sender_lock) = TIMEOUT_EVENT_TX.get() + && let Some(ref tx) = *sender_lock.lock() + { + for (call_id, rx_count) in timed_out_calls { + let _ = tx.send(super::SipEvent::CallTimeout { call_id, rx_count }); } } } diff --git a/sipcord-bridge/src/transport/sip/callbacks.rs b/sipcord-bridge/src/transport/sip/callbacks.rs index 634b8fe..84757c7 100644 --- a/sipcord-bridge/src/transport/sip/callbacks.rs +++ b/sipcord-bridge/src/transport/sip/callbacks.rs @@ -76,25 +76,27 @@ pub unsafe fn extract_source_ip(rdata: *const pjsip_rx_data) -> Option { return None; } - // pjsip stores source info in pkt_info.src_name as a C string (null-terminated char array) - let src_name = &(*rdata).pkt_info.src_name; + unsafe { + // pjsip stores source info in pkt_info.src_name as a C string (null-terminated char array) + let src_name = &(*rdata).pkt_info.src_name; - // Find the null terminator - let len = src_name - .iter() - .position(|&c| c == 0) - .unwrap_or(src_name.len()); + // Find the null terminator + let len = src_name + .iter() + .position(|&c| c == 0) + .unwrap_or(src_name.len()); - // Convert to Rust string - let ip_str = std::str::from_utf8(std::slice::from_raw_parts( - src_name.as_ptr() as *const u8, - len, - )) - .ok()?; + // Convert to Rust string + let ip_str = std::str::from_utf8(std::slice::from_raw_parts( + src_name.as_ptr() as *const u8, + len, + )) + .ok()?; - // pjsip's src_name contains only the IP address (port is in src_port), - // so parse directly as IpAddr. This handles both IPv4 and IPv6. - ip_str.parse().ok() + // pjsip's src_name contains only the IP address (port is in src_port), + // so parse directly as IpAddr. This handles both IPv4 and IPv6. + ip_str.parse().ok() + } } /// Extract User-Agent header from pjsip_rx_data @@ -103,29 +105,31 @@ pub unsafe fn extract_user_agent(rdata: *const pjsip_rx_data) -> Option return None; } - let msg = (*rdata).msg_info.msg; - if msg.is_null() { - return None; + unsafe { + let msg = (*rdata).msg_info.msg; + if msg.is_null() { + return None; + } + + // Find User-Agent header by name + let hdr_name = CString::new("User-Agent").ok()?; + let name = pj_str(hdr_name.as_ptr() as *mut c_char); + + let hdr = pjsip_msg_find_hdr_by_name(msg, &name, ptr::null_mut()); + if hdr.is_null() { + return None; + } + + // Cast to generic string header + let str_hdr = hdr as *const pjsip_generic_string_hdr; + if str_hdr.is_null() { + return None; + } + + // Extract the header value + let value = pj_str_to_string(&(*str_hdr).hvalue); + if value.is_empty() { None } else { Some(value) } } - - // Find User-Agent header by name - let hdr_name = CString::new("User-Agent").ok()?; - let name = pj_str(hdr_name.as_ptr() as *mut c_char); - - let hdr = pjsip_msg_find_hdr_by_name(msg, &name, ptr::null_mut()); - if hdr.is_null() { - return None; - } - - // Cast to generic string header - let str_hdr = hdr as *const pjsip_generic_string_hdr; - if str_hdr.is_null() { - return None; - } - - // Extract the header value - let value = pj_str_to_string(&(*str_hdr).hvalue); - if value.is_empty() { None } else { Some(value) } } /// Check if User-Agent indicates a SIPVicious scanner or similar tool @@ -144,235 +148,241 @@ pub unsafe fn extract_digest_auth_from_rdata( return None; } - let rdata = &*rdata; - let msg = rdata.msg_info.msg; - if msg.is_null() { - return None; - } + unsafe { + let rdata = &*rdata; + let msg = rdata.msg_info.msg; + if msg.is_null() { + return None; + } - // Find Authorization header by type (pjsip parses it into a structured format) - let hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_AUTHORIZATION, ptr::null_mut()); + // Find Authorization header by type (pjsip parses it into a structured format) + let hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_AUTHORIZATION, ptr::null_mut()); - if hdr.is_null() { - tracing::debug!("No Authorization header found"); - return None; - } + if hdr.is_null() { + tracing::debug!("No Authorization header found"); + return None; + } - // Cast to authorization header type - let auth_hdr = hdr as *const pjsip_authorization_hdr; - if auth_hdr.is_null() { - return None; - } + // Cast to authorization header type + let auth_hdr = hdr as *const pjsip_authorization_hdr; + if auth_hdr.is_null() { + return None; + } - // Check the scheme is Digest - let scheme = pj_str_to_string(&(*auth_hdr).scheme); - tracing::debug!("Authorization scheme: {}", scheme); + // Check the scheme is Digest + let scheme = pj_str_to_string(&(*auth_hdr).scheme); + tracing::debug!("Authorization scheme: {}", scheme); + + if scheme.to_lowercase() != "digest" { + tracing::debug!( + "Authorization header is not Digest auth (scheme: {})", + scheme + ); + return None; + } + + // Extract digest credentials from the parsed structure + let digest = &(*auth_hdr).credential.digest; + + let params = DigestAuthParams { + username: pj_str_to_string(&digest.username), + realm: pj_str_to_string(&digest.realm), + nonce: pj_str_to_string(&digest.nonce), + uri: pj_str_to_string(&digest.uri), + response: pj_str_to_string(&digest.response), + method: String::new(), // Will be set by caller + qop: { + let qop = pj_str_to_string(&digest.qop); + if qop.is_empty() { None } else { Some(qop) } + }, + nc: { + let nc = pj_str_to_string(&digest.nc); + if nc.is_empty() { None } else { Some(nc) } + }, + cnonce: { + let cnonce = pj_str_to_string(&digest.cnonce); + if cnonce.is_empty() { + None + } else { + Some(cnonce) + } + }, + }; - if scheme.to_lowercase() != "digest" { tracing::debug!( - "Authorization header is not Digest auth (scheme: {})", - scheme + "Extracted Digest auth: user={}, realm={}, nonce={}, uri={}, response={}", + params.username, + params.realm, + params.nonce, + params.uri, + params.response ); - return None; + + // Validate we have the required fields + if params.username.is_empty() + || params.realm.is_empty() + || params.nonce.is_empty() + || params.uri.is_empty() + || params.response.is_empty() + { + tracing::warn!("Digest auth missing required fields: {:?}", params); + return None; + } + + Some(params) } - - // Extract digest credentials from the parsed structure - let digest = &(*auth_hdr).credential.digest; - - let params = DigestAuthParams { - username: pj_str_to_string(&digest.username), - realm: pj_str_to_string(&digest.realm), - nonce: pj_str_to_string(&digest.nonce), - uri: pj_str_to_string(&digest.uri), - response: pj_str_to_string(&digest.response), - method: String::new(), // Will be set by caller - qop: { - let qop = pj_str_to_string(&digest.qop); - if qop.is_empty() { None } else { Some(qop) } - }, - nc: { - let nc = pj_str_to_string(&digest.nc); - if nc.is_empty() { None } else { Some(nc) } - }, - cnonce: { - let cnonce = pj_str_to_string(&digest.cnonce); - if cnonce.is_empty() { - None - } else { - Some(cnonce) - } - }, - }; - - tracing::debug!( - "Extracted Digest auth: user={}, realm={}, nonce={}, uri={}, response={}", - params.username, - params.realm, - params.nonce, - params.uri, - params.response - ); - - // Validate we have the required fields - if params.username.is_empty() - || params.realm.is_empty() - || params.nonce.is_empty() - || params.uri.is_empty() - || params.response.is_empty() - { - tracing::warn!("Digest auth missing required fields: {:?}", params); - return None; - } - - Some(params) } /// Send 401 Unauthorized response with WWW-Authenticate header pub unsafe fn send_401_challenge(call_id: CallId, www_auth: &str) { - // Create the WWW-Authenticate header - let hdr_name = CString::new("WWW-Authenticate").unwrap(); - let hdr_value = CString::new(www_auth).unwrap(); + unsafe { + // Create the WWW-Authenticate header + let hdr_name = CString::new("WWW-Authenticate").unwrap(); + let hdr_value = CString::new(www_auth).unwrap(); - // Create msg_data with the WWW-Authenticate header - let mut msg_data = MaybeUninit::::uninit(); - pjsua_msg_data_init(msg_data.as_mut_ptr()); - let msg_data_ptr = msg_data.assume_init_mut(); + // Create msg_data with the WWW-Authenticate header + let mut msg_data = MaybeUninit::::uninit(); + pjsua_msg_data_init(msg_data.as_mut_ptr()); + let msg_data_ptr = msg_data.assume_init_mut(); - // Create a pool for the header - let pool = pjsua_pool_create(c"auth".as_ptr(), 512, 512); - if pool.is_null() { - tracing::error!("Failed to create pool for 401 challenge"); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; + // Create a pool for the header + let pool = pjsua_pool_create(c"auth".as_ptr(), 512, 512); + if pool.is_null() { + tracing::error!("Failed to create pool for 401 challenge"); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + + // Create the header + let name = pj_str(hdr_name.as_ptr() as *mut c_char); + let value = pj_str(hdr_value.as_ptr() as *mut c_char); + + let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); + + if !hdr.is_null() { + // Add header to the list using pj_list_insert_before (insert at end of list) + pj_list_insert_before( + &mut msg_data_ptr.hdr_list as *mut _ as *mut pj_list_type, + hdr as *mut pj_list_type, + ); + } + + // Send 401 response - this will cause pjsua to send the response and then + // the client should retry with Authorization header + let reason = CString::new("Unauthorized").unwrap(); + let reason_pj = pj_str(reason.as_ptr() as *mut c_char); + + let status = pjsua_call_answer(*call_id, 401, &reason_pj, msg_data_ptr); + if status != pj_constants__PJ_SUCCESS as i32 { + tracing::warn!( + "Failed to send 401 challenge for call {}: {}", + call_id, + status + ); + // Hangup if we can't send challenge + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + } + + // DO NOT release the pool here - PJSUA may still need the header data + // after pjsua_call_answer returns. The pool will be cleaned up when + // pjsua is destroyed. This leaks ~512 bytes per 401 challenge but + // prevents use-after-free crashes. + // TODO: Track pools per-call and release them in on_call_state when call ends } - - // Create the header - let name = pj_str(hdr_name.as_ptr() as *mut c_char); - let value = pj_str(hdr_value.as_ptr() as *mut c_char); - - let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); - - if !hdr.is_null() { - // Add header to the list using pj_list_insert_before (insert at end of list) - pj_list_insert_before( - &mut msg_data_ptr.hdr_list as *mut _ as *mut pj_list_type, - hdr as *mut pj_list_type, - ); - } - - // Send 401 response - this will cause pjsua to send the response and then - // the client should retry with Authorization header - let reason = CString::new("Unauthorized").unwrap(); - let reason_pj = pj_str(reason.as_ptr() as *mut c_char); - - let status = pjsua_call_answer(*call_id, 401, &reason_pj, msg_data_ptr); - if status != pj_constants__PJ_SUCCESS as i32 { - tracing::warn!( - "Failed to send 401 challenge for call {}: {}", - call_id, - status - ); - // Hangup if we can't send challenge - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - } - - // DO NOT release the pool here - PJSUA may still need the header data - // after pjsua_call_answer returns. The pool will be cleaned up when - // pjsua is destroyed. This leaks ~512 bytes per 401 challenge but - // prevents use-after-free crashes. - // TODO: Track pools per-call and release them in on_call_state when call ends } /// Send 302 Moved Temporarily response with Contact header pointing to another bridge /// Used for multi-region channel conflict resolution - redirects caller to the active region pub unsafe fn send_302_redirect(call_id: CallId, target_domain: &str, extension: &str) { - // CRITICAL: Check if call is still valid and in a state that can receive responses - // Race condition: caller may hang up during async API auth, causing the call to be - // DISCONNECTED before we get here. Calling pjsua_call_answer on a disconnected call - // can corrupt PJSUA internal state and deadlock the SIP worker thread. - let mut ci = MaybeUninit::::uninit(); - if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { - tracing::warn!("Call {} no longer valid, skipping 302 redirect", call_id); - return; + unsafe { + // CRITICAL: Check if call is still valid and in a state that can receive responses + // Race condition: caller may hang up during async API auth, causing the call to be + // DISCONNECTED before we get here. Calling pjsua_call_answer on a disconnected call + // can corrupt PJSUA internal state and deadlock the SIP worker thread. + let mut ci = MaybeUninit::::uninit(); + if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { + tracing::warn!("Call {} no longer valid, skipping 302 redirect", call_id); + return; + } + let ci = ci.assume_init(); + + // Only send redirect if call is in INCOMING or EARLY state + // (i.e., we haven't sent a final response yet and call hasn't been disconnected) + if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { + tracing::warn!( + "Call {} already disconnected, skipping 302 redirect to {}", + call_id, + target_domain + ); + return; + } + if ci.state != pjsip_inv_state_PJSIP_INV_STATE_INCOMING + && ci.state != pjsip_inv_state_PJSIP_INV_STATE_EARLY + { + tracing::warn!( + "Call {} in unexpected state {} for 302 redirect, skipping", + call_id, + ci.state + ); + return; + } + + // Create the Contact header: sip:extension@target_domain + let contact_uri = format!("sip:{}@{}", extension, target_domain); + let hdr_name = CString::new("Contact").unwrap(); + let hdr_value = CString::new(contact_uri).unwrap(); + + // Create msg_data with the Contact header + let mut msg_data = MaybeUninit::::uninit(); + pjsua_msg_data_init(msg_data.as_mut_ptr()); + let msg_data_ptr = msg_data.assume_init_mut(); + + // Create a pool for the header + let pool = pjsua_pool_create(c"redirect".as_ptr(), 512, 512); + if pool.is_null() { + tracing::error!("Failed to create pool for 302 redirect"); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + + // Create the header + let name = pj_str(hdr_name.as_ptr() as *mut c_char); + let value = pj_str(hdr_value.as_ptr() as *mut c_char); + + let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); + + if !hdr.is_null() { + // Add header to the list using pj_list_insert_before (insert at end of list) + pj_list_insert_before( + &mut msg_data_ptr.hdr_list as *mut _ as *mut pj_list_type, + hdr as *mut pj_list_type, + ); + } + + // Send 302 response + let reason = CString::new("Moved Temporarily").unwrap(); + let reason_pj = pj_str(reason.as_ptr() as *mut c_char); + + let status = pjsua_call_answer(*call_id, 302, &reason_pj, msg_data_ptr); + if status != pj_constants__PJ_SUCCESS as i32 { + tracing::warn!( + "Failed to send 302 redirect for call {}: {}", + call_id, + status + ); + // Hangup if we can't send redirect + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + } else { + tracing::info!( + "Sent 302 redirect for call {} to {}", + call_id, + target_domain + ); + } + + // DO NOT release the pool here - PJSUA may still need the header data + // after pjsua_call_answer returns. Same issue as send_401_challenge. } - let ci = ci.assume_init(); - - // Only send redirect if call is in INCOMING or EARLY state - // (i.e., we haven't sent a final response yet and call hasn't been disconnected) - if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { - tracing::warn!( - "Call {} already disconnected, skipping 302 redirect to {}", - call_id, - target_domain - ); - return; - } - if ci.state != pjsip_inv_state_PJSIP_INV_STATE_INCOMING - && ci.state != pjsip_inv_state_PJSIP_INV_STATE_EARLY - { - tracing::warn!( - "Call {} in unexpected state {} for 302 redirect, skipping", - call_id, - ci.state - ); - return; - } - - // Create the Contact header: sip:extension@target_domain - let contact_uri = format!("sip:{}@{}", extension, target_domain); - let hdr_name = CString::new("Contact").unwrap(); - let hdr_value = CString::new(contact_uri).unwrap(); - - // Create msg_data with the Contact header - let mut msg_data = MaybeUninit::::uninit(); - pjsua_msg_data_init(msg_data.as_mut_ptr()); - let msg_data_ptr = msg_data.assume_init_mut(); - - // Create a pool for the header - let pool = pjsua_pool_create(c"redirect".as_ptr(), 512, 512); - if pool.is_null() { - tracing::error!("Failed to create pool for 302 redirect"); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - - // Create the header - let name = pj_str(hdr_name.as_ptr() as *mut c_char); - let value = pj_str(hdr_value.as_ptr() as *mut c_char); - - let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); - - if !hdr.is_null() { - // Add header to the list using pj_list_insert_before (insert at end of list) - pj_list_insert_before( - &mut msg_data_ptr.hdr_list as *mut _ as *mut pj_list_type, - hdr as *mut pj_list_type, - ); - } - - // Send 302 response - let reason = CString::new("Moved Temporarily").unwrap(); - let reason_pj = pj_str(reason.as_ptr() as *mut c_char); - - let status = pjsua_call_answer(*call_id, 302, &reason_pj, msg_data_ptr); - if status != pj_constants__PJ_SUCCESS as i32 { - tracing::warn!( - "Failed to send 302 redirect for call {}: {}", - call_id, - status - ); - // Hangup if we can't send redirect - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - } else { - tracing::info!( - "Sent 302 redirect for call {} to {}", - call_id, - target_domain - ); - } - - // DO NOT release the pool here - PJSUA may still need the header data - // after pjsua_call_answer returns. Same issue as send_401_challenge. } // PJSUA C callbacks @@ -382,68 +392,71 @@ pub unsafe extern "C" fn on_incoming_call_cb( raw_call_id: pjsua_call_id, rdata: *mut pjsip_rx_data, ) { - let call_id = CallId::new(raw_call_id); - let mut ci = MaybeUninit::::uninit(); - if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { - return; - } - let ci = ci.assume_init(); + unsafe { + let call_id = CallId::new(raw_call_id); + let mut ci = MaybeUninit::::uninit(); + if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { + return; + } + let ci = ci.assume_init(); - // Extract From and To URIs - let from_uri = pj_str_to_string(&ci.remote_info); - let to_uri = pj_str_to_string(&ci.local_info); + // Extract From and To URIs + let from_uri = pj_str_to_string(&ci.remote_info); + let to_uri = pj_str_to_string(&ci.local_info); - // Extract username from From URI (caller's SIP username) - let sip_username = extract_sip_username(&from_uri); + // Extract username from From URI (caller's SIP username) + let sip_username = extract_sip_username(&from_uri); - // Extract extension from To URI (the number they dialed) - let extension = extract_sip_username(&to_uri); + // Extract extension from To URI (the number they dialed) + let extension = extract_sip_username(&to_uri); - // Extract source IP for ban checking - let source_ip = extract_source_ip(rdata); + // Extract source IP for ban checking + let source_ip = extract_source_ip(rdata); - // Check if IP is banned or timed out - silently drop - if let Some(ip) = source_ip { - if let Some(ban_mgr) = crate::services::ban::global() { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { - let result = ban_mgr.check_banned(&ip); - if result.is_banned { - if result.should_log { - let ban_type = if result.is_permanent { - "permanently banned" - } else { - "timed out" - }; - tracing::debug!( - "Blocked {} IP: {} (call {}, offense_level={})", - ban_type, - ip, - call_id, - result.offense_level - ); - } - pjsua_call_hangup(*call_id, 403, ptr::null(), ptr::null()); - return; + // Check if IP is banned or timed out - silently drop + if let Some(ip) = source_ip + && let Some(ban_mgr) = crate::services::ban::global() + && ban_mgr.is_enabled() + && !ban_mgr.is_whitelisted(&ip) + { + let result = ban_mgr.check_banned(&ip); + if result.is_banned { + if result.should_log { + let ban_type = if result.is_permanent { + "permanently banned" + } else { + "timed out" + }; + tracing::debug!( + "Blocked {} IP: {} (call {}, offense_level={})", + ban_type, + ip, + call_id, + result.offense_level + ); } + pjsua_call_hangup(*call_id, 403, ptr::null(), ptr::null()); + return; } } - } - // Check User-Agent for SIPVicious scanners - instant permaban - if let Some(user_agent) = extract_user_agent(rdata) { - if is_sipvicious_scanner(&user_agent) { + // Check User-Agent for SIPVicious scanners - instant permaban + if let Some(user_agent) = extract_user_agent(rdata) + && is_sipvicious_scanner(&user_agent) + { if let Some(ip) = source_ip { - if let Some(ban_mgr) = crate::services::ban::global() { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { - let result = ban_mgr.record_permanent_ban(ip, "sipvicious_scanner"); - if result.should_log { - tracing::warn!( - "PERMABAN IP {} - SIPVicious scanner detected: User-Agent='{}' (call {})", - ip, - user_agent, - call_id - ); - } + if let Some(ban_mgr) = crate::services::ban::global() + && ban_mgr.is_enabled() + && !ban_mgr.is_whitelisted(&ip) + { + let result = ban_mgr.record_permanent_ban(ip, "sipvicious_scanner"); + if result.should_log { + tracing::warn!( + "PERMABAN IP {} - SIPVicious scanner detected: User-Agent='{}' (call {})", + ip, + user_agent, + call_id + ); } } } else { @@ -456,138 +469,138 @@ pub unsafe extern "C" fn on_incoming_call_cb( pjsua_call_hangup(*call_id, 403, ptr::null(), ptr::null()); return; } - } - // Extension-length ban checks use config values - if let Some(ban_mgr) = crate::services::ban::global() { - let ext_len = extension.len(); - let is_numeric = extension.chars().all(|c: char| c.is_ascii_digit()); + // Extension-length ban checks use config values + if let Some(ban_mgr) = crate::services::ban::global() { + let ext_len = extension.len(); + let is_numeric = extension.chars().all(|c: char| c.is_ascii_digit()); - // Check for very long extension (permaban, likely fraud) - if ext_len >= ban_mgr.permaban_extension_min_length() && is_numeric { - if let Some(ip) = source_ip { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { - let result = ban_mgr.record_permanent_ban(ip, "very_long_extension"); - if result.should_log { - tracing::warn!( - "PERMABAN IP {} for very long extension: {} ({} digits, call {})", - ip, - extension, - ext_len, - call_id - ); + // Check for very long extension (permaban, likely fraud) + if ext_len >= ban_mgr.permaban_extension_min_length() && is_numeric { + if let Some(ip) = source_ip { + if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { + let result = ban_mgr.record_permanent_ban(ip, "very_long_extension"); + if result.should_log { + tracing::warn!( + "PERMABAN IP {} for very long extension: {} ({} digits, call {})", + ip, + extension, + ext_len, + call_id + ); + } } + } else { + tracing::warn!( + "Rejecting very long extension: {} ({} digits, call {})", + extension, + ext_len, + call_id + ); } - } else { - tracing::warn!( - "Rejecting very long extension: {} ({} digits, call {})", - extension, - ext_len, - call_id - ); + pjsua_call_hangup(*call_id, 404, ptr::null(), ptr::null()); + return; } - pjsua_call_hangup(*call_id, 404, ptr::null(), ptr::null()); - return; - } - // Check for mid-length suspicious extension (progressive timeout) - if ext_len >= ban_mgr.suspicious_extension_min_length() - && ext_len <= ban_mgr.suspicious_extension_max_length() - && is_numeric - { - if let Some(ip) = source_ip { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { - let result = ban_mgr.record_offense(ip, "suspicious_extension"); - if result.should_log { - tracing::warn!( - "Timed out IP {} for suspicious extension: {} (call {}, offense_level={}, timeout={}s)", - ip, - extension, - call_id, - result.offense_level, - result.timeout_secs - ); + // Check for mid-length suspicious extension (progressive timeout) + if ext_len >= ban_mgr.suspicious_extension_min_length() + && ext_len <= ban_mgr.suspicious_extension_max_length() + && is_numeric + { + if let Some(ip) = source_ip { + if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { + let result = ban_mgr.record_offense(ip, "suspicious_extension"); + if result.should_log { + tracing::warn!( + "Timed out IP {} for suspicious extension: {} (call {}, offense_level={}, timeout={}s)", + ip, + extension, + call_id, + result.offense_level, + result.timeout_secs + ); + } } + } else { + tracing::warn!( + "Rejecting suspicious extension: {} ({} digits, call {})", + extension, + ext_len, + call_id + ); } - } else { - tracing::warn!( - "Rejecting suspicious extension: {} ({} digits, call {})", - extension, - ext_len, - call_id - ); + pjsua_call_hangup(*call_id, 404, ptr::null(), ptr::null()); + return; } - pjsua_call_hangup(*call_id, 404, ptr::null(), ptr::null()); - return; } - } - // Try to extract Digest auth params from Authorization header - let digest_params = extract_digest_auth_from_rdata(rdata); - - tracing::info!( - "Incoming call {} from {} to extension {} (auth: {})", - call_id, - sip_username, - extension, - if digest_params.is_some() { - "present" - } else { - "none" - } - ); - - // Check if we have Authorization header with Digest auth - if let Some(mut params) = digest_params { - // We have Digest auth, fill in remaining fields - params.method = "INVITE".to_string(); + // Try to extract Digest auth params from Authorization header + let digest_params = extract_digest_auth_from_rdata(rdata); tracing::info!( - "Digest auth: user={}, realm={}, nonce={}, response={}", - params.username, - params.realm, - params.nonce, - params.response + "Incoming call {} from {} to extension {} (auth: {})", + call_id, + sip_username, + extension, + if digest_params.is_some() { + "present" + } else { + "none" + } ); - // NOTE: We no longer answer with 200 OK here. - // The bridge coordinator will: - // 1. Send 183 Session Progress (early media) to start playing connecting sound - // 2. Connect to Discord - // 3. Send 200 OK once Discord is ready - // - // This allows the caller to hear "connecting..." while waiting for Discord. + // Check if we have Authorization header with Digest auth + if let Some(mut params) = digest_params { + // We have Digest auth, fill in remaining fields + params.method = "INVITE".to_string(); - // Trigger callbacks with Digest auth params - // The bridge coordinator handles the call flow from here - if let Some(callbacks) = CALLBACKS.get() { - if let Some(ref handlers) = *callbacks.lock() { + tracing::info!( + "Digest auth: user={}, realm={}, nonce={}, response={}", + params.username, + params.realm, + params.nonce, + params.response + ); + + // NOTE: We no longer answer with 200 OK here. + // The bridge coordinator will: + // 1. Send 183 Session Progress (early media) to start playing connecting sound + // 2. Connect to Discord + // 3. Send 200 OK once Discord is ready + // + // This allows the caller to hear "connecting..." while waiting for Discord. + + // Trigger callbacks with Digest auth params + // The bridge coordinator handles the call flow from here + if let Some(callbacks) = CALLBACKS.get() + && let Some(ref handlers) = *callbacks.lock() + { (handlers.on_incoming_call)(call_id, sip_username, extension.clone(), source_ip); (handlers.on_call_authenticated)(call_id, params, extension, source_ip); } + } else { + // No Authorization header - send 401 challenge + tracing::info!("No auth header, sending 401 challenge for call {}", call_id); + + // Generate a cryptographically random nonce + let nonce = { + let bytes: [u8; 16] = rand::random(); + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + }; + + // Create WWW-Authenticate header value + // Format: Digest realm="sipcord", nonce="xxx", algorithm=MD5, qop="auth" + let www_auth = format!( + "Digest realm=\"{}\", nonce=\"{}\", algorithm=MD5, qop=\"auth\"", + SIP_REALM, nonce + ); + + // Send 401 Unauthorized with WWW-Authenticate header + send_401_challenge(call_id, &www_auth); } - } else { - // No Authorization header - send 401 challenge - tracing::info!("No auth header, sending 401 challenge for call {}", call_id); - - // Generate a cryptographically random nonce - let nonce = { - let bytes: [u8; 16] = rand::random(); - bytes - .iter() - .map(|b| format!("{:02x}", b)) - .collect::() - }; - - // Create WWW-Authenticate header value - // Format: Digest realm="sipcord", nonce="xxx", algorithm=MD5, qop="auth" - let www_auth = format!( - "Digest realm=\"{}\", nonce=\"{}\", algorithm=MD5, qop=\"auth\"", - SIP_REALM, nonce - ); - - // Send 401 Unauthorized with WWW-Authenticate header - send_401_challenge(call_id, &www_auth); } } @@ -596,232 +609,236 @@ pub unsafe extern "C" fn on_dtmf_digit_cb(raw_call_id: pjsua_call_id, digit: c_i let digit_char = char::from_u32(digit as u32).unwrap_or('?'); // Forward DTMF to callback handler (buffering done in mod.rs) - if let Some(callbacks) = CALLBACKS.get() { - if let Some(ref handlers) = *callbacks.lock() { - (handlers.on_dtmf)(call_id, digit_char); - } + if let Some(callbacks) = CALLBACKS.get() + && let Some(ref handlers) = *callbacks.lock() + { + (handlers.on_dtmf)(call_id, digit_char); } } pub unsafe extern "C" fn on_call_state_cb(raw_call_id: pjsua_call_id, _e: *mut pjsip_event) { - let call_id = CallId::new(raw_call_id); - let mut ci = MaybeUninit::::uninit(); - if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { - return; - } - let ci = ci.assume_init(); - - // Check for outbound call state changes - if let Some(tracking_id) = super::get_outbound_tracking_id(call_id) { - // This is an outbound call (Discord -> SIP) - if ci.state == pjsip_inv_state_PJSIP_INV_STATE_EARLY { - // Ringing (180 Ringing or 183 Session Progress) - // Ringing is tracked via ws_client::report_call_status from the bridge coordinator - tracing::info!( - "Outbound call {} ringing (tracking_id={})", - call_id, - tracking_id - ); - } else if ci.state == pjsip_inv_state_PJSIP_INV_STATE_CONFIRMED { - tracing::info!( - "Outbound call {} answered (tracking_id={})", - call_id, - tracking_id - ); - // Emit answered event - the SIP event handler in bridge/mod.rs picks this up - if let Some(event_tx) = OUTBOUND_EVENT_TX.get() { - let _ = event_tx.try_send(super::SipEvent::OutboundCallAnswered { - tracking_id: tracking_id.clone(), - call_id, - }); - } - } else if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { - let tracking_id = super::remove_outbound_tracking(call_id); - if let Some(tid) = tracking_id { - let last_status = ci.last_status; - let last_status_text = pj_str_to_string(&ci.last_status_text); - tracing::info!( - "Outbound call {} disconnected (tracking_id={}, status={} {})", - call_id, - tid, - last_status, - last_status_text - ); - if let Some(event_tx) = OUTBOUND_EVENT_TX.get() { - let _ = event_tx.try_send(super::SipEvent::OutboundCallFailed { - tracking_id: tid, - call_id: Some(call_id), - reason: format!("{} {}", last_status, last_status_text), - }); - } - } - // Fall through to normal disconnect handling below — - // outbound calls ARE tracked in sip_calls/bridges and need - // proper cleanup (on_call_ended → CallEnded event). - } - // For non-disconnect states, return early - outbound calls don't use the normal flow - if ci.state != pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { + unsafe { + let call_id = CallId::new(raw_call_id); + let mut ci = MaybeUninit::::uninit(); + if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { return; } - } + let ci = ci.assume_init(); - // Check if call ended - if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { - // Clean up audio buffer - if let Some(buffers) = AUDIO_OUT_BUFFERS.get() { - buffers.remove(&call_id); + // Check for outbound call state changes + if let Some(tracking_id) = super::get_outbound_tracking_id(call_id) { + // This is an outbound call (Discord -> SIP) + if ci.state == pjsip_inv_state_PJSIP_INV_STATE_EARLY { + // Ringing (180 Ringing or 183 Session Progress) + // Ringing is tracked via ws_client::report_call_status from the bridge coordinator + tracing::info!( + "Outbound call {} ringing (tracking_id={})", + call_id, + tracking_id + ); + } else if ci.state == pjsip_inv_state_PJSIP_INV_STATE_CONFIRMED { + tracing::info!( + "Outbound call {} answered (tracking_id={})", + call_id, + tracking_id + ); + // Emit answered event - the SIP event handler in bridge/mod.rs picks this up + if let Some(event_tx) = OUTBOUND_EVENT_TX.get() { + let _ = event_tx.try_send(super::SipEvent::OutboundCallAnswered { + tracking_id: tracking_id.clone(), + call_id, + }); + } + } else if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { + let tracking_id = super::remove_outbound_tracking(call_id); + if let Some(tid) = tracking_id { + let last_status = ci.last_status; + let last_status_text = pj_str_to_string(&ci.last_status_text); + tracing::info!( + "Outbound call {} disconnected (tracking_id={}, status={} {})", + call_id, + tid, + last_status, + last_status_text + ); + if let Some(event_tx) = OUTBOUND_EVENT_TX.get() { + let _ = event_tx.try_send(super::SipEvent::OutboundCallFailed { + tracking_id: tid, + call_id: Some(call_id), + reason: format!("{} {}", last_status, last_status_text), + }); + } + } + // Fall through to normal disconnect handling below — + // outbound calls ARE tracked in sip_calls/bridges and need + // proper cleanup (on_call_ended → CallEnded event). + } + // For non-disconnect states, return early - outbound calls don't use the normal flow + if ci.state != pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { + return; + } } - // Clean up RTP activity tracking - remove_call_rtp_tracking(call_id); + // Check if call ended + if ci.state == pjsip_inv_state_PJSIP_INV_STATE_DISCONNECTED { + // Clean up audio buffer + if let Some(buffers) = AUDIO_OUT_BUFFERS.get() { + buffers.remove(&call_id); + } - let counted_ids = - COUNTED_CALL_IDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); - let (was_counted, new_count) = { - let mut ids = counted_ids.lock(); - let was_counted = ids.remove(&call_id); - (was_counted, ids.len()) - }; + // Clean up RTP activity tracking + remove_call_rtp_tracking(call_id); - // An authenticated call needs cleanup if it was in COUNTED_CALL_IDS (normal - // case, or REMOTE_HOLD which now stays counted) OR if it has a CALL_CHANNELS - // entry (which persists through LOCAL_HOLD). Without this, calls that - // disconnect during LOCAL_HOLD would skip cleanup, leaving the bridge and - // Discord connection alive forever. - let was_authenticated = was_counted - || CALL_CHANNELS - .get() - .map(|c| c.contains_key(&call_id)) - .unwrap_or(false); + let counted_ids = + COUNTED_CALL_IDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let (was_counted, new_count) = { + let mut ids = counted_ids.lock(); + let was_counted = ids.remove(&call_id); + (was_counted, ids.len()) + }; - if was_authenticated { - tracing::info!("Call {} ended (active_media_calls={})", call_id, new_count); + // An authenticated call needs cleanup if it was in COUNTED_CALL_IDS (normal + // case, or REMOTE_HOLD which now stays counted) OR if it has a CALL_CHANNELS + // entry (which persists through LOCAL_HOLD). Without this, calls that + // disconnect during LOCAL_HOLD would skip cleanup, leaving the bridge and + // Discord connection alive forever. + let was_authenticated = was_counted + || CALL_CHANNELS + .get() + .map(|c| c.contains_key(&call_id)) + .unwrap_or(false); - if let Some(callbacks) = CALLBACKS.get() { - if let Some(ref handlers) = *callbacks.lock() { + if was_authenticated { + tracing::info!("Call {} ended (active_media_calls={})", call_id, new_count); + + if let Some(callbacks) = CALLBACKS.get() + && let Some(ref handlers) = *callbacks.lock() + { (handlers.on_call_ended)(call_id); } - } - if new_count == 0 { - tracing::debug!("Last call ended, stopping audio thread"); - stop_audio_thread(); + if new_count == 0 { + tracing::debug!("Last call ended, stopping audio thread"); + stop_audio_thread(); + } } + // Spam/unauthenticated calls - no logging, no callbacks } - // Spam/unauthenticated calls - no logging, no callbacks } } pub unsafe extern "C" fn on_call_media_state_cb(raw_call_id: pjsua_call_id) { - let call_id = CallId::new(raw_call_id); - let mut ci = MaybeUninit::::uninit(); - if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { - return; - } - let ci = ci.assume_init(); + unsafe { + let call_id = CallId::new(raw_call_id); + let mut ci = MaybeUninit::::uninit(); + if pjsua_call_get_info(*call_id, ci.as_mut_ptr()) != pj_constants__PJ_SUCCESS as i32 { + return; + } + let ci = ci.assume_init(); - // Log media state changes (debug level for general changes, specific states logged at info) - let media_status_str = if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_NONE { - "NONE" - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ACTIVE { - "ACTIVE" - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_LOCAL_HOLD { - "LOCAL_HOLD" - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_REMOTE_HOLD { - "REMOTE_HOLD" - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ERROR { - "ERROR" - } else { - "UNKNOWN" - }; - - tracing::info!( - "Call {} media state changed to: {} (status={})", - call_id, - media_status_str, - ci.media_status - ); - - // Check if media is active - if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ACTIVE { - // Get the conference port for this call - let conf_port = ConfPort::new(pjsua_call_get_conf_port(*call_id)); - - // Log media direction for diagnostics - let media_dir = if ci.media_cnt > 0 { ci.media[0].dir } else { 0 }; - let dir = MediaDir::from(media_dir); - - // Check if call is already registered with a channel - let pending_channel = CALL_CHANNELS - .get() - .and_then(|c| c.get(&call_id).map(|r| *r)); - - // Get codec info including ptime - let mut stream_info = MaybeUninit::::uninit(); - let codec_info = if pjsua_call_get_stream_info(*call_id, 0, stream_info.as_mut_ptr()) - == pj_constants__PJ_SUCCESS as i32 - { - let si = stream_info.assume_init(); - // si.info is a union, for audio it's pjmedia_stream_info - let audio_info = si.info.aud; - let codec_name = std::ffi::CStr::from_ptr( - audio_info.fmt.encoding_name.ptr as *const std::ffi::c_char, - ) - .to_string_lossy(); - let clock_rate = audio_info.fmt.clock_rate; - let channel_cnt = audio_info.fmt.channel_cnt; - // Get ptime from the param field (need to dereference pointer) - let param = &*audio_info.param; - let ptime = param.setting.frm_per_pkt as u32 * param.info.frm_ptime as u32; - format!( - "{} @ {}Hz {}ch, ptime={}ms, frm_per_pkt={}, frm_ptime={}", - codec_name, - clock_rate, - channel_cnt, - ptime, - param.setting.frm_per_pkt, - param.info.frm_ptime - ) + // Log media state changes (debug level for general changes, specific states logged at info) + let media_status_str = if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_NONE { + "NONE" + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ACTIVE { + "ACTIVE" + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_LOCAL_HOLD { + "LOCAL_HOLD" + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_REMOTE_HOLD { + "REMOTE_HOLD" + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ERROR { + "ERROR" } else { - "unknown".to_string() + "UNKNOWN" }; tracing::info!( - "Call {} MEDIA ACTIVE: conf_port={}, media_dir={}, media_cnt={}, call_state={}, pending_channel={:?}, codec={}", + "Call {} media state changed to: {} (status={})", call_id, - conf_port, - dir, - ci.media_cnt, - ci.state, - pending_channel, - codec_info + media_status_str, + ci.media_status ); - if conf_port.is_valid() { - tracing::info!( - "Call {} media active, storing conference port {} (NOT connecting to master yet)", - call_id, - conf_port - ); + // Check if media is active + if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ACTIVE { + // Get the conference port for this call + let conf_port = ConfPort::new(pjsua_call_get_conf_port(*call_id)); - // Store the conf_port for this call - connections will be made when - // the channel is assigned via register_call_channel() - // This enables per-channel audio isolation: calls in different channels - // won't hear each other. - // - // If this call is already registered with a channel and the - // conf_port changed (due to re-INVITE/media renegotiation), we must - // reconnect it to maintain audio flow. - let old_conf_port = { - let ports = CALL_CONF_PORTS.get_or_init(DashMap::new); - let old = ports.get(&call_id).map(|r| *r); - ports.insert(call_id, conf_port); - old + // Log media direction for diagnostics + let media_dir = if ci.media_cnt > 0 { ci.media[0].dir } else { 0 }; + let dir = MediaDir::from(media_dir); + + // Check if call is already registered with a channel + let pending_channel = CALL_CHANNELS + .get() + .and_then(|c| c.get(&call_id).map(|r| *r)); + + // Get codec info including ptime + let mut stream_info = MaybeUninit::::uninit(); + let codec_info = if pjsua_call_get_stream_info(*call_id, 0, stream_info.as_mut_ptr()) + == pj_constants__PJ_SUCCESS as i32 + { + let si = stream_info.assume_init(); + // si.info is a union, for audio it's pjmedia_stream_info + let audio_info = si.info.aud; + let codec_name = std::ffi::CStr::from_ptr( + audio_info.fmt.encoding_name.ptr as *const std::ffi::c_char, + ) + .to_string_lossy(); + let clock_rate = audio_info.fmt.clock_rate; + let channel_cnt = audio_info.fmt.channel_cnt; + // Get ptime from the param field (need to dereference pointer) + let param = &*audio_info.param; + let ptime = param.setting.frm_per_pkt as u32 * param.info.frm_ptime as u32; + format!( + "{} @ {}Hz {}ch, ptime={}ms, frm_per_pkt={}, frm_ptime={}", + codec_name, + clock_rate, + channel_cnt, + ptime, + param.setting.frm_per_pkt, + param.info.frm_ptime + ) + } else { + "unknown".to_string() }; - // If conf_port changed and call is registered with a channel, reconnect it - if let Some(old_port) = old_conf_port { - if old_port != conf_port { + tracing::info!( + "Call {} MEDIA ACTIVE: conf_port={}, media_dir={}, media_cnt={}, call_state={}, pending_channel={:?}, codec={}", + call_id, + conf_port, + dir, + ci.media_cnt, + ci.state, + pending_channel, + codec_info + ); + + if conf_port.is_valid() { + tracing::info!( + "Call {} media active, storing conference port {} (NOT connecting to master yet)", + call_id, + conf_port + ); + + // Store the conf_port for this call - connections will be made when + // the channel is assigned via register_call_channel() + // This enables per-channel audio isolation: calls in different channels + // won't hear each other. + // + // If this call is already registered with a channel and the + // conf_port changed (due to re-INVITE/media renegotiation), we must + // reconnect it to maintain audio flow. + let old_conf_port = { + let ports = CALL_CONF_PORTS.get_or_init(DashMap::new); + let old = ports.get(&call_id).map(|r| *r); + ports.insert(call_id, conf_port); + old + }; + + // If conf_port changed and call is registered with a channel, reconnect it + if let Some(old_port) = old_conf_port + && old_port != conf_port + { tracing::info!( "Call {} conf_port changed from {} to {} (media renegotiation), reconnecting", call_id, @@ -898,158 +915,157 @@ pub unsafe extern "C" fn on_call_media_state_cb(raw_call_id: pjsua_call_id) { } } } - } - tracing::info!( - "Call {} conf_port {} stored, awaiting channel registration", - call_id, - conf_port - ); - - // Initialize RTP activity tracking for this call - init_call_rtp_tracking(call_id); - - // Track this call_id and start audio thread if this is the first active call - // IMPORTANT: Start audio thread BEFORE completing pending channel registration! - // The PJMEDIA conference bridge needs to be actively clocked when connections - // are made, otherwise the connections may not work properly. - let counted_ids = - COUNTED_CALL_IDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); - let (is_new, count) = { - let mut ids = counted_ids.lock(); - let is_new = ids.insert(call_id); - (is_new, ids.len()) - }; - - // Only count this call if we haven't already (prevents double-counting on re-INVITE) - if is_new { tracing::info!( - "Call {} media ACTIVE, active_media_calls={}", + "Call {} conf_port {} stored, awaiting channel registration", call_id, - count + conf_port ); - if count == 1 { - tracing::info!("First active call, starting audio thread"); - start_audio_thread(); + // Initialize RTP activity tracking for this call + init_call_rtp_tracking(call_id); + + // Track this call_id and start audio thread if this is the first active call + // IMPORTANT: Start audio thread BEFORE completing pending channel registration! + // The PJMEDIA conference bridge needs to be actively clocked when connections + // are made, otherwise the connections may not work properly. + let counted_ids = + COUNTED_CALL_IDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let (is_new, count) = { + let mut ids = counted_ids.lock(); + let is_new = ids.insert(call_id); + (is_new, ids.len()) + }; + + // Only count this call if we haven't already (prevents double-counting on re-INVITE) + if is_new { + tracing::info!( + "Call {} media ACTIVE, active_media_calls={}", + call_id, + count + ); + + if count == 1 { + tracing::info!("First active call, starting audio thread"); + start_audio_thread(); + } + } else { + tracing::warn!( + "Call {} media ACTIVE but already counted! Skipping.", + call_id + ); } - } else { - tracing::warn!( - "Call {} media ACTIVE but already counted! Skipping.", - call_id - ); - } - // If returning from hold (is_new=true but call already in CHANNEL_CALLS), - // remove from CHANNEL_CALLS so complete_pending_channel_registration does - // a full fresh bidirectional reconnection. PJSUA may have changed the - // underlying media stream during the hold/unhold re-INVITE cycle. - // For first-time active calls, the call won't be in CHANNEL_CALLS yet, - // so this is a no-op. - if is_new { - if let Some(channel_id) = CALL_CHANNELS - .get() - .and_then(|c| c.get(&call_id).map(|r| *r)) + // If returning from hold (is_new=true but call already in CHANNEL_CALLS), + // remove from CHANNEL_CALLS so complete_pending_channel_registration does + // a full fresh bidirectional reconnection. PJSUA may have changed the + // underlying media stream during the hold/unhold re-INVITE cycle. + // For first-time active calls, the call won't be in CHANNEL_CALLS yet, + // so this is a no-op. + if is_new + && let Some(channel_id) = CALL_CHANNELS + .get() + .and_then(|c| c.get(&call_id).map(|r| *r)) { let channel_calls = CHANNEL_CALLS .get_or_init(|| parking_lot::RwLock::new(std::collections::HashMap::new())); let mut map = channel_calls.write(); - if let Some(calls) = map.get_mut(&channel_id) { - if calls.remove(&call_id) { - if calls.is_empty() { - map.remove(&channel_id); - } - tracing::info!( - "Call {} returning from hold - removed from CHANNEL_CALLS for fresh reconnection", - call_id - ); + if let Some(calls) = map.get_mut(&channel_id) + && calls.remove(&call_id) + { + if calls.is_empty() { + map.remove(&channel_id); } + tracing::info!( + "Call {} returning from hold - removed from CHANNEL_CALLS for fresh reconnection", + call_id + ); } } + + // If the call was already registered with a channel (Discord connected before + // media was ready), complete the registration now. This must happen AFTER + // the audio thread has actually started processing (not just spawned). + // queue_pending_channel_completion returns true if queued (thread not ready), + // false if we should complete immediately (thread is ready). + if !queue_pending_channel_completion(call_id, conf_port) { + tracing::info!( + "Audio thread already ready, completing channel registration immediately for call {}", + call_id + ); + complete_pending_channel_registration(call_id, conf_port); + } + } else { + tracing::warn!("Call {} has invalid conference port", call_id); } - - // If the call was already registered with a channel (Discord connected before - // media was ready), complete the registration now. This must happen AFTER - // the audio thread has actually started processing (not just spawned). - // queue_pending_channel_completion returns true if queued (thread not ready), - // false if we should complete immediately (thread is ready). - if !queue_pending_channel_completion(call_id, conf_port) { - tracing::info!( - "Audio thread already ready, completing channel registration immediately for call {}", - call_id - ); - complete_pending_channel_registration(call_id, conf_port); - } - } else { - tracing::warn!("Call {} has invalid conference port", call_id); - } - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_NONE { - // Media went to NONE - this could happen during call setup/teardown - let active_calls = COUNTED_CALL_IDS - .get() - .map(|ids| ids.lock().len()) - .unwrap_or(0); - tracing::warn!( - "Call {} media went to NONE, active_media_calls={}", - call_id, - active_calls - ); - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ERROR { - // Media error - this is bad! - let active_calls = COUNTED_CALL_IDS - .get() - .map(|ids| ids.lock().len()) - .unwrap_or(0); - tracing::error!( - "Call {} media ERROR! active_media_calls={}", - call_id, - active_calls - ); - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_LOCAL_HOLD { - tracing::info!("Call {} put on LOCAL_HOLD - disconnecting audio", call_id); - - // Disconnect the call from its channel without full teardown. - // CALL_CHANNELS and CALL_CONF_PORTS are preserved so the existing - // ACTIVE code path can reconnect when the call comes off hold. - disconnect_call_for_hold(call_id); - - // Remove from COUNTED_CALL_IDS and stop audio thread if no other active calls - let counted_ids = - COUNTED_CALL_IDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); - let (was_counted, new_count) = { - let mut ids = counted_ids.lock(); - let was_counted = ids.remove(&call_id); - (was_counted, ids.len()) - }; - - if was_counted { - tracing::info!( - "Call {} removed from active calls on hold (active_media_calls={})", + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_NONE { + // Media went to NONE - this could happen during call setup/teardown + let active_calls = COUNTED_CALL_IDS + .get() + .map(|ids| ids.lock().len()) + .unwrap_or(0); + tracing::warn!( + "Call {} media went to NONE, active_media_calls={}", call_id, - new_count + active_calls ); - if new_count == 0 { - tracing::debug!("No active calls remaining after hold, stopping audio thread"); - stop_audio_thread(); - } - } - } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_REMOTE_HOLD { - // Remote end put us on hold (e.g., Cisco hold button). - // Keep conference connections AND audio thread running — many phones resume - // RTP without sending a re-INVITE, so we never get an ACTIVE callback. - // By keeping everything connected, audio naturally resumes when RTP flows again. - // - // Do NOT send re-INVITE or UPDATE — some phones (Cisco 7960G) reject UPDATE - // with 405 and disconnect the call, and re-INVITE fails with 70013 because - // the hold transaction is still active. - // - // Pause RTP inactivity tracking — phones send no RTP during hold. - remove_call_rtp_tracking(call_id); + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_ERROR { + // Media error - this is bad! + let active_calls = COUNTED_CALL_IDS + .get() + .map(|ids| ids.lock().len()) + .unwrap_or(0); + tracing::error!( + "Call {} media ERROR! active_media_calls={}", + call_id, + active_calls + ); + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_LOCAL_HOLD { + tracing::info!("Call {} put on LOCAL_HOLD - disconnecting audio", call_id); - tracing::info!( - "Call {} put on REMOTE_HOLD - keeping audio connected (RTP tracking paused)", - call_id - ); + // Disconnect the call from its channel without full teardown. + // CALL_CHANNELS and CALL_CONF_PORTS are preserved so the existing + // ACTIVE code path can reconnect when the call comes off hold. + disconnect_call_for_hold(call_id); + + // Remove from COUNTED_CALL_IDS and stop audio thread if no other active calls + let counted_ids = + COUNTED_CALL_IDS.get_or_init(|| Mutex::new(std::collections::HashSet::new())); + let (was_counted, new_count) = { + let mut ids = counted_ids.lock(); + let was_counted = ids.remove(&call_id); + (was_counted, ids.len()) + }; + + if was_counted { + tracing::info!( + "Call {} removed from active calls on hold (active_media_calls={})", + call_id, + new_count + ); + if new_count == 0 { + tracing::debug!("No active calls remaining after hold, stopping audio thread"); + stop_audio_thread(); + } + } + } else if ci.media_status == pjsua_call_media_status_PJSUA_CALL_MEDIA_REMOTE_HOLD { + // Remote end put us on hold (e.g., Cisco hold button). + // Keep conference connections AND audio thread running — many phones resume + // RTP without sending a re-INVITE, so we never get an ACTIVE callback. + // By keeping everything connected, audio naturally resumes when RTP flows again. + // + // Do NOT send re-INVITE or UPDATE — some phones (Cisco 7960G) reject UPDATE + // with 405 and disconnect the call, and re-INVITE fails with 70013 because + // the hold transaction is still active. + // + // Pause RTP inactivity tracking — phones send no RTP during hold. + remove_call_rtp_tracking(call_id); + + tracing::info!( + "Call {} put on REMOTE_HOLD - keeping audio connected (RTP tracking paused)", + call_id + ); + } } } @@ -1070,83 +1086,85 @@ unsafe fn sdp_has_t38(offer: *const pjmedia_sdp_session) -> Option { - t38_version = value.parse().unwrap_or(0); - } - "T38MaxBitRate" => { - max_bit_rate = value.parse().unwrap_or(14400); - } - "T38FaxRateManagement" => { - rate_management = value; - } - "T38FaxUdpEC" => { - udp_ec = value; - } - _ => {} + // Check media type == "image" + let media_type = pj_str_to_string(&(*m).desc.media); + if media_type != "image" { + continue; } + + // Check transport contains "udptl" + let transport = pj_str_to_string(&(*m).desc.transport); + if !transport.to_lowercase().contains("udptl") { + continue; + } + + // This is a T.38 media line + let remote_port = (*m).desc.port; + if remote_port == 0 { + continue; // Disabled media line + } + + // Extract IP from connection line (media-level c= or session-level c=) + let conn = if !(*m).conn.is_null() { + (*m).conn + } else if !(*offer).conn.is_null() { + (*offer).conn + } else { + tracing::warn!("T.38 SDP offer has no connection line"); + continue; + }; + let remote_ip = pj_str_to_string(&(*conn).addr); + + // Extract T.38 attributes with defaults + let mut t38_version: u8 = 0; + let mut max_bit_rate: u32 = 14400; + let mut rate_management = "transferredTCF".to_string(); + let mut udp_ec = "t38UDPRedundancy".to_string(); + + for j in 0..(*m).attr_count { + let attr = (*m).attr[j as usize]; + if attr.is_null() { + continue; + } + let name = pj_str_to_string(&(*attr).name); + let value = pj_str_to_string(&(*attr).value); + + match name.as_str() { + "T38FaxVersion" => { + t38_version = value.parse().unwrap_or(0); + } + "T38MaxBitRate" => { + max_bit_rate = value.parse().unwrap_or(14400); + } + "T38FaxRateManagement" => { + rate_management = value; + } + "T38FaxUdpEC" => { + udp_ec = value; + } + _ => {} + } + } + + return Some(T38OfferParams { + remote_ip, + remote_port, + t38_version, + max_bit_rate, + rate_management, + udp_ec, + }); } - return Some(T38OfferParams { - remote_ip, - remote_port, - t38_version, - max_bit_rate, - rate_management, - udp_ec, - }); + None } - - None } /// Callback for incoming re-INVITE with SDP offer. @@ -1172,112 +1190,113 @@ pub unsafe extern "C" fn on_call_rx_reinvite_cb( code: *mut pjsip_status_code, opt: *mut pjsua_call_setting, ) { - let call_id = CallId::new(raw_call_id); + unsafe { + let call_id = CallId::new(raw_call_id); - // Check for T.38 offer BEFORE applying hold-stripping logic - if let Some(t38_params) = sdp_has_t38(offer) { - tracing::info!( - "Call {} received T.38 re-INVITE: remote={}:{}, version={}, rate={}, ec={}", - call_id, - t38_params.remote_ip, - t38_params.remote_port, - t38_params.t38_version, - t38_params.max_bit_rate, - t38_params.udp_ec - ); + // Check for T.38 offer BEFORE applying hold-stripping logic + if let Some(t38_params) = sdp_has_t38(offer) { + tracing::info!( + "Call {} received T.38 re-INVITE: remote={}:{}, version={}, rate={}, ec={}", + call_id, + t38_params.remote_ip, + t38_params.remote_port, + t38_params.t38_version, + t38_params.max_bit_rate, + t38_params.udp_ec + ); - // Handle T.38 re-INVITE by sending 200 OK at the dialog level, - // completely bypassing pjsip's inv session and pjsua's media handling. - // - // Why dialog-level? Three layers of pjsip fight us: - // 1. pjsua_media_channel_init() crashes on T.38 (not audio) - // 2. pjsip_inv_answer() asserts inv->last_answer (not set yet) - // 3. pjsip_inv_send_msg() triggers on_media_update → crash - // - // By using pjsip_dlg_send_response() directly, we send the 200 OK - // without touching the inv session's media machinery. We then cancel - // the SDP offer and set code=488 so pjsua skips all media processing. + // Handle T.38 re-INVITE by sending 200 OK at the dialog level, + // completely bypassing pjsip's inv session and pjsua's media handling. + // + // Why dialog-level? Three layers of pjsip fight us: + // 1. pjsua_media_channel_init() crashes on T.38 (not audio) + // 2. pjsip_inv_answer() asserts inv->last_answer (not set yet) + // 3. pjsip_inv_send_msg() triggers on_media_update → crash + // + // By using pjsip_dlg_send_response() directly, we send the 200 OK + // without touching the inv session's media machinery. We then cancel + // the SDP offer and set code=488 so pjsua skips all media processing. - // 1. Bind a std::net::UdpSocket within the configured RTP port range - // so firewall rules (which typically allow only the RTP range) also pass fax traffic. - let env_config = crate::config::EnvConfig::global(); - let rtp_start = env_config.rtp_port_start; - let rtp_end = env_config.rtp_port_end; - let std_socket = { - let mut bound = None; - for port in rtp_start..=rtp_end { - match std::net::UdpSocket::bind(("0.0.0.0", port)) { - Ok(s) => { - bound = Some(s); - break; + // 1. Bind a std::net::UdpSocket within the configured RTP port range + // so firewall rules (which typically allow only the RTP range) also pass fax traffic. + let env_config = crate::config::EnvConfig::global(); + let rtp_start = env_config.rtp_port_start; + let rtp_end = env_config.rtp_port_end; + let std_socket = { + let mut bound = None; + for port in rtp_start..=rtp_end { + match std::net::UdpSocket::bind(("0.0.0.0", port)) { + Ok(s) => { + bound = Some(s); + break; + } + Err(_) => continue, } - Err(_) => continue, } - } - match bound { - Some(s) => s, - None => { - tracing::error!( - "Call {}: failed to bind UDPTL socket in RTP range {}-{}", - call_id, - rtp_start, - rtp_end - ); + match bound { + Some(s) => s, + None => { + tracing::error!( + "Call {}: failed to bind UDPTL socket in RTP range {}-{}", + call_id, + rtp_start, + rtp_end + ); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + } + }; + let local_port = match std_socket.local_addr() { + Ok(addr) => addr.port(), + Err(e) => { + tracing::error!("Call {}: failed to get UDPTL local addr: {}", call_id, e); pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); return; } - } - }; - let local_port = match std_socket.local_addr() { - Ok(addr) => addr.port(), - Err(e) => { - tracing::error!("Call {}: failed to get UDPTL local addr: {}", call_id, e); + }; + + // 2. Navigate rdata → tsx → dlg → inv + if rdata.is_null() { + tracing::error!("Call {}: rdata null for T.38 re-INVITE", call_id); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + let tsx = pjsip_rdata_get_tsx(rdata); + if tsx.is_null() { + tracing::error!("Call {}: no transaction for T.38 re-INVITE", call_id); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + let dlg = pjsip_tsx_get_dlg(tsx); + if dlg.is_null() { + tracing::error!("Call {}: no dialog for T.38 re-INVITE", call_id); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + let inv = pjsip_dlg_get_inv_session(dlg); + if inv.is_null() { + tracing::error!("Call {}: no inv session for T.38 re-INVITE", call_id); pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); return; } - }; - // 2. Navigate rdata → tsx → dlg → inv - if rdata.is_null() { - tracing::error!("Call {}: rdata null for T.38 re-INVITE", call_id); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - let tsx = pjsip_rdata_get_tsx(rdata); - if tsx.is_null() { - tracing::error!("Call {}: no transaction for T.38 re-INVITE", call_id); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - let dlg = pjsip_tsx_get_dlg(tsx); - if dlg.is_null() { - tracing::error!("Call {}: no dialog for T.38 re-INVITE", call_id); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - let inv = pjsip_dlg_get_inv_session(dlg); - if inv.is_null() { - tracing::error!("Call {}: no inv session for T.38 re-INVITE", call_id); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - - // 3. Build and parse T.38 SDP - // Use RTP_PUBLIC_IP for the SDP c= line, matching what pjsua uses for audio SDP. - // Many SIP devices (e.g. Cisco ATAs) cannot resolve hostnames in SDP and will - // silently fall back to the audio endpoint, sending UDPTL to the wrong port. - let config = crate::config::EnvConfig::global(); - let local_ip = config - .rtp_public_ip - .clone() - .unwrap_or_else(|| config.sip_public_host_or_default().to_string()); - tracing::debug!("Using {} for T.38 SDP c= line", local_ip); - let sess_id = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let sdp_str = format!( - "v=0\r\n\ + // 3. Build and parse T.38 SDP + // Use RTP_PUBLIC_IP for the SDP c= line, matching what pjsua uses for audio SDP. + // Many SIP devices (e.g. Cisco ATAs) cannot resolve hostnames in SDP and will + // silently fall back to the audio endpoint, sending UDPTL to the wrong port. + let config = crate::config::EnvConfig::global(); + let local_ip = config + .rtp_public_ip + .clone() + .unwrap_or_else(|| config.sip_public_host_or_default().to_string()); + tracing::debug!("Using {} for T.38 SDP c= line", local_ip); + let sess_id = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let sdp_str = format!( + "v=0\r\n\ o=- {} {} IN IP4 {}\r\n\ s=T.38 Fax\r\n\ c=IN IP4 {}\r\n\ @@ -1289,144 +1308,145 @@ pub unsafe extern "C" fn on_call_rx_reinvite_cb( a=T38FaxMaxBuffer:260\r\n\ a=T38FaxMaxDatagram:316\r\n\ a=T38FaxUdpEC:t38UDPRedundancy\r\n", - sess_id, sess_id, local_ip, local_ip, local_port - ); - - let pool = pjsua_pool_create(c"t38sdp".as_ptr(), 1024, 256); - if pool.is_null() { - tracing::error!("Call {}: failed to create pool for T.38 SDP", call_id); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - - let sdp_bytes = sdp_str.as_bytes(); - let mut sdp: *mut pjmedia_sdp_session = ptr::null_mut(); - let status = pjmedia_sdp_parse( - pool, - sdp_bytes.as_ptr() as *mut std::os::raw::c_char, - sdp_bytes.len(), - &mut sdp, - ); - if status != pj_constants__PJ_SUCCESS as i32 || sdp.is_null() { - tracing::error!( - "Call {}: failed to parse T.38 SDP (status={})", - call_id, - status + sess_id, sess_id, local_ip, local_ip, local_port ); - pj_pool_release(pool); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - // 4. Create 200 OK at dialog level (bypasses inv session media handling) - let mut tdata: *mut pjsip_tx_data = ptr::null_mut(); - let status = pjsip_dlg_create_response(dlg, rdata, 200, ptr::null(), &mut tdata); - if status != pj_constants__PJ_SUCCESS as i32 || tdata.is_null() { - tracing::error!( - "Call {}: pjsip_dlg_create_response failed (status={})", - call_id, - status + let pool = pjsua_pool_create(c"t38sdp".as_ptr(), 1024, 256); + if pool.is_null() { + tracing::error!("Call {}: failed to create pool for T.38 SDP", call_id); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + + let sdp_bytes = sdp_str.as_bytes(); + let mut sdp: *mut pjmedia_sdp_session = ptr::null_mut(); + let status = pjmedia_sdp_parse( + pool, + sdp_bytes.as_ptr() as *mut std::os::raw::c_char, + sdp_bytes.len(), + &mut sdp, ); - pj_pool_release(pool); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } + if status != pj_constants__PJ_SUCCESS as i32 || sdp.is_null() { + tracing::error!( + "Call {}: failed to parse T.38 SDP (status={})", + call_id, + status + ); + pj_pool_release(pool); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } - // Attach SDP body to the 200 OK - let mut body: *mut pjsip_msg_body = ptr::null_mut(); - let status = pjsip_create_sdp_body((*tdata).pool, sdp, &mut body); - if status != pj_constants__PJ_SUCCESS as i32 || body.is_null() { - tracing::error!( - "Call {}: pjsip_create_sdp_body failed (status={})", + // 4. Create 200 OK at dialog level (bypasses inv session media handling) + let mut tdata: *mut pjsip_tx_data = ptr::null_mut(); + let status = pjsip_dlg_create_response(dlg, rdata, 200, ptr::null(), &mut tdata); + if status != pj_constants__PJ_SUCCESS as i32 || tdata.is_null() { + tracing::error!( + "Call {}: pjsip_dlg_create_response failed (status={})", + call_id, + status + ); + pj_pool_release(pool); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + + // Attach SDP body to the 200 OK + let mut body: *mut pjsip_msg_body = ptr::null_mut(); + let status = pjsip_create_sdp_body((*tdata).pool, sdp, &mut body); + if status != pj_constants__PJ_SUCCESS as i32 || body.is_null() { + tracing::error!( + "Call {}: pjsip_create_sdp_body failed (status={})", + call_id, + status + ); + pjsip_tx_data_dec_ref(tdata); + pj_pool_release(pool); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + (*(*tdata).msg).body = body; + + // 5. Send 200 OK directly through the dialog transaction + let status = pjsip_dlg_send_response(dlg, tsx, tdata); + if status != pj_constants__PJ_SUCCESS as i32 { + tracing::error!( + "Call {}: pjsip_dlg_send_response failed (status={})", + call_id, + status + ); + pj_pool_release(pool); + pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + return; + } + + // 6. Cancel the SDP offer on the negotiator (REMOTE_OFFER → DONE). + // This prevents pjsip from trying to negotiate or reject later. + if !(*inv).neg.is_null() { + pjmedia_sdp_neg_cancel_offer((*inv).neg); + } + + // 7. Tell pjsua to skip ALL media processing for this re-INVITE. + // Setting code != 200 makes pjsua_call_on_rx_offer goto on_return + // immediately, avoiding apply_call_setting/pjsua_media_channel_init. + // + // After this, pjsip's inv session will try to send a 488 rejection + // via pjsip_dlg_send_response(dlg, tsx, tdata). But the transaction + // was already terminated by our 200 OK above (INVITE UAS tsx → + // TERMINATED after 2xx per sip_transaction.c:3172). The terminated + // tsx's state handler returns PJ_EIGNORED for TX_MSG events, so the + // 488 is never sent on the wire. + if !code.is_null() { + *code = 488; + } + + tracing::info!( + "Sent T.38 200 OK for call {} (local={}:{}) via dialog", call_id, - status + local_ip, + local_port ); - pjsip_tx_data_dec_ref(tdata); - pj_pool_release(pool); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); - return; - } - (*(*tdata).msg).body = body; - // 5. Send 200 OK directly through the dialog transaction - let status = pjsip_dlg_send_response(dlg, tsx, tdata); - if status != pj_constants__PJ_SUCCESS as i32 { - tracing::error!( - "Call {}: pjsip_dlg_send_response failed (status={})", - call_id, - status - ); - pj_pool_release(pool); - pjsua_call_hangup(*call_id, 500, ptr::null(), ptr::null()); + // 8. Store pre-bound socket for async UDPTL handler + T38_PRESOCKETS.insert(raw_call_id, std_socket); + + // 9. Emit T38Offered event (with local_port so handler knows which port) + if let Some(event_tx) = OUTBOUND_EVENT_TX.get() { + let _ = event_tx.try_send(super::SipEvent::T38Offered { + call_id, + remote_ip: t38_params.remote_ip, + remote_port: t38_params.remote_port, + t38_version: t38_params.t38_version, + max_bit_rate: t38_params.max_bit_rate, + rate_management: t38_params.rate_management, + udp_ec: t38_params.udp_ec, + local_port, + }); + } + return; } - // 6. Cancel the SDP offer on the negotiator (REMOTE_OFFER → DONE). - // This prevents pjsip from trying to negotiate or reject later. - if !(*inv).neg.is_null() { - pjmedia_sdp_neg_cancel_offer((*inv).neg); + // Normal re-INVITE (audio): apply hold-stripping logic + // Set MEDIA_DIR flag to force sendrecv as default direction + if !opt.is_null() { + (*opt).flag |= pjsua_call_flag_PJSUA_CALL_SET_MEDIA_DIR; + (*opt).media_dir[0] = pjmedia_dir_PJMEDIA_DIR_ENCODING_DECODING; } - // 7. Tell pjsua to skip ALL media processing for this re-INVITE. - // Setting code != 200 makes pjsua_call_on_rx_offer goto on_return - // immediately, avoiding apply_call_setting/pjsua_media_channel_init. - // - // After this, pjsip's inv session will try to send a 488 rejection - // via pjsip_dlg_send_response(dlg, tsx, tdata). But the transaction - // was already terminated by our 200 OK above (INVITE UAS tsx → - // TERMINATED after 2xx per sip_transaction.c:3172). The terminated - // tsx's state handler returns PJ_EIGNORED for TX_MSG events, so the - // 488 is never sent on the wire. - if !code.is_null() { - *code = 488; - } + // Strip hold direction from the SDP negotiator's cloned remote offer. + // The negotiator clones the offer before this callback, so we must modify + // the clone (via rdata → tsx → dlg → inv → neg → neg_remote_sdp). + // Without this, update_media_direction() in sdp_neg.c rewrites our answer + // from sendrecv to recvonly when the remote offer has sendonly. + let stripped = strip_hold_from_neg_remote(call_id, rdata); tracing::info!( - "Sent T.38 200 OK for call {} (local={}:{}) via dialog", + "Call {} received re-INVITE, forcing sendrecv (sdp_stripped={})", call_id, - local_ip, - local_port + stripped ); - - // 8. Store pre-bound socket for async UDPTL handler - T38_PRESOCKETS.insert(raw_call_id, std_socket); - - // 9. Emit T38Offered event (with local_port so handler knows which port) - if let Some(event_tx) = OUTBOUND_EVENT_TX.get() { - let _ = event_tx.try_send(super::SipEvent::T38Offered { - call_id, - remote_ip: t38_params.remote_ip, - remote_port: t38_params.remote_port, - t38_version: t38_params.t38_version, - max_bit_rate: t38_params.max_bit_rate, - rate_management: t38_params.rate_management, - udp_ec: t38_params.udp_ec, - local_port, - }); - } - - return; } - - // Normal re-INVITE (audio): apply hold-stripping logic - // Set MEDIA_DIR flag to force sendrecv as default direction - if !opt.is_null() { - (*opt).flag |= pjsua_call_flag_PJSUA_CALL_SET_MEDIA_DIR; - (*opt).media_dir[0] = pjmedia_dir_PJMEDIA_DIR_ENCODING_DECODING; - } - - // Strip hold direction from the SDP negotiator's cloned remote offer. - // The negotiator clones the offer before this callback, so we must modify - // the clone (via rdata → tsx → dlg → inv → neg → neg_remote_sdp). - // Without this, update_media_direction() in sdp_neg.c rewrites our answer - // from sendrecv to recvonly when the remote offer has sendonly. - let stripped = strip_hold_from_neg_remote(call_id, rdata); - - tracing::info!( - "Call {} received re-INVITE, forcing sendrecv (sdp_stripped={})", - call_id, - stripped - ); } /// Strip hold direction attributes from the SDP negotiator's remote offer clone. @@ -1437,79 +1457,81 @@ unsafe fn strip_hold_from_neg_remote(call_id: CallId, rdata: *mut pjsip_rx_data) return false; } - // rdata → transaction → dialog → inv session → SDP negotiator - let tsx = pjsip_rdata_get_tsx(rdata); - if tsx.is_null() { - tracing::warn!("Call {}: no transaction for re-INVITE", call_id); - return false; - } - - let dlg = pjsip_tsx_get_dlg(tsx); - if dlg.is_null() { - tracing::warn!("Call {}: no dialog for re-INVITE", call_id); - return false; - } - - let inv = pjsip_dlg_get_inv_session(dlg); - if inv.is_null() { - tracing::warn!("Call {}: no inv session for re-INVITE", call_id); - return false; - } - - let neg = (*inv).neg; - if neg.is_null() { - tracing::warn!("Call {}: no SDP negotiator", call_id); - return false; - } - - // Get the negotiator's cloned remote offer - let mut remote: *const pjmedia_sdp_session = ptr::null(); - let status = pjmedia_sdp_neg_get_neg_remote(neg, &mut remote); - if status != pj_constants__PJ_SUCCESS as i32 || remote.is_null() { - tracing::warn!( - "Call {}: failed to get remote SDP from negotiator (status={})", - call_id, - status - ); - return false; - } - - // Modify the clone in-place: strip hold direction attributes. - // Cast away const — safe because neg_remote_sdp is a deep clone, not the original. - // Removing these makes the SDP negotiator treat the offer as sendrecv (RFC 3264 default). - let remote_mut = remote as *mut pjmedia_sdp_session; - let mut stripped_any = false; - - for i in 0..(*remote_mut).media_count { - let m = (*remote_mut).media[i as usize]; - if m.is_null() { - continue; + unsafe { + // rdata → transaction → dialog → inv session → SDP negotiator + let tsx = pjsip_rdata_get_tsx(rdata); + if tsx.is_null() { + tracing::warn!("Call {}: no transaction for re-INVITE", call_id); + return false; } - let sendonly = c"sendonly".as_ptr(); - let recvonly = c"recvonly".as_ptr(); - let inactive = c"inactive".as_ptr(); + let dlg = pjsip_tsx_get_dlg(tsx); + if dlg.is_null() { + tracing::warn!("Call {}: no dialog for re-INVITE", call_id); + return false; + } - let had_sendonly = !pjmedia_sdp_media_find_attr2(m, sendonly, ptr::null()).is_null(); - let had_recvonly = !pjmedia_sdp_media_find_attr2(m, recvonly, ptr::null()).is_null(); - let had_inactive = !pjmedia_sdp_media_find_attr2(m, inactive, ptr::null()).is_null(); + let inv = pjsip_dlg_get_inv_session(dlg); + if inv.is_null() { + tracing::warn!("Call {}: no inv session for re-INVITE", call_id); + return false; + } - if had_sendonly || had_recvonly || had_inactive { - pjmedia_sdp_media_remove_all_attr(m, sendonly); - pjmedia_sdp_media_remove_all_attr(m, recvonly); - pjmedia_sdp_media_remove_all_attr(m, inactive); - stripped_any = true; + let neg = (*inv).neg; + if neg.is_null() { + tracing::warn!("Call {}: no SDP negotiator", call_id); + return false; + } - tracing::debug!( - "Call {} media {}: stripped hold direction (sendonly={}, recvonly={}, inactive={})", + // Get the negotiator's cloned remote offer + let mut remote: *const pjmedia_sdp_session = ptr::null(); + let status = pjmedia_sdp_neg_get_neg_remote(neg, &mut remote); + if status != pj_constants__PJ_SUCCESS as i32 || remote.is_null() { + tracing::warn!( + "Call {}: failed to get remote SDP from negotiator (status={})", call_id, - i, - had_sendonly, - had_recvonly, - had_inactive + status ); + return false; } - } - stripped_any + // Modify the clone in-place: strip hold direction attributes. + // Cast away const — safe because neg_remote_sdp is a deep clone, not the original. + // Removing these makes the SDP negotiator treat the offer as sendrecv (RFC 3264 default). + let remote_mut = remote as *mut pjmedia_sdp_session; + let mut stripped_any = false; + + for i in 0..(*remote_mut).media_count { + let m = (*remote_mut).media[i as usize]; + if m.is_null() { + continue; + } + + let sendonly = c"sendonly".as_ptr(); + let recvonly = c"recvonly".as_ptr(); + let inactive = c"inactive".as_ptr(); + + let had_sendonly = !pjmedia_sdp_media_find_attr2(m, sendonly, ptr::null()).is_null(); + let had_recvonly = !pjmedia_sdp_media_find_attr2(m, recvonly, ptr::null()).is_null(); + let had_inactive = !pjmedia_sdp_media_find_attr2(m, inactive, ptr::null()).is_null(); + + if had_sendonly || had_recvonly || had_inactive { + pjmedia_sdp_media_remove_all_attr(m, sendonly); + pjmedia_sdp_media_remove_all_attr(m, recvonly); + pjmedia_sdp_media_remove_all_attr(m, inactive); + stripped_any = true; + + tracing::debug!( + "Call {} media {}: stripped hold direction (sendonly={}, recvonly={}, inactive={})", + call_id, + i, + had_sendonly, + had_recvonly, + had_inactive + ); + } + } + + stripped_any + } } diff --git a/sipcord-bridge/src/transport/sip/channel_audio.rs b/sipcord-bridge/src/transport/sip/channel_audio.rs index 8eb4e58..6e260f9 100644 --- a/sipcord-bridge/src/transport/sip/channel_audio.rs +++ b/sipcord-bridge/src/transport/sip/channel_audio.rs @@ -84,10 +84,12 @@ pub unsafe extern "C" fn channel_port_get_frame( return -1; // PJ_EINVAL } - let channel_id = Snowflake::new((*this_port).port_data.ldata as u64); + let channel_id = unsafe { Snowflake::new((*this_port).port_data.ldata as u64) }; if *channel_id == 0 { - (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_NONE; - (*frame).size = 0; + unsafe { + (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_NONE; + (*frame).size = 0; + } return pj_constants__PJ_SUCCESS as pj_status_t; } @@ -169,10 +171,10 @@ pub unsafe extern "C" fn channel_port_get_frame( } if samples_len > 0 { - let samples = std::slice::from_raw_parts(samples_ptr, samples_len); - super::ffi::frame_utils::fill_audio_frame(frame, samples); + let samples = unsafe { std::slice::from_raw_parts(samples_ptr, samples_len) }; + unsafe { super::ffi::frame_utils::fill_audio_frame(frame, samples) }; } else { - super::ffi::frame_utils::fill_silence_frame(frame); + unsafe { super::ffi::frame_utils::fill_silence_frame(frame) }; } pj_constants__PJ_SUCCESS as pj_status_t @@ -186,51 +188,51 @@ fn get_samples_from_buffer(channel_id: Snowflake, buf: &mut [i16; SAMPLES_PER_FR static DRAIN_COUNT: AtomicU64 = AtomicU64::new(0); static UNDERRUN_COUNT: AtomicU64 = AtomicU64::new(0); - if let Some(consumer_entry) = get_discord_to_sip_consumers().get(&channel_id) { - if let Some(mut consumer) = consumer_entry.try_lock() { - let available = consumer.slots(); - if available >= SAMPLES_PER_FRAME { - let count = DRAIN_COUNT.fetch_add(1, Ordering::Relaxed); - if count.is_multiple_of(250) { - tracing::debug!( - "Discord->SIP drain: channel={}, available={}, draining {}", - channel_id, - available, - SAMPLES_PER_FRAME - ); - } - if let Ok(chunk) = consumer.read_chunk(SAMPLES_PER_FRAME) { - let (first, second) = chunk.as_slices(); - buf[..first.len()].copy_from_slice(first); - if !second.is_empty() { - buf[first.len()..first.len() + second.len()].copy_from_slice(second); - } - chunk.commit_all(); - } - return SAMPLES_PER_FRAME; - } else if available > 0 { - // Partial buffer - drain what we have, zero-fill the rest - let underruns = UNDERRUN_COUNT.fetch_add(1, Ordering::Relaxed) + 1; - if underruns <= 10 || underruns.is_multiple_of(100) { - tracing::warn!( - "BUFFER UNDERRUN (Discord->SIP): channel={}, only {} available (need {}), total: {}", - channel_id, - available, - SAMPLES_PER_FRAME, - underruns - ); - } - buf[available..].fill(0); - if let Ok(chunk) = consumer.read_chunk(available) { - let (first, second) = chunk.as_slices(); - buf[..first.len()].copy_from_slice(first); - if !second.is_empty() { - buf[first.len()..first.len() + second.len()].copy_from_slice(second); - } - chunk.commit_all(); - } - return available; + if let Some(consumer_entry) = get_discord_to_sip_consumers().get(&channel_id) + && let Some(mut consumer) = consumer_entry.try_lock() + { + let available = consumer.slots(); + if available >= SAMPLES_PER_FRAME { + let count = DRAIN_COUNT.fetch_add(1, Ordering::Relaxed); + if count.is_multiple_of(250) { + tracing::debug!( + "Discord->SIP drain: channel={}, available={}, draining {}", + channel_id, + available, + SAMPLES_PER_FRAME + ); } + if let Ok(chunk) = consumer.read_chunk(SAMPLES_PER_FRAME) { + let (first, second) = chunk.as_slices(); + buf[..first.len()].copy_from_slice(first); + if !second.is_empty() { + buf[first.len()..first.len() + second.len()].copy_from_slice(second); + } + chunk.commit_all(); + } + return SAMPLES_PER_FRAME; + } else if available > 0 { + // Partial buffer - drain what we have, zero-fill the rest + let underruns = UNDERRUN_COUNT.fetch_add(1, Ordering::Relaxed) + 1; + if underruns <= 10 || underruns.is_multiple_of(100) { + tracing::warn!( + "BUFFER UNDERRUN (Discord->SIP): channel={}, only {} available (need {}), total: {}", + channel_id, + available, + SAMPLES_PER_FRAME, + underruns + ); + } + buf[available..].fill(0); + if let Ok(chunk) = consumer.read_chunk(available) { + let (first, second) = chunk.as_slices(); + buf[..first.len()].copy_from_slice(first); + if !second.is_empty() { + buf[first.len()..first.len() + second.len()].copy_from_slice(second); + } + chunk.commit_all(); + } + return available; } } @@ -254,11 +256,13 @@ pub unsafe extern "C" fn channel_port_put_frame( } // Only process audio frames with data - if (*frame).type_ != pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO || (*frame).size == 0 { + if unsafe { + (*frame).type_ != pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO || (*frame).size == 0 + } { return pj_constants__PJ_SUCCESS as pj_status_t; } - let channel_id = Snowflake::new((*this_port).port_data.ldata as u64); + let channel_id = unsafe { Snowflake::new((*this_port).port_data.ldata as u64) }; if *channel_id == 0 { return pj_constants__PJ_SUCCESS as pj_status_t; } @@ -270,16 +274,18 @@ pub unsafe extern "C" fn channel_port_put_frame( call_count, this_port, channel_id, - (*frame).size + unsafe { (*frame).size } ); } else if call_count == 10 { tracing::trace!("channel_port_put_frame: suppressing further per-call logs"); } // View frame buffer as i16 slice (zero-copy) - let num_samples = (*frame).size / 2; - let frame_buf = (*frame).buf as *const i16; - let samples = std::slice::from_raw_parts(frame_buf, num_samples); + let samples = unsafe { + let num_samples = (*frame).size / 2; + let frame_buf = (*frame).buf as *const i16; + std::slice::from_raw_parts(frame_buf, num_samples) + }; // Store in the SIP->Discord buffer for this channel let buffers = CHANNEL_AUDIO_IN.get_or_init(DashMap::new); @@ -324,7 +330,7 @@ pub unsafe extern "C" fn channel_port_put_frame( /// Custom on_destroy callback for channel buffer ports pub unsafe extern "C" fn channel_port_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t { if !this_port.is_null() { - // Remove from reverse mapping + // Remove from reverse mapping (no unsafe ops needed here, just pointer-to-usize cast) let port_key = this_port as usize; if let Some(mapping) = PORT_TO_CHANNEL.get() { mapping.lock().remove(&port_key); @@ -348,10 +354,12 @@ unsafe fn connect_call_to_channel( ) { // Connect this call to other calls in the same channel for &(other_call_id, other_conf_port) in other_calls { - let status1 = - pjmedia_conf_connect_port(conf, *conf_port as u32, *other_conf_port as u32, 0); - let status2 = - pjmedia_conf_connect_port(conf, *other_conf_port as u32, *conf_port as u32, 0); + let (status1, status2) = unsafe { + ( + pjmedia_conf_connect_port(conf, *conf_port as u32, *other_conf_port as u32, 0), + pjmedia_conf_connect_port(conf, *other_conf_port as u32, *conf_port as u32, 0), + ) + }; if status1 == pj_constants__PJ_SUCCESS as i32 && status2 == pj_constants__PJ_SUCCESS as i32 { @@ -377,10 +385,14 @@ unsafe fn connect_call_to_channel( // Connect call to channel's conference port bidirectionally if let Some(channel_slot) = get_or_create_channel_port(channel_id) { - // Channel port -> call (Discord audio reaches this call) - let status1 = pjmedia_conf_connect_port(conf, *channel_slot as u32, *conf_port as u32, 0); - // Call -> channel port (SIP audio goes to channel for Discord) - let status2 = pjmedia_conf_connect_port(conf, *conf_port as u32, *channel_slot as u32, 0); + let (status1, status2) = unsafe { + ( + // Channel port -> call (Discord audio reaches this call) + pjmedia_conf_connect_port(conf, *channel_slot as u32, *conf_port as u32, 0), + // Call -> channel port (SIP audio goes to channel for Discord) + pjmedia_conf_connect_port(conf, *conf_port as u32, *channel_slot as u32, 0), + ) + }; if status1 != pj_constants__PJ_SUCCESS as i32 { tracing::warn!( @@ -429,8 +441,10 @@ unsafe fn disconnect_call_from_channel( // Disconnect from other calls in the channel (both directions) for &other_call_id in remaining_calls { if let Some(other_conf_port) = conf_ports.get(&other_call_id).map(|r| *r) { - pjmedia_conf_disconnect_port(conf, *conf_port as u32, *other_conf_port as u32); - pjmedia_conf_disconnect_port(conf, *other_conf_port as u32, *conf_port as u32); + unsafe { + pjmedia_conf_disconnect_port(conf, *conf_port as u32, *other_conf_port as u32); + pjmedia_conf_disconnect_port(conf, *other_conf_port as u32, *conf_port as u32); + } tracing::debug!( "Disconnected call {} from call {} in channel {}", call_id, @@ -442,8 +456,10 @@ unsafe fn disconnect_call_from_channel( // Disconnect from channel port (both directions) if let Some(channel_slot) = get_channel_slot(channel_id) { - pjmedia_conf_disconnect_port(conf, *channel_slot as u32, *conf_port as u32); - pjmedia_conf_disconnect_port(conf, *conf_port as u32, *channel_slot as u32); + unsafe { + pjmedia_conf_disconnect_port(conf, *channel_slot as u32, *conf_port as u32); + pjmedia_conf_disconnect_port(conf, *conf_port as u32, *channel_slot as u32); + } tracing::debug!( "Disconnected channel {} slot {} <-> call {} (port {}) bidirectionally", channel_id, diff --git a/sipcord-bridge/src/transport/sip/ffi/direct_player.rs b/sipcord-bridge/src/transport/sip/ffi/direct_player.rs index 37c3210..20113a1 100644 --- a/sipcord-bridge/src/transport/sip/ffi/direct_player.rs +++ b/sipcord-bridge/src/transport/sip/ffi/direct_player.rs @@ -11,6 +11,10 @@ use std::collections::HashMap; /// Custom get_frame callback for direct player ports /// Returns samples from the player's buffer, advancing position each call +/// +/// # Safety +/// Called by the pjmedia conference bridge. `this_port` and `frame` must be +/// valid, non-null pointers to pjmedia structures owned by pjsua. pub unsafe extern "C" fn direct_player_get_frame( this_port: *mut pjmedia_port, frame: *mut pjmedia_frame, @@ -45,13 +49,13 @@ pub unsafe extern "C" fn direct_player_get_frame( if let Some((buffer, pos)) = state.get_mut(&port_key) { if *pos < buffer.len() { let end = (*pos + SAMPLES_PER_FRAME).min(buffer.len()); - super::frame_utils::fill_audio_frame(frame, &buffer[*pos..end]); + unsafe { super::frame_utils::fill_audio_frame(frame, &buffer[*pos..end]) }; *pos = end; } else { - super::frame_utils::fill_silence_frame(frame); // Playback complete + unsafe { super::frame_utils::fill_silence_frame(frame) }; // Playback complete } } else { - super::frame_utils::fill_silence_frame(frame); + unsafe { super::frame_utils::fill_silence_frame(frame) }; } } @@ -59,6 +63,10 @@ pub unsafe extern "C" fn direct_player_get_frame( } /// Custom on_destroy callback for direct player ports +/// +/// # Safety +/// Called by pjmedia when the port is being destroyed. `this_port` must be +/// a valid pointer to a pjmedia_port that was previously created by this module. pub unsafe extern "C" fn direct_player_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t { if !this_port.is_null() { let port_key = this_port as usize; diff --git a/sipcord-bridge/src/transport/sip/ffi/frame_utils.rs b/sipcord-bridge/src/transport/sip/ffi/frame_utils.rs index c1afeaa..59c89b3 100644 --- a/sipcord-bridge/src/transport/sip/ffi/frame_utils.rs +++ b/sipcord-bridge/src/transport/sip/ffi/frame_utils.rs @@ -24,7 +24,7 @@ pub unsafe fn get_conference_bridge() -> Option<*mut pjmedia_conf> { if master_port.is_null() { return None; } - let conf = (*master_port).port_data.pdata as *mut pjmedia_conf; + let conf = unsafe { (*master_port).port_data.pdata as *mut pjmedia_conf }; if conf.is_null() { return None; } @@ -38,18 +38,20 @@ pub unsafe fn get_conference_bridge() -> Option<*mut pjmedia_conf> { /// `frame` must be a valid, non-null pointer to a pjmedia_frame with a buffer /// large enough for SAMPLES_PER_FRAME i16 samples. pub unsafe fn fill_audio_frame(frame: *mut pjmedia_frame, samples: &[i16]) { - let frame_buf = (*frame).buf as *mut i16; - std::ptr::copy_nonoverlapping(samples.as_ptr(), frame_buf, samples.len()); - // Pad with silence if we got fewer samples than a full frame - if samples.len() < SAMPLES_PER_FRAME { - std::ptr::write_bytes( - frame_buf.add(samples.len()), - 0, - SAMPLES_PER_FRAME - samples.len(), - ); + unsafe { + let frame_buf = (*frame).buf as *mut i16; + std::ptr::copy_nonoverlapping(samples.as_ptr(), frame_buf, samples.len()); + // Pad with silence if we got fewer samples than a full frame + if samples.len() < SAMPLES_PER_FRAME { + std::ptr::write_bytes( + frame_buf.add(samples.len()), + 0, + SAMPLES_PER_FRAME - samples.len(), + ); + } + (*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t; + (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; } - (*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t; - (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; } /// Fill a pjmedia_frame with silence. @@ -58,10 +60,12 @@ pub unsafe fn fill_audio_frame(frame: *mut pjmedia_frame, samples: &[i16]) { /// `frame` must be a valid, non-null pointer to a pjmedia_frame with a buffer /// large enough for SAMPLES_PER_FRAME i16 samples. pub unsafe fn fill_silence_frame(frame: *mut pjmedia_frame) { - let frame_buf = (*frame).buf as *mut u8; - std::ptr::write_bytes(frame_buf, 0, SAMPLES_PER_FRAME * 2); - (*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t; - (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; + unsafe { + let frame_buf = (*frame).buf as *mut u8; + std::ptr::write_bytes(frame_buf, 0, SAMPLES_PER_FRAME * 2); + (*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t; + (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; + } } /// No-op put_frame callback for ports that only produce audio. @@ -119,14 +123,14 @@ pub unsafe fn create_and_connect_port( ) -> Result { // Get or create the memory pool let pool = pool.get_or_init(|| { - let p = pjsua_pool_create(pool_name.as_ptr() as *const _, 4096, 4096); + let p = unsafe { pjsua_pool_create(pool_name.as_ptr() as *const _, 4096, 4096) }; Mutex::new(SendablePool(p)) }); let pool_ptr = pool.lock().0; // Allocate pjmedia_port structure let port_size = std::mem::size_of::(); - let port = pj_pool_alloc(pool_ptr, port_size) as *mut pjmedia_port; + let port = unsafe { pj_pool_alloc(pool_ptr, port_size) as *mut pjmedia_port }; if port.is_null() { anyhow::bail!( "Failed to allocate {} port for call {}", @@ -134,7 +138,7 @@ pub unsafe fn create_and_connect_port( call_id ); } - std::ptr::write_bytes(port as *mut u8, 0, port_size); + unsafe { std::ptr::write_bytes(port as *mut u8, 0, port_size) }; // Create port name let port_name = format!("{}{}", name_prefix, call_id); @@ -142,38 +146,41 @@ pub unsafe fn create_and_connect_port( .map_err(|e| anyhow::anyhow!("Invalid port name: {}", e))?; // Initialize port info - pjmedia_port_info_init( - &mut (*port).info, - &pj_str(port_name_cstr.as_ptr() as *mut _), - signature, - CONF_SAMPLE_RATE, - CONF_CHANNELS, - 16, - SAMPLES_PER_FRAME as u32, - ); + unsafe { + pjmedia_port_info_init( + &mut (*port).info, + &pj_str(port_name_cstr.as_ptr() as *mut _), + signature, + CONF_SAMPLE_RATE, + CONF_CHANNELS, + 16, + SAMPLES_PER_FRAME as u32, + ); - // Set callbacks - (*port).get_frame = Some(callbacks.get_frame); - (*port).put_frame = Some(callbacks.put_frame); - (*port).on_destroy = callbacks.on_destroy; + // Set callbacks + (*port).get_frame = Some(callbacks.get_frame); + (*port).put_frame = Some(callbacks.put_frame); + (*port).on_destroy = callbacks.on_destroy; + } // Add to conference let mut player_slot: i32 = 0; - let status = pjsua_conf_add_port(pool_ptr, port, &mut player_slot); + let status = unsafe { pjsua_conf_add_port(pool_ptr, port, &mut player_slot) }; if status != pj_constants__PJ_SUCCESS as i32 { anyhow::bail!("Failed to add {} port to conf: {}", name_prefix, status); } // Connect player port to the target call's port - let conf = get_conference_bridge(); + let conf = unsafe { get_conference_bridge() }; let Some(conf) = conf else { - pjsua_conf_remove_port(player_slot); + unsafe { pjsua_conf_remove_port(player_slot) }; anyhow::bail!("Failed to get conference bridge for {} port", name_prefix); }; - let status = pjmedia_conf_connect_port(conf, player_slot as u32, *call_conf_port as u32, 0); + let status = + unsafe { pjmedia_conf_connect_port(conf, player_slot as u32, *call_conf_port as u32, 0) }; if status != pj_constants__PJ_SUCCESS as i32 { - pjsua_conf_remove_port(player_slot); + unsafe { pjsua_conf_remove_port(player_slot) }; anyhow::bail!("Failed to connect {} port to call: {}", name_prefix, status); } diff --git a/sipcord-bridge/src/transport/sip/ffi/init.rs b/sipcord-bridge/src/transport/sip/ffi/init.rs index 0f98222..266200d 100644 --- a/sipcord-bridge/src/transport/sip/ffi/init.rs +++ b/sipcord-bridge/src/transport/sip/ffi/init.rs @@ -161,7 +161,7 @@ unsafe extern "C" fn pjsip_log_callback(level: c_int, data: *const c_char, _len: return; } - let c_str = std::ffi::CStr::from_ptr(data); + let c_str = unsafe { std::ffi::CStr::from_ptr(data) }; let msg = c_str.to_string_lossy(); let msg = msg.trim_end(); @@ -375,10 +375,10 @@ pub fn init_pjsua(config: &SipConfig, tls_config: Option<&TlsConfig>) -> Result< tracing::info!("TCP transport created on port {}", config.port); // Create TLS transport if configured (skip gracefully if certs missing) - if let Some(tls) = tls_config { - if !create_tls_transport(tls, &config.public_host)? { - tracing::warn!("TLS transport not created - running without TLS"); - } + if let Some(tls) = tls_config + && !create_tls_transport(tls, &config.public_host)? + { + tracing::warn!("TLS transport not created - running without TLS"); } // Start pjsua diff --git a/sipcord-bridge/src/transport/sip/ffi/looping_player.rs b/sipcord-bridge/src/transport/sip/ffi/looping_player.rs index 976d5ab..a8ca7b6 100644 --- a/sipcord-bridge/src/transport/sip/ffi/looping_player.rs +++ b/sipcord-bridge/src/transport/sip/ffi/looping_player.rs @@ -38,6 +38,10 @@ pub struct LoopingPlayerState { /// Custom get_frame callback for looping player ports /// Returns samples from the player's buffer, looping back to start when reaching end +/// +/// # Safety +/// Called by the pjmedia conference bridge. `this_port` and `frame` must be +/// valid, non-null pointers to pjmedia structures owned by pjsua. pub unsafe extern "C" fn looping_player_get_frame( this_port: *mut pjmedia_port, frame: *mut pjmedia_frame, @@ -73,7 +77,9 @@ pub unsafe extern "C" fn looping_player_get_frame( if player_data.is_active.load(Ordering::SeqCst) && !player_data.samples.is_empty() { let pos = player_data.position; let end = (pos + SAMPLES_PER_FRAME).min(player_data.samples.len()); - super::frame_utils::fill_audio_frame(frame, &player_data.samples[pos..end]); + unsafe { + super::frame_utils::fill_audio_frame(frame, &player_data.samples[pos..end]) + }; // Advance position, loop back if at end player_data.position = if end >= player_data.samples.len() { @@ -82,10 +88,10 @@ pub unsafe extern "C" fn looping_player_get_frame( end }; } else { - super::frame_utils::fill_silence_frame(frame); + unsafe { super::frame_utils::fill_silence_frame(frame) }; } } else { - super::frame_utils::fill_silence_frame(frame); + unsafe { super::frame_utils::fill_silence_frame(frame) }; } } @@ -93,6 +99,10 @@ pub unsafe extern "C" fn looping_player_get_frame( } /// Custom on_destroy callback for looping player ports +/// +/// # Safety +/// Called by pjmedia when the port is being destroyed. `this_port` must be +/// a valid pointer to a pjmedia_port that was previously created by this module. pub unsafe extern "C" fn looping_player_on_destroy(this_port: *mut pjmedia_port) -> pj_status_t { if !this_port.is_null() { let port_key = this_port as usize; @@ -194,10 +204,10 @@ pub fn stop_loop(call_id: CallId) { if let Some(state) = state { // Mark as inactive (get_frame will return silence) - if let Some(data) = LOOPING_PLAYER_DATA.get() { - if let Some(player_data) = data.lock().get(&state.port_key) { - player_data.is_active.store(false, Ordering::SeqCst); - } + if let Some(data) = LOOPING_PLAYER_DATA.get() + && let Some(player_data) = data.lock().get(&state.port_key) + { + player_data.is_active.store(false, Ordering::SeqCst); } // Remove from conference diff --git a/sipcord-bridge/src/transport/sip/ffi/streaming_player.rs b/sipcord-bridge/src/transport/sip/ffi/streaming_player.rs index 30118e3..9213796 100644 --- a/sipcord-bridge/src/transport/sip/ffi/streaming_player.rs +++ b/sipcord-bridge/src/transport/sip/ffi/streaming_player.rs @@ -118,9 +118,9 @@ pub unsafe extern "C" fn streaming_get_frame( // Fill frame buffer if !samples.is_empty() { - super::frame_utils::fill_audio_frame(frame, &samples); + unsafe { super::frame_utils::fill_audio_frame(frame, &samples) }; } else { - super::frame_utils::fill_silence_frame(frame); + unsafe { super::frame_utils::fill_silence_frame(frame) }; } pj_constants__PJ_SUCCESS as pj_status_t diff --git a/sipcord-bridge/src/transport/sip/ffi/test_tone.rs b/sipcord-bridge/src/transport/sip/ffi/test_tone.rs index 05bb5ff..13e3c6c 100644 --- a/sipcord-bridge/src/transport/sip/ffi/test_tone.rs +++ b/sipcord-bridge/src/transport/sip/ffi/test_tone.rs @@ -76,7 +76,7 @@ pub unsafe extern "C" fn test_tone_get_frame( } if tone_state.finished { - super::frame_utils::fill_silence_frame(frame); + unsafe { super::frame_utils::fill_silence_frame(frame) }; } else { // Copy from precomputed LUT with wraparound (two memcpy calls max) let lut = tone_lut(); @@ -85,27 +85,29 @@ pub unsafe extern "C" fn test_tone_get_frame( tone_state.phase += SAMPLES_PER_FRAME as u64; let first_chunk = (lut_len - phase).min(SAMPLES_PER_FRAME); - let frame_buf = (*frame).buf as *mut i16; - std::ptr::copy_nonoverlapping( - lut[phase..phase + first_chunk].as_ptr(), - frame_buf, - first_chunk, - ); - - if first_chunk < SAMPLES_PER_FRAME { - let remaining = SAMPLES_PER_FRAME - first_chunk; + unsafe { + let frame_buf = (*frame).buf as *mut i16; std::ptr::copy_nonoverlapping( - lut.as_ptr(), - frame_buf.add(first_chunk), - remaining, + lut[phase..phase + first_chunk].as_ptr(), + frame_buf, + first_chunk, ); - } - (*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t; - (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; + if first_chunk < SAMPLES_PER_FRAME { + let remaining = SAMPLES_PER_FRAME - first_chunk; + std::ptr::copy_nonoverlapping( + lut.as_ptr(), + frame_buf.add(first_chunk), + remaining, + ); + } + + (*frame).size = (SAMPLES_PER_FRAME * 2) as pj_size_t; + (*frame).type_ = pjmedia_frame_type_PJMEDIA_FRAME_TYPE_AUDIO; + } } } else { - super::frame_utils::fill_silence_frame(frame); + unsafe { super::frame_utils::fill_silence_frame(frame) }; } } diff --git a/sipcord-bridge/src/transport/sip/ffi/utils.rs b/sipcord-bridge/src/transport/sip/ffi/utils.rs index 04a1c78..a976cc0 100644 --- a/sipcord-bridge/src/transport/sip/ffi/utils.rs +++ b/sipcord-bridge/src/transport/sip/ffi/utils.rs @@ -11,7 +11,7 @@ pub unsafe fn pj_str_to_string(s: &pj_str_t) -> String { return String::new(); } - let slice = std::slice::from_raw_parts(s.ptr as *const u8, s.slen as usize); + let slice = unsafe { std::slice::from_raw_parts(s.ptr as *const u8, s.slen as usize) }; String::from_utf8_lossy(slice).to_string() } diff --git a/sipcord-bridge/src/transport/sip/mod.rs b/sipcord-bridge/src/transport/sip/mod.rs index f7105c3..19cc404 100644 --- a/sipcord-bridge/src/transport/sip/mod.rs +++ b/sipcord-bridge/src/transport/sip/mod.rs @@ -539,10 +539,9 @@ fn make_outbound_call(sip_uri: &str, caller_display_name: Option<&str>) -> Resul == ::pjsua::pj_constants__PJ_SUCCESS as i32 { let ai = acc_info.assume_init(); - let uri_str = std::ffi::CStr::from_ptr(ai.acc_uri.ptr) + std::ffi::CStr::from_ptr(ai.acc_uri.ptr) .to_string_lossy() - .into_owned(); - uri_str + .into_owned() } else { "sip:sipcord@localhost".to_string() }; diff --git a/sipcord-bridge/src/transport/sip/nat.rs b/sipcord-bridge/src/transport/sip/nat.rs index 2af1f81..947fdbb 100644 --- a/sipcord-bridge/src/transport/sip/nat.rs +++ b/sipcord-bridge/src/transport/sip/nat.rs @@ -79,12 +79,11 @@ fn sanitize_sdp_missing_rtpmap(sdp: &str) -> Option { let mut rtpmap_pts: std::collections::HashSet = std::collections::HashSet::new(); for line in section_lines { // a=rtpmap:96 opus/48000/2 - if let Some(rest) = line.strip_prefix("a=rtpmap:") { - if let Some(pt_str) = rest.split_whitespace().next() { - if let Ok(pt) = pt_str.parse::() { - rtpmap_pts.insert(pt); - } - } + if let Some(rest) = line.strip_prefix("a=rtpmap:") + && let Some(pt_str) = rest.split_whitespace().next() + && let Ok(pt) = pt_str.parse::() + { + rtpmap_pts.insert(pt); } } @@ -94,11 +93,12 @@ fn sanitize_sdp_missing_rtpmap(sdp: &str) -> Option { let mut stripped_pts: Vec = Vec::new(); for pt_str in payload_types { - if let Ok(pt) = pt_str.parse::() { - if pt >= 96 && !rtpmap_pts.contains(&pt) { - stripped_pts.push(pt); - continue; - } + if let Ok(pt) = pt_str.parse::() + && pt >= 96 + && !rtpmap_pts.contains(&pt) + { + stripped_pts.push(pt); + continue; } kept_pts.push(pt_str); } @@ -134,14 +134,12 @@ fn sanitize_sdp_missing_rtpmap(sdp: &str) -> Option { // Copy section attribute lines, stripping a=fmtp: for removed PTs let stripped_set: std::collections::HashSet = stripped_pts.into_iter().collect(); for line in section_lines { - if let Some(rest) = line.strip_prefix("a=fmtp:") { - if let Some(pt_str) = rest.split_whitespace().next() { - if let Ok(pt) = pt_str.parse::() { - if stripped_set.contains(&pt) { - continue; // skip fmtp for stripped PT - } - } - } + if let Some(rest) = line.strip_prefix("a=fmtp:") + && let Some(pt_str) = rest.split_whitespace().next() + && let Ok(pt) = pt_str.parse::() + && stripped_set.contains(&pt) + { + continue; // skip fmtp for stripped PT } result_lines.push(line.to_string()); } @@ -164,14 +162,14 @@ fn is_rfc1918(ip: Ipv4Addr) -> bool { /// /// Returns `None` if transport info is invalid or the address is not IPv4. unsafe fn extract_dst_ipv4(tdata: *const pjsip_tx_data) -> Option { - if (*tdata).tp_info.transport.is_null() || (*tdata).tp_info.dst_addr_len <= 0 { + if unsafe { (*tdata).tp_info.transport.is_null() || (*tdata).tp_info.dst_addr_len <= 0 } { return None; } - let dst_addr = &(*tdata).tp_info.dst_addr; + let dst_addr = unsafe { &(*tdata).tp_info.dst_addr }; // PJ_AF_INET is typically 2 (same as AF_INET on most systems) - if dst_addr.addr.sa_family == 2 { - let addr_in = &dst_addr.ipv4; + if unsafe { dst_addr.addr.sa_family } == 2 { + let addr_in = unsafe { &dst_addr.ipv4 }; let ip_bytes = addr_in.sin_addr.s_addr.to_ne_bytes(); Some(Ipv4Addr::new( ip_bytes[0], @@ -195,32 +193,33 @@ unsafe fn rewrite_contact_host( new_host: &str, new_port: u16, ) -> bool { - let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) - as *mut pjsip_contact_hdr; + let contact_hdr = + unsafe { pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) } + as *mut pjsip_contact_hdr; if contact_hdr.is_null() { return false; } - let uri = (*contact_hdr).uri; + let uri = unsafe { (*contact_hdr).uri }; if uri.is_null() { return false; } // Unwrap via vtable to handle pjsip_name_addr wrapping - let uri_vptr = (*(uri as *const pjsip_uri)).vptr; + let uri_vptr = unsafe { (*(uri as *const pjsip_uri)).vptr }; if uri_vptr.is_null() { return false; } - let get_uri_fn = match (*uri_vptr).p_get_uri { + let get_uri_fn = match unsafe { (*uri_vptr).p_get_uri } { Some(f) => f, None => return false, }; - let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void); + let sip_uri_raw = unsafe { get_uri_fn(uri as *mut std::os::raw::c_void) }; if sip_uri_raw.is_null() { return false; } let sip_uri = sip_uri_raw as *mut pjsip_sip_uri; - if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 { + if unsafe { (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 } { return false; } @@ -228,15 +227,17 @@ unsafe fn rewrite_contact_host( return false; }; let host_len = new_host.len(); - let pool_str = pj_pool_alloc(pool, host_len + 1) as *mut c_char; + let pool_str = unsafe { pj_pool_alloc(pool, host_len + 1) } as *mut c_char; if pool_str.is_null() { return false; } - ptr::copy_nonoverlapping(host_cstr.as_ptr(), pool_str, host_len + 1); - (*sip_uri).host.ptr = pool_str; - (*sip_uri).host.slen = host_len as i64; - (*sip_uri).port = new_port as i32; + unsafe { + ptr::copy_nonoverlapping(host_cstr.as_ptr(), pool_str, host_len + 1); + (*sip_uri).host.ptr = pool_str; + (*sip_uri).host.slen = host_len as i64; + (*sip_uri).port = new_port as i32; + } true } @@ -251,12 +252,13 @@ unsafe fn rewrite_sdp_body( old_ip: &str, new_ip: &str, ) -> bool { - let body = (*msg).body; - if body.is_null() || (*body).len == 0 || (*body).data.is_null() { + let body = unsafe { (*msg).body }; + if body.is_null() || unsafe { (*body).len == 0 || (*body).data.is_null() } { return false; } - let body_slice = std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize); + let body_slice = + unsafe { std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize) }; let Ok(sdp_str) = std::str::from_utf8(body_slice) else { return false; }; @@ -284,14 +286,16 @@ unsafe fn rewrite_sdp_body( } let new_len = new_sdp.len(); - let new_body_ptr = pj_pool_alloc(pool, new_len) as *mut u8; + let new_body_ptr = unsafe { pj_pool_alloc(pool, new_len) } as *mut u8; if new_body_ptr.is_null() { return false; } - ptr::copy_nonoverlapping(new_sdp.as_ptr(), new_body_ptr, new_len); - (*body).data = new_body_ptr as *mut _; - (*body).len = new_len as u32; + unsafe { + ptr::copy_nonoverlapping(new_sdp.as_ptr(), new_body_ptr, new_len); + (*body).data = new_body_ptr as *mut _; + (*body).len = new_len as u32; + } true } @@ -310,7 +314,7 @@ unsafe fn rewrite_local_network_tdata(tdata: *mut pjsip_tx_data, direction: &str return false; } - let Some(dst_ip) = extract_dst_ipv4(tdata) else { + let Some(dst_ip) = (unsafe { extract_dst_ipv4(tdata) }) else { return false; }; @@ -318,7 +322,7 @@ unsafe fn rewrite_local_network_tdata(tdata: *mut pjsip_tx_data, direction: &str return false; } - let msg = (*tdata).msg; + let msg = unsafe { (*tdata).msg }; if msg.is_null() { return false; } @@ -326,7 +330,7 @@ unsafe fn rewrite_local_network_tdata(tdata: *mut pjsip_tx_data, direction: &str let mut changed = false; // Rewrite Contact header - if rewrite_contact_host((*tdata).pool, msg, local_host, *port) { + if unsafe { rewrite_contact_host((*tdata).pool, msg, local_host, *port) } { tracing::debug!( "Rewrote {} Contact header for local client {}: host -> {}:{}", direction, @@ -338,17 +342,17 @@ unsafe fn rewrite_local_network_tdata(tdata: *mut pjsip_tx_data, direction: &str } // Rewrite SDP body if we have an RTP public IP to replace - if let Some(public_ip) = rtp_public_ip { - if rewrite_sdp_body((*tdata).pool, msg, public_ip, local_host) { - tracing::debug!( - "Rewrote {} SDP for local client {}: {} -> {}", - direction, - dst_ip, - public_ip, - local_host - ); - changed = true; - } + if let Some(public_ip) = rtp_public_ip + && unsafe { rewrite_sdp_body((*tdata).pool, msg, public_ip, local_host) } + { + tracing::debug!( + "Rewrote {} SDP for local client {}: {} -> {}", + direction, + dst_ip, + public_ip, + local_host + ); + changed = true; } changed @@ -370,42 +374,43 @@ unsafe fn rewrite_private_contact_for_external(tdata: *mut pjsip_tx_data, direct return false; } - let msg = (*tdata).msg; + let msg = unsafe { (*tdata).msg }; if msg.is_null() { return false; } // Find Contact header - let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) - as *mut pjsip_contact_hdr; + let contact_hdr = + unsafe { pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) } + as *mut pjsip_contact_hdr; if contact_hdr.is_null() { return false; } - let uri = (*contact_hdr).uri; + let uri = unsafe { (*contact_hdr).uri }; if uri.is_null() { return false; } // Unwrap via vtable to handle pjsip_name_addr wrapping - let uri_vptr = (*(uri as *const pjsip_uri)).vptr; + let uri_vptr = unsafe { (*(uri as *const pjsip_uri)).vptr }; if uri_vptr.is_null() { return false; } - let get_uri_fn = match (*uri_vptr).p_get_uri { + let get_uri_fn = match unsafe { (*uri_vptr).p_get_uri } { Some(f) => f, None => return false, }; - let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void); + let sip_uri_raw = unsafe { get_uri_fn(uri as *mut std::os::raw::c_void) }; if sip_uri_raw.is_null() { return false; } let sip_uri = sip_uri_raw as *mut pjsip_sip_uri; - if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 { + if unsafe { (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 } { return false; } - let host = pj_str_to_string(&(*sip_uri).host); + let host = unsafe { pj_str_to_string(&(*sip_uri).host) }; // Only rewrite if Contact host is a private (RFC 1918) IP let contact_ip: Ipv4Addr = match host.parse() { @@ -418,14 +423,14 @@ unsafe fn rewrite_private_contact_for_external(tdata: *mut pjsip_tx_data, direct } // Skip if destination is also private (local-network rewrite handles that) - if let Some(dst_ip) = extract_dst_ipv4(tdata) { - if is_rfc1918(dst_ip) { - return false; - } + if let Some(dst_ip) = unsafe { extract_dst_ipv4(tdata) } + && is_rfc1918(dst_ip) + { + return false; } // Rewrite Contact to public host - if rewrite_contact_host((*tdata).pool, msg, public_host, *port) { + if unsafe { rewrite_contact_host((*tdata).pool, msg, public_host, *port) } { tracing::debug!( "Rewrote {} Contact for external client: {} -> {}:{}", direction, @@ -448,15 +453,17 @@ unsafe fn rewrite_private_contact_for_external(tdata: *mut pjsip_tx_data, direct /// 2. Public-host rewrite: for external clients, replace private Contact IPs /// with the public hostname so they can route BYE back to us pub unsafe extern "C" fn on_tx_response_cb(tdata: *mut pjsip_tx_data) -> pj_status_t { - let local_rewrite = rewrite_local_network_tdata(tdata, "response"); - let public_rewrite = rewrite_private_contact_for_external(tdata, "response"); + let local_rewrite = unsafe { rewrite_local_network_tdata(tdata, "response") }; + let public_rewrite = unsafe { rewrite_private_contact_for_external(tdata, "response") }; // If we modified headers, the buffer was already serialized by mod-msg-print // (priority 8, before our module at priority 32). Invalidate and re-encode // so the changes actually reach the wire. if local_rewrite || public_rewrite { - pjsip_tx_data_invalidate_msg(tdata); - pjsip_tx_data_encode(tdata); + unsafe { + pjsip_tx_data_invalidate_msg(tdata); + pjsip_tx_data_encode(tdata); + } } pj_constants__PJ_SUCCESS as pj_status_t @@ -465,15 +472,17 @@ pub unsafe extern "C" fn on_tx_response_cb(tdata: *mut pjsip_tx_data) -> pj_stat /// Callback to rewrite Contact header and SDP body in outgoing requests. /// Same dual-rewrite logic as the response path. pub unsafe extern "C" fn on_tx_request_cb(tdata: *mut pjsip_tx_data) -> pj_status_t { - let local_rewrite = rewrite_local_network_tdata(tdata, "request"); - let public_rewrite = rewrite_private_contact_for_external(tdata, "request"); + let local_rewrite = unsafe { rewrite_local_network_tdata(tdata, "request") }; + let public_rewrite = unsafe { rewrite_private_contact_for_external(tdata, "request") }; // If we modified headers, the buffer was already serialized by mod-msg-print // (priority 8, before our module at priority 32). Invalidate and re-encode // so the changes actually reach the wire. if local_rewrite || public_rewrite { - pjsip_tx_data_invalidate_msg(tdata); - pjsip_tx_data_encode(tdata); + unsafe { + pjsip_tx_data_invalidate_msg(tdata); + pjsip_tx_data_encode(tdata); + } } pj_constants__PJ_SUCCESS as pj_status_t @@ -492,38 +501,37 @@ pub unsafe extern "C" fn on_rx_request_nat_fixup_cb(rdata: *mut pjsip_rx_data) - return pj_constants__PJ_FALSE as pj_bool_t; } - let msg = (*rdata).msg_info.msg; + let msg = unsafe { (*rdata).msg_info.msg }; if msg.is_null() { return pj_constants__PJ_FALSE as pj_bool_t; } // Only process requests (safety check) - if (*msg).type_ != pjsip_msg_type_e_PJSIP_REQUEST_MSG { + if unsafe { (*msg).type_ } != pjsip_msg_type_e_PJSIP_REQUEST_MSG { return pj_constants__PJ_FALSE as pj_bool_t; } // Only process INVITE and re-INVITE (they carry SDP with media addresses) - let method_id = (*msg).line.req.method.id; + let method_id = unsafe { (*msg).line.req.method.id }; if method_id != pjsip_method_e_PJSIP_INVITE_METHOD { return pj_constants__PJ_FALSE as pj_bool_t; } // Check if there's a body (SDP) - let body = (*msg).body; - if body.is_null() || (*body).len == 0 || (*body).data.is_null() { + let body = unsafe { (*msg).body }; + if body.is_null() || unsafe { (*body).len == 0 || (*body).data.is_null() } { return pj_constants__PJ_FALSE as pj_bool_t; } // Extract source IP from packet info - let src_name = &(*rdata).pkt_info.src_name; + let src_name = unsafe { &(*rdata).pkt_info.src_name }; let name_len = src_name .iter() .position(|&c| c == 0) .unwrap_or(src_name.len()); - let src_ip_str = match std::str::from_utf8(std::slice::from_raw_parts( - src_name.as_ptr() as *const u8, - name_len, - )) { + let src_ip_str = match std::str::from_utf8(unsafe { + std::slice::from_raw_parts(src_name.as_ptr() as *const u8, name_len) + }) { Ok(s) => s, Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, }; @@ -533,87 +541,89 @@ pub unsafe extern "C" fn on_rx_request_nat_fixup_cb(rdata: *mut pjsip_rx_data) - }; // Parse SDP to find c= line IP and check if it's a private address - let body_slice = std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize); + let body_slice = + unsafe { std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize) }; let sdp_str = match std::str::from_utf8(body_slice) { Ok(s) => s, Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, }; // Find any connection address in the SDP that needs NAT fixup. - // Check ALL c= lines (session-level and per-media) — if any contain a + // Check ALL c= lines (session-level and per-media) -- if any contain a // private IP different from the packet source, rewrite the SDP. let mut needs_rewrite = false; let mut private_ip_str: Option<&str> = None; for line in sdp_str.lines() { if let Some(addr_str) = line.strip_prefix("c=IN IP4 ") { let addr_str = addr_str.trim(); - if let Ok(sdp_ip) = addr_str.parse::() { - if is_rfc1918(sdp_ip) && sdp_ip != src_ip { - needs_rewrite = true; - private_ip_str = Some(addr_str); - break; - } + if let Ok(sdp_ip) = addr_str.parse::() + && is_rfc1918(sdp_ip) + && sdp_ip != src_ip + { + needs_rewrite = true; + private_ip_str = Some(addr_str); + break; } } } - if needs_rewrite { - if let Some(private_ip) = private_ip_str { - let pool = (*rdata).tp_info.pool; - if !pool.is_null() { - if rewrite_sdp_body(pool, msg, private_ip, src_ip_str) { - tracing::debug!( - "NAT fixup (INVITE): SDP rewritten {} -> {} (from {}:{})", - private_ip, - src_ip_str, - src_ip_str, - (*rdata).pkt_info.src_port - ); - } - } + if needs_rewrite && let Some(private_ip) = private_ip_str { + let pool = unsafe { (*rdata).tp_info.pool }; + if !pool.is_null() && unsafe { rewrite_sdp_body(pool, msg, private_ip, src_ip_str) } { + tracing::debug!( + "NAT fixup (INVITE): SDP rewritten {} -> {} (from {}:{})", + private_ip, + src_ip_str, + src_ip_str, + unsafe { (*rdata).pkt_info.src_port } + ); } } // Also rewrite Contact header if present and has private IP - let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) - as *mut pjsip_contact_hdr; + let contact_hdr = + unsafe { pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) } + as *mut pjsip_contact_hdr; if !contact_hdr.is_null() { - let uri = (*contact_hdr).uri; + let uri = unsafe { (*contact_hdr).uri }; if !uri.is_null() { - let uri_vptr = (*(uri as *const pjsip_uri)).vptr; - if !uri_vptr.is_null() { - if let Some(get_uri_fn) = (*uri_vptr).p_get_uri { - let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void); - if !sip_uri_raw.is_null() { - let sip_uri = sip_uri_raw as *mut pjsip_sip_uri; - let contact_host = pj_str_to_string(&(*sip_uri).host); - if let Ok(contact_ip) = contact_host.parse::() { - if is_rfc1918(contact_ip) && contact_ip != src_ip { - let src_port = (*rdata).pkt_info.src_port as u16; - let pool = (*rdata).tp_info.pool; - if !pool.is_null() { - if let Ok(new_host_cstr) = CString::new(src_ip_str) { - let host_len = src_ip_str.len(); - let pool_str = - pj_pool_alloc(pool, host_len + 1) as *mut c_char; - if !pool_str.is_null() { - ptr::copy_nonoverlapping( - new_host_cstr.as_ptr(), - pool_str, - host_len + 1, - ); - (*sip_uri).host.ptr = pool_str; - (*sip_uri).host.slen = host_len as i64; - (*sip_uri).port = src_port as i32; - tracing::debug!( - "NAT fixup (INVITE): Contact rewritten {} -> {}:{}", - contact_host, - src_ip_str, - src_port - ); - } - } + let uri_vptr = unsafe { (*(uri as *const pjsip_uri)).vptr }; + if !uri_vptr.is_null() + && let Some(get_uri_fn) = unsafe { (*uri_vptr).p_get_uri } + { + let sip_uri_raw = unsafe { get_uri_fn(uri as *mut std::os::raw::c_void) }; + if !sip_uri_raw.is_null() { + let sip_uri = sip_uri_raw as *mut pjsip_sip_uri; + let contact_host = unsafe { pj_str_to_string(&(*sip_uri).host) }; + if let Ok(contact_ip) = contact_host.parse::() + && is_rfc1918(contact_ip) + && contact_ip != src_ip + { + let src_port = unsafe { (*rdata).pkt_info.src_port } as u16; + let pool = unsafe { (*rdata).tp_info.pool }; + if !pool.is_null() + && let Ok(new_host_cstr) = CString::new(src_ip_str) + { + let host_len = src_ip_str.len(); + let pool_str = + unsafe { pj_pool_alloc(pool, host_len + 1) } as *mut c_char; + if !pool_str.is_null() { + unsafe { + ptr::copy_nonoverlapping( + new_host_cstr.as_ptr(), + pool_str, + host_len + 1, + ); + (*sip_uri).host.ptr = pool_str; + (*sip_uri).host.slen = host_len as i64; + (*sip_uri).port = src_port as i32; } + tracing::debug!( + "NAT fixup (INVITE): Contact rewritten {} -> {}:{}", + contact_host, + src_ip_str, + src_port + ); } } } @@ -624,26 +634,28 @@ pub unsafe extern "C" fn on_rx_request_nat_fixup_cb(rdata: *mut pjsip_rx_data) - // Sanitize SDP: strip dynamic payload types (96+) that lack a=rtpmap attributes. // Without this, PJSIP's SDP validator rejects these INVITEs with EMISSINGRTPMAP. - let body = (*msg).body; - if !body.is_null() && (*body).len > 0 && !(*body).data.is_null() { + let body = unsafe { (*msg).body }; + if !body.is_null() && unsafe { (*body).len > 0 && !(*body).data.is_null() } { let body_slice = - std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize); - if let Ok(sdp_str) = std::str::from_utf8(body_slice) { - if let Some(sanitized) = sanitize_sdp_missing_rtpmap(sdp_str) { - let pool = (*rdata).tp_info.pool; - if !pool.is_null() { - let new_len = sanitized.len(); - let new_body_ptr = pj_pool_alloc(pool, new_len) as *mut u8; - if !new_body_ptr.is_null() { + unsafe { std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize) }; + if let Ok(sdp_str) = std::str::from_utf8(body_slice) + && let Some(sanitized) = sanitize_sdp_missing_rtpmap(sdp_str) + { + let pool = unsafe { (*rdata).tp_info.pool }; + if !pool.is_null() { + let new_len = sanitized.len(); + let new_body_ptr = unsafe { pj_pool_alloc(pool, new_len) } as *mut u8; + if !new_body_ptr.is_null() { + unsafe { ptr::copy_nonoverlapping(sanitized.as_ptr(), new_body_ptr, new_len); (*body).data = new_body_ptr as *mut _; (*body).len = new_len as u32; - tracing::debug!( - "SDP sanitized: stripped orphan dynamic payload types (from {}:{})", - src_ip_str, - (*rdata).pkt_info.src_port - ); } + tracing::debug!( + "SDP sanitized: stripped orphan dynamic payload types (from {}:{})", + src_ip_str, + unsafe { (*rdata).pkt_info.src_port } + ); } } } @@ -670,27 +682,26 @@ pub unsafe extern "C" fn on_rx_response_nat_fixup_cb(rdata: *mut pjsip_rx_data) return pj_constants__PJ_FALSE as pj_bool_t; } - let msg = (*rdata).msg_info.msg; + let msg = unsafe { (*rdata).msg_info.msg }; if msg.is_null() { return pj_constants__PJ_FALSE as pj_bool_t; } // Only process 1xx/2xx responses (provisional and success) - let status_code = (*msg).line.status.code; + let status_code = unsafe { (*msg).line.status.code }; if !(100..300).contains(&status_code) { return pj_constants__PJ_FALSE as pj_bool_t; } // Extract source IP from pkt_info.src_name (null-terminated char array) - let src_name = &(*rdata).pkt_info.src_name; + let src_name = unsafe { &(*rdata).pkt_info.src_name }; let name_len = src_name .iter() .position(|&c| c == 0) .unwrap_or(src_name.len()); - let src_ip_str = match std::str::from_utf8(std::slice::from_raw_parts( - src_name.as_ptr() as *const u8, - name_len, - )) { + let src_ip_str = match std::str::from_utf8(unsafe { + std::slice::from_raw_parts(src_name.as_ptr() as *const u8, name_len) + }) { Ok(s) => s, Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, }; @@ -698,11 +709,12 @@ pub unsafe extern "C" fn on_rx_response_nat_fixup_cb(rdata: *mut pjsip_rx_data) Ok(ip) => ip, Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, // IPv6 or invalid }; - let src_port = (*rdata).pkt_info.src_port as u16; + let src_port = unsafe { (*rdata).pkt_info.src_port } as u16; // Find Contact header in the response - let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) - as *mut pjsip_contact_hdr; + let contact_hdr = + unsafe { pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) } + as *mut pjsip_contact_hdr; if contact_hdr.is_null() { return pj_constants__PJ_FALSE as pj_bool_t; } @@ -711,26 +723,26 @@ pub unsafe extern "C" fn on_rx_response_nat_fixup_cb(rdata: *mut pjsip_rx_data) // The rx path requires vtable-based URI unwrapping (p_get_uri) because // the Contact URI may be wrapped in a pjsip_name_addr, unlike the tx // path where we can cast directly. - let uri = (*contact_hdr).uri; + let uri = unsafe { (*contact_hdr).uri }; if uri.is_null() { return pj_constants__PJ_FALSE as pj_bool_t; } - let uri_vptr = (*(uri as *const pjsip_uri)).vptr; + let uri_vptr = unsafe { (*(uri as *const pjsip_uri)).vptr }; if uri_vptr.is_null() { return pj_constants__PJ_FALSE as pj_bool_t; } - let get_uri_fn = match (*uri_vptr).p_get_uri { + let get_uri_fn = match unsafe { (*uri_vptr).p_get_uri } { Some(f) => f, None => return pj_constants__PJ_FALSE as pj_bool_t, }; - let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void); + let sip_uri_raw = unsafe { get_uri_fn(uri as *mut std::os::raw::c_void) }; if sip_uri_raw.is_null() { return pj_constants__PJ_FALSE as pj_bool_t; } let sip_uri = sip_uri_raw as *mut pjsip_sip_uri; // Parse Contact host as IPv4 - let contact_host = pj_str_to_string(&(*sip_uri).host); + let contact_host = unsafe { pj_str_to_string(&(*sip_uri).host) }; let contact_ip: Ipv4Addr = match contact_host.parse() { Ok(ip) => ip, Err(_) => return pj_constants__PJ_FALSE as pj_bool_t, // Hostname, skip @@ -753,12 +765,14 @@ pub unsafe extern "C" fn on_rx_response_nat_fixup_cb(rdata: *mut pjsip_rx_data) ); // Rewrite Contact URI host to the public source IP - let pool = (*rdata).tp_info.pool; - if !pool.is_null() { - if let Ok(new_host_cstr) = CString::new(src_ip_str) { - let host_len = src_ip_str.len(); - let pool_str = pj_pool_alloc(pool, host_len + 1) as *mut c_char; - if !pool_str.is_null() { + let pool = unsafe { (*rdata).tp_info.pool }; + if !pool.is_null() + && let Ok(new_host_cstr) = CString::new(src_ip_str) + { + let host_len = src_ip_str.len(); + let pool_str = unsafe { pj_pool_alloc(pool, host_len + 1) } as *mut c_char; + if !pool_str.is_null() { + unsafe { ptr::copy_nonoverlapping(new_host_cstr.as_ptr(), pool_str, host_len + 1); (*sip_uri).host.ptr = pool_str; (*sip_uri).host.slen = host_len as i64; @@ -768,27 +782,28 @@ pub unsafe extern "C" fn on_rx_response_nat_fixup_cb(rdata: *mut pjsip_rx_data) } // Rewrite SDP body: replace private IP with public source IP. - // Parse the SDP c= line directly to get the actual media IP — it may differ + // Parse the SDP c= line directly to get the actual media IP -- it may differ // from the Contact header IP (e.g., dual-homed phone or double NAT). - let body = (*msg).body; - if !body.is_null() && (*body).len > 0 && !(*body).data.is_null() { + let body = unsafe { (*msg).body }; + if !body.is_null() && unsafe { (*body).len > 0 && !(*body).data.is_null() } { let body_slice = - std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize); + unsafe { std::slice::from_raw_parts((*body).data as *const u8, (*body).len as usize) }; if let Ok(sdp_str) = std::str::from_utf8(body_slice) { for line in sdp_str.lines() { if let Some(addr_str) = line.strip_prefix("c=IN IP4 ") { let addr_str = addr_str.trim(); - if let Ok(sdp_ip) = addr_str.parse::() { - if is_rfc1918(sdp_ip) && sdp_ip != src_ip { - if rewrite_sdp_body(pool, msg, addr_str, src_ip_str) { - tracing::debug!( - "NAT fixup: SDP rewritten {} -> {}", - addr_str, - src_ip_str - ); - } - break; + if let Ok(sdp_ip) = addr_str.parse::() + && is_rfc1918(sdp_ip) + && sdp_ip != src_ip + { + if unsafe { rewrite_sdp_body(pool, msg, addr_str, src_ip_str) } { + tracing::debug!( + "NAT fixup: SDP rewritten {} -> {}", + addr_str, + src_ip_str + ); } + break; } } } diff --git a/sipcord-bridge/src/transport/sip/register_handler.rs b/sipcord-bridge/src/transport/sip/register_handler.rs index 284f874..30a5205 100644 --- a/sipcord-bridge/src/transport/sip/register_handler.rs +++ b/sipcord-bridge/src/transport/sip/register_handler.rs @@ -72,92 +72,101 @@ pub fn set_register_module_ptr(ptr: *mut pjsip_module) { /// Initialize a pjsip_hdr as a list head (equivalent to pj_list_init C macro). #[inline] unsafe fn pj_list_init_hdr(hdr: *mut pjsip_hdr) { - (*hdr).next = hdr as *mut _; - (*hdr).prev = hdr as *mut _; + unsafe { + (*hdr).next = hdr as *mut _; + (*hdr).prev = hdr as *mut _; + } } /// Send a simple stateless SIP response (no custom headers). unsafe fn send_simple_response(rdata: *mut pjsip_rx_data, status_code: u16, reason: &str) { - let endpt = pjsua_get_pjsip_endpt(); - if !endpt.is_null() { - let reason_cstr = CString::new(reason).unwrap(); - let reason_pj = pj_str(reason_cstr.as_ptr() as *mut c_char); - pjsip_endpt_respond_stateless( - endpt, - rdata, - status_code.into(), - &reason_pj, - ptr::null(), - ptr::null(), - ); + unsafe { + let endpt = pjsua_get_pjsip_endpt(); + if !endpt.is_null() { + let reason_cstr = CString::new(reason).unwrap(); + let reason_pj = pj_str(reason_cstr.as_ptr() as *mut c_char); + pjsip_endpt_respond_stateless( + endpt, + rdata, + status_code.into(), + &reason_pj, + ptr::null(), + ptr::null(), + ); + } } } /// Send a stateless 200 OK with an Expires header. unsafe fn send_register_ok(rdata: *mut pjsip_rx_data, expires: u32) { - let endpt = pjsua_get_pjsip_endpt(); - if endpt.is_null() { - return; - } - - let expires_str = format!("{}", expires); - let hdr_name = CString::new("Expires").unwrap(); - let hdr_value = CString::new(expires_str).unwrap(); - - let pool = pjsua_pool_create(c"register_ok".as_ptr(), 512, 512); - if !pool.is_null() { - let name = pj_str(hdr_name.as_ptr() as *mut c_char); - let value = pj_str(hdr_value.as_ptr() as *mut c_char); - let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); - - if !hdr.is_null() { - let hdr_list = pj_pool_alloc(pool, std::mem::size_of::()) as *mut pjsip_hdr; - if !hdr_list.is_null() { - pj_list_init_hdr(hdr_list); - pj_list_insert_before(hdr_list as *mut pj_list_type, hdr as *mut pj_list_type); - - let status = pjsip_endpt_respond_stateless( - endpt, - rdata, - 200, - ptr::null(), - hdr_list, - ptr::null(), - ); - if status != pj_constants__PJ_SUCCESS as i32 { - tracing::warn!("Failed to respond 200 OK to REGISTER: {}", status); - } - // Release pool — pjsip_endpt_respond_stateless clones what it - // needs into rdata's pool, so our header pool can be freed now. - pj_pool_release(pool); - return; - } + unsafe { + let endpt = pjsua_get_pjsip_endpt(); + if endpt.is_null() { + return; } - // Header creation failed — release the pool before falling through - pj_pool_release(pool); - } - // Fallback: respond without Expires header - let status = - pjsip_endpt_respond_stateless(endpt, rdata, 200, ptr::null(), ptr::null(), ptr::null()); - if status != pj_constants__PJ_SUCCESS as i32 { - tracing::warn!("Failed to respond 200 OK to REGISTER: {}", status); + let expires_str = format!("{}", expires); + let hdr_name = CString::new("Expires").unwrap(); + let hdr_value = CString::new(expires_str).unwrap(); + + let pool = pjsua_pool_create(c"register_ok".as_ptr(), 512, 512); + if !pool.is_null() { + let name = pj_str(hdr_name.as_ptr() as *mut c_char); + let value = pj_str(hdr_value.as_ptr() as *mut c_char); + let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); + + if !hdr.is_null() { + let hdr_list = + pj_pool_alloc(pool, std::mem::size_of::()) as *mut pjsip_hdr; + if !hdr_list.is_null() { + pj_list_init_hdr(hdr_list); + pj_list_insert_before(hdr_list as *mut pj_list_type, hdr as *mut pj_list_type); + + let status = pjsip_endpt_respond_stateless( + endpt, + rdata, + 200, + ptr::null(), + hdr_list, + ptr::null(), + ); + if status != pj_constants__PJ_SUCCESS as i32 { + tracing::warn!("Failed to respond 200 OK to REGISTER: {}", status); + } + // Release pool — pjsip_endpt_respond_stateless clones what it + // needs into rdata's pool, so our header pool can be freed now. + pj_pool_release(pool); + return; + } + } + // Header creation failed — release the pool before falling through + pj_pool_release(pool); + } + + // Fallback: respond without Expires header + let status = + pjsip_endpt_respond_stateless(endpt, rdata, 200, ptr::null(), ptr::null(), ptr::null()); + if status != pj_constants__PJ_SUCCESS as i32 { + tracing::warn!("Failed to respond 200 OK to REGISTER: {}", status); + } } } /// Detect transport type (UDP/TCP/TLS) from the incoming request. unsafe fn detect_transport(rdata: *mut pjsip_rx_data) -> crate::services::registrar::SipTransport { - if !(*rdata).tp_info.transport.is_null() { - let tp_type = (*(*rdata).tp_info.transport).key.type_ as u32; - if tp_type == pjsip_transport_type_e_PJSIP_TRANSPORT_TLS { - crate::services::registrar::SipTransport::Tls - } else if tp_type == pjsip_transport_type_e_PJSIP_TRANSPORT_TCP { - crate::services::registrar::SipTransport::Tcp + unsafe { + if !(*rdata).tp_info.transport.is_null() { + let tp_type = (*(*rdata).tp_info.transport).key.type_ as u32; + if tp_type == pjsip_transport_type_e_PJSIP_TRANSPORT_TLS { + crate::services::registrar::SipTransport::Tls + } else if tp_type == pjsip_transport_type_e_PJSIP_TRANSPORT_TCP { + crate::services::registrar::SipTransport::Tcp + } else { + crate::services::registrar::SipTransport::Udp + } } else { crate::services::registrar::SipTransport::Udp } - } else { - crate::services::registrar::SipTransport::Udp } } @@ -168,37 +177,39 @@ unsafe fn create_register_tsx( rdata: *mut pjsip_rx_data, expires: u32, ) -> Option { - let endpt = pjsua_get_pjsip_endpt(); - let module_ptr = REGISTER_MODULE_PTR.load(Ordering::Acquire); + unsafe { + let endpt = pjsua_get_pjsip_endpt(); + let module_ptr = REGISTER_MODULE_PTR.load(Ordering::Acquire); - if endpt.is_null() || module_ptr.is_null() { - return None; + if endpt.is_null() || module_ptr.is_null() { + return None; + } + + // Create UAS transaction + let mut tsx: *mut pjsip_transaction = ptr::null_mut(); + let status = pjsip_tsx_create_uas2(module_ptr, rdata, ptr::null_mut(), &mut tsx); + if status != pj_constants__PJ_SUCCESS as i32 || tsx.is_null() { + return None; + } + + // Feed the request to the transaction (starts Timer F, stores headers) + pjsip_tsx_recv_msg(tsx, rdata); + + // Pre-build a 200 OK response while rdata is still valid. + // The status code / reason will be modified before sending if auth fails. + let mut tdata: *mut pjsip_tx_data = ptr::null_mut(); + let status = pjsip_endpt_create_response(endpt, rdata, 200, ptr::null(), &mut tdata); + if status != pj_constants__PJ_SUCCESS as i32 || tdata.is_null() { + pjsip_tsx_terminate(tsx, 500); + return None; + } + + Some(PendingRegisterTsx { + tsx: SendableTsx(tsx), + tdata: SendableTdata(tdata), + expires, + }) } - - // Create UAS transaction - let mut tsx: *mut pjsip_transaction = ptr::null_mut(); - let status = pjsip_tsx_create_uas2(module_ptr, rdata, ptr::null_mut(), &mut tsx); - if status != pj_constants__PJ_SUCCESS as i32 || tsx.is_null() { - return None; - } - - // Feed the request to the transaction (starts Timer F, stores headers) - pjsip_tsx_recv_msg(tsx, rdata); - - // Pre-build a 200 OK response while rdata is still valid. - // The status code / reason will be modified before sending if auth fails. - let mut tdata: *mut pjsip_tx_data = ptr::null_mut(); - let status = pjsip_endpt_create_response(endpt, rdata, 200, ptr::null(), &mut tdata); - if status != pj_constants__PJ_SUCCESS as i32 || tdata.is_null() { - pjsip_tsx_terminate(tsx, 500); - return None; - } - - Some(PendingRegisterTsx { - tsx: SendableTsx(tsx), - tdata: SendableTdata(tdata), - expires, - }) } // Main callback @@ -216,61 +227,63 @@ unsafe fn create_register_tsx( /// b. Cache hit + mismatch -> immediate 403 Forbidden (stateless) /// c. Cache miss -> defer via UAS transaction, verify via API, respond later pub unsafe extern "C" fn on_rx_request_cb(rdata: *mut pjsip_rx_data) -> pj_bool_t { - if rdata.is_null() { - return pj_constants__PJ_FALSE as pj_bool_t; - } + unsafe { + if rdata.is_null() { + return pj_constants__PJ_FALSE as pj_bool_t; + } - let msg = (*rdata).msg_info.msg; - if msg.is_null() { - return pj_constants__PJ_FALSE as pj_bool_t; - } + let msg = (*rdata).msg_info.msg; + if msg.is_null() { + return pj_constants__PJ_FALSE as pj_bool_t; + } - // Check if this is a REGISTER request - let method_id = (*msg).line.req.method.id; - if method_id != pjsip_method_e_PJSIP_REGISTER_METHOD { - // Not REGISTER, let other modules handle it - return pj_constants__PJ_FALSE as pj_bool_t; - } + // Check if this is a REGISTER request + let method_id = (*msg).line.req.method.id; + if method_id != pjsip_method_e_PJSIP_REGISTER_METHOD { + // Not REGISTER, let other modules handle it + return pj_constants__PJ_FALSE as pj_bool_t; + } - // Extract source IP for logging and ban checking - let source_ip = extract_source_ip(rdata); - let ip_str = source_ip - .map(|ip| ip.to_string()) - .unwrap_or_else(|| "unknown".to_string()); + // Extract source IP for logging and ban checking + let source_ip = extract_source_ip(rdata); + let ip_str = source_ip + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "unknown".to_string()); - // Extract source port - let source_port = (*rdata).pkt_info.src_port as u16; + // Extract source port + let source_port = (*rdata).pkt_info.src_port as u16; - // Ban checks: skip if banning disabled or IP is whitelisted - if let Some(ip) = source_ip { - if let Some(ban_mgr) = crate::services::ban::global() { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { - // Check if IP is banned - let result = ban_mgr.check_banned(&ip); - if result.is_banned { - tracing::debug!("Rejecting REGISTER from banned IP {}", ip); - send_simple_response(rdata, 403, "Forbidden"); - return pj_constants__PJ_TRUE as pj_bool_t; - } + // Ban checks: skip if banning disabled or IP is whitelisted + if let Some(ip) = source_ip + && let Some(ban_mgr) = crate::services::ban::global() + && ban_mgr.is_enabled() + && !ban_mgr.is_whitelisted(&ip) + { + // Check if IP is banned + let result = ban_mgr.check_banned(&ip); + if result.is_banned { + tracing::debug!("Rejecting REGISTER from banned IP {}", ip); + send_simple_response(rdata, 403, "Forbidden"); + return pj_constants__PJ_TRUE as pj_bool_t; } } - } - // Check User-Agent for SIPVicious scanners - instant permaban - if let Some(user_agent) = extract_user_agent(rdata) { - if is_sipvicious_scanner(&user_agent) { + // Check User-Agent for SIPVicious scanners - instant permaban + if let Some(user_agent) = extract_user_agent(rdata) + && is_sipvicious_scanner(&user_agent) + { if let Some(ip) = source_ip { - if let Some(ban_mgr) = crate::services::ban::global() { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) { - let result = - ban_mgr.record_permanent_ban(ip, "sipvicious_scanner_register"); - if result.should_log { - tracing::warn!( - "PERMABAN IP {} - SIPVicious scanner detected in REGISTER: User-Agent='{}'", - ip, - user_agent - ); - } + if let Some(ban_mgr) = crate::services::ban::global() + && ban_mgr.is_enabled() + && !ban_mgr.is_whitelisted(&ip) + { + let result = ban_mgr.record_permanent_ban(ip, "sipvicious_scanner_register"); + if result.should_log { + tracing::warn!( + "PERMABAN IP {} - SIPVicious scanner detected in REGISTER: User-Agent='{}'", + ip, + user_agent + ); } } } else { @@ -282,29 +295,30 @@ pub unsafe extern "C" fn on_rx_request_cb(rdata: *mut pjsip_rx_data) -> pj_bool_ send_simple_response(rdata, 403, "Forbidden"); return pj_constants__PJ_TRUE as pj_bool_t; } - } - // Rate limit REGISTER requests - if let Some(ip) = source_ip { - if let Some(ban_mgr) = crate::services::ban::global() { - if ban_mgr.is_enabled() && !ban_mgr.is_whitelisted(&ip) && ban_mgr.record_register(ip) { - tracing::debug!("Rejecting REGISTER from {} - rate limit exceeded", ip); - send_simple_response(rdata, 429, "Too Many Requests"); - return pj_constants__PJ_TRUE as pj_bool_t; - } + // Rate limit REGISTER requests + if let Some(ip) = source_ip + && let Some(ban_mgr) = crate::services::ban::global() + && ban_mgr.is_enabled() + && !ban_mgr.is_whitelisted(&ip) + && ban_mgr.record_register(ip) + { + tracing::debug!("Rejecting REGISTER from {} - rate limit exceeded", ip); + send_simple_response(rdata, 429, "Too Many Requests"); + return pj_constants__PJ_TRUE as pj_bool_t; } - } - // Try to extract Digest auth params from Authorization header - let digest_params = extract_digest_auth_from_rdata(rdata); + // Try to extract Digest auth params from Authorization header + let digest_params = extract_digest_auth_from_rdata(rdata); - if let Some(mut params) = digest_params { - // Has auth - fill in REGISTER method - params.method = "REGISTER".to_string(); + if let Some(mut params) = digest_params { + // Has auth - fill in REGISTER method + params.method = "REGISTER".to_string(); - // Check auth failure cooldown before processing - if let Some(cache) = crate::services::auth_cache::AuthCache::global() { - if cache.is_in_cooldown(¶ms.username) { + // Check auth failure cooldown before processing + if let Some(cache) = crate::services::auth_cache::AuthCache::global() + && cache.is_in_cooldown(¶ms.username) + { tracing::debug!( "Rejecting REGISTER from {} (user={}) - auth cooldown active", ip_str, @@ -313,71 +327,26 @@ pub unsafe extern "C" fn on_rx_request_cb(rdata: *mut pjsip_rx_data) -> pj_bool_ send_simple_response(rdata, 429, "Too Many Requests"); return pj_constants__PJ_TRUE as pj_bool_t; } - } - // Extract fields needed for all code paths - let contact_uri = extract_contact_uri(rdata); - let expires = extract_expires(rdata); - let source_addr = source_ip.map(|ip| SocketAddr::new(ip, source_port)); - let transport = detect_transport(rdata); + // Extract fields needed for all code paths + let contact_uri = extract_contact_uri(rdata); + let expires = extract_expires(rdata); + let source_addr = source_ip.map(|ip| SocketAddr::new(ip, source_port)); + let transport = detect_transport(rdata); - // Auth cache verification - if let Some(cache) = crate::services::auth_cache::AuthCache::global() { - use crate::services::auth_cache::VerifyResult; - match cache.check(¶ms) { - VerifyResult::Verified => { - // Cache hit, auth OK — fast-path 200 OK - tracing::debug!( - "REGISTER auth OK (cached): user={} from {}", - params.username, - ip_str - ); - send_register_ok(rdata, expires); - // Send to async handler for registrar update - if let Some(tx) = REGISTER_EVENT_TX.get() { - let _ = tx.try_send(RegisterRequest { - digest_auth: params, - contact_uri: contact_uri.unwrap_or_default(), - source_addr, - transport, - expires, - pending_tsx: None, - }); - } - return pj_constants__PJ_TRUE as pj_bool_t; - } - VerifyResult::Mismatch => { - // Wrong password (cached HA1 didn't match) — 403 - tracing::debug!( - "REGISTER auth mismatch (cached): user={} from {}", - params.username, - ip_str - ); - send_simple_response(rdata, 403, "Forbidden"); - // Send to async so API can re-verify (cache may be stale - // after a password change) and update failure counts - if let Some(tx) = REGISTER_EVENT_TX.get() { - let _ = tx.try_send(RegisterRequest { - digest_auth: params, - contact_uri: contact_uri.unwrap_or_default(), - source_addr, - transport, - expires, - pending_tsx: None, - }); - } - return pj_constants__PJ_TRUE as pj_bool_t; - } - VerifyResult::Miss => { - // No cached HA1 — need API round-trip. - // Create a UAS transaction so we can respond after the - // async handler completes, without blocking pjsip. - tracing::debug!( - "REGISTER cache miss: user={} from {}, deferring to API", - params.username, - ip_str - ); - if let Some(pending) = create_register_tsx(rdata, expires) { + // Auth cache verification + if let Some(cache) = crate::services::auth_cache::AuthCache::global() { + use crate::services::auth_cache::VerifyResult; + match cache.check(¶ms) { + VerifyResult::Verified => { + // Cache hit, auth OK — fast-path 200 OK + tracing::debug!( + "REGISTER auth OK (cached): user={} from {}", + params.username, + ip_str + ); + send_register_ok(rdata, expires); + // Send to async handler for registrar update if let Some(tx) = REGISTER_EVENT_TX.get() { let _ = tx.try_send(RegisterRequest { digest_auth: params, @@ -385,105 +354,151 @@ pub unsafe extern "C" fn on_rx_request_cb(rdata: *mut pjsip_rx_data) -> pj_bool_ source_addr, transport, expires, - pending_tsx: Some(pending), + pending_tsx: None, }); } return pj_constants__PJ_TRUE as pj_bool_t; } - // Transaction creation failed — fall through to stateless - // 200 OK below (same behaviour as before this change). - tracing::warn!( - "Failed to create tsx for deferred REGISTER, falling back to stateless 200" + VerifyResult::Mismatch => { + // Wrong password (cached HA1 didn't match) — 403 + tracing::debug!( + "REGISTER auth mismatch (cached): user={} from {}", + params.username, + ip_str + ); + send_simple_response(rdata, 403, "Forbidden"); + // Send to async so API can re-verify (cache may be stale + // after a password change) and update failure counts + if let Some(tx) = REGISTER_EVENT_TX.get() { + let _ = tx.try_send(RegisterRequest { + digest_auth: params, + contact_uri: contact_uri.unwrap_or_default(), + source_addr, + transport, + expires, + pending_tsx: None, + }); + } + return pj_constants__PJ_TRUE as pj_bool_t; + } + VerifyResult::Miss => { + // No cached HA1 — need API round-trip. + // Create a UAS transaction so we can respond after the + // async handler completes, without blocking pjsip. + tracing::debug!( + "REGISTER cache miss: user={} from {}, deferring to API", + params.username, + ip_str + ); + if let Some(pending) = create_register_tsx(rdata, expires) { + if let Some(tx) = REGISTER_EVENT_TX.get() { + let _ = tx.try_send(RegisterRequest { + digest_auth: params, + contact_uri: contact_uri.unwrap_or_default(), + source_addr, + transport, + expires, + pending_tsx: Some(pending), + }); + } + return pj_constants__PJ_TRUE as pj_bool_t; + } + // Transaction creation failed — fall through to stateless + // 200 OK below (same behaviour as before this change). + tracing::warn!( + "Failed to create tsx for deferred REGISTER, falling back to stateless 200" + ); + } + } + } + + // Default path: stateless 200 OK + async verification + // (non-sipcord builds, auth cache unavailable, or tsx creation failed) + tracing::debug!( + "REGISTER with auth from {} (user={}), responding 200 OK (stateless)", + ip_str, + params.username + ); + if let Some(tx) = REGISTER_EVENT_TX.get() { + let _ = tx.try_send(RegisterRequest { + digest_auth: params, + contact_uri: contact_uri.unwrap_or_default(), + source_addr, + transport, + expires, + pending_tsx: None, + }); + } + send_register_ok(rdata, expires); + } else { + // No Authorization header - send 401 challenge + tracing::debug!( + "REGISTER without auth from {}, sending 401 challenge", + ip_str + ); + + let endpt = pjsua_get_pjsip_endpt(); + if endpt.is_null() { + tracing::error!("Failed to get PJSIP endpoint for REGISTER 401 response"); + return pj_constants__PJ_TRUE as pj_bool_t; + } + + // Generate a cryptographically random nonce + let nonce = { + let bytes: [u8; 16] = rand::random(); + bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + }; + + let www_auth = format!( + "Digest realm=\"{}\", nonce=\"{}\", algorithm=MD5, qop=\"auth\"", + SIP_REALM, nonce + ); + + // Create WWW-Authenticate header + let hdr_name = CString::new("WWW-Authenticate").unwrap(); + let hdr_value = CString::new(www_auth).unwrap(); + + let pool = pjsua_pool_create(c"register_401".as_ptr(), 512, 512); + if pool.is_null() { + tracing::error!("Failed to create pool for REGISTER 401 response"); + return pj_constants__PJ_TRUE as pj_bool_t; + } + + let name = pj_str(hdr_name.as_ptr() as *mut c_char); + let value = pj_str(hdr_value.as_ptr() as *mut c_char); + let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); + + if !hdr.is_null() { + let hdr_list = + pj_pool_alloc(pool, std::mem::size_of::()) as *mut pjsip_hdr; + if !hdr_list.is_null() { + pj_list_init_hdr(hdr_list); + pj_list_insert_before(hdr_list as *mut pj_list_type, hdr as *mut pj_list_type); + + let status = pjsip_endpt_respond_stateless( + endpt, + rdata, + 401, + ptr::null(), + hdr_list, + ptr::null(), ); + + if status != pj_constants__PJ_SUCCESS as i32 { + tracing::warn!("Failed to respond 401 to REGISTER: {}", status); + } } } + // Release pool — pjsip_endpt_respond_stateless clones headers internally + pj_pool_release(pool); } - // Default path: stateless 200 OK + async verification - // (non-sipcord builds, auth cache unavailable, or tsx creation failed) - tracing::debug!( - "REGISTER with auth from {} (user={}), responding 200 OK (stateless)", - ip_str, - params.username - ); - if let Some(tx) = REGISTER_EVENT_TX.get() { - let _ = tx.try_send(RegisterRequest { - digest_auth: params, - contact_uri: contact_uri.unwrap_or_default(), - source_addr, - transport, - expires, - pending_tsx: None, - }); - } - send_register_ok(rdata, expires); - } else { - // No Authorization header - send 401 challenge - tracing::debug!( - "REGISTER without auth from {}, sending 401 challenge", - ip_str - ); - - let endpt = pjsua_get_pjsip_endpt(); - if endpt.is_null() { - tracing::error!("Failed to get PJSIP endpoint for REGISTER 401 response"); - return pj_constants__PJ_TRUE as pj_bool_t; - } - - // Generate a cryptographically random nonce - let nonce = { - let bytes: [u8; 16] = rand::random(); - bytes - .iter() - .map(|b| format!("{:02x}", b)) - .collect::() - }; - - let www_auth = format!( - "Digest realm=\"{}\", nonce=\"{}\", algorithm=MD5, qop=\"auth\"", - SIP_REALM, nonce - ); - - // Create WWW-Authenticate header - let hdr_name = CString::new("WWW-Authenticate").unwrap(); - let hdr_value = CString::new(www_auth).unwrap(); - - let pool = pjsua_pool_create(c"register_401".as_ptr(), 512, 512); - if pool.is_null() { - tracing::error!("Failed to create pool for REGISTER 401 response"); - return pj_constants__PJ_TRUE as pj_bool_t; - } - - let name = pj_str(hdr_name.as_ptr() as *mut c_char); - let value = pj_str(hdr_value.as_ptr() as *mut c_char); - let hdr = pjsip_generic_string_hdr_create(pool, &name, &value); - - if !hdr.is_null() { - let hdr_list = pj_pool_alloc(pool, std::mem::size_of::()) as *mut pjsip_hdr; - if !hdr_list.is_null() { - pj_list_init_hdr(hdr_list); - pj_list_insert_before(hdr_list as *mut pj_list_type, hdr as *mut pj_list_type); - - let status = pjsip_endpt_respond_stateless( - endpt, - rdata, - 401, - ptr::null(), - hdr_list, - ptr::null(), - ); - - if status != pj_constants__PJ_SUCCESS as i32 { - tracing::warn!("Failed to respond 401 to REGISTER: {}", status); - } - } - } - // Release pool — pjsip_endpt_respond_stateless clones headers internally - pj_pool_release(pool); + // Return TRUE to indicate we handled this request + pj_constants__PJ_TRUE as pj_bool_t } - - // Return TRUE to indicate we handled this request - pj_constants__PJ_TRUE as pj_bool_t } // Extraction helpers @@ -494,56 +509,58 @@ unsafe fn extract_contact_uri(rdata: *mut pjsip_rx_data) -> Option { return None; } - let msg = (*rdata).msg_info.msg; - if msg.is_null() { - return None; + unsafe { + let msg = (*rdata).msg_info.msg; + if msg.is_null() { + return None; + } + + let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) + as *const pjsip_contact_hdr; + + if contact_hdr.is_null() { + return None; + } + + let uri = (*contact_hdr).uri; + if uri.is_null() { + return None; + } + + // The Contact header URI is typically a pjsip_name_addr wrapping a pjsip_sip_uri. + // We must unwrap it via the vtable's p_get_uri (equivalent to pjsip_uri_get_uri() + // which is an inline C function not available through FFI). + let uri_vptr = (*(uri as *const pjsip_uri)).vptr; + if uri_vptr.is_null() { + return None; + } + let get_uri_fn = (*uri_vptr).p_get_uri?; + let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void); + if sip_uri_raw.is_null() { + return None; + } + let sip_uri = sip_uri_raw as *const pjsip_sip_uri; + if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 { + return None; + } + + let host = pj_str_to_string(&(*sip_uri).host); + let port = (*sip_uri).port; + let user = if !(*sip_uri).user.ptr.is_null() && (*sip_uri).user.slen > 0 { + Some(pj_str_to_string(&(*sip_uri).user)) + } else { + None + }; + + let uri_str = match (user, port) { + (Some(u), p) if p > 0 => format!("sip:{}@{}:{}", u, host, p), + (Some(u), _) => format!("sip:{}@{}", u, host), + (None, p) if p > 0 => format!("sip:{}:{}", host, p), + (None, _) => format!("sip:{}", host), + }; + + Some(uri_str) } - - let contact_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_CONTACT, ptr::null_mut()) - as *const pjsip_contact_hdr; - - if contact_hdr.is_null() { - return None; - } - - let uri = (*contact_hdr).uri; - if uri.is_null() { - return None; - } - - // The Contact header URI is typically a pjsip_name_addr wrapping a pjsip_sip_uri. - // We must unwrap it via the vtable's p_get_uri (equivalent to pjsip_uri_get_uri() - // which is an inline C function not available through FFI). - let uri_vptr = (*(uri as *const pjsip_uri)).vptr; - if uri_vptr.is_null() { - return None; - } - let get_uri_fn = (*uri_vptr).p_get_uri?; - let sip_uri_raw = get_uri_fn(uri as *mut std::os::raw::c_void); - if sip_uri_raw.is_null() { - return None; - } - let sip_uri = sip_uri_raw as *const pjsip_sip_uri; - if (*sip_uri).host.ptr.is_null() || (*sip_uri).host.slen <= 0 { - return None; - } - - let host = pj_str_to_string(&(*sip_uri).host); - let port = (*sip_uri).port; - let user = if !(*sip_uri).user.ptr.is_null() && (*sip_uri).user.slen > 0 { - Some(pj_str_to_string(&(*sip_uri).user)) - } else { - None - }; - - let uri_str = match (user, port) { - (Some(u), p) if p > 0 => format!("sip:{}@{}:{}", u, host, p), - (Some(u), _) => format!("sip:{}@{}", u, host), - (None, p) if p > 0 => format!("sip:{}:{}", host, p), - (None, _) => format!("sip:{}", host), - }; - - Some(uri_str) } /// Extract Expires value from REGISTER request (header or Contact param) @@ -552,21 +569,23 @@ unsafe fn extract_expires(rdata: *mut pjsip_rx_data) -> u32 { return 3600; } - let msg = (*rdata).msg_info.msg; - if msg.is_null() { - return 3600; + unsafe { + let msg = (*rdata).msg_info.msg; + if msg.is_null() { + return 3600; + } + + // Try Expires header first + let expires_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_EXPIRES, ptr::null_mut()) + as *const pjsip_expires_hdr; + + if !expires_hdr.is_null() { + return (*expires_hdr).ivalue as u32; + } + + // Default + 3600 } - - // Try Expires header first - let expires_hdr = pjsip_msg_find_hdr(msg, pjsip_hdr_e_PJSIP_H_EXPIRES, ptr::null_mut()) - as *const pjsip_expires_hdr; - - if !expires_hdr.is_null() { - return (*expires_hdr).ivalue as u32; - } - - // Default - 3600 } // Types