Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions crates/asterisk-integration-tests/tests/e2e_media_fail_closed.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
//! End-to-end acceptance for M6 CP3: `external_media_address` FQDN-vs-literal in
//! SDP, **fail-closed on DNS failure**.
//!
//! `external_media_address` was an uninterpreted string emitted verbatim into
//! `c=`/`o=`. CP3 interprets it: an IP literal is emitted; an FQDN is resolved;
//! and if the FQDN does NOT resolve, rustisk must FAIL CLOSED — reject the call
//! setup rather than advertise an unresolved FQDN or fall back to a leaky
//! internal address.
//!
//! Receiver-side proof: a SIP peer sends an INVITE and observes what rustisk
//! sends back.
//! * **positive control** — an IP-literal external resolves and the call is
//! ANSWERED 200 with that address in `c=`.
//! * **fail-closed** — an unresolvable FQDN external -> rustisk REJECTS with a
//! 4xx and NEVER sends a 200 (so no bogus/internal media address reaches the
//! peer).
//!
//! RED control (captured in the PR body): make the resolution fall open (emit
//! the internal address instead of failing closed) -> the peer receives a 200
//! for the unresolvable-FQDN case -> the "no 200 / must be rejected" assertion
//! goes RED.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use asterisk_apps::adapter::register_all_apps;
use asterisk_codecs::codecs;
use asterisk_core::channel::tech_registry::TECH_REGISTRY;
use asterisk_core::pbx::{Context, Dialplan, Extension, Priority};
use asterisk_sip::channel_driver::SipChannelDriver;
use asterisk_sip::event_handler::SipEventHandler;
use asterisk_sip::parser::SipMessage;
use asterisk_sip::pjsip_config::{
set_global_pjsip_config, EndpointConfig, PjsipConfig, TransportConfig,
};
use asterisk_sip::sdp::SessionDescription;
use asterisk_sip::session::SipSession;
use asterisk_sip::transport::UdpTransport;
use tokio::net::UdpSocket;

const EXTEN: &str = "100";

/// Wait for a specific SIP status code for `call_id`, skipping others (so a
/// retransmitted response from an earlier call cannot be mistaken for this one).
async fn recv_sip_status(
sock: &UdpSocket,
status: u16,
call_id: &str,
budget: Duration,
) -> Option<SipMessage> {
let deadline = Instant::now() + budget;
let mut buf = [0u8; 4096];
while Instant::now() < deadline {
if let Ok(Ok((len, _))) =
tokio::time::timeout(Duration::from_millis(300), sock.recv_from(&mut buf)).await
{
if let Ok(msg) = SipMessage::parse(&buf[..len]) {
if msg.status_code() == Some(status) && msg.call_id() == Some(call_id) {
return Some(msg);
}
}
}
}
None
}

/// Collect every distinct status code seen for `call_id` within the budget
/// (responses for other Call-IDs are ignored, so an earlier call's retransmit
/// cannot contaminate this assertion).
async fn collect_status_codes(sock: &UdpSocket, call_id: &str, budget: Duration) -> Vec<u16> {
let deadline = Instant::now() + budget;
let mut buf = [0u8; 4096];
let mut seen = Vec::new();
while Instant::now() < deadline {
if let Ok(Ok((len, _))) =
tokio::time::timeout(Duration::from_millis(300), sock.recv_from(&mut buf)).await
{
if let Ok(msg) = SipMessage::parse(&buf[..len]) {
if msg.call_id() != Some(call_id) {
continue;
}
if let Some(code) = msg.status_code() {
if !seen.contains(&code) {
seen.push(code);
}
}
}
}
}
seen
}

fn invite_request(call_id: &str, contact_port: u16, sdp: &str) -> SipMessage {
let raw = format!(
"INVITE sip:{EXTEN}@127.0.0.1 SIP/2.0\r\n\
Via: SIP/2.0/UDP 127.0.0.1:{contact_port};branch=z9hG4bK{call_id}inv\r\n\
From: \"Caller\" <sip:caller@127.0.0.1>;tag=caller{call_id}\r\n\
To: <sip:{EXTEN}@127.0.0.1>\r\n\
Call-ID: {call_id}\r\n\
CSeq: 1 INVITE\r\n\
Contact: <sip:caller@127.0.0.1:{contact_port}>\r\n\
Content-Type: application/sdp\r\n\
Content-Length: {len}\r\n\
\r\n\
{sdp}",
len = sdp.len()
);
SipMessage::parse(raw.as_bytes()).unwrap()
}

fn config_with_external_media(external: &str) -> PjsipConfig {
PjsipConfig {
endpoints: vec![EndpointConfig {
name: EXTEN.to_string(),
context: "default".to_string(),
auth: None,
..Default::default()
}],
transports: vec![TransportConfig {
name: "transport-udp".to_string(),
protocol: "udp".to_string(),
bind: "127.0.0.1:5060".parse().unwrap(),
external_media_address: Some(external.to_string()),
external_signaling_address: None,
external_signaling_port: None,
cert_file: None,
priv_key_file: None,
// Empty local_net: the loopback caller is treated as EXTERNAL, so
// the external_media_address path (and CP3 resolution) is exercised.
local_net: vec![],
}],
..Default::default()
}
}

fn dialplan() -> Dialplan {
let mut dp = Dialplan::new();
let mut ctx = Context::new("default");
let mut ext = Extension::new(EXTEN);
ext.add_priority(Priority {
priority: 1,
app: "Answer".to_string(),
app_data: String::new(),
label: None,
});
ctx.add_extension(ext);
dp.add_context(ctx);
dp
}

async fn send_invite(
handler: &Arc<SipEventHandler>,
sip_local: SocketAddr,
caller: &UdpSocket,
call_id: &str,
) -> Option<String> {
let caller_addr = caller.local_addr().unwrap();
let offer = SessionDescription::create_offer("127.0.0.1", 40000, &[codecs::pcmu()]);
let invite = invite_request(call_id, caller_addr.port(), &offer.to_string());
let session = SipSession::new_inbound(&invite, sip_local, caller_addr).expect("session");
handler
.handle_incoming_invite(&invite, caller_addr, session)
.await
}

#[tokio::test]
async fn external_media_fqdn_fails_closed() {
register_all_apps();

let handler_transport: Arc<dyn asterisk_sip::transport::SipTransport> = Arc::new(
UdpTransport::bind("127.0.0.1:0".parse().unwrap())
.await
.unwrap(),
);
let sip_local: SocketAddr = handler_transport.local_addr().unwrap();
let driver = Arc::new(SipChannelDriver::new(sip_local));
driver.set_transport(handler_transport.clone());
TECH_REGISTRY.register(driver.clone());
let handler = Arc::new(SipEventHandler::new(Arc::new(dialplan()), handler_transport));
handler.set_channel_driver(driver.clone());

let caller = UdpSocket::bind("127.0.0.1:0").await.unwrap();

// ---- POSITIVE CONTROL: IP-literal external resolves -> 200 answered ----
set_global_pjsip_config(config_with_external_media("203.0.113.99"));
let accepted = send_invite(&handler, sip_local, &caller, "media-fc-ok").await;
assert_eq!(
accepted.as_deref(),
Some("media-fc-ok"),
"positive control: an IP-literal external_media_address must be accepted"
);
let ok = recv_sip_status(&caller, 200, "media-fc-ok", Duration::from_secs(5))
.await
.expect("positive control: IP-literal external must be answered 200");
let answer = SessionDescription::parse(&ok.body).expect("200 must carry SDP");
assert_eq!(
answer.connection.as_ref().unwrap().addr,
"203.0.113.99",
"positive control: the resolved IP literal is advertised in c="
);
println!("[E2E] positive control: IP-literal external_media_address -> 200 with c=203.0.113.99");

// ---- FAIL-CLOSED: unresolvable FQDN external -> rejected, NO 200 -------
set_global_pjsip_config(config_with_external_media("no-such-host.invalid"));
let accepted = send_invite(&handler, sip_local, &caller, "media-fc-fail").await;
assert_eq!(
accepted, None,
"fail-closed: an unresolvable external_media_address FQDN must NOT be accepted"
);
let codes = collect_status_codes(&caller, "media-fc-fail", Duration::from_secs(3)).await;
assert!(
!codes.contains(&200),
"fail-closed: rustisk must NEVER send a 200 (with a bogus/internal c=) for an \
unresolvable external_media_address FQDN; saw {codes:?}"
);
assert!(
codes.iter().any(|c| (400..600).contains(c)),
"fail-closed: rustisk must reject the INVITE with a 4xx/5xx; saw {codes:?}"
);
println!("[E2E] fail-closed: unresolvable external_media_address FQDN -> rejected {codes:?}, no 200");

set_global_pjsip_config(PjsipConfig::default());
}
15 changes: 9 additions & 6 deletions crates/asterisk-sip/src/channel_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,12 +587,15 @@ impl ChannelDriver for SipChannelDriver {

// Create SDP offer with a concrete, routable connection address
// (external_media_address / routed interface — never 0.0.0.0,
// issue #56).
let sdp = SessionDescription::create_offer(
&crate::sdp::advertised_media_ip(self.local_addr, remote_addr),
rtp_port,
&channel_codecs,
);
// issue #56). Fail closed (CP3): if a configured external_media_address
// FQDN does not resolve, do NOT offer a bogus/internal media address —
// fail the dial.
let Some(media_ip) = crate::sdp::advertised_media_ip(self.local_addr, remote_addr) else {
return Err(AsteriskError::Internal(format!(
"outbound {dest}: external_media_address did not resolve; refusing to offer a media address (fail-closed)"
)));
};
let sdp = SessionDescription::create_offer(&media_ip, rtp_port, &channel_codecs);
sip_session.local_sdp = Some(sdp);

let counter = next_channel_suffix();
Expand Down
80 changes: 67 additions & 13 deletions crates/asterisk-sip/src/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,7 +698,22 @@ impl SipEventHandler {
// offer address.
let media_peer = crate::sdp_rtp::remote_rtp_endpoint(&remote_sdp)
.unwrap_or(remote_addr);
let local_ip = crate::sdp::advertised_media_ip(session.local_addr, media_peer);
// Fail closed (CP3): if a configured external_media_address FQDN does
// not resolve, do NOT answer with a bogus/internal c=/o= address —
// reject the INVITE (488) rather than leak or blackhole the media.
let Some(local_ip) = crate::sdp::advertised_media_ip(session.local_addr, media_peer)
else {
let unique_id = channel.lock().unique_id.0.clone();
store::deregister(&unique_id);
self.callid_map.write().remove(&call_id);
warn!(call_id = %call_id, "Fail-closed: external_media_address did not resolve; rejecting INVITE 488");
if let Ok(resp) = request.create_response(488, "Not Acceptable Here") {
if self.may_send_invite_final(request, &resp) {
let _ = self.transport.send(&resp, remote_addr).await;
}
}
return None;
};
let rtp_result = match self.channel_driver.get() {
Some(driver) => driver.allocate_rtp_session(session.local_addr.ip()).await,
None => self
Expand Down Expand Up @@ -1482,13 +1497,17 @@ impl SipEventHandler {
/// advertises the channel's REAL bound RTP port and a routable connection
/// address (never 0.0.0.0), with route/NAT selection targeting the offer's
/// media endpoint and falling back to the signaling source.
/// Build the SDP answer for an in-dialog renegotiation (UPDATE/re-INVITE
/// with an offer). Returns `None` — fail closed (CP3) — if a configured
/// `external_media_address` FQDN does not resolve, so the caller rejects the
/// renegotiation rather than answering with a bogus/internal media address.
async fn renegotiation_answer(
&self,
channel_name: &str,
offer: &SessionDescription,
local_addr: SocketAddr,
remote_addr: SocketAddr,
) -> SessionDescription {
) -> Option<SessionDescription> {
let channel_codecs = self
.channel_driver
.get()
Expand All @@ -1502,8 +1521,8 @@ impl SipEventHandler {
None => 10000,
};
let media_peer = crate::sdp_rtp::remote_rtp_endpoint(offer).unwrap_or(remote_addr);
let local_ip = crate::sdp::advertised_media_ip(local_addr, media_peer);
SessionDescription::create_answer(offer, &local_ip, media_port, &channel_codecs)
let local_ip = crate::sdp::advertised_media_ip(local_addr, media_peer)?;
Some(SessionDescription::create_answer(offer, &local_ip, media_port, &channel_codecs))
}

/// Handle an in-dialog **UPDATE** (RFC 3311). Two shapes:
Expand Down Expand Up @@ -1605,8 +1624,24 @@ impl SipEventHandler {
return;
}

// Apply the offer to the media plane BEFORE answering, so the pump
// is renegotiated the instant our 200 goes out.
// Build the answer FIRST — before mutating the media plane — so a
// fail-closed rejection (CP3: external_media_address did not
// resolve) rejects the UPDATE with the ESTABLISHED call's media
// untouched, rather than leaving the pump repointed to a rejected
// address after the peer got a 488 (codex CP3 F1).
let Some(answer) = self
.renegotiation_answer(&channel_name, &offer, local_addr, remote_addr)
.await
else {
warn!(call_id = %call_id, "Fail-closed: external_media_address did not resolve; rejecting UPDATE 488 (media unchanged)");
if let Ok(resp) = request.create_response(488, "Not Acceptable Here") {
let _ = self.send_server_response(&resp, remote_addr).await;
}
return;
};

// Answer resolved: now apply the offer to the media plane, so the
// pump is renegotiated the instant our 200 goes out.
if let Some(driver) = self.channel_driver.get() {
if let Err(error) = driver.apply_inbound_offer(&channel_name, &offer).await {
warn!(call_id = %call_id, %error, "UPDATE media renegotiation failed");
Expand All @@ -1616,10 +1651,6 @@ impl SipEventHandler {
return;
}
}

let answer = self
.renegotiation_answer(&channel_name, &offer, local_addr, remote_addr)
.await;
{
let mut cs = cs_arc.lock().await;
cs.session.remote_sdp = Some(offer);
Expand Down Expand Up @@ -2072,6 +2103,28 @@ impl SipEventHandler {
}
}

// Fail closed (CP3) BEFORE touching the media plane: resolve the
// answer's media address first. If a configured external_media_address
// FQDN does not resolve, reject the re-INVITE (488) with the
// ESTABLISHED call's media untouched, rather than repointing/holding the
// pump and THEN 488-ing the peer (codex CP3 F1).
let resolved_local_ip = if let Some(ref offer) = remote_sdp {
let media_peer = crate::sdp_rtp::remote_rtp_endpoint(offer).unwrap_or(remote_addr);
match crate::sdp::advertised_media_ip(session.local_addr, media_peer) {
Some(ip) => Some(ip),
None => {
warn!(call_id = %call_id, "Fail-closed: external_media_address did not resolve; rejecting re-INVITE 488 (media unchanged)");
let response = request.create_response(488, "Not Acceptable Here").ok()?;
if self.may_send_invite_final(request, &response) {
let _ = self.transport.send(&response, remote_addr).await;
}
return None;
}
}
} else {
None
};

// ACTUALLY renegotiate the media plane (the load-bearing half of the
// re-INVITE — previously this handler answered but never touched the
// media session, so a codec/port change or hold was a sent-claim only):
Expand Down Expand Up @@ -2109,9 +2162,10 @@ impl SipEventHandler {
// issue #56). Route/NAT selection targets the re-INVITE's media
// endpoint, falling back to the signaling source.
let answer_sdp = if let Some(ref offer) = remote_sdp {
let media_peer = crate::sdp_rtp::remote_rtp_endpoint(offer)
.unwrap_or(remote_addr);
let local_ip = crate::sdp::advertised_media_ip(session.local_addr, media_peer);
// Media address already resolved fail-closed above (before any
// mutation); reuse it rather than resolving a second time.
let local_ip = resolved_local_ip
.expect("resolved_local_ip is Some whenever remote_sdp is Some");
let answer = SessionDescription::create_answer(
offer,
&local_ip,
Expand Down
Loading
Loading