feat(proxy-engine,webrtc): add B2BUA SIP leg handling and WebRTC call bridging for outbound calls
This commit is contained in:
@@ -15,6 +15,7 @@ use crate::dtmf::DtmfDetector;
|
||||
use crate::ipc::{emit_event, OutTx};
|
||||
use crate::registrar::Registrar;
|
||||
use crate::rtp::RtpPortPool;
|
||||
use crate::sip_leg::{LegState, SipLeg, SipLegAction, SipLegConfig};
|
||||
use sip_proto::helpers::parse_sdp_endpoint;
|
||||
use sip_proto::message::SipMessage;
|
||||
use sip_proto::rewrite::{rewrite_sdp, rewrite_sip_uri};
|
||||
@@ -24,9 +25,23 @@ use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
/// A B2BUA call with a SipLeg for the provider side.
|
||||
/// The other side is either a WebRTC session or another SipLeg.
|
||||
pub struct B2buaCall {
|
||||
pub id: String,
|
||||
pub provider_leg: SipLeg,
|
||||
pub webrtc_session_id: Option<String>,
|
||||
pub number: String,
|
||||
pub created_at: std::time::Instant,
|
||||
/// RTP socket allocated for the provider leg (used for WebRTC audio bridging).
|
||||
pub rtp_socket: Option<Arc<UdpSocket>>,
|
||||
}
|
||||
|
||||
pub struct CallManager {
|
||||
/// Active passthrough calls, keyed by SIP Call-ID.
|
||||
calls: HashMap<String, PassthroughCall>,
|
||||
/// Active B2BUA calls, keyed by SIP Call-ID of the provider leg.
|
||||
b2bua_calls: HashMap<String, B2buaCall>,
|
||||
/// Call ID counter.
|
||||
next_call_num: u64,
|
||||
/// Output channel for events.
|
||||
@@ -37,6 +52,7 @@ impl CallManager {
|
||||
pub fn new(out_tx: OutTx) -> Self {
|
||||
Self {
|
||||
calls: HashMap::new(),
|
||||
b2bua_calls: HashMap::new(),
|
||||
next_call_num: 0,
|
||||
out_tx,
|
||||
}
|
||||
@@ -68,7 +84,12 @@ impl CallManager {
|
||||
) -> bool {
|
||||
let sip_call_id = msg.call_id().to_string();
|
||||
|
||||
// Check if this Call-ID belongs to an active call.
|
||||
// Check B2BUA calls first (provider legs with dialog management).
|
||||
if self.b2bua_calls.contains_key(&sip_call_id) {
|
||||
return self.route_b2bua_message(&sip_call_id, msg, from_addr, socket).await;
|
||||
}
|
||||
|
||||
// Check passthrough calls.
|
||||
if !self.calls.contains_key(&sip_call_id) {
|
||||
return false;
|
||||
}
|
||||
@@ -494,14 +515,89 @@ impl CallManager {
|
||||
|
||||
/// Check if a SIP Call-ID belongs to any active call.
|
||||
pub fn has_call(&self, sip_call_id: &str) -> bool {
|
||||
self.calls.contains_key(sip_call_id)
|
||||
self.calls.contains_key(sip_call_id) || self.b2bua_calls.contains_key(sip_call_id)
|
||||
}
|
||||
|
||||
// --- Dashboard outbound call (B2BUA) ---
|
||||
/// Get the RTP socket for a B2BUA call (by our internal call ID).
|
||||
/// Used by webrtc_link to set up the audio bridge.
|
||||
pub fn get_b2bua_rtp_socket(&self, call_id: &str) -> Option<Arc<UdpSocket>> {
|
||||
for b2bua in self.b2bua_calls.values() {
|
||||
if b2bua.id == call_id {
|
||||
return b2bua.rtp_socket.clone();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Initiate an outbound call from the dashboard.
|
||||
/// Builds an INVITE from scratch and sends it to the provider.
|
||||
/// The browser connects separately via WebRTC and gets linked to this call.
|
||||
// --- B2BUA outbound call ---
|
||||
|
||||
/// Route a SIP message to a B2BUA call's provider leg.
|
||||
async fn route_b2bua_message(
|
||||
&mut self,
|
||||
sip_call_id: &str,
|
||||
msg: &SipMessage,
|
||||
from_addr: SocketAddr,
|
||||
socket: &UdpSocket,
|
||||
) -> bool {
|
||||
let b2bua = match self.b2bua_calls.get_mut(sip_call_id) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let call_id = b2bua.id.clone();
|
||||
let action = b2bua.provider_leg.handle_message(msg);
|
||||
|
||||
match action {
|
||||
SipLegAction::None => {}
|
||||
SipLegAction::Send(buf) => {
|
||||
let _ = socket.send_to(&buf, b2bua.provider_leg.config.sip_target).await;
|
||||
}
|
||||
SipLegAction::StateChange(LegState::Ringing) => {
|
||||
emit_event(&self.out_tx, "call_ringing", serde_json::json!({ "call_id": call_id }));
|
||||
}
|
||||
SipLegAction::ConnectedWithAck(ack_buf) => {
|
||||
let _ = socket.send_to(&ack_buf, b2bua.provider_leg.config.sip_target).await;
|
||||
let remote = b2bua.provider_leg.remote_media;
|
||||
let sip_pt = b2bua.provider_leg.config.codecs.first().copied().unwrap_or(9);
|
||||
emit_event(&self.out_tx, "call_answered", serde_json::json!({
|
||||
"call_id": call_id,
|
||||
"provider_media_addr": remote.map(|a| a.ip().to_string()),
|
||||
"provider_media_port": remote.map(|a| a.port()),
|
||||
"sip_pt": sip_pt,
|
||||
}));
|
||||
}
|
||||
SipLegAction::Terminated(reason) => {
|
||||
let duration = b2bua.created_at.elapsed().as_secs();
|
||||
emit_event(&self.out_tx, "call_ended", serde_json::json!({
|
||||
"call_id": call_id, "reason": reason, "duration": duration,
|
||||
}));
|
||||
self.b2bua_calls.remove(sip_call_id);
|
||||
return true;
|
||||
}
|
||||
SipLegAction::SendAndTerminate(buf, reason) => {
|
||||
let _ = socket.send_to(&buf, from_addr).await;
|
||||
let duration = b2bua.created_at.elapsed().as_secs();
|
||||
emit_event(&self.out_tx, "call_ended", serde_json::json!({
|
||||
"call_id": call_id, "reason": reason, "duration": duration,
|
||||
}));
|
||||
self.b2bua_calls.remove(sip_call_id);
|
||||
return true;
|
||||
}
|
||||
SipLegAction::AuthRetry { ack_407, invite_with_auth } => {
|
||||
let target = b2bua.provider_leg.config.sip_target;
|
||||
if let Some(ack) = ack_407 {
|
||||
let _ = socket.send_to(&ack, target).await;
|
||||
}
|
||||
let _ = socket.send_to(&invite_with_auth, target).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Initiate an outbound call from the dashboard using B2BUA mode.
|
||||
/// Creates a SipLeg for the provider side with proper dialog + auth handling.
|
||||
pub async fn make_outbound_call(
|
||||
&mut self,
|
||||
number: &str,
|
||||
@@ -515,7 +611,6 @@ impl CallManager {
|
||||
let call_id = self.next_call_id();
|
||||
let lan_ip = &config.proxy.lan_ip;
|
||||
let lan_port = config.proxy.lan_port;
|
||||
let pub_ip = public_ip.unwrap_or(lan_ip.as_str());
|
||||
|
||||
let provider_dest: SocketAddr = match provider_config.outbound_proxy.to_socket_addr() {
|
||||
Some(a) => a,
|
||||
@@ -528,70 +623,38 @@ impl CallManager {
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// Build the SIP Call-ID for this new dialog.
|
||||
// Build the SIP Call-ID for the provider dialog.
|
||||
let sip_call_id = sip_proto::helpers::generate_call_id(None);
|
||||
|
||||
// Build SDP offer.
|
||||
let sdp = sip_proto::helpers::build_sdp(&sip_proto::helpers::SdpOptions {
|
||||
ip: pub_ip,
|
||||
port: rtp_alloc.port,
|
||||
payload_types: &provider_config.codecs,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Build INVITE.
|
||||
let to_uri = format!("sip:{number}@{}", provider_config.domain);
|
||||
let invite = SipMessage::create_request(
|
||||
"INVITE",
|
||||
&to_uri,
|
||||
sip_proto::message::RequestOptions {
|
||||
via_host: pub_ip.to_string(),
|
||||
via_port: lan_port,
|
||||
via_transport: None,
|
||||
via_branch: Some(sip_proto::helpers::generate_branch()),
|
||||
from_uri: registered_aor.to_string(),
|
||||
from_display_name: None,
|
||||
from_tag: Some(sip_proto::helpers::generate_tag()),
|
||||
to_uri: to_uri.clone(),
|
||||
to_display_name: None,
|
||||
to_tag: None,
|
||||
call_id: Some(sip_call_id.clone()),
|
||||
cseq: Some(1),
|
||||
contact: Some(format!("<sip:{pub_ip}:{lan_port}>")),
|
||||
max_forwards: Some(70),
|
||||
body: Some(sdp),
|
||||
content_type: Some("application/sdp".to_string()),
|
||||
extra_headers: Some(vec![
|
||||
("User-Agent".to_string(), "SipRouter/1.0".to_string()),
|
||||
("Allow".to_string(), "INVITE, ACK, OPTIONS, CANCEL, BYE, INFO".to_string()),
|
||||
]),
|
||||
},
|
||||
);
|
||||
|
||||
// Send INVITE to provider.
|
||||
let _ = socket.send_to(&invite.serialize(), provider_dest).await;
|
||||
|
||||
// Create call entry — device_addr is a dummy (WebRTC will be linked later).
|
||||
let dummy_addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||
let call = PassthroughCall {
|
||||
id: call_id.clone(),
|
||||
sip_call_id: sip_call_id.clone(),
|
||||
state: CallState::SettingUp,
|
||||
direction: CallDirection::Outbound,
|
||||
created_at: Instant::now(),
|
||||
caller_number: Some(registered_aor.to_string()),
|
||||
callee_number: Some(number.to_string()),
|
||||
provider_id: provider_config.id.clone(),
|
||||
provider_addr: provider_dest,
|
||||
provider_media: None,
|
||||
device_addr: dummy_addr,
|
||||
device_media: None,
|
||||
// Create a SipLeg with provider credentials for auth handling.
|
||||
let leg_config = SipLegConfig {
|
||||
lan_ip: lan_ip.clone(),
|
||||
lan_port,
|
||||
public_ip: public_ip.map(|s| s.to_string()),
|
||||
sip_target: provider_dest,
|
||||
username: Some(provider_config.username.clone()),
|
||||
password: Some(provider_config.password.clone()),
|
||||
registered_aor: Some(registered_aor.to_string()),
|
||||
codecs: provider_config.codecs.clone(),
|
||||
rtp_port: rtp_alloc.port,
|
||||
rtp_socket: rtp_alloc.socket.clone(),
|
||||
pkt_from_device: 0,
|
||||
pkt_from_provider: 0,
|
||||
};
|
||||
self.calls.insert(sip_call_id, call);
|
||||
|
||||
let mut leg = SipLeg::new(format!("{call_id}-prov"), leg_config);
|
||||
|
||||
// Send the INVITE.
|
||||
let to_uri = format!("sip:{number}@{}", provider_config.domain);
|
||||
leg.send_invite(registered_aor, &to_uri, &sip_call_id, socket).await;
|
||||
|
||||
// Store as B2BUA call.
|
||||
let b2bua = B2buaCall {
|
||||
id: call_id.clone(),
|
||||
provider_leg: leg,
|
||||
webrtc_session_id: None,
|
||||
number: number.to_string(),
|
||||
created_at: std::time::Instant::now(),
|
||||
rtp_socket: Some(rtp_alloc.socket.clone()),
|
||||
};
|
||||
self.b2bua_calls.insert(sip_call_id, b2bua);
|
||||
|
||||
Some(call_id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user