feat(proxy-engine,webrtc): add B2BUA SIP leg handling and WebRTC call bridging for outbound calls

This commit is contained in:
2026-04-10 12:19:20 +00:00
parent 82f2742db5
commit 9e5aa35fee
9 changed files with 869 additions and 144 deletions

View File

@@ -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)
}