From d7b555bbd2a77ef3c16138870a990c74968532d9 Mon Sep 17 00:00:00 2001 From: Repin Agent Date: Fri, 17 Jul 2026 06:31:29 -0600 Subject: [PATCH] =?UTF-8?q?feat(sip):=20M7=20CP1=20=E2=80=94=20wire-correc?= =?UTF-8?q?t=20digest=20challenge=20handling=20on=20origination=20(M-f)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today an origination INVITE that gets a 401/407 was never answered with credentials: `OutboundAuthenticator::create_authenticated_request` had no production caller, and the challenge was treated as a plain call failure. Fix all of M-f so rustisk can originate through a challenging carrier, proven RECEIVER-SIDE against an offline carrier UAS. Changes: - authenticator: the credentialed retry is now a NEW client transaction — the top Via branch is rewritten to a fresh branch (was reused: the M-f defect), alongside the existing CSeq increment + Authorization. `replace_via_branch` preserves rport/transport/sent-by. - session: `build_auth_retry_invite` answers a 401/407 and adopts the retry as the live INVITE; `build_ack` (2xx) and `build_cancel` now carry the INVITE's REAL CSeq (were hardcoded 1); `build_ack`/`build_bye` target the dialog route set (Record-Route -> Route headers, Request-URI = refreshed Contact) via loose/strict routing; `in_dialog_next_hop` resolves the first route hop. - event_handler: on a 401/407 to our INVITE, bounded credentialed retry (`MAX_OUTBOUND_AUTH_ATTEMPTS`) as a new INVITE transaction; the 2xx ACK and in-dialog BYE are sent to the route-set/Contact target, not the response source. The non-2xx ACK itself is already emitted by the transaction layer. - pjsip_config / channel_driver: new `outbound_auth` endpoint field; the resolved credential rides on the outbound session. Proofs: session cp1_tests (RED-capable: revert to CSeq 1 -> "1 ACK"!="2 ACK"); authenticator branch test; and tests/cp1-origination-challenge — an isolated offline-carrier harness that verifies, receiver-side, (a) the 401 is ACKed (same branch), (b) the retry carries a new branch + CSeq++ + a valid digest, (c) the 2xx ACK and BYE land on the route-set/Contact target (a SEPARATE address) not the core, and (d) the in-dialog CSeq is real (ACK=2, BYE=3). --- crates/asterisk-sip/src/authenticator.rs | 85 ++++- crates/asterisk-sip/src/channel_driver.rs | 20 ++ crates/asterisk-sip/src/event_handler.rs | 83 ++++- crates/asterisk-sip/src/pjsip_config.rs | 11 +- crates/asterisk-sip/src/session/mod.rs | 315 ++++++++++++++++- .../ami_originate.py | 65 ++++ tests/cp1-origination-challenge/carrier.py | 330 ++++++++++++++++++ .../config/asterisk.conf.tmpl | 4 + .../config/extensions.conf | 8 + .../config/manager.conf | 11 + .../config/pjsip.conf.tmpl | 40 +++ .../cp1-origination-challenge/config/rtp.conf | 3 + tests/cp1-origination-challenge/run.sh | 299 ++++++++++++++++ 13 files changed, 1258 insertions(+), 16 deletions(-) create mode 100755 tests/cp1-origination-challenge/ami_originate.py create mode 100755 tests/cp1-origination-challenge/carrier.py create mode 100644 tests/cp1-origination-challenge/config/asterisk.conf.tmpl create mode 100644 tests/cp1-origination-challenge/config/extensions.conf create mode 100644 tests/cp1-origination-challenge/config/manager.conf create mode 100644 tests/cp1-origination-challenge/config/pjsip.conf.tmpl create mode 100644 tests/cp1-origination-challenge/config/rtp.conf create mode 100755 tests/cp1-origination-challenge/run.sh diff --git a/crates/asterisk-sip/src/authenticator.rs b/crates/asterisk-sip/src/authenticator.rs index 2486c49..f21ac9b 100644 --- a/crates/asterisk-sip/src/authenticator.rs +++ b/crates/asterisk-sip/src/authenticator.rs @@ -345,7 +345,13 @@ impl OutboundAuthenticator { // Clone the original request and add the Authorization header. let mut new_request = original_request.clone(); - // Increment CSeq. + // Increment CSeq and mint a FRESH top Via branch. The credentialed retry + // is a NEW client transaction (RFC 3261 §8.1.3.5 / §17.1.1.1): reusing + // the challenged request's branch collides with the transaction the 401/ + // 407 just terminated, so the retry must carry both an incremented CSeq + // and a new branch. Reusing the branch was the M-f defect. + let new_branch = generate_branch(); + let mut via_rewritten = false; for h in &mut new_request.headers { if h.name.eq_ignore_ascii_case(header_names::CSEQ) { if let Some((num_str, method_str)) = h.value.split_once(' ') { @@ -353,6 +359,11 @@ impl OutboundAuthenticator { h.value = format!("{} {}", num + 1, method_str); } } + } else if !via_rewritten && h.name.eq_ignore_ascii_case(header_names::VIA) { + // Only the topmost (our own) Via carries this transaction's + // branch; a downstream proxy's Via must be left untouched. + h.value = replace_via_branch(&h.value, &new_branch); + via_rewritten = true; } } @@ -493,6 +504,45 @@ fn generate_nonce() -> String { hex::encode(bytes) } +/// Generate a fresh RFC 3261 magic-cookie Via branch for a new client +/// transaction. +fn generate_branch() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 8] = rng.gen(); + format!("z9hG4bK{}", hex::encode(bytes)) +} + +/// Replace the `branch=` parameter of a Via header value with `new_branch`, +/// preserving every other Via parameter (`rport`, `received`, transport, host). +/// If the Via carries no `branch=` param, one is appended. +fn replace_via_branch(via_value: &str, new_branch: &str) -> String { + let mut out = Vec::new(); + let mut replaced = false; + for (idx, part) in via_value.split(';').enumerate() { + if idx == 0 { + // The sent-protocol + sent-by (e.g. "SIP/2.0/UDP host:port"). + out.push(part.trim_end().to_string()); + continue; + } + let trimmed = part.trim(); + if trimmed.eq_ignore_ascii_case("branch") + || trimmed + .split_once('=') + .is_some_and(|(k, _)| k.trim().eq_ignore_ascii_case("branch")) + { + out.push(format!("branch={}", new_branch)); + replaced = true; + } else { + out.push(trimmed.to_string()); + } + } + if !replaced { + out.push(format!("branch={}", new_branch)); + } + out.join(";") +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -827,5 +877,38 @@ mod tests { // CSeq should be incremented. let cseq = authed_request.cseq().unwrap(); assert!(cseq.starts_with("2 ")); + + // The credentialed retry MUST be a new client transaction: its top Via + // branch differs from the challenged request's branch (M-f: reusing the + // branch collides with the just-completed 401 transaction). Before the + // fix this branch was `z9hG4bK123`, identical to the original. + let orig_branch = extract_branch(&request.get_header(header_names::VIA).unwrap()); + let retry_branch = extract_branch(&authed_request.get_header(header_names::VIA).unwrap()); + assert_eq!(orig_branch.as_deref(), Some("z9hG4bK123")); + assert!( + retry_branch.is_some() && retry_branch.as_deref() != Some("z9hG4bK123"), + "retry Via must carry a FRESH branch, got {:?}", + retry_branch + ); + } + + fn extract_branch(via: &str) -> Option { + via.split(';') + .find_map(|p| p.trim().strip_prefix("branch=")) + .map(|b| b.trim().to_string()) + } + + /// `replace_via_branch` swaps only the branch and preserves the sent-by and + /// every other Via parameter (rport, transport). + #[test] + fn test_replace_via_branch_preserves_other_params() { + let via = "SIP/2.0/UDP 10.0.0.9:5060;rport;branch=z9hG4bKold"; + let out = replace_via_branch(via, "z9hG4bKnew"); + assert!(out.starts_with("SIP/2.0/UDP 10.0.0.9:5060")); + assert!(out.contains(";rport")); + assert!(out.contains(";branch=z9hG4bKnew")); + assert!(!out.contains("z9hG4bKold")); + // Exactly one branch param. + assert_eq!(out.matches("branch=").count(), 1); } } diff --git a/crates/asterisk-sip/src/channel_driver.rs b/crates/asterisk-sip/src/channel_driver.rs index 21221ee..4e3fab2 100644 --- a/crates/asterisk-sip/src/channel_driver.rs +++ b/crates/asterisk-sip/src/channel_driver.rs @@ -585,6 +585,24 @@ impl ChannelDriver for SipChannelDriver { // Create SIP session let mut sip_session = SipSession::new_outbound(self.local_addr, remote_addr); + // Resolve the endpoint's OUTBOUND digest credential so a carrier's + // 401/407 on this origination INVITE can be answered (M-f). Absent + // `outbound_auth`, a challenge is a hard failure (no credentials to + // present). `dest` is the endpoint name here (a raw URI dial has no + // endpoint and therefore no outbound auth). + sip_session.outbound_auth = endpoint_config + .as_ref() + .and_then(|config| { + let ep = config.find_endpoint(dest)?; + let auth_name = ep.outbound_auth.as_deref()?; + let auth = config.find_auth(auth_name)?; + Some(crate::authenticator::AuthCredentials::new( + &auth.username, + &auth.password, + auth.realm.as_deref().unwrap_or(""), + )) + }); + // Create SDP offer with a concrete, routable connection address // (external_media_address / routed interface — never 0.0.0.0, // issue #56). Fail closed (CP3): if a configured external_media_address @@ -710,6 +728,8 @@ impl ChannelDriver for SipChannelDriver { local_tag: session.local_tag.clone(), early_media: session.early_media.clone(), early_media_config: session.early_media_config.clone(), + outbound_auth: session.outbound_auth.clone(), + auth_attempts: session.auth_attempts, }; handler.register_outbound_session( &session.call_id, diff --git a/crates/asterisk-sip/src/event_handler.rs b/crates/asterisk-sip/src/event_handler.rs index 00d2810..01b8338 100644 --- a/crates/asterisk-sip/src/event_handler.rs +++ b/crates/asterisk-sip/src/event_handler.rs @@ -111,6 +111,13 @@ struct CallState { _rtp_reservation: Option, } +/// Maximum credentialed INVITE retries in response to a 401/407 on an +/// origination leg. RFC 3261 gives a UAC one retry per challenge; a well-behaved +/// carrier challenges once (nonce), so a single retry suffices. Bounding it +/// stops a misbehaving or malicious carrier that keeps re-challenging (or issues +/// `stale`) from driving an unbounded INVITE loop (M-f "bounded retries"). +const MAX_OUTBOUND_AUTH_ATTEMPTS: u32 = 2; + #[cfg(not(test))] const ABANDONED_SIGNALING_GRACE: std::time::Duration = crate::transaction::timers::TIMER_B; @@ -280,6 +287,21 @@ impl SipEventHandler { } } + /// Send an INVITE (e.g. a credentialed challenge retry) as a NEW INVITE + /// client transaction when the stack is attached, so it gets its own + /// retransmission/timeout timers. Handler-only tests fall back to the raw + /// transport. + async fn send_client_invite( + &self, + request: SipMessage, + remote_addr: SocketAddr, + ) -> Result<(), crate::transport::TransportError> { + match self.stack.get() { + Some(stack) => stack.send_invite(request, remote_addr).await.map(|_| ()), + None => self.transport.send(&request, remote_addr).await, + } + } + async fn validate_in_dialog_request( &self, request: &SipMessage, @@ -1378,6 +1400,56 @@ impl SipEventHandler { if !cs.session.is_outbound { return; } + + // Wire-correct digest challenge handling on origination (M-f). The + // transaction layer has ALREADY sent the RFC 3261 §17.1.1.3 non-2xx ACK + // for this 401/407 (stack::process_response, reusing the INVITE branch + + // real CSeq) before this event was delivered. What is missing — and is + // added here — is answering the challenge with a NEW INVITE transaction + // (fresh branch, incremented CSeq, Authorization). Do it BEFORE + // on_response so the challenge is not treated as a generic failure. + if matches!(status_code, 401 | 407) { + // Bounded credentialed retry. + let may_retry = cs.session.outbound_auth.is_some() + && cs.session.auth_attempts < MAX_OUTBOUND_AUTH_ATTEMPTS; + if may_retry { + if let Some(retry) = cs.session.build_auth_retry_invite(response) { + if let Err(error) = self.send_client_invite(retry, remote_addr).await { + warn!(call_id = %call_id, %error, + "Failed to send credentialed INVITE retry"); + } else { + info!(call_id = %call_id, status_code, + attempt = cs.session.auth_attempts, + "Sent credentialed INVITE retry for auth challenge"); + return; + } + } else { + warn!(call_id = %call_id, + "Auth challenge unanswerable (no/invalid outbound credentials)"); + } + } else if cs.session.outbound_auth.is_some() { + warn!(call_id = %call_id, + "Auth challenge retry bound reached; failing origination"); + } else { + debug!(call_id = %call_id, + "Auth challenge received but endpoint has no outbound_auth; failing"); + } + + // No (further) retry: fail the leg like any other final failure. + let abandoned = cs.abandoned; + drop(cs); + if abandoned { + self.release_outbound_leg(&channel_name); + return; + } + if let Some(channel) = store::find_by_name(&channel_name) { + let mut ch = channel.lock(); + ch.hangup_cause = HangupCause::NormalClearing; + ch.softhangup(softhangup::AST_SOFTHANGUP_DEV); + } + return; + } + cs.session.on_response(response); if let Some(to_tag) = response.get_header("To") @@ -1387,8 +1459,15 @@ impl SipEventHandler { } if (200..300).contains(&status_code) { + // ACK — and every subsequent in-dialog request (BYE) — is routed + // through the dialog route set (Record-Route) to the refreshed + // Contact / first route hop, NOT the original request-URI (M-f). + // Refresh the stored next hop so the abandoned-crossing BYE and the + // reject-answer BYE below use the same proxy path. + let ack_hop = cs.session.in_dialog_next_hop().unwrap_or(remote_addr); + cs.next_hop = ack_hop; if let Some(ack) = cs.session.build_ack() { - if let Err(error) = self.transport.send(&ack, remote_addr).await { + if let Err(error) = self.transport.send(&ack, ack_hop).await { warn!(call_id = %call_id, %error, "Failed to send ACK"); } else { debug!(call_id = %call_id, "Sent ACK for 200 OK"); @@ -1429,7 +1508,7 @@ impl SipEventHandler { warn!(call_id = %call_id, channel = %channel_name, %error, "Rejecting unusable outbound SDP answer"); if let Some(bye) = cs.session.build_bye() { - if let Err(send_error) = self.send_client_request(bye, remote_addr).await { + if let Err(send_error) = self.send_client_request(bye, cs.next_hop).await { warn!(call_id = %call_id, "Failed to send BYE after rejecting answer: {}", send_error); } diff --git a/crates/asterisk-sip/src/pjsip_config.rs b/crates/asterisk-sip/src/pjsip_config.rs index 6f91fb1..c9c65aa 100644 --- a/crates/asterisk-sip/src/pjsip_config.rs +++ b/crates/asterisk-sip/src/pjsip_config.rs @@ -69,8 +69,13 @@ pub struct EndpointConfig { pub disallow: Vec, /// Codecs to allow. pub allow: Vec, - /// Reference to an auth section name. + /// Reference to an auth section name (INBOUND: the credential this endpoint + /// is challenged against). pub auth: Option, + /// Reference to an auth section used for OUTBOUND digest — the credential + /// rustisk presents when this endpoint (a carrier/trunk) challenges our + /// origination INVITE with 401/407 (M-f). Distinct from `auth`. + pub outbound_auth: Option, /// Reference to an AOR section name. pub aors: Option, /// Whether to allow direct media (RTP bypass). @@ -119,6 +124,7 @@ impl Default for EndpointConfig { disallow: Vec::new(), allow: Vec::new(), auth: None, + outbound_auth: None, aors: None, direct_media: true, rtp_symmetric: false, @@ -576,6 +582,9 @@ fn parse_endpoint(cat: &asterisk_config::Category) -> EndpointConfig { if let Some(v) = get_last_variable(cat,"auth") { ep.auth = Some(v.to_string()); } + if let Some(v) = get_last_variable(cat,"outbound_auth") { + ep.outbound_auth = Some(v.to_string()); + } if let Some(v) = get_last_variable(cat,"aors") { ep.aors = Some(v.to_string()); } diff --git a/crates/asterisk-sip/src/session/mod.rs b/crates/asterisk-sip/src/session/mod.rs index 38a71ea..c40da67 100644 --- a/crates/asterisk-sip/src/session/mod.rs +++ b/crates/asterisk-sip/src/session/mod.rs @@ -141,6 +141,14 @@ pub struct SipSession { pub early_media: EarlyMediaState, /// Early media configuration. pub early_media_config: EarlyMediaConfig, + /// Outbound digest credentials to answer a 401/407 challenge on this + /// origination leg (M-f). `None` for legs whose endpoint has no + /// `outbound_auth`, in which case a challenge is a hard failure (no retry). + pub outbound_auth: Option, + /// How many credentialed INVITE retries this leg has already sent. Bounds + /// the challenge/response loop so a carrier that keeps challenging cannot + /// drive an unbounded resend (M-f "bounded retries"). + pub auth_attempts: u32, } impl SipSession { @@ -166,6 +174,8 @@ impl SipSession { local_tag, early_media: EarlyMediaState::default(), early_media_config: EarlyMediaConfig::default(), + outbound_auth: None, + auth_attempts: 0, } } @@ -203,6 +213,8 @@ impl SipSession { local_tag, early_media: EarlyMediaState::default(), early_media_config: EarlyMediaConfig::default(), + outbound_auth: None, + auth_attempts: 0, }) } @@ -418,20 +430,94 @@ impl SipSession { Some(SocketAddr::new(ip, uri.port.unwrap_or(5060))) } - /// Build an ACK request. + /// The CSeq **number** of the INVITE this session sent (1 on the first + /// attempt, higher after a credentialed challenge retry). ACK and CANCEL + /// must carry this exact number, not a hardcoded 1 (M-f). + pub fn invite_cseq_num(&self) -> u32 { + self.invite + .as_ref() + .and_then(|inv| inv.cseq()) + .and_then(|cs| cs.split_whitespace().next()) + .and_then(|n| n.parse::().ok()) + .unwrap_or(1) + } + + /// Resolve loose/strict routing for an in-dialog request (RFC 3261 + /// §12.2.1.1). Returns `(request_uri, route_header_values)`. + /// + /// With a Record-Route-established route set of loose-routing proxies + /// (`;lr`, as Chime / AWS Voice Connector and every RFC 3261 proxy emit), + /// the Request-URI is the peer's Contact (remote target) and each route set + /// entry becomes a `Route` header. A strict (non-`lr`) first hop is handled + /// per spec by promoting it to the Request-URI and appending the target. + fn in_dialog_routing(&self) -> Option<(String, Vec)> { + let dialog = self.dialog.as_ref()?; + let target = if dialog.remote_target.is_empty() { + dialog.remote_uri.clone() + } else { + dialog.remote_target.clone() + }; + if target.is_empty() { + return None; + } + if dialog.route_set.is_empty() { + return Some((target, Vec::new())); + } + let first_uri = extract_uri(&dialog.route_set[0]).unwrap_or_else(|| dialog.route_set[0].clone()); + let loose = SipUri::parse(&first_uri) + .map(|u| u.parameters.contains_key("lr")) + .unwrap_or(true); + if loose { + Some((target, dialog.route_set.clone())) + } else { + let mut routes: Vec = dialog.route_set[1..].to_vec(); + routes.push(format!("<{}>", target)); + Some((first_uri, routes)) + } + } + + /// The physical next hop (IP:port) an in-dialog request must be sent to: + /// the first route-set hop when a route set exists (loose routing keeps the + /// datagram on the proxy path), otherwise the resolved remote target + /// (Contact). `None` when neither is an addressable IP literal. + pub fn in_dialog_next_hop(&self) -> Option { + let dialog = self.dialog.as_ref()?; + let hop = if let Some(first) = dialog.route_set.first() { + extract_uri(first)? + } else if !dialog.remote_target.is_empty() { + dialog.remote_target.clone() + } else { + return None; + }; + let uri = SipUri::parse(&hop).ok()?; + let ip: std::net::IpAddr = uri.host.parse().ok()?; + Some(SocketAddr::new(ip, uri.port.unwrap_or(5060))) + } + + /// Build the 2xx ACK request (RFC 3261 §13.2.2.4). This is a NEW transaction + /// (fresh branch), sent through the dialog route set to the remote target + /// (Contact), carrying the INVITE's actual CSeq number — NOT a hardcoded 1. pub fn build_ack(&self) -> Option { let invite = self.invite.as_ref()?; let dialog = self.dialog.as_ref()?; - let uri = match &invite.start_line { - StartLine::Request(r) => r.uri.clone(), - _ => return None, - }; + let (request_uri, route_headers) = self.in_dialog_routing().or_else(|| { + // No dialog target available — fall back to the original R-URI so an + // ACK is still emitted rather than silently dropped. + match &invite.start_line { + StartLine::Request(r) => Some((r.uri.to_string(), Vec::new())), + _ => None, + } + })?; + let uri = SipUri::parse(&request_uri).ok().or_else(|| match &invite.start_line { + StartLine::Request(r) => Some(r.uri.clone()), + _ => None, + })?; let branch = format!("z9hG4bK{}", &Uuid::new_v4().to_string().replace('-', "")[..16]); let sig = self.signaling_hostport(); - let headers = vec![ + let mut headers = vec![ SipHeader { name: header_names::VIA.to_string(), value: format!("SIP/2.0/UDP {};branch={}", sig, branch) }, SipHeader { name: header_names::MAX_FORWARDS.to_string(), value: "70".to_string() }, SipHeader { name: header_names::FROM.to_string(), value: invite.from_header()?.to_string() }, @@ -440,9 +526,12 @@ impl SipSession { value: format!("{};tag={}", invite.to_header()?.split(";tag=").next().unwrap_or(""), dialog.remote_tag), }, SipHeader { name: header_names::CALL_ID.to_string(), value: self.call_id.clone() }, - SipHeader { name: header_names::CSEQ.to_string(), value: "1 ACK".to_string() }, - SipHeader { name: header_names::CONTENT_LENGTH.to_string(), value: "0".to_string() }, + SipHeader { name: header_names::CSEQ.to_string(), value: format!("{} ACK", self.invite_cseq_num()) }, ]; + for route in &route_headers { + headers.push(SipHeader { name: header_names::ROUTE.to_string(), value: route.clone() }); + } + headers.push(SipHeader { name: header_names::CONTENT_LENGTH.to_string(), value: "0".to_string() }); Some(SipMessage { start_line: StartLine::Request(RequestLine { @@ -455,13 +544,42 @@ impl SipSession { }) } + /// Build a credentialed retry INVITE answering a 401/407 `challenge`, and + /// adopt it as this session's current INVITE so a subsequent ACK/CANCEL + /// derives the right (incremented) CSeq and the response matches. Returns + /// `None` when the leg has no `outbound_auth` or the challenge is + /// unparseable. The retry is a NEW client transaction: fresh branch, + /// incremented CSeq, `Authorization`/`Proxy-Authorization` attached. + pub fn build_auth_retry_invite(&mut self, challenge: &SipMessage) -> Option { + let creds = self.outbound_auth.clone()?; + let invite = self.invite.as_ref()?; + let retry = crate::authenticator::OutboundAuthenticator::create_authenticated_request( + invite, + challenge, + std::slice::from_ref(&creds), + )?; + self.auth_attempts += 1; + // Adopt the retry as the live INVITE: its CSeq/branch now govern the + // transaction, and build_ack/build_cancel read CSeq from here. + self.invite = Some(retry.clone()); + self.state = SessionState::Initiated; + Some(retry) + } + /// Build a BYE request. pub fn build_bye(&mut self) -> Option { let sig = self.signaling_hostport(); + // Loose/strict routing per the established route set (Contact + Route), + // so an in-dialog BYE follows the same proxy path as the ACK instead of + // being sent blind to the original request-URI. Fall back to the + // symmetric INVITE source tuple when no addressable target exists. + let (request_uri, route_headers) = self + .in_dialog_routing() + .unwrap_or_else(|| (format!("sip:{}", self.remote_addr), Vec::new())); let dialog = self.dialog.as_mut()?; let cseq = dialog.next_cseq(); - let uri = SipUri::parse(&dialog.remote_target).ok().unwrap_or_else(|| SipUri { + let uri = SipUri::parse(&request_uri).ok().unwrap_or_else(|| SipUri { scheme: "sip".to_string(), user: None, password: None, @@ -477,15 +595,18 @@ impl SipSession { let to_value = format!("<{}>;tag={}", dialog.remote_uri, dialog.remote_tag); - let headers = vec![ + let mut headers = vec![ SipHeader { name: header_names::VIA.to_string(), value: format!("SIP/2.0/UDP {};branch={}", sig, branch) }, SipHeader { name: header_names::MAX_FORWARDS.to_string(), value: "70".to_string() }, SipHeader { name: header_names::FROM.to_string(), value: from_value }, SipHeader { name: header_names::TO.to_string(), value: to_value }, SipHeader { name: header_names::CALL_ID.to_string(), value: self.call_id.clone() }, SipHeader { name: header_names::CSEQ.to_string(), value: format!("{} BYE", cseq) }, - SipHeader { name: header_names::CONTENT_LENGTH.to_string(), value: "0".to_string() }, ]; + for route in &route_headers { + headers.push(SipHeader { name: header_names::ROUTE.to_string(), value: route.clone() }); + } + headers.push(SipHeader { name: header_names::CONTENT_LENGTH.to_string(), value: "0".to_string() }); self.state = SessionState::Terminating; @@ -536,8 +657,10 @@ impl SipSession { value: self.call_id.clone(), }, SipHeader { + // CANCEL matches the INVITE's CSeq NUMBER (RFC 3261 §9.1), + // method CANCEL — not a hardcoded 1 (M-f). name: header_names::CSEQ.to_string(), - value: "1 CANCEL".to_string(), + value: format!("{} CANCEL", self.invite_cseq_num()), }, SipHeader { name: header_names::CONTENT_LENGTH.to_string(), @@ -664,3 +787,171 @@ impl SipSession { } } } + +#[cfg(test)] +mod cp1_tests { + use super::*; + use crate::authenticator::AuthCredentials; + + fn addr(s: &str) -> SocketAddr { + s.parse().unwrap() + } + + fn cseq_of(msg: &SipMessage) -> String { + msg.cseq().unwrap().to_string() + } + + fn branch_of(msg: &SipMessage) -> String { + msg.get_header(header_names::VIA) + .unwrap() + .split(';') + .find_map(|p| p.trim().strip_prefix("branch=")) + .unwrap() + .to_string() + } + + fn request_uri(msg: &SipMessage) -> String { + match &msg.start_line { + StartLine::Request(r) => r.uri.to_string(), + _ => panic!("not a request"), + } + } + + /// Drive an outbound session through: INVITE(1) -> 401 challenge -> + /// credentialed retry INVITE(2) -> 200 with a CHANGED Contact and a + /// Record-Route. Returns the established session. + fn established_after_challenge() -> SipSession { + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + s.outbound_auth = Some(AuthCredentials::new("carrier", "s3cr3t", "")); + let invite = s.build_invite_with_uri("sip:+15550001111@10.0.0.2:5060", "sip:+15550001111@10.0.0.2"); + // The carrier's 401 challenge (To gains a tag). + let challenge = SipMessage::parse( + format!( + "SIP/2.0 401 Unauthorized\r\n\ + {via}\r\n\ + {from}\r\n\ + To: ;tag=carrier401\r\n\ + Call-ID: {cid}\r\n\ + CSeq: 1 INVITE\r\n\ + WWW-Authenticate: Digest realm=\"carrier\", nonce=\"abc123\", algorithm=MD5, qop=\"auth\"\r\n\ + Content-Length: 0\r\n\r\n", + via = format!("Via: {}", invite.get_header(header_names::VIA).unwrap()), + from = format!("From: {}", invite.from_header().unwrap()), + cid = s.call_id, + ) + .as_bytes(), + ) + .unwrap(); + let retry = s.build_auth_retry_invite(&challenge).expect("retry built"); + // Sanity: the retry is a new transaction with an incremented CSeq. + assert_eq!(cseq_of(&retry), "2 INVITE"); + assert_ne!(branch_of(&retry), branch_of(&invite)); + assert!(retry.get_header(header_names::AUTHORIZATION).is_some()); + assert_eq!(s.auth_attempts, 1); + + // The carrier answers the RETRY with a 200 carrying a CHANGED Contact + // (10.0.0.9:5070, not the request-URI 10.0.0.2) and a Record-Route. + let ok = SipMessage::parse( + format!( + "SIP/2.0 200 OK\r\n\ + {via}\r\n\ + {from}\r\n\ + To: ;tag=carrier200\r\n\ + Call-ID: {cid}\r\n\ + CSeq: 2 INVITE\r\n\ + Record-Route: \r\n\ + Contact: \r\n\ + Content-Length: 0\r\n\r\n", + via = format!("Via: {}", retry.get_header(header_names::VIA).unwrap()), + from = format!("From: {}", retry.from_header().unwrap()), + cid = s.call_id, + ) + .as_bytes(), + ) + .unwrap(); + s.on_response(&ok); + assert_eq!(s.state, SessionState::Established); + s + } + + #[test] + fn twoxx_ack_targets_route_set_and_contact_with_real_cseq() { + let s = established_after_challenge(); + let ack = s.build_ack().unwrap(); + // Real CSeq: the RETRY INVITE was CSeq 2, so the ACK is "2 ACK". A + // hardcoded "1 ACK" (the M-f defect) would fail this. + assert_eq!(cseq_of(&ack), "2 ACK"); + // Loose routing: Request-URI is the refreshed Contact (10.0.0.9:5070), + // NOT the original request-URI (10.0.0.2). + assert_eq!(request_uri(&ack), "sip:carrier@10.0.0.9:5070"); + assert!(request_uri(&ack).contains("10.0.0.9:5070")); + // The Record-Route became a Route header. + let routes: Vec<_> = ack.get_headers(header_names::ROUTE); + assert_eq!(routes.len(), 1); + assert!(routes[0].contains("10.0.0.9:5070")); + // Physical next hop is the route-set first hop. + assert_eq!(s.in_dialog_next_hop(), Some(addr("10.0.0.9:5070"))); + } + + #[test] + fn bye_follows_route_set_to_target_with_incremented_cseq() { + let mut s = established_after_challenge(); + let bye = s.build_bye().unwrap(); + // In-dialog CSeq advances beyond the INVITE's 2. + assert_eq!(cseq_of(&bye), "3 BYE"); + // Request-URI is the Contact target, Route header carries the route set. + assert_eq!(request_uri(&bye), "sip:carrier@10.0.0.9:5070"); + let routes = bye.get_headers(header_names::ROUTE); + assert_eq!(routes.len(), 1); + assert!(routes[0].contains("10.0.0.9:5070")); + } + + #[test] + fn cancel_carries_invite_real_cseq_and_branch() { + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + s.outbound_auth = Some(AuthCredentials::new("carrier", "s3cr3t", "")); + let invite = s.build_invite_with_uri("sip:+15550001111@10.0.0.2:5060", "sip:+15550001111@10.0.0.2"); + let challenge = SipMessage::parse( + format!( + "SIP/2.0 401 Unauthorized\r\n\ + Via: {via}\r\n\ + From: {from}\r\n\ + To: ;tag=c1\r\n\ + Call-ID: {cid}\r\n\ + CSeq: 1 INVITE\r\n\ + WWW-Authenticate: Digest realm=\"carrier\", nonce=\"n\", algorithm=MD5, qop=\"auth\"\r\n\ + Content-Length: 0\r\n\r\n", + via = invite.get_header(header_names::VIA).unwrap(), + from = invite.from_header().unwrap(), + cid = s.call_id, + ) + .as_bytes(), + ) + .unwrap(); + let retry = s.build_auth_retry_invite(&challenge).unwrap(); + // CANCEL matches the LIVE (retried) INVITE: same branch + same CSeq + // number (2), method CANCEL. A hardcoded "1 CANCEL" is the M-f defect. + let cancel = s.build_cancel().unwrap(); + assert_eq!(cseq_of(&cancel), "2 CANCEL"); + assert_eq!(branch_of(&cancel), branch_of(&retry)); + } + + #[test] + fn no_retry_without_outbound_auth() { + let mut s = SipSession::new_outbound(addr("10.0.0.1:5060"), addr("10.0.0.2:5060")); + let _invite = s.build_invite_with_uri("sip:x@10.0.0.2:5060", "sip:x@10.0.0.2"); + let challenge = SipMessage::parse( + b"SIP/2.0 401 Unauthorized\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bKx\r\n\ + From: ;tag=t\r\n\ + To: ;tag=c\r\n\ + Call-ID: cid\r\n\ + CSeq: 1 INVITE\r\n\ + WWW-Authenticate: Digest realm=\"carrier\", nonce=\"n\", algorithm=MD5\r\n\ + Content-Length: 0\r\n\r\n", + ) + .unwrap(); + assert!(s.build_auth_retry_invite(&challenge).is_none()); + assert_eq!(s.auth_attempts, 0); + } +} diff --git a/tests/cp1-origination-challenge/ami_originate.py b/tests/cp1-origination-challenge/ami_originate.py new file mode 100755 index 0000000..7cf5dc5 --- /dev/null +++ b/tests/cp1-origination-challenge/ami_originate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Trigger one rustisk outbound Dial via a single authenticated AMI Originate. + + ami_originate.py HOST PORT ENDPOINT ACTION_ID + +Sends `Originate Channel: PJSIP/` asynchronously. rustisk resolves the +endpoint's contact (live registrar binding preferred over static config) and +sends the INVITE to it. Prints the AMI response; exits nonzero if the action was +not queued. +""" + +import socket +import sys + + +def main(): + if len(sys.argv) != 5: + raise SystemExit("usage: ami_originate.py HOST PORT ENDPOINT ACTION_ID") + host, port, endpoint, action_id = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4] + + login = ( + "Action: Login\r\n" + "Username: cp1\r\n" + "Secret: cp1-local-only\r\n" + "\r\n" + ) + originate = ( + "Action: Originate\r\n" + "ActionID: %s\r\n" + "Channel: PJSIP/%s\r\n" + "Context: default\r\n" + "Exten: s\r\n" + "Priority: 1\r\n" + "Timeout: 4000\r\n" + "Async: true\r\n" + "\r\n" + ) % (action_id, endpoint) + logoff = "Action: Logoff\r\n\r\n" + + payload = (login + originate + logoff).encode("utf-8") + response = bytearray() + with socket.create_connection((host, port), timeout=4) as mgr: + mgr.settimeout(4) + mgr.sendall(payload) + try: + while b"Response: Goodbye\r\n" not in response: + chunk = mgr.recv(65536) + if not chunk: + break + response.extend(chunk) + except socket.timeout: + pass + + text = response.decode("utf-8", "replace") + sys.stdout.write(text) + # Require the Originate-specific queued message. A bare "Success" is NOT + # sufficient: the Login reply also carries "Success", so matching it would + # green-light a session whose Originate actually failed. + if "successfully queued" not in text: + sys.stderr.write("Originate not queued\n") + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/tests/cp1-origination-challenge/carrier.py b/tests/cp1-origination-challenge/carrier.py new file mode 100755 index 0000000..551e09a --- /dev/null +++ b/tests/cp1-origination-challenge/carrier.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Offline carrier UAS for the CP1 wire-correct-challenge harness (stdlib only). + +Two roles, one script, each on its OWN container IP so the harness can prove — +RECEIVER-SIDE — that in-dialog requests land on the route-set target and NOT on +the original request-URI: + + --role core The INVITE target / signalling peer. On the first INVITE (no + Authorization) it CHALLENGES with 401 + WWW-Authenticate and + captures the ACK the caller sends for that final (RFC 3261 + §17.1.1.3 — same branch). If no ACK arrives it RETRANSMITS the + 401 and finally records NO-ACK (the RED for "challenge not + ACKed"). On the credentialed retry INVITE it validates the + digest and captures the branch + CSeq + auth result, then + answers 100 -> 200 OK with a CHANGED Contact and a + Record-Route, both pointing at the route target T. Everything + it sees is written to --capture receiver-side. + + --role target Holds address T (the Contact / Record-Route target). It sends + no INVITE and challenges nothing; it only captures the 2xx ACK + and the in-dialog BYE that a route-set-correct caller delivers + here, and answers the BYE 200 so the client transaction + settles. A datagram here proves route-set/Contact targeting; + the same datagram arriving at `core` instead is the RED. + +The agent derives its own routable IP by route-selecting toward the caller +(rustisk), so the harness never hardcodes the Docker-assigned address. +""" + +import argparse +import hashlib +import re +import socket +import sys +import time + +SIP_PORT = 5060 + + +def log(msg): + sys.stderr.write("[carrier] " + msg + "\n") + sys.stderr.flush() + + +def md5_hex(s): + return hashlib.md5(s.encode("utf-8")).hexdigest() + + +def own_ip_toward(peer_ip): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect((peer_ip, 15060)) + return s.getsockname()[0] + finally: + s.close() + + +def get_headers(text, name): + out = [] + for line in text.split("\r\n"): + if line == "": + break + if ":" in line: + hn, hv = line.split(":", 1) + if hn.strip().lower() == name.lower(): + out.append(hv.strip()) + return out + + +def get_header(text, name): + vals = get_headers(text, name) + return vals[0] if vals else None + + +def branch_of(text): + via = get_header(text, "Via") + if not via: + return None + for part in via.split(";"): + part = part.strip() + if part.startswith("branch="): + return part[len("branch="):].strip() + return None + + +def cseq_of(text): + cs = get_header(text, "CSeq") + return cs.strip() if cs else None + + +def request_uri(text): + first = text.split("\r\n", 1)[0] + toks = first.split(" ") + return toks[1] if len(toks) >= 2 else "?" + + +def parse_authorization(value): + value = value.strip() + if value.lower().startswith("digest"): + value = value[len("digest"):].strip() + params = {} + for m in re.finditer(r'(\w+)\s*=\s*(?:"([^"]*)"|([^,]+))', value): + key = m.group(1).lower() + params[key] = (m.group(2) if m.group(2) is not None else m.group(3)).strip() + return params + + +def digest_valid(auth_params, method, password): + """Recompute the digest response server-side and compare (qop=auth + RFC 2069).""" + try: + username = auth_params["username"] + realm = auth_params.get("realm", "") + nonce = auth_params["nonce"] + uri = auth_params["uri"] + given = auth_params["response"] + except KeyError: + return False + ha1 = md5_hex("%s:%s:%s" % (username, realm, password)) + ha2 = md5_hex("%s:%s" % (method, uri)) + qop = auth_params.get("qop") + if qop and "auth" in qop: + cnonce = auth_params.get("cnonce") + nc = auth_params.get("nc") + if not cnonce or not nc: + return False + expected = md5_hex("%s:%s:%s:%s:auth:%s" % (ha1, nonce, nc, cnonce, ha2)) + else: + expected = md5_hex("%s:%s:%s" % (ha1, nonce, ha2)) + return expected == given + + +def append_capture(path, line): + with open(path, "a") as f: + f.write(line + "\n") + f.flush() + log(line) + + +def build_response(req_text, code, reason, extra_headers=None, to_tag=None, sdp=None): + vias = get_headers(req_text, "Via") + frm = get_header(req_text, "From") or "" + to = get_header(req_text, "To") or "" + call_id = get_header(req_text, "Call-ID") or "" + cseq = get_header(req_text, "CSeq") or "" + if to_tag and "tag=" not in to: + to = to + ";tag=%s" % to_tag + lines = ["SIP/2.0 %d %s" % (code, reason)] + for v in vias: + lines.append("Via: %s" % v) + lines.append("From: %s" % frm) + lines.append("To: %s" % to) + lines.append("Call-ID: %s" % call_id) + lines.append("CSeq: %s" % cseq) + for h in extra_headers or []: + lines.append(h) + body = sdp or "" + if body: + lines.append("Content-Type: application/sdp") + lines.append("Content-Length: %d" % len(body)) + lines.append("") + lines.append(body) + return ("\r\n".join(lines)).encode("utf-8") + + +def carrier_sdp(own): + """Minimal G.711u answer so the caller's outbound answer applies cleanly.""" + return ( + "v=0\r\n" + "o=carrier 0 0 IN IP4 %s\r\n" + "s=-\r\n" + "c=IN IP4 %s\r\n" + "t=0 0\r\n" + "m=audio 40000 RTP/AVP 0\r\n" + "a=rtpmap:0 PCMU/8000\r\n" + ) % (own, own) + + +def serve_core(sock, own, capture, route_target, realm, password, answer_delay): + """Challenge -> capture ACK -> validate retry -> 200 with changed Contact + RR.""" + nonce = "cp1nonce" + hashlib.md5(own.encode()).hexdigest()[:8] + # Per-Call-ID state: challenged INVITE branch + retransmit bookkeeping. + challenged = {} # call_id -> {"branch", "retx", "deadline", "acked"} + answered = set() # call_id already 200'd + RETX_INTERVAL = 1.0 + MAX_RETX = 3 + while True: + try: + sock.settimeout(0.2) + data, src = sock.recvfrom(8192) + except socket.timeout: + now = time.time() + for cid, st in list(challenged.items()): + if st["acked"]: + continue + if now >= st["deadline"]: + if st["retx"] < MAX_RETX: + st["retx"] += 1 + st["deadline"] = now + RETX_INTERVAL + sock.sendto(st["challenge_bytes"], st["src"]) + append_capture(capture, "RETX-401 own=%s callid=%s n=%d" % (own, cid, st["retx"])) + else: + append_capture(capture, "NO-ACK own=%s callid=%s branch=%s (challenge never ACKed)" % (own, cid, st["branch"])) + st["acked"] = True # stop reporting + continue + except OSError: + break + text = data.decode("utf-8", "replace") + first = text.split("\r\n", 1)[0] + call_id = get_header(text, "Call-ID") or "?" + + if first.startswith("INVITE "): + auth = get_header(text, "Authorization") or get_header(text, "Proxy-Authorization") + branch = branch_of(text) + cseq = cseq_of(text) + if not auth: + # First INVITE -> challenge 401. + append_capture(capture, "INVITE own=%s src=%s:%d branch=%s cseq=%s auth=no callid=%s" % ( + own, src[0], src[1], branch, cseq, call_id)) + chal = ('Digest realm="%s", nonce="%s", algorithm=MD5, qop="auth"' % (realm, nonce)) + resp = build_response(text, 401, "Unauthorized", + extra_headers=["WWW-Authenticate: %s" % chal], + to_tag="cp1core401") + sock.sendto(resp, src) + challenged[call_id] = { + "branch": branch, "retx": 0, "deadline": time.time() + RETX_INTERVAL, + "acked": False, "challenge_bytes": resp, "src": src, + } + else: + # Credentialed retry INVITE. + params = parse_authorization(auth) + valid = digest_valid(params, "INVITE", password) + prev = challenged.get(call_id, {}) + append_capture(capture, "RETRY-INVITE own=%s src=%s:%d branch=%s cseq=%s auth=yes valid=%s prev_branch=%s callid=%s" % ( + own, src[0], src[1], branch, cseq, "yes" if valid else "no", prev.get("branch"), call_id)) + if not valid: + sock.sendto(build_response(text, 403, "Forbidden", to_tag="cp1core403"), src) + continue + if call_id in answered: + continue + answered.add(call_id) + # 100 Trying, then 200 OK with CHANGED Contact + Record-Route at T. + sock.sendto(build_response(text, 100, "Trying"), src) + if answer_delay > 0: + time.sleep(answer_delay) + contact = "Contact: " % (route_target, SIP_PORT) + rr = "Record-Route: " % (route_target, SIP_PORT) + ok = build_response(text, 200, "OK", extra_headers=[rr, contact], + to_tag="cp1core200", sdp=carrier_sdp(own)) + sock.sendto(ok, src) + append_capture(capture, "SENT-200 own=%s contact=%s:%d record_route=%s:%d callid=%s" % ( + own, route_target, SIP_PORT, route_target, SIP_PORT, call_id)) + elif first.startswith("ACK "): + branch = branch_of(text) + cseq = cseq_of(text) + st = challenged.get(call_id) + if st and branch == st["branch"] and not st.get("marked_ack"): + st["acked"] = True + st["marked_ack"] = True + append_capture(capture, "ACK-CHALLENGE own=%s src=%s:%d branch=%s cseq=%s callid=%s" % ( + own, src[0], src[1], branch, cseq, call_id)) + else: + # A 2xx ACK reaching CORE means the route set was IGNORED (RED). + append_capture(capture, "ACK-2XX-AT-CORE own=%s src=%s:%d branch=%s cseq=%s callid=%s" % ( + own, src[0], src[1], branch, cseq, call_id)) + elif first.startswith("BYE "): + append_capture(capture, "BYE-AT-CORE own=%s src=%s:%d cseq=%s callid=%s" % ( + own, src[0], src[1], cseq_of(text), call_id)) + sock.sendto(build_response(text, 200, "OK"), src) + elif first.startswith("CANCEL "): + append_capture(capture, "CANCEL-AT-CORE own=%s src=%s:%d cseq=%s callid=%s" % ( + own, src[0], src[1], cseq_of(text), call_id)) + sock.sendto(build_response(text, 200, "OK"), src) + + +def serve_target(sock, own, capture): + """Capture the 2xx ACK and the BYE that a route-set-correct caller delivers here.""" + while True: + try: + sock.settimeout(0.5) + data, src = sock.recvfrom(8192) + except socket.timeout: + continue + except OSError: + break + text = data.decode("utf-8", "replace") + first = text.split("\r\n", 1)[0] + call_id = get_header(text, "Call-ID") or "?" + if first.startswith("ACK "): + append_capture(capture, "ACK-2XX-AT-TARGET own=%s src=%s:%d branch=%s cseq=%s ruri=%s callid=%s" % ( + own, src[0], src[1], branch_of(text), cseq_of(text), request_uri(text), call_id)) + elif first.startswith("BYE "): + append_capture(capture, "BYE-AT-TARGET own=%s src=%s:%d cseq=%s ruri=%s callid=%s" % ( + own, src[0], src[1], cseq_of(text), request_uri(text), call_id)) + sock.sendto(build_response(text, 200, "OK"), src) + elif first.startswith("INVITE "): + append_capture(capture, "STRAY-INVITE-AT-TARGET own=%s src=%s:%d callid=%s" % ( + own, src[0], src[1], call_id)) + sock.sendto(build_response(text, 200, "OK", to_tag="strayt"), src) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--role", choices=["core", "target"], required=True) + ap.add_argument("--caller", required=True, help="rustisk container IP (for route selection)") + ap.add_argument("--capture", required=True) + ap.add_argument("--route-target", default="", help="core: the T address to advertise in Contact/Record-Route") + ap.add_argument("--realm", default="carrier") + ap.add_argument("--password", default="s3cr3t") + ap.add_argument("--answer-delay", type=float, default=0.0) + args = ap.parse_args() + + own = own_ip_toward(args.caller) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("0.0.0.0", SIP_PORT)) + log("role=%s own=%s caller=%s route_target=%s" % (args.role, own, args.caller, args.route_target)) + # Readiness marker (first capture line) so the harness can gate on liveness. + append_capture(args.capture, "READY role=%s own=%s" % (args.role, own)) + + if args.role == "core": + if not args.route_target: + log("FATAL: core requires --route-target") + sys.exit(2) + serve_core(sock, own, args.capture, args.route_target, args.realm, args.password, args.answer_delay) + else: + serve_target(sock, own, args.capture) + + +if __name__ == "__main__": + main() diff --git a/tests/cp1-origination-challenge/config/asterisk.conf.tmpl b/tests/cp1-origination-challenge/config/asterisk.conf.tmpl new file mode 100644 index 0000000..08a069b --- /dev/null +++ b/tests/cp1-origination-challenge/config/asterisk.conf.tmpl @@ -0,0 +1,4 @@ +[directories] +astetcdir = @CONFIG_DIR@ +astrundir = @RUN_DIR@ +astincludedir = @RUN_DIR@/include diff --git a/tests/cp1-origination-challenge/config/extensions.conf b/tests/cp1-origination-challenge/config/extensions.conf new file mode 100644 index 0000000..8b13397 --- /dev/null +++ b/tests/cp1-origination-challenge/config/extensions.conf @@ -0,0 +1,8 @@ +; Minimal dialplan (AMI Originate requires one). The outbound INVITE to the +; carrier is the Originate's Channel (PJSIP/carrier). Wait() holds the channel +; alive through the 401 -> credentialed-retry -> 200 exchange so the dialog is +; established; Hangup() then drives the in-dialog BYE to the route-set target, +; giving the harness a receiver-side BYE to assert on. +[default] +exten => s,1,Wait(2) + same => n,Hangup() diff --git a/tests/cp1-origination-challenge/config/manager.conf b/tests/cp1-origination-challenge/config/manager.conf new file mode 100644 index 0000000..3eff0ea --- /dev/null +++ b/tests/cp1-origination-challenge/config/manager.conf @@ -0,0 +1,11 @@ +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 15038 + +; Least-privilege driver account for the harness. `write = system` is exactly +; what AMI Originate requires. +[cp1] +secret = cp1-local-only +read = all +write = system diff --git a/tests/cp1-origination-challenge/config/pjsip.conf.tmpl b/tests/cp1-origination-challenge/config/pjsip.conf.tmpl new file mode 100644 index 0000000..51da603 --- /dev/null +++ b/tests/cp1-origination-challenge/config/pjsip.conf.tmpl @@ -0,0 +1,40 @@ +; CP1 wire-correct-challenge harness pjsip config. +; +; `carrier` — an ORIGINATION endpoint (a stand-in trunk). Dial(PJSIP/carrier) +; resolves the AoR `carrier_aor`'s static contact, which the harness +; templates to the offline carrier CORE's runtime IP (@CORE_S@). +; `outbound_auth = carrierauth` supplies the digest credential +; rustisk presents when the carrier challenges the origination INVITE +; with 401 (M-f). No inbound `auth` — this trunk never authenticates +; us on the inbound leg; it only challenges our OUTBOUND INVITE. +; +; The carrier core answers with a 200 whose Contact + Record-Route point at a +; SEPARATE address (the route target T), so a route-set-correct ACK/BYE lands on +; T and NOT back on the core — the receiver-side proof of Route/Contact +; targeting. + +[transport-udp] +type = transport +protocol = udp +bind = 0.0.0.0:15060 + +[carrier] +type = endpoint +context = default +disallow = all +allow = ulaw +direct_media = no +rtp_symmetric = yes +dtmf_mode = rfc4733 +aors = carrier_aor +outbound_auth = carrierauth + +[carrier_aor] +type = aor +contact = sip:carrier@@CORE_S@:5060 + +[carrierauth] +type = auth +auth_type = userpass +username = carrier +password = s3cr3t diff --git a/tests/cp1-origination-challenge/config/rtp.conf b/tests/cp1-origination-challenge/config/rtp.conf new file mode 100644 index 0000000..8ef5b32 --- /dev/null +++ b/tests/cp1-origination-challenge/config/rtp.conf @@ -0,0 +1,3 @@ +[general] +rtpstart = 31000 +rtpend = 31040 diff --git a/tests/cp1-origination-challenge/run.sh b/tests/cp1-origination-challenge/run.sh new file mode 100755 index 0000000..195c4f6 --- /dev/null +++ b/tests/cp1-origination-challenge/run.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# CP1 (M-f) wire-correct digest challenge harness. +# +# Proves, end-to-end on an isolated `--internal` Docker network, that rustisk's +# origination path handles a carrier's 401 challenge WIRE-CORRECTLY — every +# claim verified RECEIVER-SIDE on the offline carrier's captured datagrams, +# never a rustisk TX log: +# +# (a) the carrier's 401 is ACKed (same branch) — the carrier captures the ACK +# and never has to retransmit its 401 / record NO-ACK; +# (b) the retry INVITE arrives with a NEW Via branch, an incremented CSeq, and +# a VALID digest Authorization (carrier recomputes the response); +# (c) after the carrier's 200 (CHANGED Contact + Record-Route pointing at a +# SEPARATE route target T), the 2xx ACK and the in-dialog BYE land on T — +# NOT back on the core / original request-URI; +# (d) the 2xx ACK carries the REAL CSeq (2, matching the authed INVITE) and the +# BYE the next in-dialog CSeq (3) — not a hardcoded 1. +# +# Isolated Docker only: it never touches the live voice stack, Helm, k8s, the +# carrier trunk, or the real PIN. A throwaway six-digit TEST pin is mounted +# read-only (rustisk fails closed without one) and removed on exit. +# +# tests/cp1-origination-challenge/run.sh +# +# All Docker is reaped on exit (trap). +set -euo pipefail + +HARNESS_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "$HARNESS_DIR/../.." && pwd)" +RUNTIME_DIR="$REPO_DIR/target/cp1-origination-challenge" +CONFIG_DIR="$RUNTIME_DIR/config" +RUN_DIR="$RUNTIME_DIR/run" +RUSTISK_LOG="$RUNTIME_DIR/rustisk.log" +PROOF="$RUNTIME_DIR/PROOF.txt" +CORE_CAPTURE="$RUNTIME_DIR/core.log" +TARGET_CAPTURE="$RUNTIME_DIR/target.log" + +RUSTISK_IMAGE="python@sha256:e031123e3d85762b141ad1cbc56452ba69c6e722ebf2f042cc0dc86c47c0d8b3" + +NET="cp1-net-$$" +RUSTISK_CONTAINER="cp1-rustisk-$$" +CORE_CONTAINER="cp1-core-$$" +TARGET_CONTAINER="cp1-target-$$" +THIRD_OCTET="$((20 + ($$ % 200)))" +SUBNET="10.251.$THIRD_OCTET.0/24" +IP_RANGE="10.251.$THIRD_OCTET.32/27" +RUSTISK_IP="10.251.$THIRD_OCTET.2" # fixed; core/target IPs stay DYNAMIC +SECRET_DIR="" + +reap_container() { + local c="$1" hp i + docker inspect "$c" >/dev/null 2>&1 || return 0 + hp="" + for i in 1 2 3 4 5; do + hp="$(docker inspect -f '{{.State.Pid}}' "$c" 2>/dev/null || true)" + [[ -n "$hp" && "$hp" != "0" ]] && break + sleep 0.3 + done + if [[ -n "$hp" && "$hp" != "0" ]]; then + kill -TERM "$hp" 2>/dev/null || true + timeout 3 docker wait "$c" >/dev/null 2>&1 || true + if docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null | grep -q true; then + kill -KILL "$hp" 2>/dev/null || true + timeout 3 docker wait "$c" >/dev/null 2>&1 || true + fi + fi + timeout 10 docker rm -f "$c" >/dev/null 2>&1 || true + for _ in $(seq 1 20); do docker inspect "$c" >/dev/null 2>&1 || return 0; sleep 0.25; done + return 1 +} + +cleanup() { + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + local leaked=0 + reap_container "$CORE_CONTAINER" || leaked=1 + reap_container "$TARGET_CONTAINER" || leaked=1 + reap_container "$RUSTISK_CONTAINER" || leaked=1 + timeout 10 docker network rm "$NET" >/dev/null 2>&1 || true + if [[ -n "$SECRET_DIR" && "$SECRET_DIR" == /mnt/data/herodevs-agents/cp1-pin-secret.* ]]; then + rm -rf "$SECRET_DIR" + fi + local still_net="" + docker network inspect "$NET" >/dev/null 2>&1 && still_net="$NET" + if (( leaked == 1 )) || [[ -n "$still_net" ]]; then + printf 'CLEANUP WARNING: leaked docker resources — reap by hand (docker rm -f, docker network rm %s)\n' "$NET" >&2 + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +require_command() { command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"; } +say() { printf '%s\n' "$*"; } +container_ip() { docker inspect -f "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}" "$1" 2>/dev/null; } + +wait_for_file_line() { + local file="$1" pattern="$2" timeout="$3" + local deadline=$(( $(date +%s) + timeout )) + while (( $(date +%s) < deadline )); do + [[ -f "$file" ]] && grep -q -- "$pattern" "$file" && return 0 + sleep 0.3 + done + return 1 +} + +wait_for_rustisk_boot() { + local deadline=$(( $(date +%s) + 40 )) + while (( $(date +%s) < deadline )); do + if docker logs "$RUSTISK_CONTAINER" 2>&1 | grep -q 'fully booted'; then return 0; fi + if ! docker inspect -f '{{.State.Running}}' "$RUSTISK_CONTAINER" 2>/dev/null | grep -q true; then + docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true + fail "rustisk container exited during boot; see $RUSTISK_LOG" + fi + sleep 0.5 + done + return 1 +} + +wait_for_ami() { + local deadline=$(( $(date +%s) + 20 )) + while (( $(date +%s) < deadline )); do + if docker exec "$RUSTISK_CONTAINER" python3 -c \ + "import socket,sys; s=socket.create_connection(('127.0.0.1',15038),2); d=s.recv(64); sys.exit(0 if d else 1)" \ + >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + return 1 +} + +# --------------------------------------------------------------------------- +require_command docker +require_command python3 +require_command cargo + +say '=== CP1 wire-correct digest challenge harness ===' +rm -rf "$RUNTIME_DIR" +mkdir -p "$CONFIG_DIR" "$RUN_DIR" +: >"$CORE_CAPTURE"; : >"$TARGET_CAPTURE" + +SECRET_DIR="$(mktemp -d /mnt/data/herodevs-agents/cp1-pin-secret.XXXXXX)" +chmod 700 "$SECRET_DIR" +umask 077 +printf '%06d\n' "$(( (RANDOM * 32768 + RANDOM) % 1000000 ))" >"$SECRET_DIR/pin" + +say "Building rustisk (Rust 1.97.0, CARGO_BUILD_JOBS=${CARGO_BUILD_JOBS:-6})..." +( cd "$REPO_DIR" && CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-6}" cargo +1.97.0 build -p rustisk-cli ) +[[ -x "$REPO_DIR/target/debug/rustisk" ]] || fail "rustisk debug binary not built" + +sed -e "s|@CONFIG_DIR@|$CONFIG_DIR|g" -e "s|@RUN_DIR@|$RUN_DIR|g" \ + "$HARNESS_DIR/config/asterisk.conf.tmpl" >"$CONFIG_DIR/asterisk.conf" +cp "$HARNESS_DIR/config/manager.conf" "$CONFIG_DIR/manager.conf" +cp "$HARNESS_DIR/config/extensions.conf" "$CONFIG_DIR/extensions.conf" +cp "$HARNESS_DIR/config/rtp.conf" "$CONFIG_DIR/rtp.conf" +printf '[general]\nsecret_file = /run/secrets/rustisk/pin\n' >"$CONFIG_DIR/pin_gate.conf" + +say "Creating isolated --internal network $NET ($SUBNET, dynamic pool $IP_RANGE)..." +docker network create --internal --subnet "$SUBNET" --ip-range "$IP_RANGE" "$NET" >/dev/null + +# --- Bring up the route TARGET first so the core can advertise its IP T ------- +say 'Starting carrier route target (holds T; captures 2xx ACK + BYE)...' +docker run -d --rm --name "$TARGET_CONTAINER" \ + --network "$NET" --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/carrier.py,dst=/carrier.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /carrier.py --role target --caller "$RUSTISK_IP" \ + --capture /runtime/target.log >/dev/null +T="" +for _ in $(seq 1 40); do T="$(container_ip "$TARGET_CONTAINER")"; [[ -n "$T" ]] && break; sleep 0.25; done +[[ -n "$T" ]] || fail "could not read route-target container IP T" +wait_for_file_line "$TARGET_CAPTURE" "READY role=target" 15 || fail "route target never became ready" +say "Route target IP T = $T" + +# --- Bring up the carrier CORE (challenges, answers with Contact/RR = T) ------- +say 'Starting carrier core (challenges 401; answers 200 with Contact + Record-Route = T)...' +docker run -d --rm --name "$CORE_CONTAINER" \ + --network "$NET" --user "$(id -u):$(id -g)" \ + --mount "type=bind,src=$HARNESS_DIR/carrier.py,dst=/carrier.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=/runtime" \ + "$RUSTISK_IMAGE" python3 /carrier.py --role core --caller "$RUSTISK_IP" \ + --route-target "$T" --capture /runtime/core.log >/dev/null +S="" +for _ in $(seq 1 40); do S="$(container_ip "$CORE_CONTAINER")"; [[ -n "$S" ]] && break; sleep 0.25; done +[[ -n "$S" ]] || fail "could not read carrier core container IP S" +wait_for_file_line "$CORE_CAPTURE" "READY role=core" 15 || fail "carrier core never became ready" +[[ "$S" != "$T" ]] || fail "core S and target T collided ($S) — route-set proof needs distinct addresses" +say "Carrier core IP S = $S (route target T = $T; distinct)" + +# --- Template pjsip.conf so Dial(PJSIP/carrier) resolves to the core S -------- +sed -e "s|@CORE_S@|$S|g" "$HARNESS_DIR/config/pjsip.conf.tmpl" >"$CONFIG_DIR/pjsip.conf" + +# --- Start rustisk (fixed IP R) ---------------------------------------------- +say "Starting isolated rustisk at $RUSTISK_IP..." +docker run -d --rm --name "$RUSTISK_CONTAINER" \ + --network "$NET" --ip "$RUSTISK_IP" \ + --ulimit nofile=65536:65536 --user "$(id -u):$(id -g)" \ + --entrypoint /rustisk \ + --mount "type=bind,src=$REPO_DIR/target/debug/rustisk,dst=/rustisk,readonly" \ + --mount "type=bind,src=$HARNESS_DIR/ami_originate.py,dst=/ami_originate.py,readonly" \ + --mount "type=bind,src=$RUNTIME_DIR,dst=$RUNTIME_DIR" \ + --mount "type=bind,src=$SECRET_DIR/pin,dst=/run/secrets/rustisk/pin,readonly" \ + "$RUSTISK_IMAGE" -f -vvv -C "$CONFIG_DIR/asterisk.conf" >/dev/null + +wait_for_rustisk_boot || fail "rustisk did not report fully booted" +wait_for_ami || fail "rustisk AMI (127.0.0.1:15038) never became reachable" +say 'rustisk booted; AMI reachable.' + +# --- Trigger one origination via AMI Originate -------------------------------- +say 'AMI Originate PJSIP/carrier ...' +docker exec -i "$RUSTISK_CONTAINER" python3 /ami_originate.py 127.0.0.1 15038 carrier cp1-orig >/dev/null \ + || fail "AMI Originate failed to queue" + +# Wait for the full receiver-side cycle to complete on the carrier captures. +wait_for_file_line "$CORE_CAPTURE" "RETRY-INVITE " 15 || { docker logs "$RUSTISK_CONTAINER" >"$RUSTISK_LOG" 2>&1 || true; fail "carrier core never saw the credentialed retry INVITE"; } +wait_for_file_line "$TARGET_CAPTURE" "ACK-2XX-AT-TARGET " 15 || fail "route target never saw the 2xx ACK" +wait_for_file_line "$TARGET_CAPTURE" "BYE-AT-TARGET " 15 || fail "route target never saw the in-dialog BYE" +sleep 1 # let any stray datagram to the core land before asserting silence + +# ============================================================================ +# Receiver-side assertions +# ============================================================================ +VERDICT_OK=1 +note() { printf '%s\n' "$*"; } + +# (a) challenge ACKed — core captured ACK-CHALLENGE, and never recorded NO-ACK. +if grep -q "ACK-CHALLENGE " "$CORE_CAPTURE" && ! grep -q "NO-ACK " "$CORE_CAPTURE"; then + A="PASS" +else + A="FAIL"; VERDICT_OK=0 +fi + +# (b) retry INVITE: NEW branch, incremented CSeq, VALID digest. +# NB: match " branch=" (leading space) so the retry line's own branch is read, +# not its "prev_branch=" field. +INV_BRANCH="$(grep -m1 "INVITE own=.* auth=no " "$CORE_CAPTURE" | sed -n 's/.* branch=\([^ ]*\).*/\1/p')" +RETRY_LINE="$(grep -m1 "RETRY-INVITE " "$CORE_CAPTURE" || true)" +RETRY_BRANCH="$(printf '%s' "$RETRY_LINE" | sed -n 's/.* branch=\([^ ]*\).*/\1/p')" +RETRY_CSEQ="$(printf '%s' "$RETRY_LINE" | sed -n 's/.*cseq=\([0-9]*\).*/\1/p')" +RETRY_VALID="$(printf '%s' "$RETRY_LINE" | sed -n 's/.*valid=\([a-z]*\).*/\1/p')" +if [[ -n "$INV_BRANCH" && -n "$RETRY_BRANCH" && "$RETRY_BRANCH" != "$INV_BRANCH" \ + && "$RETRY_CSEQ" == "2" && "$RETRY_VALID" == "yes" ]]; then + B="PASS" +else + B="FAIL"; VERDICT_OK=0 +fi + +# (c) route-set/Contact targeting: ACK + BYE at T, NOT at core. +if grep -q "ACK-2XX-AT-TARGET " "$TARGET_CAPTURE" && grep -q "BYE-AT-TARGET " "$TARGET_CAPTURE" \ + && ! grep -q "ACK-2XX-AT-CORE " "$CORE_CAPTURE" && ! grep -q "BYE-AT-CORE " "$CORE_CAPTURE"; then + C="PASS" +else + C="FAIL"; VERDICT_OK=0 +fi + +# (d) real CSeq on the in-dialog requests at T: ACK = "2 ACK", BYE = "3 BYE". +if grep -q "ACK-2XX-AT-TARGET .*cseq=2 ACK" "$TARGET_CAPTURE" \ + && grep -q "BYE-AT-TARGET .*cseq=3 BYE" "$TARGET_CAPTURE"; then + D="PASS" +else + D="FAIL"; VERDICT_OK=0 +fi + +{ + echo "CP1 wire-correct digest challenge harness — PROOF" + echo "generated: $(date -u +%FT%TZ)" + echo "rustisk HEAD: $(cd "$REPO_DIR" && git rev-parse --short HEAD 2>/dev/null || echo unknown)" + echo + echo "rustisk IP (fixed): $RUSTISK_IP" + echo "carrier core IP S: $S (INVITE target; challenges; answers 200)" + echo "route target IP T: $T (Contact + Record-Route target)" + echo "S != T: $([[ "$S" != "$T" ]] && echo yes || echo NO)" + echo + echo "(a) 401 challenge ACKed (same branch), no retransmit: $A" + echo "(b) retry INVITE new-branch + CSeq++ + valid digest: $B" + echo " first INVITE branch: $INV_BRANCH" + echo " retry INVITE branch: $RETRY_BRANCH (cseq=$RETRY_CSEQ valid=$RETRY_VALID)" + echo "(c) 2xx ACK + BYE land on route target T, not core: $C" + echo "(d) in-dialog CSeq real (ACK=2, BYE=3), not hardcoded 1:$D" + echo + echo "--- carrier CORE capture (receiver-side) ---" + cat "$CORE_CAPTURE" 2>/dev/null || true + echo "--- route TARGET capture (receiver-side) ---" + cat "$TARGET_CAPTURE" 2>/dev/null || true +} >"$PROOF" + +say '' +say '================ VERDICT ================' +cat "$PROOF" + +if (( VERDICT_OK == 1 )); then + say '' + say "PASS: CP1 origination challenge handled wire-correctly (receiver-side: (a) ACK, (b) new-branch+CSeq++ +digest, (c) route-set target, (d) real CSeq)." + say "Proof: $PROOF" + exit 0 +else + fail "CP1 harness verdict FAILED (see verdict above)" +fi