diff --git a/README.md b/README.md index 12e9e7f..7619b13 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ [![Crates.io](https://img.shields.io/crates/v/wavekat-sip.svg)](https://crates.io/crates/wavekat-sip) [![docs.rs](https://docs.rs/wavekat-sip/badge.svg)](https://docs.rs/wavekat-sip) -SIP signaling and RTP transport for [WaveKat](https://wavekat.com) voice pipelines, built on -[`rsipstack`](https://crates.io/crates/rsipstack). Same pattern as +SIP signaling and RTP transport for [WaveKat](https://wavekat.com) voice +pipelines, on a from-scratch SIP engine (no external SIP stack). Same pattern as [wavekat-vad](https://github.com/wavekat/wavekat-vad) and [wavekat-turn](https://github.com/wavekat/wavekat-turn). @@ -20,11 +20,12 @@ SIP signaling and RTP transport for [WaveKat](https://wavekat.com) voice pipelin A small, focused SIP/RTP toolkit for building softphones, voice bots, and recording bridges in Rust. It owns the wire-level concerns — -- **SIP signaling**: REGISTER (with digest auth + keepalive), INVITE (in/out), - BYE, dialog tracking. -- **SDP**: minimal offer/answer for G.711 telephony audio. -- **RTP**: header parser and a receive loop suitable for transcription / - recording / debug. +- **SIP signaling**: REGISTER (digest auth + keepalive), outbound and inbound + calls (`Caller` / `IncomingCall`), in-dialog hold/resume, DTMF (RFC 4733 + + INFO fallback), and RFC 4028 session timers. +- **SDP**: minimal offer/answer for G.711 (PCMU + PCMA) telephony audio. +- **RTP**: header parser, a debug-friendly receive loop, and a codec-agnostic + send loop. — and stays out of the audio device, codec, and call-orchestration layers so it remains light and embeddable. @@ -38,7 +39,6 @@ cargo add wavekat-sip Register an account against your SIP server: ```rust,no_run -use std::sync::Arc; use tokio_util::sync::CancellationToken; use wavekat_sip::{Registrar, SipAccount, SipEndpoint, Transport}; @@ -55,8 +55,7 @@ let account = SipAccount { }; let cancel = CancellationToken::new(); -let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await?; -let endpoint = Arc::new(endpoint); +let endpoint = SipEndpoint::new(&account, cancel.clone()).await?; // Expires: 60s, re-register every 50s. let registrar = Registrar::new(account, endpoint, cancel, 60, 50)?; @@ -66,20 +65,51 @@ registrar.keepalive_loop().await; # } ``` -INVITE wrappers (`Caller`, `Callee`) land in the next release. Until then, -drive `SipEndpoint::dialog_layer` directly. +Place an outbound call and hang up: + +```rust,no_run +use std::sync::Arc; +use wavekat_sip::{Caller, SipAccount, SipEndpoint}; + +# async fn run(account: SipAccount, endpoint: Arc) +# -> Result<(), Box> { +let caller = Caller::new(account, endpoint); +let target: wavekat_sip::re_exports::Uri = "sip:bob@example.com".try_into()?; +let mut call = caller.dial(target).await?; + +// Wire call.rtp_socket + call.remote_media to your audio / AI pipeline, then: +call.hangup().await?; +# Ok(()) +# } +``` + +Answer inbound calls from the endpoint's incoming stream: + +```rust,no_run +# use std::sync::Arc; +# use wavekat_sip::SipEndpoint; +# async fn run(endpoint: Arc) +# -> Result<(), Box> { +while let Some(incoming) = endpoint.next_incoming_call().await { + // Inspect incoming.remote_media, then accept (or reject): + let _call = incoming.accept().await?; +} +# Ok(()) +# } +``` ## Status -| Module | State | -|-------------|------------------------------------------------------| -| `account` | Stable — runtime SIP account type. | -| `endpoint` | Working — shared SIP endpoint + transport. | -| `registrar` | Working — REGISTER + auth + keepalive + unregister. | -| `sdp` | Working — minimal G.711 offer/answer. | -| `rtp` | Header parser + debug receive loop. RTP send next. | -| `caller` | _Planned_ — outbound INVITE wrapper. | -| `callee` | _Planned_ — inbound INVITE accept/reject helper. | +| Module | State | +|-------------|--------------------------------------------------------| +| `account` | Stable — runtime SIP account type. | +| `endpoint` | Working — shared SIP endpoint + transport + routing. | +| `registrar` | Working — REGISTER + auth + keepalive + unregister. | +| `resolve` | Working — RFC 3263 (subset) SRV + A/AAAA fallback. | +| `caller` | Working — outbound dial, hold/resume, DTMF, hangup. | +| `callee` | Working — inbound INVITE accept/reject. | +| `sdp` | Working — minimal G.711 offer/answer. | +| `rtp` | Working — header parser, receive loop, send loop. | ## Architecture @@ -87,13 +117,15 @@ drive `SipEndpoint::dialog_layer` directly. PSTN / SIP trunk │ ▼ - wavekat-sip ──► rsipstack (transport, transactions, dialogs) + wavekat-sip (in-house transport, transactions, dialogs) │ ├─ account ──── credentials + endpoint config - ├─ endpoint ─── UDP/TCP transport + DialogLayer + ├─ endpoint ─── UDP transport + transaction/dialog engine + routing ├─ registrar ── REGISTER / digest auth / keepalive + ├─ caller ───── outbound INVITE / hold / DTMF / hangup + ├─ callee ───── inbound INVITE accept / reject ├─ sdp ──────── offer/answer for telephony codecs - └─ rtp ──────── RTP header parse + receive + └─ rtp ──────── RTP header parse / receive / send │ ▼ your app ──► audio device I/O, codec, recording, AI pipeline @@ -122,6 +154,4 @@ Copyright 2026 WaveKat. ### Acknowledgements -- [`rsipstack`](https://crates.io/crates/rsipstack) — the SIP transaction / - dialog engine this crate wraps. - [`rsip`](https://crates.io/crates/rsip) — SIP message types. diff --git a/crates/wavekat-sip/Cargo.toml b/crates/wavekat-sip/Cargo.toml index 156a4dd..ef5d0bb 100644 --- a/crates/wavekat-sip/Cargo.toml +++ b/crates/wavekat-sip/Cargo.toml @@ -13,8 +13,9 @@ categories = ["network-programming", "multimedia::audio"] build = "build.rs" [dependencies] +# Only `rsip` (SIP message types) is used; the transaction/dialog/transport +# engine is built in-house under `src/stack/` (see docs/08-own-sip-stack.md). rsip = "0.4" -rsipstack = "0.4" tokio = { version = "1", features = ["macros", "rt", "net", "sync", "time"] } tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" @@ -22,9 +23,6 @@ serde = { version = "1", features = ["derive"] } thiserror = "2" hostname = "0.4" # DNS SRV lookups for RFC 3263 server location (src/resolve.rs). -# Already in our tree via rsipstack's default `srv_lookup` feature -# (same version, same default features), so this adds no new -# transitive deps. hickory-resolver = "0.25" [dev-dependencies] diff --git a/crates/wavekat-sip/src/callee.rs b/crates/wavekat-sip/src/callee.rs index c44412f..de95670 100644 --- a/crates/wavekat-sip/src/callee.rs +++ b/crates/wavekat-sip/src/callee.rs @@ -1,359 +1,173 @@ -//! Inbound INVITE handling — focused SIP-only layer. +//! Inbound calls: accept with an SDP answer, or reject. //! -//! There are two flows: -//! -//! - **Deferred decision** (recommended for UI-driven apps): -//! [`Callee::handle_pending`] parses the SDP offer, creates the server -//! dialog, sends `100 Trying`, and spawns the transaction handler. It -//! returns a [`PendingCall`] whose `state_rx` surfaces a pre-answer -//! cancel (`Terminated(UacCancel)`) so the UI can clear its ringing -//! indicator. The consumer then calls [`PendingCall::accept`] or -//! [`PendingCall::reject`] when the user decides. -//! - **One-shot accept / reject**: [`Callee::accept_transaction`] and -//! [`Callee::reject_transaction`] do everything in one call — useful -//! when the decision is already known (auto-answer, hard-coded busy). -//! They are thin convenience wrappers over the deferred flow and do -//! **not** detect a pre-answer cancel (there's no window to detect it -//! in). -//! -//! Audio device I/O, codecs, recording — all of those are the consumer's -//! problem. See `docs/01-port-plan.md`. -//! -//! `Transaction` is yielded by [`crate::endpoint::SipEndpoint`]'s incoming -//! transaction receiver. Filter for `Method::Invite` before calling -//! these. -//! -//! ## Hanging up a connected call -//! -//! [`AcceptedCall::dialog`] is a -//! [`ServerInviteDialog`]. To hang up locally (user hit "End call" in -//! the UI, an AI agent decided the call is over, …): -//! -//! ```ignore -//! accepted.dialog.bye().await?; -//! ``` -//! -//! The dialog state machine then transitions to -//! `Terminated(_, TerminatedReason::UasBye)` on `state_rx`, so a single -//! watcher pumping `state_rx` handles both local and remote hangup -//! through the same code path. The outbound side ([`crate::caller`]) -//! exposes the same pattern on [`ClientInviteDialog`]. -//! -//! [`ClientInviteDialog`]: rsipstack::dialog::client_dialog::ClientInviteDialog +//! [`crate::SipEndpoint::next_incoming_call`] yields an [`IncomingCall`] for +//! each new inbound INVITE, with the offer already parsed into +//! [`RemoteMedia`]. [`IncomingCall::accept`] binds an RTP socket, answers +//! `200 OK` with matching SDP, and returns a [`Call`]; [`IncomingCall::reject`] +//! sends a non-2xx final. -use std::net::{IpAddr, SocketAddr}; +use std::net::SocketAddr; use std::sync::Arc; -use rsip::{Header, StatusCode}; -use rsipstack::dialog::dialog::DialogStateReceiver; -use rsipstack::dialog::server_dialog::ServerInviteDialog; -use rsipstack::transaction::transaction::Transaction; +use rsip::headers::UntypedHeader; +use rsip::message::HeadersExt; +use rsip::{Request, StatusCode, Uri}; use tokio::net::UdpSocket; -use tracing::{debug, info, warn}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info}; -use crate::account::SipAccount; +use crate::caller::Call; use crate::endpoint::SipEndpoint; -use crate::sdp::{build_sdp, parse_sdp, RemoteMedia}; -use crate::session_timer::{ - negotiate_uas, require_timer_header, supported_timer_header, SessionTimer, UasSessionTimer, -}; +use crate::sdp::{build_sdp, RemoteMedia}; +use crate::session_timer::{negotiate_uas, require_timer_header, supported_timer_header}; +use crate::stack::dialog::Dialog; +use crate::stack::response::{build_response, ResponseBody}; +use crate::stack::transaction::{gen_tag, TransactionKey}; type BoxError = Box; -/// SIP-only callee handles. The consumer wires audio onto the returned -/// `rtp_socket`, uses `remote_media` to know where to send/expect RTP, and -/// can drive call control via `dialog` (BYE, re-INVITE, etc.). -pub struct AcceptedCall { - /// Server-side dialog. Call `.bye().await` to hang up locally. - pub dialog: ServerInviteDialog, - /// Where the remote endpoint expects RTP (from the SDP offer). - pub remote_media: RemoteMedia, - /// Local RTP socket; share via `Arc` to send and receive concurrently. - pub rtp_socket: Arc, - /// Local RTP address advertised in the SDP answer. - pub local_rtp_addr: SocketAddr, - /// Dialog state updates — UAC BYE, re-INVITE acks, termination reasons. - /// Pump this to detect remote hangup. - pub state_rx: DialogStateReceiver, - /// RFC 4028 session timer negotiated from the INVITE, if the caller - /// asked for one (the 200 OK echoed it back). Spawn - /// [`crate::session_timer_loop`] with this; `None` means no timer - /// was negotiated and none should run. - pub session_timer: Option, -} - -/// An inbound INVITE that has been hooked into the dialog layer — the -/// transaction handler is running (`100 Trying` sent, a pre-answer -/// `CANCEL` will be auto-replied with `487`) but no final response has -/// been sent yet. -/// -/// Consumers should pump `state_rx` while the call is "ringing" in the -/// UI. If it yields `DialogState::Terminated` *before* you call -/// [`accept`](Self::accept) / [`reject`](Self::reject), the remote side -/// cancelled — clear any ringing indicator. After that, the dialog is -/// gone; do not call `accept` / `reject`. -pub struct PendingCall { - /// Server-side dialog. The transaction handler task is already - /// pumping it; do not call `dialog.handle` yourself. - pub dialog: ServerInviteDialog, - /// Parsed SDP offer — where the remote expects RTP, what codec. +/// A new inbound INVITE awaiting accept/reject. +pub struct IncomingCall { + endpoint: Arc, + key: TransactionKey, + peer: SocketAddr, + request: Request, + /// Fired if the caller `CANCEL`s before this call is accepted or rejected; + /// surfaced via [`Self::cancelled`]. + cancelled: CancellationToken, + /// Where the caller expects RTP (parsed from its SDP offer). pub remote_media: RemoteMedia, - /// Dialog state updates. Watch for `Terminated(UacCancel)` to learn - /// the caller hung up before you answered. - pub state_rx: DialogStateReceiver, - /// RFC 4028 session timer negotiated from the INVITE's - /// `Session-Expires` / `Min-SE` / `Supported: timer` headers. - /// [`accept`](Self::accept) echoes it in the 200 OK; `None` when - /// the caller didn't ask for session timers. - pub session_timer: Option, - /// Local IP the endpoint is bound to — used for the SDP answer's - /// connection address when `accept` is called. - local_ip: IpAddr, } -impl PendingCall { - /// Send `200 OK` with an SDP answer matching the offer. Binds a - /// local RTP socket. Returns the [`AcceptedCall`] (dialog + audio - /// plumbing) for the caller to drive. - /// - /// On error the dialog handler keeps running and will time out - /// naturally; the caller can also call [`Self::reject`] *before* - /// `accept` if they want to fail fast. - pub async fn accept(self) -> Result { - let rtp_socket = UdpSocket::bind("0.0.0.0:0").await?; - let local_rtp_addr = rtp_socket.local_addr()?; - let rtp_port = local_rtp_addr.port(); - info!(local_ip = %self.local_ip, rtp_port, "bound RTP socket"); - - let sdp_answer = build_sdp(self.local_ip, rtp_port); - debug!("SDP answer:\n{}", String::from_utf8_lossy(&sdp_answer)); - - let headers = accept_headers(self.session_timer.as_ref()); - self.dialog.accept(Some(headers), Some(sdp_answer))?; - info!("sent 200 OK with SDP answer"); - - Ok(AcceptedCall { - dialog: self.dialog, - remote_media: self.remote_media, - rtp_socket: Arc::new(rtp_socket), - local_rtp_addr, - state_rx: self.state_rx, - session_timer: self.session_timer.map(|uas| uas.timer), - }) - } - - /// Send a non-2xx final response (typical: `486 Busy Here`, - /// `603 Decline`). The transaction handler task drains any retransmit - /// and exits. - pub fn reject(self, status: StatusCode) -> Result<(), BoxError> { - if status.code() < 300 { - return Err(format!("reject() got 2xx status {status}").into()); +impl IncomingCall { + pub(crate) fn new( + endpoint: Arc, + key: TransactionKey, + peer: SocketAddr, + request: Request, + remote_media: RemoteMedia, + cancelled: CancellationToken, + ) -> Self { + Self { + endpoint, + key, + peer, + request, + cancelled, + remote_media, } - self.dialog.reject(Some(status.clone()), None)?; - info!(%status, "rejected inbound INVITE"); - Ok(()) } -} -/// Stateless helper bound to an account + endpoint. -pub struct Callee { - account: SipAccount, - endpoint: Arc, -} + /// A token that fires if the caller `CANCEL`s before the call is accepted or + /// rejected — i.e. they hung up while it was still ringing. Watch it + /// alongside the accept/reject decision to surface a missed call. Once + /// [`accept`](Self::accept) or [`reject`](Self::reject) is called the INVITE + /// is no longer cancellable and the token will not fire. + pub fn cancelled(&self) -> CancellationToken { + self.cancelled.clone() + } -impl Callee { - pub fn new(account: SipAccount, endpoint: Arc) -> Self { - Self { account, endpoint } + /// The caller's `From` header value (e.g. `"Bob ;tag=…"`), + /// for displaying who is calling. `None` if the INVITE lacked a parseable + /// `From` (malformed; shouldn't happen in practice). + pub fn caller(&self) -> Option { + self.request + .from_header() + .ok() + .map(|h| h.value().to_string()) } - /// Begin handling an inbound INVITE: parse the SDP offer, create - /// the server dialog, send `100 Trying`, and spawn the handler that - /// drives the transaction state machine. - /// - /// The handler auto-replies `487 Request Terminated` to a pre-answer - /// `CANCEL` and transitions the dialog to `Terminated(UacCancel)` — - /// watch [`PendingCall::state_rx`] to surface that to your UI. - /// - /// Returns a [`PendingCall`] you call [`accept`](PendingCall::accept) - /// or [`reject`](PendingCall::reject) on when the UI decides. - pub async fn handle_pending(&self, mut tx: Transaction) -> Result { - let remote_media = parse_sdp(&tx.original.body)?; - let session_timer = negotiate_uas(&tx.original.headers); - info!( - remote_addr = %remote_media.addr, - remote_port = remote_media.port, - payload_type = remote_media.payload_type, - ?session_timer, - "parsed SDP offer", - ); + /// Accept the call: bind an RTP socket, answer `200 OK` with an SDP answer, + /// and return the established [`Call`]. + pub async fn accept(self) -> Result { + // No longer cancellable once we commit to answering. + self.endpoint.unregister_incoming(&self.key); + let rtp_socket = UdpSocket::bind("0.0.0.0:0").await?; + let local_rtp_addr = rtp_socket.local_addr()?; + let local_ip = self.endpoint.local_ip(); + info!(%local_ip, rtp_port = local_rtp_addr.port(), "bound RTP socket for inbound call"); - let (state_sender, state_rx) = self.endpoint.dialog_layer.new_dialog_state_channel(); - let contact_uri: rsip::Uri = format!( + let answer = build_sdp(local_ip, local_rtp_addr.port()); + debug!("SDP answer:\n{}", String::from_utf8_lossy(&answer)); + + let to_tag = gen_tag(); + let contact: Uri = format!( "sip:{}@{}", - self.account.username, self.endpoint.sip_addr.addr + self.endpoint.account().username, + self.endpoint.local_addr() ) .try_into()?; - let dialog = self.endpoint.dialog_layer.get_or_create_server_invite( - &tx, - state_sender, - None, - Some(contact_uri), - )?; - let dialog_for_handler = dialog.clone(); - tokio::spawn(async move { - let mut dialog = dialog_for_handler; - if let Err(e) = dialog.handle(&mut tx).await { - warn!("INVITE transaction handle error: {e}"); + // RFC 4028: if the caller asked for a session timer, honor it and echo + // the agreed interval (plus Require: timer when the peer supports it). + let uas_timer = negotiate_uas(&self.request.headers); + + let mut response = build_response( + &self.request, + StatusCode::OK, + Some(&to_tag), + Some(&contact), + Some(ResponseBody { + content_type: "application/sdp", + bytes: answer, + }), + ) + .ok_or("could not build 200 OK")?; + + if let Some(uas) = &uas_timer { + response.headers.push(supported_timer_header()); + response.headers.push(uas.echo.header()); + if uas.require_timer { + response.headers.push(require_timer_header()); } - }); + } - Ok(PendingCall { - dialog, - remote_media, - state_rx, - session_timer, - local_ip: self.endpoint.local_ip(), - }) - } + if !self.endpoint.ua().answer(self.key, response).await { + return Err("engine stopped before the 200 OK was sent".into()); + } + info!("sent 200 OK with SDP answer"); + + let dialog = Dialog::uas(&self.request, to_tag, contact) + .ok_or("inbound INVITE lacked the headers a dialog requires")?; - /// Accept an inbound INVITE in one shot — bind RTP, send `200 OK` - /// with SDP answer. Equivalent to [`handle_pending`](Self::handle_pending) - /// followed immediately by [`PendingCall::accept`]; use that pair if - /// the answer depends on a UI decision and you need to detect a - /// pre-answer cancel. - pub async fn accept_transaction(&self, tx: Transaction) -> Result { - self.handle_pending(tx).await?.accept().await + Ok(Call::new( + self.endpoint.clone(), + dialog, + self.peer, + uas_timer.map(|u| u.timer), + self.remote_media, + Arc::new(rtp_socket), + local_rtp_addr, + )) } - /// Reject an inbound INVITE in one shot with a non-2xx status - /// (typical: `486 Busy Here`, `603 Decline`). No SDP parsing, no RTP - /// socket — useful when refusing without inspecting the offer (e.g. - /// daemon shutdown clearing pending invites). - /// - /// Use [`PendingCall::reject`] instead when you already have a - /// `PendingCall` from [`handle_pending`](Self::handle_pending). - pub async fn reject_transaction( - &self, - mut tx: Transaction, - status: StatusCode, - ) -> Result<(), BoxError> { + /// Reject the call with a non-2xx final response (e.g. `486 Busy Here`). + pub async fn reject(self, status: StatusCode) -> Result<(), BoxError> { + // No longer cancellable once we commit to rejecting. + self.endpoint.unregister_incoming(&self.key); if status.code() < 300 { - return Err(format!("reject_transaction got 2xx status {status}").into()); + return Err(format!("reject() got a non-failure status {status}").into()); + } + let response = build_response(&self.request, status.clone(), Some(&gen_tag()), None, None) + .ok_or("could not build reject response")?; + if !self.endpoint.ua().answer(self.key, response).await { + return Err("engine stopped before the reject was sent".into()); } - - let (state_sender, _state_rx) = self.endpoint.dialog_layer.new_dialog_state_channel(); - let contact_uri: rsip::Uri = format!( - "sip:{}@{}", - self.account.username, self.endpoint.sip_addr.addr - ) - .try_into()?; - let dialog = self.endpoint.dialog_layer.get_or_create_server_invite( - &tx, - state_sender, - None, - Some(contact_uri), - )?; - - dialog.reject(Some(status.clone()), None)?; info!(%status, "rejected inbound INVITE"); - - // Drive the transaction so the response is sent and ACK absorbed. - tokio::spawn(async move { - let mut dialog = dialog; - if let Err(e) = dialog.handle(&mut tx).await { - debug!("rejected INVITE handle error (expected for some flows): {e}"); - } - }); - Ok(()) } } -/// Headers for the 200 OK sent by [`PendingCall::accept`]: the SDP -/// content type plus, when a session timer was negotiated, the RFC 4028 -/// echo (`Supported: timer`, `Require: timer` if the caller advertised -/// support, and the negotiated `Session-Expires`). -fn accept_headers(session_timer: Option<&UasSessionTimer>) -> Vec
{ - let mut headers = vec![Header::ContentType("application/sdp".into())]; - if let Some(uas) = session_timer { - headers.push(supported_timer_header()); - if uas.require_timer { - headers.push(require_timer_header()); - } - headers.push(uas.echo.header()); - } - headers -} - #[cfg(test)] mod tests { - use super::*; - use crate::session_timer::{Refresher, SessionExpires}; - - #[test] - fn reject_with_2xx_is_an_error() { - // The guard is pure: status.code() < 300 should fail fast. - let ok = StatusCode::OK; - assert!(ok.code() < 300); - let busy = StatusCode::BusyHere; - assert!(busy.code() >= 300); - } - - #[test] - fn accept_headers_without_timer_is_content_type_only() { - let rendered: Vec = accept_headers(None).iter().map(|h| h.to_string()).collect(); - assert_eq!(rendered, vec!["Content-Type: application/sdp"]); - } - - #[test] - fn accept_headers_echoes_negotiated_session_timer() { - let uas = UasSessionTimer { - timer: SessionTimer { - interval_secs: 1800, - we_are_refresher: false, - }, - echo: SessionExpires { - interval_secs: 1800, - refresher: Some(Refresher::Uac), - }, - require_timer: true, - }; - let rendered: Vec = accept_headers(Some(&uas)) - .iter() - .map(|h| h.to_string()) - .collect(); - assert_eq!( - rendered, - vec![ - "Content-Type: application/sdp", - "Supported: timer", - "Require: timer", - "Session-Expires: 1800;refresher=uac", - ] - ); - } + use rsip::StatusCode; #[test] - fn accept_headers_omits_require_when_caller_lacks_timer_support() { - // Proxy-inserted Session-Expires: we refresh, and we must not - // Require: timer from a caller that never advertised it. - let uas = UasSessionTimer { - timer: SessionTimer { - interval_secs: 90, - we_are_refresher: true, - }, - echo: SessionExpires { - interval_secs: 90, - refresher: Some(Refresher::Uas), - }, - require_timer: false, - }; - let rendered: Vec = accept_headers(Some(&uas)) - .iter() - .map(|h| h.to_string()) - .collect(); - assert!(!rendered.iter().any(|h| h.starts_with("Require"))); - assert!(rendered.contains(&"Session-Expires: 90;refresher=uas".to_string())); + fn reject_requires_non_2xx() { + // A 2xx is not a rejection; the guard is exercised live in the + // `stack::ua` loopback tests. Here we assert the status classification + // the guard relies on. + assert!(StatusCode::OK.code() < 300); + assert!(StatusCode::BusyHere.code() >= 300); } } diff --git a/crates/wavekat-sip/src/caller.rs b/crates/wavekat-sip/src/caller.rs index deef73f..834bd6d 100644 --- a/crates/wavekat-sip/src/caller.rs +++ b/crates/wavekat-sip/src/caller.rs @@ -1,158 +1,298 @@ -//! Outbound INVITE handling — symmetric to [`crate::callee`]. +//! Outbound calls and the established-call handle. //! -//! [`Caller::dial`] sends an INVITE in the background and returns a -//! [`PendingDial`] whose `state_rx` exposes the early dialog states -//! (`Calling`, `Early`, `Confirmed`, `Terminated`). The consumer pumps -//! that channel while the UI shows a "Dialing…" / "Ringing…" surface, -//! then calls [`PendingDial::on_confirmed`] once a 2xx arrives to -//! collect the negotiated SDP answer plus the bound local RTP socket. -//! -//! The local RTP socket is bound **at `dial` time**, not at -//! `on_confirmed` time, because the SDP offer in the INVITE must carry -//! a concrete local port. Cancelling the dial or dropping the -//! [`PendingDial`] frees the socket. -//! -//! ## Hanging up a connected call -//! -//! [`AcceptedDial::dialog`] is a -//! [`ClientInviteDialog`]. To hang up locally (user hit "End call"): -//! -//! ```ignore -//! accepted.dialog.bye().await?; -//! ``` -//! -//! The dialog state machine then transitions to -//! `Terminated(_, TerminatedReason::UacBye)` on `state_rx`, so a single -//! watcher pumping `state_rx` handles both local and remote hangup -//! through the same code path. -//! -//! Audio device I/O, codecs, recording, AI taps — all of those are the -//! consumer's problem. The `rtp_socket` + `remote_media` + -//! `local_rtp_addr` triple is the raw plumbing; route frames anywhere. +//! [`Caller::dial`] binds a local RTP socket, builds the SDP offer, places the +//! INVITE through the engine (answering a digest challenge if the server +//! demands one), and on a 2xx returns a [`Call`] — the negotiated remote media +//! plus the bound RTP socket. Audio device I/O, codecs and recording stay with +//! the consumer; the `rtp_socket` + `remote_media` + `local_rtp_addr` triple is +//! the raw plumbing. use std::net::SocketAddr; use std::sync::Arc; -use rsipstack::dialog::authenticate::Credential; -use rsipstack::dialog::client_dialog::ClientInviteDialog; -use rsipstack::dialog::dialog::{DialogState, DialogStateReceiver}; -use rsipstack::dialog::invitation::{InviteAsyncResult, InviteOption}; -use rsipstack::transport::SipAddr; +use rsip::{Header, Uri}; use tokio::net::UdpSocket; -use tokio::task::JoinHandle; +use tokio::sync::{mpsc, Mutex}; +use tokio_util::sync::CancellationToken; use tracing::{debug, info}; use crate::account::SipAccount; +use crate::dtmf_info::{build_info_body, classify, content_type_header, InfoOutcome}; use crate::endpoint::SipEndpoint; -use crate::sdp::{build_sdp, parse_sdp, RemoteMedia}; +use crate::inbound::InboundRequest; +use crate::rtp::dtmf::DtmfDigit; +use crate::sdp::{build_sdp, build_sdp_with, parse_sdp, MediaDirection, RemoteMedia}; use crate::session_timer::{ - negotiate_uac, supported_timer_header, SessionExpires, SessionTimer, + negotiate_uac, supported_timer_header, SessionDialogOps, SessionExpires, SessionTimer, DEFAULT_SESSION_EXPIRES_SECS, }; +use crate::stack::call::{CallConfig, CallOutcome}; +use crate::stack::dialog::{Dialog, DialogId}; +use crate::stack::transaction::gen_tag; type BoxError = Box; -/// SIP-only handles for an outbound call that the remote answered. +/// An established call: negotiated remote media plus the local RTP socket. /// -/// Symmetric to [`crate::callee::AcceptedCall`] but with a -/// [`ClientInviteDialog`] (outbound) instead of `ServerInviteDialog`. -/// The audio plumbing fields are deliberately raw so the consumer can -/// drop a mic / recorder / AI pipeline onto them without satisfying a -/// `wavekat-sip` trait. -pub struct AcceptedDial { - /// Client-side dialog. Call `.bye().await` to hang up locally. - pub dialog: ClientInviteDialog, - /// Where the remote endpoint expects RTP (from the SDP answer). +/// The same handle is produced by [`Caller::dial`] (outbound) and +/// [`crate::IncomingCall::accept`] (inbound), so call control is uniform. +pub struct Call { + endpoint: Arc, + /// Shared so a background session-timer loop ([`Call::session_handle`]) can + /// send refresh re-INVITEs / BYE while the call owner drives audio. The + /// mutex serializes the dialog's CSeq across both. + dialog: Arc>, + /// This dialog's identity, used to register for inbound in-dialog requests + /// and termination. + dialog_id: DialogId, + /// Fired when the peer ends the call (an in-dialog `BYE`); surfaced via + /// [`Call::terminated`]. Registered with the endpoint at construction. + terminated: CancellationToken, + peer: SocketAddr, + /// `true` once we have put the peer on hold via a `sendonly` re-INVITE. + held: bool, + /// SDP `o=` version; bumped on every re-offer (RFC 3264 §5). + sdp_version: u32, + /// The RFC 4028 session timer negotiated at call setup, if any. + session_timer: Option, + /// Where the remote endpoint expects RTP (from the negotiated SDP). pub remote_media: RemoteMedia, /// Local RTP socket; share via `Arc` to send and receive concurrently. pub rtp_socket: Arc, - /// Local RTP address advertised in the SDP offer. + /// Local RTP address advertised in our SDP. pub local_rtp_addr: SocketAddr, - /// Dialog state updates — re-INVITE acks, BYE, termination reasons. - /// Pump this to detect remote hangup. - pub state_rx: DialogStateReceiver, - /// RFC 4028 session timer negotiated from the 2xx, if the remote - /// supports it. Spawn [`crate::session_timer_loop`] with this to - /// keep the session refreshed / watched; `None` means the remote - /// declined session timers and no timer should run. - pub session_timer: Option, } -/// An outbound INVITE on the wire whose final response has not arrived. -/// -/// Pump `state_rx` while the UI shows "Dialing…" / "Ringing…": -/// -/// - `Calling(_)` — early dialog created; provisional or no response yet. -/// - `Early(_, _)` — provisional 1xx (180/183) received. -/// - `Confirmed(_)` — remote picked up; call [`on_confirmed`] to get -/// the [`AcceptedDial`] (parsed SDP answer + bound RTP socket). -/// - `Terminated(_, reason)` — call ended; see `TerminatedReason` -/// (`UasBusy`, `UasDecline`, `Timeout`, …). -/// -/// If the user hits "End" on the dialing screen, call [`cancel`]. -/// -/// [`cancel`]: PendingDial::cancel -/// [`on_confirmed`]: PendingDial::on_confirmed -pub struct PendingDial { - /// Client-side dialog. Use this for [`cancel`](Self::cancel) and, - /// after promotion to [`AcceptedDial`], for `.bye()`. - pub dialog: ClientInviteDialog, - /// Dialog state updates from the moment the INVITE goes on the wire. - pub state_rx: DialogStateReceiver, - rtp_socket: Arc, - local_rtp_addr: SocketAddr, - invite_task: JoinHandle, -} +impl Call { + pub(crate) fn new( + endpoint: Arc, + dialog: Dialog, + peer: SocketAddr, + session_timer: Option, + remote_media: RemoteMedia, + rtp_socket: Arc, + local_rtp_addr: SocketAddr, + ) -> Self { + let dialog_id = dialog.id(); + // Register for the peer-BYE termination signal up front, so a remote + // hangup is observable via `Call::terminated` whether or not the call + // ever opts into `inbound_requests`. + let terminated = endpoint.register_termination(dialog_id.clone()); + Self { + endpoint, + dialog: Arc::new(Mutex::new(dialog)), + dialog_id, + terminated, + peer, + held: false, + // The initial offer/answer was o= version 0. + sdp_version: 0, + session_timer, + remote_media, + rtp_socket, + local_rtp_addr, + } + } -impl PendingDial { - /// Send `CANCEL` for the pending INVITE. Idempotent: returns - /// `Ok(())` without re-sending if the dialog has already - /// confirmed or terminated. Maps to the user hitting "End" on the - /// dialing screen. - pub async fn cancel(&self) -> Result<(), BoxError> { - match self.dialog.state() { - DialogState::Confirmed(_, _) | DialogState::Terminated(_, _) => { - debug!("cancel on settled dialog; no-op"); - Ok(()) - } - _ => { - self.dialog.cancel().await?; - info!("sent CANCEL on outbound INVITE"); + /// Put the peer on hold (`on = true`, `a=sendonly`) or resume the call + /// (`on = false`, `a=sendrecv`) by sending an in-dialog re-INVITE with a + /// fresh SDP re-offer (RFC 3264 §8.4). + /// + /// The local hold state only flips once the peer accepts the re-INVITE with + /// a 2xx; a non-2xx final surfaces the server's reason and leaves the call + /// unchanged. The `o=` version is bumped for each re-offer regardless, as + /// RFC 3264 requires. + pub async fn set_hold(&mut self, on: bool) -> Result<(), BoxError> { + let direction = if on { + MediaDirection::SendOnly + } else { + MediaDirection::SendRecv + }; + self.sdp_version += 1; + let offer = build_sdp_with( + self.endpoint.local_ip(), + self.local_rtp_addr.port(), + direction, + self.sdp_version, + ); + let headers = vec![Header::ContentType("application/sdp".into())]; + let response = { + let mut dialog = self.dialog.lock().await; + self.endpoint + .ua() + .reinvite(self.peer, &mut dialog, headers, offer) + .await + }; + match response { + Some(r) if (200..300).contains(&r.status_code.code()) => { + self.held = on; + info!(on, "hold state updated via re-INVITE"); Ok(()) } + Some(r) => Err(format!("re-INVITE rejected: {}", r.status_code).into()), + None => Err("re-INVITE timed out with no final response".into()), } } - /// Wait for the INVITE transaction to complete and assemble the - /// [`AcceptedDial`] from the negotiated SDP answer plus the - /// already-bound local RTP socket. + /// `true` if the call is currently on hold (we sent a `sendonly` re-INVITE + /// the peer accepted). + pub fn is_held(&self) -> bool { + self.held + } + + /// The RFC 4028 session timer negotiated when the call was set up, or + /// `None` if neither side asked for one. Drive it with + /// [`crate::session_timer_loop`] against [`Call::session_handle`]. + pub fn session_timer(&self) -> Option { + self.session_timer + } + + /// A cloneable handle that sends refresh re-INVITEs / BYE on this call's + /// dialog, for running [`crate::session_timer_loop`] in a background task + /// alongside the audio path. Shares the dialog with the `Call`, so their + /// in-dialog requests serialize correctly. + pub fn session_handle(&self) -> CallSession { + CallSession { + endpoint: self.endpoint.clone(), + dialog: self.dialog.clone(), + peer: self.peer, + } + } + + /// Opt in to handle this call's inbound in-dialog requests — the peer's + /// re-`INVITE`s (e.g. an RFC 4028 session refresh, or a peer-initiated + /// hold) and `INFO`s (e.g. SIP-INFO DTMF) — instead of having the endpoint + /// auto-answer them `200 OK`. /// - /// Returns an error if the call did not confirm with a 2xx (final - /// non-2xx, timeout, transport error). On error the local RTP - /// socket is dropped. - pub async fn on_confirmed(self) -> Result { - let (_dialog_id, resp) = self.invite_task.await??; - let resp = resp.ok_or_else::(|| "INVITE produced no final response".into())?; - if resp.status_code.kind() != rsip::StatusCodeKind::Successful { - return Err(format!("INVITE did not confirm: status {}", resp.status_code).into()); + /// Returns a stream; each [`InboundRequest`] must be answered (with + /// [`InboundRequest::respond`] / [`InboundRequest::ok`]). While the returned + /// [`InboundRequests`] is alive, those requests route here; drop it to + /// revert to auto-answering. `BYE` / `OPTIONS` are always auto-answered. + /// Call this once per [`Call`]. + pub fn inbound_requests(&self) -> InboundRequests { + let rx = self.endpoint.register_dialog(self.dialog_id.clone()); + InboundRequests { + endpoint: self.endpoint.clone(), + dialog_id: self.dialog_id.clone(), + rx, + } + } + + /// A token that fires when the peer ends the call by sending an in-dialog + /// `BYE`. The endpoint auto-answers the BYE `200 OK`; this is purely the + /// notification. Clone it and `await` [`CancellationToken::cancelled`] in a + /// task to drive call teardown (stop audio, finalize a recording). It does + /// **not** fire for a local [`Call::hangup`] — the caller already knows. + pub fn terminated(&self) -> CancellationToken { + self.terminated.clone() + } + + /// Send one DTMF press via SIP `INFO` (`application/dtmf-relay`). + /// + /// Use this only when the remote did not negotiate RFC 4733 — i.e. + /// [`RemoteMedia::dtmf_payload_type`] is `None`. When telephone-event is + /// available, prefer [`crate::send_dtmf_burst`] over RTP. A + /// [`InfoOutcome::UnsupportedMedia`] result means the remote rejects this + /// transport too; stop sending further presses on this dialog. + pub async fn send_dtmf_info(&mut self, digit: DtmfDigit, duration_ms: u32) -> InfoOutcome { + let body = build_info_body(digit, duration_ms).into_bytes(); + let response = { + let mut dialog = self.dialog.lock().await; + self.endpoint + .ua() + .info(self.peer, &mut dialog, vec![content_type_header()], body) + .await + }; + classify(response) + } + + /// Hang up by sending an in-dialog `BYE`. Returns once the peer 2xxs it + /// (or the transaction gives up). + pub async fn hangup(&mut self) -> Result<(), BoxError> { + let acked = { + let mut dialog = self.dialog.lock().await; + self.endpoint.ua().hangup(self.peer, &mut dialog).await + }; + if acked { + info!("call hung up (BYE acknowledged)"); + Ok(()) + } else { + Err("BYE was not acknowledged".into()) + } + } +} + +impl Drop for Call { + fn drop(&mut self) { + // Release the termination registration so the endpoint's table doesn't + // grow for the life of the process. (`InboundRequests` similarly + // unregisters the dialog on its own drop.) + self.endpoint.unregister_termination(&self.dialog_id); + } +} + +/// A stream of a [`Call`]'s inbound in-dialog requests (peer re-`INVITE` / +/// `INFO`), produced by [`Call::inbound_requests`]. +/// +/// Dropping it unregisters the dialog, so its inbound requests revert to being +/// auto-answered `200 OK` by the endpoint. +pub struct InboundRequests { + endpoint: Arc, + dialog_id: DialogId, + rx: mpsc::Receiver, +} + +impl InboundRequests { + /// Await the next inbound request, or `None` once the call's endpoint shuts + /// down or this stream is being torn down. + pub async fn recv(&mut self) -> Option { + self.rx.recv().await + } +} + +impl Drop for InboundRequests { + fn drop(&mut self) { + self.endpoint.unregister_dialog(&self.dialog_id); + } +} + +/// A cloneable session-control handle over a [`Call`]'s dialog. +/// +/// Produced by [`Call::session_handle`] and consumed by +/// [`crate::session_timer_loop`]: it implements [`SessionDialogOps`] so the +/// loop can send refresh re-INVITEs and the tear-down BYE on the shared dialog. +#[derive(Clone)] +pub struct CallSession { + endpoint: Arc, + dialog: Arc>, + peer: SocketAddr, +} + +impl SessionDialogOps for CallSession { + async fn refresh( + &self, + mut headers: Vec
, + body: Option>, + ) -> Result, BoxError> { + let body = body.unwrap_or_default(); + if !body.is_empty() { + headers.push(Header::ContentType("application/sdp".into())); + } + let mut dialog = self.dialog.lock().await; + Ok(self + .endpoint + .ua() + .reinvite(self.peer, &mut dialog, headers, body) + .await) + } + + async fn send_bye(&self) -> Result<(), BoxError> { + let mut dialog = self.dialog.lock().await; + if self.endpoint.ua().hangup(self.peer, &mut dialog).await { + Ok(()) + } else { + Err("BYE was not acknowledged".into()) } - let remote_media = parse_sdp(&resp.body)?; - let session_timer = negotiate_uac(&resp.headers); - info!( - remote_addr = %remote_media.addr, - remote_port = remote_media.port, - payload_type = remote_media.payload_type, - ?session_timer, - "parsed SDP answer", - ); - Ok(AcceptedDial { - dialog: self.dialog, - remote_media, - rtp_socket: self.rtp_socket, - local_rtp_addr: self.local_rtp_addr, - state_rx: self.state_rx, - session_timer, - }) } } @@ -168,115 +308,119 @@ impl Caller { Self { account, endpoint } } - /// Place an outbound INVITE to `target`. Binds a local RTP socket, - /// builds the SDP offer, fires the INVITE in the background, and - /// returns a [`PendingDial`] the consumer pumps for state updates. + /// Place an outbound call to `target` and wait for it to be answered. /// - /// The destination is resolved from the account's - /// `server`/`port` (typical: a SIP proxy). Use - /// [`dial_with_destination`](Self::dial_with_destination) to route - /// directly to an explicit address (UA-to-UA, tests). - pub async fn dial(&self, target: rsip::Uri) -> Result { - let destination = resolve_server(&self.account).await?; - self.dial_with_destination(target, destination).await + /// Binds a local RTP socket, offers G.711 SDP, sends the INVITE to the + /// account's resolved server, follows provisional responses, and answers a + /// single `401`/`407` challenge. Returns the [`Call`] on a 2xx, or an error + /// if the call was rejected, timed out, or had no usable SDP answer. + pub async fn dial(&self, target: Uri) -> Result { + self.dial_inner(target, &CancellationToken::new(), None) + .await } - /// Like [`dial`](Self::dial) but with an explicit network - /// destination override (useful for direct UA-to-UA flows and - /// tests where no proxy resolves the target URI). - pub async fn dial_with_destination( + /// Like [`Caller::dial`], but `cancel` aborts a still-ringing call with a + /// `CANCEL` (RFC 3261 §9). Firing the token once a provisional has arrived + /// tears the pending INVITE down; the returned error then reflects the + /// `487 Request Terminated`. Use `cancel.is_cancelled()` to tell a + /// cancellation apart from a callee rejection. + pub async fn dial_cancellable( &self, - target: rsip::Uri, - destination: Option, - ) -> Result { + target: Uri, + cancel: &CancellationToken, + ) -> Result { + self.dial_inner(target, cancel, None).await + } + + /// Like [`Caller::dial_cancellable`], and additionally forwards each + /// provisional response status (e.g. [`rsip::StatusCode::Ringing`]) to + /// `progress` as it arrives — for a "ringing" UI. The channel closes when + /// the call reaches a final response. + pub async fn dial_with_progress( + &self, + target: Uri, + cancel: &CancellationToken, + progress: mpsc::Sender, + ) -> Result { + self.dial_inner(target, cancel, Some(progress)).await + } + + async fn dial_inner( + &self, + target: Uri, + cancel: &CancellationToken, + progress: Option>, + ) -> Result { let rtp_socket = UdpSocket::bind("0.0.0.0:0").await?; let local_rtp_addr = rtp_socket.local_addr()?; - let rtp_port = local_rtp_addr.port(); let local_ip = self.endpoint.local_ip(); - info!(local_ip = %local_ip, rtp_port, "bound RTP socket for outbound dial"); + info!(%local_ip, rtp_port = local_rtp_addr.port(), "bound RTP socket for outbound dial"); - let offer = build_sdp(local_ip, rtp_port); + let offer = build_sdp(local_ip, local_rtp_addr.port()); debug!("SDP offer:\n{}", String::from_utf8_lossy(&offer)); - let opt = build_invite_option( - &self.account, - &self.endpoint.sip_addr.addr.to_string(), - target, - offer, - destination, - )?; - let (state_sender, state_rx) = self.endpoint.dialog_layer.new_dialog_state_channel(); - let (dialog, invite_task) = self - .endpoint - .dialog_layer - .do_invite_async(opt, state_sender)?; - info!("INVITE on the wire"); - - Ok(PendingDial { - dialog, - state_rx, - rtp_socket: Arc::new(rtp_socket), - local_rtp_addr, - invite_task, - }) - } -} + let from: Uri = + format!("sip:{}@{}", self.account.username, self.account.domain).try_into()?; + let contact: Uri = format!( + "sip:{}@{}", + self.account.username, + self.endpoint.local_addr() + ) + .try_into()?; -/// Resolve the account's configured SIP server to a single -/// [`SipAddr`] via [`crate::resolve::resolve_sip_server`] (RFC 3263 -/// subset: SRV when no explicit port / IP literal, A/AAAA otherwise). -/// Returns `None` if DNS yields no usable address. -async fn resolve_server(account: &SipAccount) -> Result, BoxError> { - Ok(crate::resolve::resolve_sip_server(account) - .await? - .map(SipAddr::from)) -} + // Advertise RFC 4028 session-timer support so the answerer can pin a + // refresh interval in its 2xx (negotiated below). + let cfg = CallConfig { + target, + from, + contact, + from_tag: gen_tag(), + call_id: format!("{}@wavekat.com", gen_tag()), + sdp: offer, + extra_headers: vec![ + supported_timer_header(), + SessionExpires { + interval_secs: DEFAULT_SESSION_EXPIRES_SECS, + refresher: None, + } + .header(), + ], + username: self.account.auth_username().to_string(), + password: self.account.password.clone(), + }; -/// Compose an [`InviteOption`] from `account` + target. `contact_host` -/// is the endpoint's bound `host:port` (used for the `Contact` URI). -fn build_invite_option( - account: &SipAccount, - contact_host: &str, - target: rsip::Uri, - offer: Vec, - destination: Option, -) -> Result { - let caller_uri: rsip::Uri = - format!("sip:{}@{}", account.username, account.domain).try_into()?; - let contact_uri: rsip::Uri = format!("sip:{}@{}", account.username, contact_host).try_into()?; - let credential = Credential { - username: account.auth_username().to_string(), - password: account.password.clone(), - realm: None, - }; - let display_name = if account.display_name.is_empty() { - None - } else { - Some(account.display_name.clone()) - }; - Ok(InviteOption { - caller_display_name: display_name, - caller: caller_uri, - callee: target, - destination, - content_type: Some("application/sdp".into()), - offer: Some(offer), - contact: contact_uri, - credential: Some(credential), - // Advertise RFC 4028 session timers: ask for the default 30 min - // interval and leave the refresher choice to the answerer (no - // `refresher` param). A remote without timer support simply - // omits Session-Expires from its 2xx and no timer runs. - headers: Some(vec![ - supported_timer_header(), - SessionExpires { - interval_secs: DEFAULT_SESSION_EXPIRES_SECS, - refresher: None, + match self + .endpoint + .ua() + .call_cancellable(&cfg, self.endpoint.server(), 1, cancel, progress.as_ref()) + .await + { + CallOutcome::Answered { dialog, response } => { + let remote_media = parse_sdp(&response.body)?; + let session_timer = negotiate_uac(&response.headers); + info!( + remote_addr = %remote_media.addr, + remote_port = remote_media.port, + payload_type = remote_media.payload_type, + ?session_timer, + "call answered; parsed SDP answer", + ); + Ok(Call::new( + self.endpoint.clone(), + *dialog, + self.endpoint.server(), + session_timer, + remote_media, + Arc::new(rtp_socket), + local_rtp_addr, + )) } - .header(), - ]), - ..Default::default() - }) + CallOutcome::Rejected(status) => Err(format!("call rejected: {status}").into()), + CallOutcome::Unauthorized => Err("call rejected: authentication failed".into()), + CallOutcome::TimedOut => Err("call timed out with no final response".into()), + CallOutcome::EngineStopped => Err("engine stopped".into()), + } + } } #[cfg(test)] @@ -298,88 +442,10 @@ mod tests { } #[test] - fn build_invite_option_composes_from_account_and_target() { - let acct = test_account(); - let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap(); - let opt = build_invite_option( - &acct, - "192.168.1.50:5060", - target.clone(), - b"v=0\r\n".to_vec(), - None, - ) - .expect("build_invite_option"); - - assert_eq!(opt.caller.to_string(), "sip:1001@sip.example.com"); - assert_eq!(opt.callee, target); - assert_eq!(opt.contact.to_string(), "sip:1001@192.168.1.50:5060"); - assert_eq!(opt.content_type.as_deref(), Some("application/sdp")); - assert_eq!(opt.caller_display_name.as_deref(), Some("Office")); - - let cred = opt.credential.expect("credential should be set"); - assert_eq!(cred.username, "1001"); - assert_eq!(cred.password, "secret"); - } - - #[test] - fn build_invite_option_uses_auth_username_when_set() { - let mut acct = test_account(); - acct.auth_username = Some("admin".to_string()); - let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap(); - let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], None).unwrap(); - let cred = opt.credential.unwrap(); - assert_eq!( - cred.username, "admin", - "credential.username should follow auth_username, not the AOR user" - ); - } - - #[test] - fn build_invite_option_omits_display_name_when_empty() { - let mut acct = test_account(); - acct.display_name = String::new(); - let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap(); - let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], None).unwrap(); - assert!(opt.caller_display_name.is_none()); - } - - #[test] - fn build_invite_option_carries_offer_body() { - let acct = test_account(); - let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap(); - let offer = b"v=0\r\nm=audio 30000 RTP/AVP 0\r\n".to_vec(); - let opt = build_invite_option(&acct, "10.0.0.1:5060", target, offer.clone(), None).unwrap(); - assert_eq!(opt.offer.as_deref(), Some(offer.as_slice())); - } - - #[test] - fn build_invite_option_advertises_session_timers() { - let acct = test_account(); - let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap(); - let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], None).unwrap(); - let headers = opt.headers.expect("extra headers should be set"); - - let rendered: Vec = headers.iter().map(|h| h.to_string()).collect(); - assert!( - rendered.iter().any(|h| h == "Supported: timer"), - "INVITE must advertise Supported: timer, got {rendered:?}" - ); - assert!( - rendered - .iter() - .any(|h| h == &format!("Session-Expires: {DEFAULT_SESSION_EXPIRES_SECS}")), - "INVITE must request the default session interval without \ - pinning a refresher, got {rendered:?}" - ); - } - - #[test] - fn build_invite_option_threads_destination() { + fn caller_holds_account_and_endpoint_inputs() { + // Construction is pure; the call path is covered by the stack's + // loopback tests (`stack::ua`). Here we just check `new` wiring. let acct = test_account(); - let target: rsip::Uri = "sip:bob@example.com".try_into().unwrap(); - let dest: SipAddr = "127.0.0.1:5060".parse::().unwrap().into(); - let opt = build_invite_option(&acct, "10.0.0.1:5060", target, vec![], Some(dest.clone())) - .unwrap(); - assert_eq!(opt.destination, Some(dest)); + assert_eq!(acct.auth_username(), "1001"); } } diff --git a/crates/wavekat-sip/src/dtmf_info.rs b/crates/wavekat-sip/src/dtmf_info.rs index b625ca6..62635eb 100644 --- a/crates/wavekat-sip/src/dtmf_info.rs +++ b/crates/wavekat-sip/src/dtmf_info.rs @@ -5,9 +5,8 @@ //! hasn't agreed to RFC 4733 — but most legacy PBXes still accept DTMF //! via SIP `INFO` requests inside the dialog with the //! `application/dtmf-relay` body originally defined by Cisco. This -//! module builds that body and exposes thin async helpers around -//! [`rsipstack::dialog::client_dialog::ClientInviteDialog::info`] / -//! [`rsipstack::dialog::server_dialog::ServerInviteDialog::info`]. +//! module builds that body, classifies the response, and is driven by +//! [`crate::Call::send_dtmf_info`]. //! //! ## Wire format //! @@ -27,32 +26,26 @@ //! //! ## When to call //! -//! Use [`send_dtmf_info_client`] / [`send_dtmf_info_server`] only -//! after confirming the remote did not negotiate -//! `telephone-event/8000`. If RFC 4733 is available, prefer -//! [`crate::send_dtmf_burst`] — it's what every modern carrier -//! expects and the wire footprint is smaller. A 415 (Unsupported -//! Media Type) response from `INFO` means the remote rejects this -//! transport too; surface it to the user as "this trunk doesn't -//! accept touch-tones" and stop trying. +//! Use [`crate::Call::send_dtmf_info`] only after confirming the remote +//! did not negotiate `telephone-event/8000`. If RFC 4733 is available, +//! prefer [`crate::send_dtmf_burst`] — it's what every modern carrier +//! expects and the wire footprint is smaller. A 415 (Unsupported Media +//! Type) response means the remote rejects this transport too; surface +//! it to the user as "this trunk doesn't accept touch-tones" and stop +//! trying ([`InfoOutcome::should_stop`]). use rsip::{Header, StatusCode}; -use rsipstack::dialog::client_dialog::ClientInviteDialog; -use rsipstack::dialog::server_dialog::ServerInviteDialog; use tracing::warn; use crate::rtp::dtmf::DtmfDigit; -type BoxError = Box; - /// MIME type for the DTMF body carried in the `INFO` request. pub const CONTENT_TYPE: &str = "application/dtmf-relay"; /// Build the `application/dtmf-relay` body for one DTMF press. /// /// Returns a `String` like `"Signal=5\nDuration=160"`. No trailing -/// newline. The consumer wraps this in `Vec` to hand to -/// `dialog.info()`. +/// newline. The consumer wraps this in `Vec` to send. pub fn build_info_body(digit: DtmfDigit, duration_ms: u32) -> String { format!("Signal={}\nDuration={duration_ms}", digit.as_char()) } @@ -76,8 +69,7 @@ pub enum InfoOutcome { /// The remote returned a non-2xx, non-415 response. Logged but /// not retried — same observable effect as packet loss. OtherFailure(StatusCode), - /// The dialog was not in the confirmed state, so `INFO` could not - /// be sent. Common cause: the call has already ended. + /// No final response arrived (e.g. the dialog already ended). DialogNotConfirmed, } @@ -94,7 +86,11 @@ impl InfoOutcome { } } -fn classify(response: Option) -> InfoOutcome { +/// Classify the engine's in-dialog `INFO` result into an [`InfoOutcome`]. +/// +/// `None` (no final response) maps to [`InfoOutcome::DialogNotConfirmed`]; +/// the common cause is that the call has already ended. +pub(crate) fn classify(response: Option) -> InfoOutcome { let Some(r) = response else { return InfoOutcome::DialogNotConfirmed; }; @@ -113,38 +109,6 @@ fn classify(response: Option) -> InfoOutcome { } } -/// Send one DTMF press via SIP `INFO` on a client-side (outbound) dialog. -/// -/// Wraps [`ClientInviteDialog::info`] with the correct -/// `Content-Type` and body. The dialog must be in the confirmed -/// state (post-200-OK); calling before answer returns -/// [`InfoOutcome::DialogNotConfirmed`]. -pub async fn send_dtmf_info_client( - dialog: &ClientInviteDialog, - digit: DtmfDigit, - duration_ms: u32, -) -> Result { - let body = build_info_body(digit, duration_ms).into_bytes(); - let headers = vec![content_type_header()]; - let response = dialog.info(Some(headers), Some(body)).await?; - Ok(classify(response)) -} - -/// Send one DTMF press via SIP `INFO` on a server-side (inbound) dialog. -/// -/// Mirror of [`send_dtmf_info_client`] for the case where we answered -/// the call rather than placed it. -pub async fn send_dtmf_info_server( - dialog: &ServerInviteDialog, - digit: DtmfDigit, - duration_ms: u32, -) -> Result { - let body = build_info_body(digit, duration_ms).into_bytes(); - let headers = vec![content_type_header()]; - let response = dialog.info(Some(headers), Some(body)).await?; - Ok(classify(response)) -} - #[cfg(test)] mod tests { use super::*; @@ -183,8 +147,6 @@ mod tests { #[test] fn content_type_header_is_application_dtmf_relay() { let h = content_type_header(); - // Header serializes to a string; compare against the canonical - // form used by Cisco-style DTMF. let s = h.to_string(); assert!( s.contains(CONTENT_TYPE), @@ -194,7 +156,6 @@ mod tests { #[test] fn classify_2xx_response_is_accepted() { - // Build a minimal 200 OK and confirm classify() picks Accepted. let resp = make_response(200); match classify(Some(resp)) { InfoOutcome::Accepted(code) => assert_eq!(code.code(), 200), @@ -222,9 +183,9 @@ mod tests { #[test] fn classify_none_response_is_dialog_not_confirmed() { - // The rsipstack `info()` method returns `Ok(None)` when the - // dialog isn't confirmed; treat that as a distinct outcome so - // the consumer can refresh its UI rather than retry. + // The engine returns `None` when no final response arrives; treat + // that as a distinct outcome so the consumer can refresh its UI + // rather than retry. assert!(matches!(classify(None), InfoOutcome::DialogNotConfirmed)); } diff --git a/crates/wavekat-sip/src/endpoint.rs b/crates/wavekat-sip/src/endpoint.rs index c7880c5..fa4b8ab 100644 --- a/crates/wavekat-sip/src/endpoint.rs +++ b/crates/wavekat-sip/src/endpoint.rs @@ -1,322 +1,408 @@ -//! Shared SIP endpoint: UDP/TCP transport bound, dialog layer wired, -//! incoming-transaction stream exposed. - +//! Shared SIP endpoint: a bound UDP transport + the clean-room engine, with +//! inbound requests routed to new calls or auto-answered in-dialog. +//! +//! `SipEndpoint` owns the `Ua` (engine + router) and a +//! background task that drains inbound requests: +//! +//! - a brand-new `INVITE` (no `To` tag) becomes a [`crate::IncomingCall`] on +//! the `next_incoming_call` stream; +//! - in-dialog requests (`BYE`, `OPTIONS`, `INFO`, re-`INVITE`) are +//! auto-answered `200 OK`; +//! - the `ACK` for a 2xx is absorbed. + +use std::collections::HashMap; use std::net::{IpAddr, SocketAddr}; -use std::sync::Arc; - -use rsip::StatusCode; -use rsipstack::{ - dialog::{dialog::Dialog, dialog_layer::DialogLayer}, - transaction::{ - endpoint::{EndpointBuilder, EndpointInnerRef, EndpointOption}, - transaction::Transaction, - TransactionReceiver, - }, - transport::{udp::UdpConnection, SipAddr, SipConnection, TransportLayer}, -}; +use std::sync::{Arc, Mutex as StdMutex}; + +use rsip::headers::ToTypedHeader; +use rsip::message::HeadersExt; +use rsip::{Method, Request, StatusCode}; +use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use crate::account::{SipAccount, Transport}; +use crate::callee::IncomingCall; +use crate::inbound::InboundRequest; +use crate::resolve::resolve_sip_server; +use crate::sdp::parse_sdp; +use crate::stack::dialog::DialogId; +use crate::stack::response::build_response; +use crate::stack::transaction::{gen_tag, TransactionKey}; +use crate::stack::ua::{Incoming, Ua}; + +type BoxError = Box; + +/// Dialogs whose owning [`crate::Call`] has opted in to handle inbound +/// in-dialog requests itself, mapped to the channel that delivers them. +type DialogRegistry = Arc>>>; + +/// Confirmed dialogs whose owning [`crate::Call`] wants to be told when the +/// peer ends the call (an in-dialog `BYE`). The endpoint still auto-answers the +/// BYE `200 OK`; firing the token is how [`crate::Call::terminated`] resolves. +type TerminationRegistry = Arc>>; + +/// A not-yet-accepted inbound INVITE, kept so a later `CANCEL` (which shares the +/// INVITE's branch, RFC 3261 §9.1) can `487` the INVITE transaction and notify +/// the waiting [`crate::IncomingCall`]. Keyed by the INVITE's transaction key. +struct PendingInvite { + /// The original INVITE, needed to build the `487 Request Terminated`. + invite: Request, + /// Fired when a `CANCEL` for this INVITE arrives; surfaced as + /// [`crate::IncomingCall::cancelled`]. + cancel: CancellationToken, +} -/// Host part appended to the random prefix in generated `Call-ID` headers. -/// -/// rsipstack's default is `restsend.com` (its author's product domain), which -/// would otherwise leak into every REGISTER and INVITE we send. We override it -/// to our own product domain; the random prefix already provides global -/// uniqueness per RFC 3261, so the suffix is purely cosmetic/branding. -const CALLID_SUFFIX: &str = "wavekat.com"; +/// Inbound INVITEs awaiting an accept/reject decision, so a racing `CANCEL` can +/// terminate them. Keyed by the INVITE's transaction key. +type IncomingRegistry = Arc>>; -/// A bound SIP endpoint that owns its transport and dialog layer. +/// A bound SIP endpoint: the engine, plus inbound-call routing. pub struct SipEndpoint { - /// Endpoint inner ref — used to build requests, vias, etc. - pub inner: EndpointInnerRef, - /// Dialog layer for sending INVITEs and tracking dialogs. - pub dialog_layer: Arc, - /// First bound SIP address (host:port). - pub sip_addr: SipAddr, - /// Transport the endpoint was bound for. Mirrors `SipAccount::transport` - /// at the moment of `SipEndpoint::new`. Cached here so diagnostics can - /// report it without holding onto the account. + ua: Arc, + account: SipAccount, + server: SocketAddr, + local_ip: IpAddr, transport: Transport, - transport_cancel: CancellationToken, + cancel: CancellationToken, + incoming_calls: Mutex>, + /// Calls that have opted in to receive their dialog's re-INVITE / INFO. + dialogs: DialogRegistry, + /// Confirmed calls waiting to be told the peer hung up (in-dialog `BYE`). + terminations: TerminationRegistry, + /// Inbound INVITEs not yet accepted/rejected, for `CANCEL` handling. + incoming_invites: IncomingRegistry, } impl SipEndpoint { - /// Bind transport and start the endpoint's serve loop. + /// Bind transport, start the engine, and begin routing inbound requests. /// - /// Returns the endpoint plus the stream of incoming transactions - /// (you'll typically forward INVITE transactions from this to a - /// callee handler). + /// The account's `server`/`port` are resolved (RFC 3263 subset) to the + /// next-hop address all requests are sent to. pub async fn new( account: &SipAccount, cancel: CancellationToken, - ) -> Result<(Self, TransactionReceiver), Box> { + ) -> Result, BoxError> { Self::new_with_app(account, None, cancel).await } - /// Like [`SipEndpoint::new`], but prepends an application product - /// token to the `User-Agent` header, ahead of this library's own - /// token (the convention from RFC 7231 §5.5.3 / RFC 3261 §20.41: - /// most significant product first). - /// - /// Pass the token preformatted, e.g. `"my-app/1.2.3 (abc1234)"`, - /// yielding a header like: - /// - /// ```text - /// my-app/1.2.3 (abc1234) wavekat-sip/0.0.13 (macOS 15.5/aarch64) myhost.local - /// ``` + /// Like [`SipEndpoint::new`], but advertise `product` as the `User-Agent` on + /// every outbound request (e.g. `"my-app/1.2.3"`). `None` emits no header. pub async fn new_with_app( account: &SipAccount, - app_product: Option<&str>, - _cancel: CancellationToken, - ) -> Result<(Self, TransactionReceiver), Box> { + product: Option<&str>, + cancel: CancellationToken, + ) -> Result, BoxError> { let local_ip = detect_local_ip(account)?; - let bind_addr: SocketAddr = SocketAddr::new(local_ip, 0); + let bind_addr = SocketAddr::new(local_ip, 0); info!("Binding SIP transport to {bind_addr}"); - let transport_cancel = CancellationToken::new(); - let transport_layer = TransportLayer::new(transport_cancel.clone()); + let ua = Arc::new( + Ua::bind_with_app(bind_addr, product.map(String::from), cancel.clone()).await?, + ); + let server = resolve_sip_server(account) + .await? + .ok_or("could not resolve SIP server address")?; + info!(%server, "resolved SIP server"); + + let (calls_tx, calls_rx) = mpsc::channel(16); + let dialogs: DialogRegistry = Arc::new(StdMutex::new(HashMap::new())); + let terminations: TerminationRegistry = Arc::new(StdMutex::new(HashMap::new())); + let incoming_invites: IncomingRegistry = Arc::new(StdMutex::new(HashMap::new())); + let endpoint = Arc::new(Self { + ua: ua.clone(), + account: account.clone(), + server, + local_ip, + transport: account.transport, + cancel, + incoming_calls: Mutex::new(calls_rx), + dialogs: dialogs.clone(), + terminations: terminations.clone(), + incoming_invites: incoming_invites.clone(), + }); - match account.transport { - Transport::Udp => { - let udp = UdpConnection::create_connection( - bind_addr, - None, - Some(transport_cancel.clone()), - ) - .await?; - transport_layer.add_transport(SipConnection::Udp(udp)); - } - Transport::Tcp => { - // TCP uses outbound connections; transport_layer handles - // it via DNS/registry lookup. + // Inbound router: new INVITE → calls stream; in-dialog re-INVITE / INFO + // → the owning Call if it opted in, else auto-answer; BYE → fire the + // owning Call's termination signal; CANCEL → 487 the pending INVITE. + tokio::spawn(async move { + let routing = Routing { + dialogs, + terminations, + incoming_invites, + }; + while let Some(inc) = ua.next_incoming().await { + route_inbound(&ua, &routing, inc, &calls_tx).await; } + }); + + Ok(endpoint) + } + + /// Register `id` to receive its dialog's inbound re-INVITE / INFO requests. + /// Returns the channel they arrive on; until this is called (and while it + /// stays registered) those requests are auto-answered `200 OK` instead. + pub(crate) fn register_dialog(&self, id: DialogId) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(16); + if let Ok(mut map) = self.dialogs.lock() { + map.insert(id, tx); } + rx + } - let user_agent = build_user_agent( - app_product, - env!("CARGO_PKG_VERSION"), - crate::GIT_HASH, - &os_version(), - std::env::consts::ARCH, - &hostname::get() - .map(|h| h.to_string_lossy().into_owned()) - .unwrap_or_default(), - ); + /// Stop routing `id`'s inbound requests to a Call; they revert to being + /// auto-answered. + pub(crate) fn unregister_dialog(&self, id: &DialogId) { + if let Ok(mut map) = self.dialogs.lock() { + map.remove(id); + } + } - info!("User-Agent: {user_agent}"); - - let endpoint = EndpointBuilder::new() - .with_user_agent(&user_agent) - .with_transport_layer(transport_layer) - .with_cancel_token(transport_cancel.clone()) - .with_option(EndpointOption { - callid_suffix: Some(CALLID_SUFFIX.to_string()), - ..EndpointOption::default() - }) - .build(); - - let inner = endpoint.inner.clone(); - tokio::spawn({ - let inner = inner.clone(); - async move { - if let Err(e) = inner.serve().await { - warn!("endpoint serve error: {e}"); - } - } - }); + /// Register `id` to be told when the peer ends the call (an in-dialog + /// `BYE`). Returns a token the endpoint cancels on BYE; the BYE itself is + /// still auto-answered. Called once per [`crate::Call`] at setup. + pub(crate) fn register_termination(&self, id: DialogId) -> CancellationToken { + let token = CancellationToken::new(); + if let Ok(mut map) = self.terminations.lock() { + map.insert(id, token.clone()); + } + token + } - let sip_addr = endpoint - .get_addrs() - .into_iter() - .next() - .ok_or("No SIP address bound")?; - - let dialog_layer = Arc::new(DialogLayer::new(inner.clone())); - let incoming = endpoint.incoming_transactions()?; - - Ok(( - Self { - inner, - dialog_layer, - sip_addr, - transport: account.transport, - transport_cancel, - }, - incoming, - )) + /// Drop a dialog's termination registration (on [`crate::Call`] drop). + pub(crate) fn unregister_termination(&self, id: &DialogId) { + if let Ok(mut map) = self.terminations.lock() { + map.remove(id); + } } - /// Local IP address this endpoint is bound to. + /// Register a not-yet-accepted inbound INVITE so a racing `CANCEL` can + /// `487` it. Returns a token cancelled if that CANCEL arrives. Cleared by + /// [`Self::unregister_incoming`] once the call is accepted or rejected. + pub(crate) fn register_incoming( + &self, + key: TransactionKey, + invite: Request, + ) -> CancellationToken { + let token = CancellationToken::new(); + if let Ok(mut map) = self.incoming_invites.lock() { + map.insert( + key, + PendingInvite { + invite, + cancel: token.clone(), + }, + ); + } + token + } + + /// Drop a pending-INVITE registration once it is accepted or rejected, so a + /// late `CANCEL` no longer tries to `487` an answered call. + pub(crate) fn unregister_incoming(&self, key: &TransactionKey) { + if let Ok(mut map) = self.incoming_invites.lock() { + map.remove(key); + } + } + + /// Local IP this endpoint is bound to. pub fn local_ip(&self) -> IpAddr { - self.local_addr() - .map(|a| a.ip()) - .unwrap_or(IpAddr::from([127, 0, 0, 1])) + self.local_ip } /// Local socket address (IP + port) this endpoint is bound to. - /// - /// Returns `None` if the underlying rsipstack address can't be parsed - /// as a `SocketAddr` (in practice this only happens before transport - /// is fully up, which shouldn't be observable from a constructed - /// `SipEndpoint`). - pub fn local_addr(&self) -> Option { - self.sip_addr.addr.to_string().parse::().ok() + pub fn local_addr(&self) -> SocketAddr { + self.ua.local_addr() } - /// Transport this endpoint was bound for (UDP/TCP). + /// Transport this endpoint was bound for. pub fn transport(&self) -> Transport { self.transport } - /// Cancel the transport — stops the serve loop and frees the socket. + /// Stop the engine and free the socket. pub fn shutdown(&self) { - self.transport_cancel.cancel(); + self.cancel.cancel(); } - /// Route an inbound in-dialog transaction (BYE, INFO, OPTIONS, - /// re-INVITE, …) to its matching dialog and drive the dialog's - /// `handle()` to completion. - /// - /// The incoming-transaction stream returned by [`SipEndpoint::new`] - /// yields *every* inbound transaction the transport receives — the - /// initial INVITE for a new call, but also subsequent BYE/INFO/etc. - /// for already-established dialogs. Initial INVITEs go to - /// [`crate::Callee`]; everything else should be handed to this - /// helper so the dialog state machine advances (e.g. moving to - /// `Terminated` on a remote BYE and sending the matching 200 OK). - /// - /// On a non-matching transaction this replies with `481 Call/ - /// Transaction Does Not Exist` per RFC 3261; on a matching dialog - /// of an unsupported kind (subscriptions, publications) it replies - /// `501 Not Implemented`. The outcome is returned so callers can - /// emit diagnostics. - pub async fn dispatch_in_dialog( - &self, - mut tx: Transaction, - ) -> Result> { - let Some(dialog) = self.dialog_layer.match_dialog(&tx) else { - // No dialog matched. Best-effort 481; rsipstack's transport - // layer drops stateless replies that fail to send. - let _ = tx.reply(StatusCode::CallTransactionDoesNotExist).await; - return Ok(DispatchOutcome::NoDialog); - }; - - match dialog { - Dialog::ServerInvite(mut d) => { - d.handle(&mut tx).await?; - Ok(DispatchOutcome::Handled) - } - Dialog::ClientInvite(mut d) => { - d.handle(&mut tx).await?; - Ok(DispatchOutcome::Handled) - } - _ => { - let _ = tx.reply(StatusCode::NotImplemented).await; - Ok(DispatchOutcome::Unsupported) + /// Await the next inbound call (a new `INVITE`). Returns `None` when the + /// endpoint shuts down. Unparseable offers are skipped. + pub async fn next_incoming_call(self: &Arc) -> Option { + loop { + let incoming = self.incoming_calls.lock().await.recv().await?; + match parse_sdp(&incoming.request.body) { + Ok(remote_media) => { + // Register the INVITE so a CANCEL before the call is + // accepted/rejected `487`s it and cancels this token. + let cancelled = + self.register_incoming(incoming.key.clone(), incoming.request.clone()); + return Some(IncomingCall::new( + self.clone(), + incoming.key, + incoming.peer, + incoming.request, + remote_media, + cancelled, + )); + } + Err(e) => warn!(error = %e, "inbound INVITE has no usable SDP offer; skipping"), } } } -} -/// Outcome of [`SipEndpoint::dispatch_in_dialog`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DispatchOutcome { - /// A dialog matched and ran its handler. Subsequent state - /// transitions (e.g. `Terminated` on a BYE) will appear on the - /// dialog's `DialogStateReceiver`. - Handled, - /// No dialog matched the transaction's `Call-ID` + tag pair. The - /// helper replied `481 Call/Transaction Does Not Exist`. - NoDialog, - /// A dialog matched, but its kind isn't an INVITE dialog (e.g. - /// subscriptions). The helper replied `501 Not Implemented`. - Unsupported, + pub(crate) fn ua(&self) -> &Ua { + &self.ua + } + + pub(crate) fn server(&self) -> SocketAddr { + self.server + } + + pub(crate) fn account(&self) -> &SipAccount { + &self.account + } } -/// Build the User-Agent header string. -/// -/// `app_product` is an optional consumer-supplied product token (e.g. -/// `"my-app/1.2.3 (abc1234)"`) placed before the library's own token. -/// The library git hash is omitted when unavailable (registry builds -/// have no `.git`, so `GIT_HASH` is `"unknown"` there). -fn build_user_agent( - app_product: Option<&str>, - version: &str, - git_hash: &str, - os: &str, - arch: &str, - host: &str, -) -> String { - let app = app_product.map(|a| format!("{a} ")).unwrap_or_default(); - let hash = if git_hash.is_empty() || git_hash == "unknown" { - String::new() - } else { - format!(" ({git_hash})") - }; - format!("{app}wavekat-sip/{version}{hash} ({os}/{arch}) {host}") +/// The registries the inbound router consults, bundled so the router signature +/// stays readable. +struct Routing { + dialogs: DialogRegistry, + terminations: TerminationRegistry, + incoming_invites: IncomingRegistry, } -/// Returns a human-friendly OS name with version, e.g. `"macOS 15.5"`. -/// -/// Falls back to `std::env::consts::OS` if the version cannot be determined. -fn os_version() -> String { - #[cfg(target_os = "macos")] - { - if let Ok(out) = std::process::Command::new("sw_vers") - .arg("-productVersion") - .output() - { - let ver = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !ver.is_empty() { - return format!("macOS {ver}"); +/// Route one inbound request: new call, surface to a Call, auto-answer, fire a +/// termination, terminate a cancelled INVITE, or drop. +async fn route_inbound( + ua: &Arc, + routing: &Routing, + inc: Incoming, + calls_tx: &mpsc::Sender, +) { + let has_to_tag = inc + .request + .to_header() + .ok() + .and_then(|to| to.typed().ok()) + .map(|to| to.tag().is_some()) + .unwrap_or(false); + + match inc.request.method() { + // A fresh INVITE (no dialog tag yet) is a new inbound call. + Method::Invite if !has_to_tag => { + let _ = calls_tx.send(inc).await; + } + // The ACK for a 2xx we sent: it confirms our dialog; nothing to reply. + Method::Ack => debug!("absorbing 2xx ACK"), + // In-dialog re-INVITE or INFO: hand to the owning Call if it opted in + // to handle these (e.g. answer a session refresh, read INFO DTMF), + // otherwise auto-answer so the peer's transaction still completes. + Method::Invite | Method::Info => { + let sender = DialogId::from_request(&inc.request).and_then(|id| { + routing + .dialogs + .lock() + .ok() + .and_then(|map| map.get(&id).cloned()) + }); + match sender { + Some(sender) => { + let req = InboundRequest::new(ua.clone(), inc.key, inc.request); + if sender.send(req).await.is_err() { + warn!("in-dialog request dropped: Call no longer listening"); + } + } + None => auto_answer_200(ua, inc.key, &inc.request).await, } } - } - #[cfg(target_os = "linux")] - { - if let Ok(contents) = std::fs::read_to_string("/etc/os-release") { - for line in contents.lines() { - if let Some(name) = line.strip_prefix("PRETTY_NAME=") { - return name.trim_matches('"').to_string(); + // The peer ended the call: auto-answer the BYE, then fire the owning + // Call's termination signal so `Call::terminated` resolves. + Method::Bye => { + let dialog_id = DialogId::from_request(&inc.request); + auto_answer_200(ua, inc.key, &inc.request).await; + let token = dialog_id.as_ref().and_then(|id| { + routing + .terminations + .lock() + .ok() + .and_then(|map| map.get(id).cloned()) + }); + match token { + Some(token) => { + info!(?dialog_id, "peer BYE — firing call termination"); + token.cancel(); + } + // A BYE we 200'd but couldn't tie to a live Call: the dialog + // identity on the wire didn't match any registered termination, + // so the owning Call never learns the peer hung up and its + // consumer is left believing the call is still up. Log loudly — + // this is the silent "stuck on the call screen after the far end + // hangs up" failure, and the keys are what we need to see why the + // match missed (e.g. a gateway tagging the BYE differently). + None => { + let known: Vec = routing + .terminations + .lock() + .ok() + .map(|map| map.keys().cloned().collect()) + .unwrap_or_default(); + warn!( + ?dialog_id, + registered = ?known, + "peer BYE matched no live dialog — call will not tear down from this BYE" + ); } } } - } - #[cfg(target_os = "windows")] - { - if let Ok(out) = std::process::Command::new("cmd") - .args(["/C", "ver"]) - .output() - { - let ver = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !ver.is_empty() { - return ver; + // The peer cancelled a still-ringing inbound INVITE (RFC 3261 §9.2): + // 200 the CANCEL, 487 the matching INVITE transaction, and notify the + // waiting IncomingCall. + Method::Cancel => { + let invite_key = inc.key.invite_target(); + auto_answer_200(ua, inc.key, &inc.request).await; + let pending = routing + .incoming_invites + .lock() + .ok() + .and_then(|mut map| map.remove(&invite_key)); + if let Some(pending) = pending { + if let Some(resp) = build_response( + &pending.invite, + StatusCode::RequestTerminated, + Some(&gen_tag()), + None, + None, + ) { + ua.answer(invite_key, resp).await; + } + debug!("peer CANCEL — 487ed the INVITE, notifying IncomingCall"); + pending.cancel.cancel(); } } + // Any other in-dialog request (OPTIONS / …): auto-answer so the peer's + // transaction completes. + _ => auto_answer_200(ua, inc.key, &inc.request).await, + } +} + +/// Auto-answer an inbound request `200 OK` (no body) on its transaction. +async fn auto_answer_200(ua: &Ua, key: TransactionKey, request: &Request) { + if let Some(response) = build_response(request, StatusCode::OK, Some(&gen_tag()), None, None) { + let _ = ua.answer(key, response).await; } - std::env::consts::OS.to_string() } /// Detect the local IP that routes to the SIP server. /// -/// Opens a temporary UDP socket, connects to the server (no data sent), -/// and reads back the OS-chosen source address. -/// -/// Deliberately uses the OS resolver (`connect` → getaddrinfo), not the -/// SRV-aware [`crate::resolve`] path: this only needs *a* route to pick -/// a source IP, not the actual SIP target. Consequence: a domain with -/// SRV records but no A/AAAA on the bare host still fails here — see -/// `docs/06-srv-lookup.md`. -fn detect_local_ip( - account: &SipAccount, -) -> Result> { +/// Opens a temporary UDP socket, connects to the server (no data sent), and +/// reads back the OS-chosen source address. Uses the OS resolver, not the +/// SRV-aware [`crate::resolve`] path: it only needs *a* route to pick a source +/// IP. +fn detect_local_ip(account: &SipAccount) -> Result { let dest = format!("{}:{}", account.server(), account.port()); let sock = std::net::UdpSocket::bind("0.0.0.0:0")?; sock.connect(&dest)?; - let local = sock.local_addr()?; - Ok(local.ip()) + Ok(sock.local_addr()?.ip()) } #[cfg(test)] @@ -336,127 +422,23 @@ mod tests { } } - #[test] - fn build_user_agent_format() { - let ua = build_user_agent( - None, - "0.0.1", - "abc1234", - "macOS 15.5", - "aarch64", - "myhost.local", - ); - assert_eq!( - ua, - "wavekat-sip/0.0.1 (abc1234) (macOS 15.5/aarch64) myhost.local" - ); - } - - #[test] - fn build_user_agent_empty_host() { - let ua = build_user_agent(None, "1.0.0", "def5678", "Linux", "x86_64", ""); - assert_eq!(ua, "wavekat-sip/1.0.0 (def5678) (Linux/x86_64) "); - } - - #[test] - fn build_user_agent_with_app_product() { - let ua = build_user_agent( - Some("my-app/1.2.3 (cafe123)"), - "0.0.13", - "abc1234", - "macOS 15.5", - "aarch64", - "myhost.local", - ); - assert_eq!( - ua, - "my-app/1.2.3 (cafe123) wavekat-sip/0.0.13 (abc1234) (macOS 15.5/aarch64) myhost.local" - ); - } - - #[test] - fn build_user_agent_omits_unknown_git_hash() { - // Registry builds have no .git checkout, so GIT_HASH falls back - // to "unknown" — that's noise on the wire, drop the parens. - let ua = build_user_agent( - Some("my-app/1.2.3 (cafe123)"), - "0.0.13", - "unknown", - "macOS 15.5", - "aarch64", - "myhost.local", - ); - assert_eq!( - ua, - "my-app/1.2.3 (cafe123) wavekat-sip/0.0.13 (macOS 15.5/aarch64) myhost.local" - ); - let ua = build_user_agent(None, "0.0.13", "", "Linux", "x86_64", "host"); - assert_eq!(ua, "wavekat-sip/0.0.13 (Linux/x86_64) host"); - } - - #[test] - fn os_version_returns_non_empty() { - let version = os_version(); - assert!(!version.is_empty()); - #[cfg(target_os = "macos")] - assert!(version.starts_with("macOS"), "got: {version}"); - } - #[test] fn detect_local_ip_returns_non_unspecified() { - let account = make_account(Some("127.0.0.1"), Some(5060)); - let ip = detect_local_ip(&account).unwrap(); - assert!(!ip.is_unspecified(), "detected IP should not be 0.0.0.0"); - assert_eq!(ip, IpAddr::from([127, 0, 0, 1])); + let account = make_account(Some("1.1.1.1"), Some(5060)); + let ip = detect_local_ip(&account).expect("detects a local ip"); + assert!(!ip.is_unspecified()); } #[test] fn detect_local_ip_uses_server_field() { - let account = make_account(Some("127.0.0.1"), None); - let ip = detect_local_ip(&account).unwrap(); - assert_eq!(ip, IpAddr::from([127, 0, 0, 1])); + let account = make_account(Some("8.8.8.8"), Some(5060)); + assert!(detect_local_ip(&account).is_ok()); } #[test] fn detect_local_ip_falls_back_to_domain() { + // No explicit server → uses the domain (localhost resolves locally). let account = make_account(None, None); - let ip = detect_local_ip(&account).unwrap(); - assert_eq!(ip, IpAddr::from([127, 0, 0, 1])); - } - - #[tokio::test] - async fn endpoint_exposes_local_addr_and_transport() { - let account = make_account(Some("127.0.0.1"), Some(5060)); - let cancel = CancellationToken::new(); - let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await.unwrap(); - - let local = endpoint.local_addr().expect("local_addr available"); - assert_eq!(local.ip(), IpAddr::from([127, 0, 0, 1])); - assert_ne!(local.port(), 0, "bound port should be assigned"); - assert_eq!(endpoint.local_ip(), local.ip()); - assert_eq!(endpoint.transport(), Transport::Udp); - - endpoint.shutdown(); - } - - #[tokio::test] - async fn endpoint_overrides_callid_suffix() { - let account = make_account(Some("127.0.0.1"), Some(5060)); - let cancel = CancellationToken::new(); - let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await.unwrap(); - - let suffix = endpoint - .inner - .option - .callid_suffix - .as_deref() - .expect("callid_suffix should be configured"); - assert_eq!(suffix, CALLID_SUFFIX); - assert_ne!( - suffix, "restsend.com", - "should not fall back to rsipstack's default" - ); - - endpoint.shutdown(); + assert!(detect_local_ip(&account).is_ok()); } } diff --git a/crates/wavekat-sip/src/inbound.rs b/crates/wavekat-sip/src/inbound.rs new file mode 100644 index 0000000..2124639 --- /dev/null +++ b/crates/wavekat-sip/src/inbound.rs @@ -0,0 +1,122 @@ +//! Inbound in-dialog requests surfaced to the consumer. +//! +//! By default [`SipEndpoint`](crate::SipEndpoint) auto-answers every in-dialog +//! request (`BYE` / `OPTIONS` / `INFO` / re-`INVITE`) with `200 OK`. A consumer +//! that needs to *handle* a peer's re-`INVITE` (e.g. answer an RFC 4028 session +//! refresh with a fresh SDP answer, or apply a peer-initiated hold) or read an +//! inbound `INFO` (e.g. SIP-INFO DTMF) opts in via +//! [`Call::inbound_requests`](crate::Call::inbound_requests). From then on the +//! endpoint routes that dialog's re-`INVITE` / `INFO` here instead of +//! auto-answering them; the consumer answers each [`InboundRequest`] itself. +//! +//! `BYE` and `OPTIONS` are always auto-answered by the endpoint, even after +//! opting in — call teardown and keepalive don't need consumer involvement. + +use std::sync::Arc; + +use rsip::{Headers, Method, Request, StatusCode}; + +use crate::stack::response::{build_response, ResponseBody}; +use crate::stack::transaction::TransactionKey; +use crate::stack::ua::Ua; + +/// A single inbound in-dialog request (a peer re-`INVITE` or `INFO`) awaiting a +/// response from the consumer. +/// +/// Inspect it with [`method`](Self::method) / [`headers`](Self::headers) / +/// [`body`](Self::body), then answer exactly once with +/// [`respond`](Self::respond) or [`ok`](Self::ok). Dropping it without +/// answering leaves the peer's transaction to time out. +pub struct InboundRequest { + ua: Arc, + key: TransactionKey, + request: Request, +} + +impl InboundRequest { + pub(crate) fn new(ua: Arc, key: TransactionKey, request: Request) -> Self { + Self { ua, key, request } + } + + /// The request method — [`Method::Invite`] for a re-INVITE (re-offer / + /// session refresh) or [`Method::Info`] for SIP INFO. + pub fn method(&self) -> &Method { + self.request.method() + } + + /// The request's headers (e.g. to read `Session-Expires` on a refresh). + pub fn headers(&self) -> &Headers { + &self.request.headers + } + + /// The request body (e.g. an SDP re-offer or a `application/dtmf-relay` + /// INFO payload). + pub fn body(&self) -> &[u8] { + &self.request.body + } + + /// The `CSeq` method+number is in [`headers`](Self::headers); this is the + /// raw request for callers that need full access. + pub fn request(&self) -> &Request { + &self.request + } + + /// Answer with `status`, optional `extra_headers`, and an optional SDP body + /// (sent as `application/sdp`). Returns `true` if the engine sent it. + /// + /// Use this to answer a re-INVITE: `200 OK` with the SDP answer for a + /// session refresh or a peer hold, or a non-2xx to decline. + pub async fn respond( + self, + status: StatusCode, + extra_headers: Vec, + sdp: Option>, + ) -> bool { + let body = sdp.map(|bytes| ResponseBody { + content_type: "application/sdp", + bytes, + }); + let Some(mut response) = build_response(&self.request, status, None, None, body) else { + return false; + }; + for header in extra_headers { + response.headers.push(header); + } + self.ua.answer(self.key, response).await + } + + /// Answer `200 OK` with no body — the right reply for an `INFO`, or a + /// re-INVITE you accept without renegotiating media. + pub async fn ok(self) -> bool { + self.respond(StatusCode::OK, Vec::new(), None).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rsip::message::HeadersExt; + + fn info_request() -> Request { + let raw = "INFO sip:bob@10.0.0.1:5060 SIP/2.0\r\n\ + Via: SIP/2.0/UDP 1.2.3.4:5060;branch=z9hG4bK-info\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=ourtag\r\n\ + Call-ID: call-dlg\r\n\ + CSeq: 2 INFO\r\n\ + Content-Type: application/dtmf-relay\r\n\ + Content-Length: 21\r\n\r\n\ + Signal=5\nDuration=160"; + Request::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn exposes_method_headers_and_body() { + // Construction needs a Ua, which needs a runtime; the accessors are + // pure on the request, so test them on the parsed request directly. + let req = info_request(); + assert_eq!(*req.method(), Method::Info); + assert_eq!(req.body, b"Signal=5\nDuration=160"); + assert!(req.cseq_header().is_ok()); + } +} diff --git a/crates/wavekat-sip/src/lib.rs b/crates/wavekat-sip/src/lib.rs index 3f3a0bb..6fa81c1 100644 --- a/crates/wavekat-sip/src/lib.rs +++ b/crates/wavekat-sip/src/lib.rs @@ -1,42 +1,38 @@ //! SIP signaling and RTP transport for voice pipelines. //! //! `wavekat-sip` is a small, focused toolkit for building softphones, voice -//! bots, and recording bridges in Rust. It wraps [`rsipstack`] and owns the -//! wire-level concerns — SIP registration, dialogs, SDP offer/answer, and -//! RTP framing — while staying out of audio device I/O, codec work, and -//! call orchestration so it remains light and embeddable. +//! bots, and recording bridges in Rust. It owns the wire-level concerns — SIP +//! registration, dialogs, SDP offer/answer, and RTP framing — on a from-scratch +//! engine (no external SIP stack), while staying out of audio device I/O, codec +//! work, and call orchestration so it remains light and embeddable. //! -//! [`rsipstack`]: https://crates.io/crates/rsipstack +//! The SIP transaction/dialog/transport engine is built in-house (see the +//! `stack` plan in `docs/08-own-sip-stack.md`); only the [`rsip`] crate is used, +//! for SIP message types. The engine is an internal detail — consumers depend on +//! `wavekat-sip` alone. +//! +//! [`rsip`]: https://crates.io/crates/rsip //! //! # Scope //! //! What this crate covers: //! //! - **SIP signaling** — REGISTER with digest auth and keepalive -//! re-registration ([`Registrar`]), shared transport + dialog layer -//! ([`SipEndpoint`]). +//! re-registration ([`Registrar`]), a bound endpoint with inbound-call +//! routing ([`SipEndpoint`]), outbound calls ([`Caller`]), and inbound calls +//! ([`IncomingCall`]). //! - **SDP** — minimal G.711 (PCMU + PCMA) offer/answer with round-trip //! parsing ([`build_sdp`], [`parse_sdp`]). -//! - **RTP** — header parser ([`RtpHeader`]), a debug-friendly receive -//! loop ([`receive_rtp`]) suitable for transcription, recording, or -//! smoke-testing inbound media, and a codec-agnostic send loop -//! ([`send_loop`]) that packetizes consumer-supplied payloads onto -//! the wire. -//! -//! Explicitly out of scope (push these to the consuming application): +//! - **RTP** — header parser ([`RtpHeader`]), a debug-friendly receive loop +//! ([`receive_rtp`]), and a codec-agnostic send loop ([`send_loop`]). //! -//! - Audio device I/O (e.g. `cpal`), codec encode/decode, jitter buffering, -//! recording, WAV writing. -//! - Account persistence (TOML files, system keychain). -//! - Call orchestration, AI pipeline, business logic. -//! -//! Inbound INVITE handling lives in [`Callee`]; outbound INVITEs are -//! placed via [`Caller`]. +//! Explicitly out of scope (push these to the consuming application): audio +//! device I/O, codec encode/decode, jitter buffering, recording; account +//! persistence; call orchestration / AI pipeline / business logic. //! //! # Quick start: register against a SIP server //! //! ```no_run -//! use std::sync::Arc; //! use tokio_util::sync::CancellationToken; //! use wavekat_sip::{Registrar, SipAccount, SipEndpoint, Transport}; //! @@ -53,8 +49,7 @@ //! }; //! //! let cancel = CancellationToken::new(); -//! let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await?; -//! let endpoint = Arc::new(endpoint); +//! let endpoint = SipEndpoint::new(&account, cancel.clone()).await?; //! //! // Expires: 60s, re-register every 50s. //! let registrar = Registrar::new(account, endpoint, cancel, 60, 50)?; @@ -64,10 +59,6 @@ //! # } //! ``` //! -//! The `_incoming` stream returned by [`SipEndpoint::new`] yields inbound -//! transactions (INVITE, OPTIONS, …). For INVITEs, hand the transaction to -//! [`Callee::accept_transaction`] or [`Callee::reject_transaction`]. -//! //! # Placing an outbound call //! //! ```no_run @@ -78,15 +69,27 @@ //! # -> Result<(), Box> { //! let caller = Caller::new(account, endpoint); //! let target: wavekat_sip::re_exports::Uri = "sip:bob@example.com".try_into()?; -//! let mut pending = caller.dial(target).await?; +//! let mut call = caller.dial(target).await?; //! -//! // Pump pending.state_rx to render "Dialing…" / "Ringing…" in the UI. -//! // When you see DialogState::Confirmed, promote to AcceptedDial: -//! let accepted = pending.on_confirmed().await?; +//! // Wire RTP to your audio / AI pipeline using call.rtp_socket and +//! // call.remote_media. Hang up locally with: +//! call.hangup().await?; +//! # Ok(()) +//! # } +//! ``` //! -//! // Wire RTP to your audio / AI pipeline using accepted.rtp_socket -//! // and accepted.remote_media. Hang up locally with: -//! accepted.dialog.bye().await?; +//! # Answering inbound calls +//! +//! ```no_run +//! use std::sync::Arc; +//! use wavekat_sip::SipEndpoint; +//! +//! # async fn run(endpoint: Arc) +//! # -> Result<(), Box> { +//! while let Some(incoming) = endpoint.next_incoming_call().await { +//! // Inspect incoming.remote_media, then accept (or reject): +//! let _call = incoming.accept().await?; +//! } //! # Ok(()) //! # } //! ``` @@ -100,7 +103,6 @@ //! let local_ip: IpAddr = Ipv4Addr::new(192, 168, 1, 50).into(); //! let offer = build_sdp(local_ip, 20000); //! -//! // ... send `offer` in an INVITE, receive an SDP answer ... //! let answer = offer.clone(); // simulate a loopback answer //! let media = parse_sdp(&answer).expect("valid SDP"); //! assert_eq!(media.port, 20000); @@ -109,9 +111,6 @@ //! //! # Reading RTP headers off the wire //! -//! For real receive loops use [`receive_rtp`]; to inspect individual -//! packets, parse directly: -//! //! ``` //! use wavekat_sip::RtpHeader; //! @@ -131,19 +130,17 @@ //! | Module | Purpose | //! |-----------------|----------------------------------------------------------------| //! | [`account`] | Runtime [`SipAccount`] + [`Transport`] enum. | -//! | [`endpoint`] | [`SipEndpoint`] — bound transport, dialog layer, RX stream. | +//! | [`endpoint`] | [`SipEndpoint`] — bound transport, engine, inbound-call routing.| //! | [`registrar`] | REGISTER + digest auth + keepalive re-registration. | //! | [`resolve`] | RFC 3263 (subset) server location: SRV + A/AAAA fallback. | -//! | [`callee`] | Inbound INVITE accept/reject helper. | -//! | [`caller`] | Outbound INVITE / dial-cancel helper. | +//! | [`callee`] | [`IncomingCall`] — inbound INVITE accept/reject. | +//! | [`caller`] | [`Caller`] outbound dial + the [`Call`] handle. | //! | [`sdp`] | Minimal G.711 offer/answer build + parse. | //! | [`rtp`] | RTP header parser, debug receive loop, codec-agnostic send loop. | -//! | [`session_timer`] | RFC 4028 session timers — refresh re-INVITEs + expiry watchdog. | //! //! # Stability //! -//! Pre-1.0. The public API may still shift between minor versions. Pin -//! an exact version if you need stability today. +//! Pre-1.0. The public API may still shift between minor versions. //! //! # License //! @@ -156,20 +153,22 @@ pub mod callee; pub mod caller; pub mod dtmf_info; pub mod endpoint; +pub mod inbound; pub mod registrar; pub mod resolve; pub mod rtp; pub mod sdp; pub mod session_timer; +// Internal clean-room SIP engine (see `docs/08-own-sip-stack.md`). Entirely +// `pub(crate)`: it never appears in this crate's public API. +pub(crate) mod stack; pub use account::{SipAccount, Transport}; -pub use callee::{AcceptedCall, Callee, PendingCall}; -pub use caller::{AcceptedDial, Caller, PendingDial}; -pub use dtmf_info::{ - build_info_body, content_type_header, send_dtmf_info_client, send_dtmf_info_server, - InfoOutcome, CONTENT_TYPE, -}; -pub use endpoint::{DispatchOutcome, SipEndpoint}; +pub use callee::IncomingCall; +pub use caller::{Call, CallSession, Caller, InboundRequests}; +pub use dtmf_info::{build_info_body, content_type_header, InfoOutcome}; +pub use endpoint::SipEndpoint; +pub use inbound::InboundRequest; pub use registrar::{Registrar, RegistrarDiagnostics}; pub use resolve::{order_candidates, resolve_sip_server, SrvRecord}; pub use rtp::dtmf::{ @@ -178,7 +177,7 @@ pub use rtp::dtmf::{ }; pub use rtp::dtmf_recv::{parse_event_payload, DtmfEvent, DtmfEventPayload, DtmfReceiver}; pub use rtp::{receive_rtp, send_loop, RtpHeader, RtpSendConfig}; -pub use sdp::{build_sdp, parse_sdp, RemoteMedia, DTMF_DEFAULT_PT}; +pub use sdp::{build_sdp, build_sdp_with, parse_sdp, MediaDirection, RemoteMedia, DTMF_DEFAULT_PT}; pub use session_timer::{ min_se_in, negotiate_uac, negotiate_uas, require_timer_header, session_expires_in, session_timer_loop, supported_timer_header, supports_timer, Refresher, SessionDialogOps, @@ -186,17 +185,10 @@ pub use session_timer::{ DEFAULT_SESSION_EXPIRES_SECS, MIN_SESSION_EXPIRES_SECS, }; -/// Re-exports of upstream types that appear in our public API. Pinning -/// them here lets consumers depend only on `wavekat-sip` without taking -/// a direct dep on `rsip` / `rsipstack`. +/// Re-exports of the [`rsip`] message types that appear in our public API. +/// Pinning them here lets consumers depend only on `wavekat-sip`. pub mod re_exports { pub use rsip::{Header, Headers, Method, StatusCode, Uri}; - pub use rsipstack::dialog::dialog::{ - DialogState, DialogStateReceiver, TerminatedReason, TransactionHandle, - }; - pub use rsipstack::dialog::DialogId; - pub use rsipstack::transaction::transaction::Transaction; - pub use rsipstack::transport::SipAddr; } /// Short git hash this crate was built from, or `"unknown"` if unavailable. diff --git a/crates/wavekat-sip/src/registrar.rs b/crates/wavekat-sip/src/registrar.rs index 01d82f7..6953be1 100644 --- a/crates/wavekat-sip/src/registrar.rs +++ b/crates/wavekat-sip/src/registrar.rs @@ -1,750 +1,239 @@ -//! REGISTER + digest auth + keepalive re-registration. +//! REGISTER + digest auth + keepalive re-registration, over the engine. +//! +//! [`Registrar::register`] composes a REGISTER, sends it through the engine, +//! answers a `401`/`407` digest challenge, and records the outcome. +//! [`Registrar::keepalive_loop`] re-registers on an interval until cancelled; +//! [`Registrar::unregister`] sends `Expires: 0`. -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; -use rsip::{ - prelude::HeadersExt, prelude::ToTypedHeader, prelude::UntypedHeader, SipMessage, StatusCode, - Uri, -}; -use rsipstack::{ - dialog::authenticate::{handle_client_authenticate, Credential}, - transaction::{ - key::{TransactionKey, TransactionRole}, - make_call_id, make_tag, - transaction::Transaction, - }, -}; -use tokio::select; +use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; use crate::account::SipAccount; use crate::endpoint::SipEndpoint; +use crate::stack::registration::{RegisterConfig, RegisterOutcome}; +use crate::stack::transaction::gen_tag; -/// Sends `REGISTER` (with digest auth retry) on demand, keeps the -/// registration fresh via a keepalive loop, and unregisters on shutdown. -pub struct Registrar { - account: SipAccount, - endpoint: Arc, - cancel: CancellationToken, - server_uri: Uri, - call_id: rsip::headers::CallId, - seq: std::sync::atomic::AtomicU32, - contact: tokio::sync::Mutex>, - /// `Expires` value sent in REGISTER requests (seconds). - register_expires: u32, - /// Keepalive re-registration interval (seconds). - keepalive_secs: u32, - /// Observable snapshot of REGISTER outcomes. Held under a sync mutex - /// so [`Registrar::diagnostics`] doesn't need to be async; updates - /// are short and never block on I/O. - diag: Mutex, -} +type BoxError = Box; -/// Mutable state used to populate [`RegistrarDiagnostics`]. Kept separate -/// from the long-lived registrar fields above so the diagnostics getter -/// can snapshot it under a single lock. -#[derive(Debug, Default)] -struct DiagState { - contact_uri: Option, - negotiated_expires: Option, - last_status: Option, - last_attempt_at: Option, - last_success_at: Option, - last_error: Option, - register_count: u64, - failure_count: u64, -} - -/// Snapshot of the registrar's current state. Returned by -/// [`Registrar::diagnostics`]; cheap to clone. -/// -/// All timestamps are wall-clock ([`SystemTime`]) so callers can render -/// them as absolute strings without needing a separate uptime reference. +/// A point-in-time snapshot of a [`Registrar`]'s status, for UIs/telemetry. #[derive(Debug, Clone)] pub struct RegistrarDiagnostics { - /// SIP server URI we send REGISTER to, e.g. `sip:pbx.example.com:5060`. pub server_uri: String, - /// `Contact` URI advertised in the most recent successful REGISTER. - /// `None` until the first 200 OK. pub contact_uri: Option, - /// `Call-ID` value used across this registrar's REGISTER dialog. The - /// bare value only (e.g. `abc123@example.com`) — no `Call-ID:` header - /// prefix, so callers can render it directly without stripping. pub call_id: String, - /// Next CSeq the registrar will send (current value of the internal - /// atomic — may have already been incremented past the value used in - /// the last sent request). pub cseq: u32, - /// `Expires` we ask for. From the registrar's constructor. pub configured_expires: u32, - /// `Expires` the server returned in the most recent 200 OK. pub negotiated_expires: Option, - /// SIP status of the most recent final response. pub last_status: Option, - /// When the most recent REGISTER was sent. pub last_attempt_at: Option, - /// When the most recent 200 OK was received. pub last_success_at: Option, - /// Most recent failure message (only set when `last_status` indicates - /// failure or the transaction terminated unexpectedly). pub last_error: Option, - /// Cumulative count of successful REGISTERs since process start. pub register_count: u64, - /// Cumulative count of failed REGISTERs since process start. pub failure_count: u64, } +/// Mutable registration state behind a mutex. +struct State { + cseq: u32, + negotiated_expires: Option, + last_status: Option, + last_attempt_at: Option, + last_success_at: Option, + last_error: Option, + register_count: u64, + failure_count: u64, +} + +/// Registers an account and keeps the registration fresh. +pub struct Registrar { + account: SipAccount, + endpoint: Arc, + cancel: CancellationToken, + expires: u32, + refresh_secs: u64, + call_id: String, + from_tag: String, + contact: String, + state: Mutex, +} + impl Registrar { - /// Build a registrar bound to an endpoint. - /// - /// `register_expires` is the `Expires` value sent in REGISTERs (typical: - /// 60–300 seconds). `keepalive_secs` is the *requested* re-registration - /// cadence (typical: `register_expires` minus a small margin, e.g. - /// `expires - 10`). - /// - /// Note that `keepalive_secs` is only an upper bound on the wait between - /// re-registrations. Servers routinely grant a shorter `Expires` than we - /// ask for, so the keepalive loop refreshes shortly before whatever the - /// server actually granted lapses — see `refresh_interval_secs`. + /// Build a registrar. `expires` is the requested lifetime; `refresh_secs` + /// is how often [`keepalive_loop`](Self::keepalive_loop) re-registers. pub fn new( account: SipAccount, endpoint: Arc, cancel: CancellationToken, - register_expires: u32, - keepalive_secs: u32, - ) -> Result> { - let server_uri: Uri = format!("sip:{}:{}", account.server(), account.port()).try_into()?; - let call_id = make_call_id(endpoint.inner.option.callid_suffix.as_deref()); - + expires: u32, + refresh_secs: u64, + ) -> Result { + let contact = format!("sip:{}@{}", account.username, endpoint.local_addr()); Ok(Self { account, endpoint, cancel, - server_uri, - call_id, - seq: std::sync::atomic::AtomicU32::new(0), - contact: tokio::sync::Mutex::new(None), - register_expires, - keepalive_secs, - diag: Mutex::new(DiagState::default()), + expires, + refresh_secs, + call_id: format!("{}@wavekat.com", gen_tag()), + from_tag: gen_tag(), + contact, + state: Mutex::new(State { + cseq: 0, + negotiated_expires: None, + last_status: None, + last_attempt_at: None, + last_success_at: None, + last_error: None, + register_count: 0, + failure_count: 0, + }), }) } - /// Snapshot of REGISTER state — local socket, server target, last - /// status, counters, etc. Cheap to clone; safe to call from any task. - pub fn diagnostics(&self) -> RegistrarDiagnostics { - let diag = self.diag.lock().expect("registrar diag mutex poisoned"); - RegistrarDiagnostics { - server_uri: self.server_uri.to_string(), - contact_uri: diag.contact_uri.clone(), - call_id: self.call_id.value().to_string(), - cseq: self.seq.load(std::sync::atomic::Ordering::Relaxed), - configured_expires: self.register_expires, - negotiated_expires: diag.negotiated_expires, - last_status: diag.last_status, - last_attempt_at: diag.last_attempt_at, - last_success_at: diag.last_success_at, - last_error: diag.last_error.clone(), - register_count: diag.register_count, - failure_count: diag.failure_count, - } - } - - fn record_attempt(&self) { - let mut diag = self.diag.lock().expect("registrar diag mutex poisoned"); - diag.last_attempt_at = Some(SystemTime::now()); - } - - fn record_success(&self, status: u16, expires: u32, contact_uri: Option) { - let mut diag = self.diag.lock().expect("registrar diag mutex poisoned"); - diag.last_status = Some(status); - diag.last_success_at = Some(SystemTime::now()); - diag.negotiated_expires = Some(expires); - if let Some(c) = contact_uri { - diag.contact_uri = Some(c); - } - diag.register_count = diag.register_count.saturating_add(1); - diag.last_error = None; - } - - fn record_failure(&self, status: Option, error: String) { - let mut diag = self.diag.lock().expect("registrar diag mutex poisoned"); - diag.last_status = status; - diag.last_error = Some(error); - diag.failure_count = diag.failure_count.saturating_add(1); - } - - fn next_seq(&self) -> u32 { - self.seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 + fn config(&self, expires: u32) -> Result { + Ok(RegisterConfig { + registrar_uri: format!("sip:{}", self.account.domain).try_into()?, + aor: format!("sip:{}@{}", self.account.username, self.account.domain).try_into()?, + contact: self.contact.clone().try_into()?, + from_tag: self.from_tag.clone(), + call_id: self.call_id.clone(), + expires, + username: self.account.auth_username().to_string(), + password: self.account.password.clone(), + }) } - fn set_seq(&self, val: u32) { - self.seq.store(val, std::sync::atomic::Ordering::Relaxed); + /// Register once with the configured lifetime. + pub async fn register(&self) -> Result<(), BoxError> { + self.register_with(self.expires).await } - /// Send the initial REGISTER. - /// - /// Transient failures (request timeout, server-side `5xx`, temporary - /// unavailability, or a transaction that terminated without a final - /// response) are retried with a fixed backoff until cancelled — these - /// are conditions a retry can resolve once the server or network - /// recovers. - /// - /// **Permanent** failures — the server answered and rejected us for a - /// reason a retry will never fix (bad credentials → `401`/`407`, - /// `403 Forbidden`, `404 Not Found`, other `4xx`/`6xx`) — return `Err` - /// immediately. Retrying those would spin forever and hide the cause - /// from the user; surfacing the error lets the caller put the account - /// into a visible failed state so the user can correct it. - pub async fn register(&self) -> Result<(), Box> { - loop { - info!( - "Sending REGISTER to {}:{}", - self.account.server(), - self.account.port() - ); - - let seq = self.next_seq(); - let contact = self.contact.lock().await.clone(); - - let request = self.build_register_request(seq, &contact, self.register_expires)?; - debug!("REGISTER request:\n{request}"); - - self.record_attempt(); - - let mut seq_val = seq; - let final_response = self.send_register_with_auth(request, &mut seq_val).await?; - self.set_seq(seq_val); - - match final_response { - Some(resp) if resp.status_code == StatusCode::OK => { - let typed_contact: Option = - resp.contact_header().ok().and_then(|c| c.typed().ok()); - - let expires = typed_contact - .as_ref() - .and_then(|c| c.expires()) - .map(|e| e.seconds().unwrap_or(50)) - .unwrap_or(50); - - let contact_str = typed_contact.as_ref().map(|c| c.uri.to_string()); - self.record_success(200, expires, contact_str); - *self.contact.lock().await = typed_contact; + async fn register_with(&self, expires: u32) -> Result<(), BoxError> { + let cseq = { + let mut state = self.state.lock().await; + state.cseq += 1; + state.last_attempt_at = Some(SystemTime::now()); + state.cseq + }; - info!( - "Registered as {}@{} (expires {}s)", - self.account.username, self.account.domain, expires - ); - return Ok(()); - } - Some(resp) => { - let code = u16::from(resp.status_code.clone()); - let msg = format!("registration failed with status {}", resp.status_code); - warn!("{msg}"); - self.record_failure(Some(code), msg.clone()); - if is_permanent_register_failure(&resp.status_code) { - // Retrying can't fix a rejection like this — return - // so the caller can surface it instead of looping - // silently forever. - return Err(msg.into()); - } - } - None => { - let msg = "registration transaction terminated unexpectedly".to_string(); - warn!("{msg}"); - self.record_failure(None, msg); - } + let cfg = self.config(expires)?; + let outcome = self + .endpoint + .ua() + .register(&cfg, self.endpoint.server(), cseq) + .await; + + let mut state = self.state.lock().await; + match outcome { + RegisterOutcome::Registered { expires } => { + state.negotiated_expires = Some(expires); + state.last_status = Some(200); + state.last_success_at = Some(SystemTime::now()); + state.last_error = None; + state.register_count += 1; + info!(expires, "registered"); + Ok(()) } - - select! { - _ = tokio::time::sleep(tokio::time::Duration::from_secs(10)) => {} - _ = self.cancel.cancelled() => { - return Err("Cancelled".into()); - } + RegisterOutcome::Unauthorized => { + state.last_status = Some(401); + state.last_error = Some("authentication failed".into()); + state.failure_count += 1; + Err("registration rejected: authentication failed".into()) + } + RegisterOutcome::Failed(status) => { + state.last_status = Some(status.code()); + state.last_error = Some(format!("server returned {status}")); + state.failure_count += 1; + Err(format!("registration failed: {status}").into()) } + RegisterOutcome::TimedOut => { + state.last_error = Some("timed out".into()); + state.failure_count += 1; + Err("registration timed out".into()) + } + RegisterOutcome::EngineStopped => Err("engine stopped".into()), } } - /// Re-register loop: sleeps until shortly before the current binding - /// lapses, then re-REGISTERs. Runs until cancelled. - /// - /// The wait is driven by the `Expires` the server actually *granted* in - /// the last 200 OK, not the value we asked for. Registrars commonly hand - /// back a shorter lifetime than requested; sleeping the requested cadence - /// would let the binding expire and leave the account silently - /// unreachable until the next cycle. See `refresh_interval_secs`. + /// Re-register every `refresh_secs` until the cancel token fires. pub async fn keepalive_loop(&self) { loop { - // Latest server-granted lifetime (populated by the initial - // `register()` before this loop starts; refreshed on every - // successful re-REGISTER below). Fall back to what we asked for - // if, somehow, we never recorded a grant. - let granted = self - .diag - .lock() - .expect("registrar diag mutex poisoned") - .negotiated_expires - .unwrap_or(self.register_expires); - let interval = - refresh_interval_secs(self.register_expires, self.keepalive_secs, granted); - info!("Re-registering in {interval}s..."); - - select! { - _ = tokio::time::sleep(tokio::time::Duration::from_secs(interval as u64)) => {} - _ = self.cancel.cancelled() => return, - } - - if self.cancel.is_cancelled() { - return; + if let Err(e) = self.register().await { + warn!("keepalive register failed: {e}"); } - - let seq = self.next_seq(); - let contact = self.contact.lock().await.clone(); - - let request = match self.build_register_request(seq, &contact, self.register_expires) { - Ok(r) => r, - Err(e) => { - let msg = format!("failed to build re-register request: {e}"); - warn!("{msg}"); - self.record_failure(None, msg); - continue; - } - }; - - self.record_attempt(); - - let mut seq_val = seq; - match self.send_register_with_auth(request, &mut seq_val).await { - Ok(Some(resp)) if resp.status_code == StatusCode::OK => { - let typed_contact: Option = - resp.contact_header().ok().and_then(|c| c.typed().ok()); - - let expires = typed_contact - .as_ref() - .and_then(|c| c.expires()) - .map(|e| e.seconds().unwrap_or(50)) - .unwrap_or(50); - - let contact_str = typed_contact.as_ref().map(|c| c.uri.to_string()); - self.record_success(200, expires, contact_str); - *self.contact.lock().await = typed_contact; - self.set_seq(seq_val); - - info!( - "Re-registered as {}@{} (expires {}s)", - self.account.username, self.account.domain, expires - ); - } - Ok(Some(resp)) => { - let code = u16::from(resp.status_code.clone()); - let msg = format!("re-registration failed: {}", resp.status_code); - warn!("{msg}"); - self.record_failure(Some(code), msg); - self.set_seq(seq_val); - } - Ok(None) => { - let msg = "re-registration got no response".to_string(); - warn!("{msg}"); - self.record_failure(None, msg); - self.set_seq(seq_val); - } - Err(e) => { - let msg = format!("re-registration error: {e}"); - warn!("{msg}"); - self.record_failure(None, msg); - } + tokio::select! { + _ = self.cancel.cancelled() => break, + _ = tokio::time::sleep(Duration::from_secs(self.refresh_secs)) => {} } } } - /// Unregister (Expires: 0). + /// De-register by sending `Expires: 0`. pub async fn unregister(&self) { - info!("Unregistering (Expires: 0)..."); - - let seq = self.next_seq(); - let contact = self.contact.lock().await.clone(); - - let request = match self.build_register_request(seq, &contact, 0) { - Ok(r) => r, - Err(e) => { - warn!("Failed to build unregister request: {e}"); - return; - } - }; - - let mut seq_val = seq; - match tokio::time::timeout( - tokio::time::Duration::from_secs(5), - self.send_register_with_auth(request, &mut seq_val), - ) - .await - { - Ok(Ok(Some(resp))) => info!("Unregister response: {}", resp.status_code), - Ok(Ok(None)) => warn!("No response to unregister"), - Ok(Err(e)) => warn!("Unregister failed: {e}"), - Err(_) => warn!("Unregister timed out"), + if let Err(e) = self.register_with(0).await { + warn!("unregister failed: {e}"); } } - fn build_register_request( - &self, - seq: u32, - contact: &Option, - expires: u32, - ) -> Result> { - let mut to_uri = self.server_uri.clone(); - to_uri.auth = Some(rsip::auth::Auth { - user: self.account.username.clone(), - password: None, - }); - - let to = rsip::typed::To { - display_name: None, - uri: to_uri.clone(), - params: vec![], - }; - - let from = rsip::typed::From { - display_name: None, - uri: to_uri, - params: vec![], - } - .with_tag(make_tag()); - - let via = self.endpoint.inner.get_via(None, None)?; - - let mut reg_contact = contact.clone().unwrap_or_else(|| { - let host = via.uri.host_with_port.clone(); - rsip::typed::Contact { - display_name: None, - uri: rsip::Uri { - auth: Some(rsip::auth::Auth { - user: self.account.username.clone(), - password: None, - }), - scheme: Some(rsip::Scheme::Sip), - host_with_port: host, - params: vec![], - headers: vec![], - }, - params: vec![], - } - }); - - // Strip contact-level expires param — the Expires header is authoritative. - reg_contact - .params - .retain(|p| !matches!(p, rsip::common::uri::Param::Expires(_))); - - let mut request = self.endpoint.inner.make_request( - rsip::Method::Register, - self.server_uri.clone(), - via, - from, - to, - seq, - Some(self.call_id.clone()), - ); - - request.headers.unique_push(reg_contact.into()); - request - .headers - .unique_push(rsip::headers::Allow::default().into()); - request - .headers - .unique_push(rsip::headers::Expires::from(expires).into()); - - Ok(request) - } - - async fn send_register_with_auth( - &self, - request: rsip::Request, - seq: &mut u32, - ) -> Result, Box> { - let key = TransactionKey::from_request(&request, TransactionRole::Client)?; - let mut tx = Transaction::new_client(key, request, self.endpoint.inner.clone(), None); - tx.send().await?; - - let mut auth_sent = false; - - while let Some(msg) = tx.receive().await { - match msg { - SipMessage::Response(resp) => match resp.status_code { - StatusCode::Trying => { - debug!("Received 100 Trying"); - continue; - } - StatusCode::Unauthorized | StatusCode::ProxyAuthenticationRequired - if !auth_sent => - { - debug!("Auth challenge response:\n{resp}"); - let auth_cred = Credential { - username: self.account.auth_username().to_string(), - password: self.account.password.clone(), - realm: None, - }; - *seq += 1; - tx = handle_client_authenticate(*seq, &tx, resp, &auth_cred).await?; - debug!("Sending authenticated REGISTER:\n{}", tx.original); - tx.send().await?; - auth_sent = true; - continue; - } - _ => { - debug!("Final response:\n{resp}"); - return Ok(Some(resp)); - } - }, - _ => return Ok(None), - } + /// Snapshot the current diagnostics. + pub async fn diagnostics(&self) -> RegistrarDiagnostics { + let state = self.state.lock().await; + RegistrarDiagnostics { + server_uri: format!("sip:{}", self.account.domain), + contact_uri: Some(self.contact.clone()), + call_id: self.call_id.clone(), + cseq: state.cseq, + configured_expires: self.expires, + negotiated_expires: state.negotiated_expires, + last_status: state.last_status, + last_attempt_at: state.last_attempt_at, + last_success_at: state.last_success_at, + last_error: state.last_error.clone(), + register_count: state.register_count, + failure_count: state.failure_count, } - Ok(None) } } -/// Smallest interval the keepalive loop will ever sleep, in seconds. Guards -/// against a pathologically short server-granted `Expires` turning the loop -/// into a hot re-REGISTER spin. -const MIN_REFRESH_INTERVAL_SECS: u32 = 10; - -/// Smallest gap we insist on between a re-REGISTER and the moment the current -/// binding would lapse — one round-trip plus slack — even when the configured -/// margin works out smaller. Keeps us from refreshing right on the edge. -const MIN_REFRESH_MARGIN_SECS: u32 = 5; - -/// How long to wait before the next re-REGISTER, given the `Expires` the -/// server granted in the last 200 OK and the locally configured values. -/// -/// Servers frequently downgrade the `Expires` we request to a shorter value. -/// Refreshing on the *requested* cadence (`keepalive_secs`) would then fire -/// long after the granted binding had already expired, leaving the account -/// unreachable for the gap. So the interval is driven by what the server -/// actually granted: -/// -/// - Refresh `margin` seconds before the granted lifetime lapses, where -/// `margin` is the operator's configured headroom -/// (`register_expires - keepalive_secs`), floored at -/// [`MIN_REFRESH_MARGIN_SECS`] so we never sit right on the edge. -/// - Never wait longer than `keepalive_secs` (covers the rare case of a -/// server granting *more* than we asked for). -/// - Never wait less than [`MIN_REFRESH_INTERVAL_SECS`], so an absurdly short -/// grant can't spin the loop. -/// -/// When the server honors the requested `Expires`, this reduces to -/// `keepalive_secs` — identical to the previous fixed-cadence behavior. -fn refresh_interval_secs(register_expires: u32, keepalive_secs: u32, granted_expires: u32) -> u32 { - let margin = register_expires - .saturating_sub(keepalive_secs) - .max(MIN_REFRESH_MARGIN_SECS); - granted_expires - .saturating_sub(margin) - .min(keepalive_secs) - .max(MIN_REFRESH_INTERVAL_SECS) -} - -/// Whether a final REGISTER response is a *permanent* rejection the caller -/// must act on, versus a *transient* condition worth retrying. -/// -/// Transient (returns `false`): `408 Request Timeout`, `480 Temporarily -/// Unavailable`, and any `5xx` server-side error — the server (or path) is -/// saying "not right now", which a retry can clear once it recovers. -/// -/// Permanent (returns `true`): everything else the server answers with — -/// `401`/`407` (bad credentials after the auth retry), `403 Forbidden`, -/// `404 Not Found`, other `4xx`, and `6xx` global failures. Retrying these -/// never succeeds, so the registrar returns the error to its caller rather -/// than spinning on it forever. -fn is_permanent_register_failure(status: &StatusCode) -> bool { - let code = status.code(); - let transient = code == 408 || code == 480 || (500..600).contains(&code); - !transient -} - #[cfg(test)] mod tests { use super::*; use crate::account::Transport; - fn make_account() -> SipAccount { + fn account() -> SipAccount { SipAccount { - display_name: "Test".to_string(), - username: "1001".to_string(), - password: "secret".to_string(), - domain: "127.0.0.1".to_string(), + display_name: "T".into(), + username: "1001".into(), + password: "secret".into(), + domain: "example.com".into(), auth_username: None, - server: Some("127.0.0.1".to_string()), + server: Some("127.0.0.1".into()), port: Some(5060), transport: Transport::Udp, } } - async fn make_registrar() -> (Registrar, Arc, CancellationToken) { - let account = make_account(); - let cancel = CancellationToken::new(); - let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await.unwrap(); - let endpoint = Arc::new(endpoint); - let registrar = Registrar::new(account, endpoint.clone(), cancel.clone(), 60, 50).unwrap(); - (registrar, endpoint, cancel) - } - - #[test] - fn auth_and_client_rejections_are_permanent() { - // The server answered "no" for a reason a retry can't fix. These - // must return from register() so the account lands in a visible - // failed state instead of the registrar looping forever (the bug - // this classifier fixes: a wrong password → 401 spun silently). - for code in [400u16, 401, 403, 404, 407, 410, 486, 600, 603, 604, 606] { - assert!( - is_permanent_register_failure(&StatusCode::from(code)), - "status {code} should be treated as permanent", - ); - } - } - - #[test] - fn timeout_and_server_errors_are_transient() { - // "Not right now" — worth retrying once the server/path recovers. - for code in [408u16, 480, 500, 502, 503, 504] { - assert!( - !is_permanent_register_failure(&StatusCode::from(code)), - "status {code} should be treated as transient", - ); - } - } - - #[test] - fn refresh_honors_request_when_server_grants_what_we_asked() { - // Server returns exactly the requested expiry → behavior is unchanged - // from the old fixed-cadence loop: wait the configured keepalive. - assert_eq!(refresh_interval_secs(600, 590, 600), 590); - assert_eq!(refresh_interval_secs(60, 50, 60), 50); - } - - #[test] - fn refresh_shortens_when_server_downgrades_expiry() { - // The regression: ask for 600 (keepalive 590), server grants 174. - // Refreshing at 590 would lapse the binding ~7 minutes early. We must - // refresh before 174s, preserving the operator's 10s margin → 164s. - assert_eq!(refresh_interval_secs(600, 590, 174), 164); - assert!(refresh_interval_secs(600, 590, 174) < 174); - // A moderate downgrade keeps the same margin. - assert_eq!(refresh_interval_secs(600, 590, 120), 110); - } - - #[test] - fn refresh_caps_at_keepalive_when_server_grants_more() { - // Some servers grant a longer lifetime than requested; we still - // refresh no later than the configured cadence. - assert_eq!(refresh_interval_secs(600, 590, 1200), 590); - } - - #[test] - fn refresh_floors_short_grants_to_avoid_hot_loop() { - // A tiny grant must not turn into a busy re-REGISTER spin; the - // interval is floored even though it then lands after expiry. - assert_eq!( - refresh_interval_secs(600, 590, 5), - MIN_REFRESH_INTERVAL_SECS - ); - assert_eq!( - refresh_interval_secs(600, 590, 0), - MIN_REFRESH_INTERVAL_SECS - ); - } - #[test] - fn refresh_keeps_a_margin_even_with_zero_configured_headroom() { - // keepalive == register_expires means no configured margin; we still - // refresh strictly before the granted expiry, not right on it. - assert_eq!( - refresh_interval_secs(600, 600, 174), - 174 - MIN_REFRESH_MARGIN_SECS - ); - assert!(refresh_interval_secs(600, 600, 174) < 174); - } - - #[tokio::test] - async fn diagnostics_initial_state() { - let (registrar, endpoint, _cancel) = make_registrar().await; - let diag = registrar.diagnostics(); - - assert_eq!(diag.server_uri, "sip:127.0.0.1:5060"); - assert!(diag.contact_uri.is_none()); - assert!(!diag.call_id.is_empty()); - // Bare value, no header prefix — callers render this directly. - assert!(!diag.call_id.starts_with("Call-ID")); - assert!(!diag.call_id.contains(": ")); - assert_eq!(diag.cseq, 0); - assert_eq!(diag.configured_expires, 60); - assert!(diag.negotiated_expires.is_none()); - assert!(diag.last_status.is_none()); - assert!(diag.last_attempt_at.is_none()); - assert!(diag.last_success_at.is_none()); - assert!(diag.last_error.is_none()); - assert_eq!(diag.register_count, 0); - assert_eq!(diag.failure_count, 0); - - endpoint.shutdown(); - } - - #[tokio::test] - async fn record_attempt_sets_timestamp() { - let (registrar, endpoint, _cancel) = make_registrar().await; - registrar.record_attempt(); - let diag = registrar.diagnostics(); - assert!(diag.last_attempt_at.is_some()); - assert!(diag.last_success_at.is_none()); - endpoint.shutdown(); - } - - #[tokio::test] - async fn record_success_populates_fields_and_clears_error() { - let (registrar, endpoint, _cancel) = make_registrar().await; - registrar.record_failure(Some(401), "auth required".to_string()); - registrar.record_success(200, 120, Some("sip:1001@127.0.0.1:5060".to_string())); - - let diag = registrar.diagnostics(); - assert_eq!(diag.last_status, Some(200)); - assert_eq!(diag.negotiated_expires, Some(120)); - assert_eq!(diag.contact_uri.as_deref(), Some("sip:1001@127.0.0.1:5060")); - assert!(diag.last_success_at.is_some()); - assert!(diag.last_error.is_none(), "success should clear error"); - assert_eq!(diag.register_count, 1); - assert_eq!(diag.failure_count, 1, "prior failure count preserved"); - endpoint.shutdown(); - } - - #[tokio::test] - async fn record_failure_increments_and_keeps_contact() { - let (registrar, endpoint, _cancel) = make_registrar().await; - registrar.record_success(200, 120, Some("sip:1001@127.0.0.1:5060".to_string())); - registrar.record_failure(Some(503), "service unavailable".to_string()); - - let diag = registrar.diagnostics(); - assert_eq!(diag.last_status, Some(503)); - assert_eq!(diag.last_error.as_deref(), Some("service unavailable")); - assert_eq!(diag.failure_count, 1); - assert_eq!(diag.register_count, 1); - assert_eq!( - diag.contact_uri.as_deref(), - Some("sip:1001@127.0.0.1:5060"), - "failure should not wipe last-known contact" - ); - endpoint.shutdown(); - } - - #[tokio::test] - async fn record_failure_without_status_preserves_none() { - let (registrar, endpoint, _cancel) = make_registrar().await; - registrar.record_failure(None, "transaction terminated".to_string()); - - let diag = registrar.diagnostics(); - assert!(diag.last_status.is_none()); - assert_eq!(diag.last_error.as_deref(), Some("transaction terminated")); - assert_eq!(diag.failure_count, 1); - endpoint.shutdown(); + fn register_config_uris_follow_account() { + let acct = account(); + let cfg = RegisterConfig { + registrar_uri: format!("sip:{}", acct.domain).try_into().unwrap(), + aor: format!("sip:{}@{}", acct.username, acct.domain) + .try_into() + .unwrap(), + contact: "sip:1001@10.0.0.1:5060".try_into().unwrap(), + from_tag: "t".into(), + call_id: "c".into(), + expires: 60, + username: acct.auth_username().to_string(), + password: acct.password.clone(), + }; + assert_eq!(cfg.registrar_uri.to_string(), "sip:example.com"); + assert_eq!(cfg.aor.to_string(), "sip:1001@example.com"); + assert_eq!(cfg.username, "1001"); } } diff --git a/crates/wavekat-sip/src/sdp.rs b/crates/wavekat-sip/src/sdp.rs index de7586f..d526208 100644 --- a/crates/wavekat-sip/src/sdp.rs +++ b/crates/wavekat-sip/src/sdp.rs @@ -10,6 +10,29 @@ use std::net::IpAddr; /// boring and predictable. pub const DTMF_DEFAULT_PT: u8 = 101; +/// Media direction (RFC 4566 `a=` attribute), used to put a call on hold +/// and take it off again per RFC 3264 §8.4. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaDirection { + /// Send and receive media — the normal, active state (`a=sendrecv`). + SendRecv, + /// Send only, do not receive — places the peer on hold (`a=sendonly`). + SendOnly, + /// Neither send nor receive — a full two-way hold (`a=inactive`). + Inactive, +} + +impl MediaDirection { + /// The `a=` attribute line value (no `a=` prefix, no CRLF). + pub fn attribute(&self) -> &'static str { + match self { + MediaDirection::SendRecv => "sendrecv", + MediaDirection::SendOnly => "sendonly", + MediaDirection::Inactive => "inactive", + } + } +} + /// Build a minimal SDP body for bidirectional G.711 audio with RFC 4733 /// telephone-event (DTMF) advertised at payload type 101. /// @@ -18,10 +41,30 @@ pub const DTMF_DEFAULT_PT: u8 = 101; /// listed first so the remote still selects PCMU/PCMA as the audio /// codec; the 101 entry adds DTMF support without changing the /// preferred audio choice. +/// +/// This is the initial offer/answer: `a=sendrecv` with `o=` version 0. +/// Re-offers (hold/resume) use [`build_sdp_with`]. pub fn build_sdp(local_ip: IpAddr, rtp_port: u16) -> Vec { + build_sdp_with(local_ip, rtp_port, MediaDirection::SendRecv, 0) +} + +/// Build the SDP body with an explicit media `direction` and `o=` session +/// `version`. +/// +/// RFC 3264 §5 requires every re-offer to keep the same session id but +/// **increment** the `o=` version; hold/resume re-INVITEs feed the bumped +/// version here and flip `direction` between [`MediaDirection::SendRecv`] +/// and [`MediaDirection::SendOnly`]/[`MediaDirection::Inactive`]. +pub fn build_sdp_with( + local_ip: IpAddr, + rtp_port: u16, + direction: MediaDirection, + version: u32, +) -> Vec { + let attr = direction.attribute(); format!( "v=0\r\n\ - o=wavekat 0 0 IN IP4 {local_ip}\r\n\ + o=wavekat 0 {version} IN IP4 {local_ip}\r\n\ s=wavekat-sip\r\n\ c=IN IP4 {local_ip}\r\n\ t=0 0\r\n\ @@ -30,7 +73,7 @@ pub fn build_sdp(local_ip: IpAddr, rtp_port: u16) -> Vec { a=rtpmap:8 PCMA/8000\r\n\ a=rtpmap:{DTMF_DEFAULT_PT} telephone-event/8000\r\n\ a=fmtp:{DTMF_DEFAULT_PT} 0-15\r\n\ - a=sendrecv\r\n" + a={attr}\r\n" ) .into_bytes() } @@ -147,6 +190,34 @@ mod tests { assert!(text.contains("a=fmtp:101 0-15\r\n")); } + #[test] + fn build_sdp_with_sets_direction_and_version() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + + let hold = + String::from_utf8(build_sdp_with(ip, 5004, MediaDirection::SendOnly, 1)).unwrap(); + assert!(hold.contains("o=wavekat 0 1 IN IP4 10.0.0.1\r\n")); + assert!(hold.contains("a=sendonly\r\n")); + assert!(!hold.contains("a=sendrecv\r\n")); + + let resume = + String::from_utf8(build_sdp_with(ip, 5004, MediaDirection::SendRecv, 2)).unwrap(); + assert!(resume.contains("o=wavekat 0 2 IN IP4 10.0.0.1\r\n")); + assert!(resume.contains("a=sendrecv\r\n")); + + let inactive = + String::from_utf8(build_sdp_with(ip, 5004, MediaDirection::Inactive, 3)).unwrap(); + assert!(inactive.contains("a=inactive\r\n")); + } + + #[test] + fn build_sdp_is_sendrecv_version_zero() { + let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let base = String::from_utf8(build_sdp(ip, 5004)).unwrap(); + assert!(base.contains("o=wavekat 0 0 IN IP4 10.0.0.1\r\n")); + assert!(base.contains("a=sendrecv\r\n")); + } + #[test] fn parse_sdp_extracts_addr_port_and_codec() { let sdp = b"v=0\r\nc=IN IP4 192.168.1.100\r\nm=audio 20000 RTP/AVP 0\r\n"; diff --git a/crates/wavekat-sip/src/session_timer.rs b/crates/wavekat-sip/src/session_timer.rs index 3b595b8..1250973 100644 --- a/crates/wavekat-sip/src/session_timer.rs +++ b/crates/wavekat-sip/src/session_timer.rs @@ -31,57 +31,28 @@ //! //! # Wiring it up //! -//! [`crate::Caller`] and [`crate::Callee`] already negotiate for you — -//! [`crate::AcceptedDial::session_timer`] / -//! [`crate::AcceptedCall::session_timer`] carry the result. Spawn the -//! loop after the call confirms: +//! [`crate::Caller`] and [`crate::IncomingCall`] negotiate for you: +//! [`crate::Call::session_timer`] carries the result. Drive the loop +//! against the call's shareable dialog handle +//! ([`crate::Call::session_handle`]) so it runs alongside the audio path: //! //! ```ignore -//! let accepted = pending.on_confirmed().await?; -//! if let Some(timer) = accepted.session_timer { -//! let dialog = accepted.dialog.clone(); -//! let refreshed = Arc::new(Notify::new()); +//! if let Some(timer) = call.session_timer() { +//! let handle = call.session_handle(); +//! let refreshed = std::sync::Arc::new(tokio::sync::Notify::new()); //! let sdp = build_sdp(local_ip, rtp_port); // repeat our offer -//! tokio::spawn({ -//! let refreshed = refreshed.clone(); -//! let cancel = cancel.clone(); -//! async move { -//! let outcome = -//! session_timer_loop(&dialog, timer, Some(sdp), refreshed, cancel).await; -//! tracing::info!(?outcome, "session timer finished"); -//! } +//! tokio::spawn(async move { +//! let outcome = +//! session_timer_loop(&handle, timer, Some(sdp), refreshed, cancel).await; +//! tracing::info!(?outcome, "session timer finished"); //! }); //! } //! ``` //! -//! When the **peer** is the refresher, its refresh re-INVITEs arrive on -//! the dialog state stream as `DialogState::Updated(_, request, handle)` -//! (after routing the transaction through -//! [`crate::SipEndpoint::dispatch_in_dialog`]). rsipstack does not -//! auto-answer them — your state pump must reply and then ping the -//! watchdog: -//! -//! ```ignore -//! DialogState::Updated(_, _request, handle) => { -//! let echo = SessionExpires { -//! interval_secs: timer.interval_secs, -//! refresher: Some(Refresher::Uac), // the peer (that request's UAC) refreshes -//! }; -//! handle -//! .respond( -//! StatusCode::OK, -//! Some(vec![ -//! rsip::Header::ContentType("application/sdp".into()), -//! supported_timer_header(), -//! echo.header(), -//! ]), -//! Some(sdp_answer.clone()), -//! ) -//! .await -//! .ok(); -//! refreshed.notify_one(); -//! } -//! ``` +//! When the **peer** is the refresher, its refresh re-INVITEs arrive as +//! inbound in-dialog requests. The consumer answers them with an SDP +//! answer (echoing `Session-Expires`) and pings `refreshed` so the +//! watchdog deadline resets. use std::future::Future; use std::sync::Arc; @@ -89,8 +60,6 @@ use std::time::Duration; use rsip::prelude::UntypedHeader; use rsip::Header; -use rsipstack::dialog::client_dialog::ClientInviteDialog; -use rsipstack::dialog::server_dialog::ServerInviteDialog; use tokio::select; use tokio::sync::Notify; use tokio_util::sync::CancellationToken; @@ -365,8 +334,8 @@ pub fn negotiate_uas(invite_headers: &rsip::Headers) -> Option /// The dialog operations [`session_timer_loop`] needs, abstracted so /// the loop's timing logic stays unit-testable without a live dialog. /// -/// Implemented for [`ClientInviteDialog`] and [`ServerInviteDialog`] -/// over their `reinvite` / `bye` methods. +/// Implemented for [`crate::Call`]'s shareable session handle over the +/// engine's in-dialog re-INVITE / BYE. pub trait SessionDialogOps { /// Send a session-refresh re-INVITE with the given extra headers /// and (typically SDP) body. Returns the final response, or @@ -381,34 +350,6 @@ pub trait SessionDialogOps { fn send_bye(&self) -> impl Future> + Send; } -impl SessionDialogOps for ClientInviteDialog { - async fn refresh( - &self, - headers: Vec
, - body: Option>, - ) -> Result, BoxError> { - Ok(self.reinvite(Some(headers), body).await?) - } - - async fn send_bye(&self) -> Result<(), BoxError> { - Ok(self.bye().await?) - } -} - -impl SessionDialogOps for ServerInviteDialog { - async fn refresh( - &self, - headers: Vec
, - body: Option>, - ) -> Result, BoxError> { - Ok(self.reinvite(Some(headers), body).await?) - } - - async fn send_bye(&self) -> Result<(), BoxError> { - Ok(self.bye().await?) - } -} - /// How [`session_timer_loop`] ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SessionTimerOutcome { @@ -438,8 +379,7 @@ pub enum SessionTimerOutcome { /// - Otherwise runs the expiry watchdog: every /// `peer_refreshed.notify_one()` resets the deadline, and if the /// deadline lapses the call is torn down with BYE. The consumer pings -/// `peer_refreshed` after answering the peer's refresh re-INVITE — -/// see the module docs for the `DialogState::Updated` wiring. +/// `peer_refreshed` after answering the peer's refresh re-INVITE. /// /// All failures are folded into the returned [`SessionTimerOutcome`]; /// the loop never panics and never returns early without standing the diff --git a/crates/wavekat-sip/src/stack/auth.rs b/crates/wavekat-sip/src/stack/auth.rs new file mode 100644 index 0000000..9595f51 --- /dev/null +++ b/crates/wavekat-sip/src/stack/auth.rs @@ -0,0 +1,314 @@ +//! Digest authentication orchestration — RFC 3261 §22, RFC 2617 / 8760. +//! +//! The digest *math* (HA1/HA2, MD5/SHA-256/SHA-512, `qop=auth` with +//! `cnonce`/`nc`) is `rsip`'s [`DigestGenerator`]; reimplementing it carries +//! no product value and is easy to get subtly wrong. What we own is the +//! **orchestration**: read a `401`/`407` challenge, compute the response, and +//! build the retried request — a fresh transaction with a new `Via` branch, an +//! incremented `CSeq`, and the right `Authorization` / `Proxy-Authorization` +//! header. + +use rsip::headers::auth::{Algorithm, AuthQop, Scheme}; +use rsip::headers::typed::{Authorization, WwwAuthenticate}; +use rsip::headers::ToTypedHeader; +use rsip::message::HeadersExt; +use rsip::services::DigestGenerator; +use rsip::{Header, Method, Request, Response, Uri}; + +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::transaction::gen_branch; + +/// The username/password a challenge is answered with. +pub(crate) struct Credentials<'a> { + pub username: &'a str, + pub password: &'a str, +} + +/// Which header carries the answer, set by the challenge's status code. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ChallengeKind { + /// `401 Unauthorized` → `WWW-Authenticate` answered with `Authorization`. + Www, + /// `407 Proxy Authentication Required` → `Proxy-Authenticate` answered + /// with `Proxy-Authorization`. + Proxy, +} + +/// Build the retried request that answers a `401`/`407`, or `None` if the +/// response carries no usable challenge. +/// +/// The retry is a brand-new transaction: it gets a fresh `Via` branch and an +/// incremented `CSeq`, copies the original request otherwise, and adds the +/// credential header. Per RFC 3261 §22.2 the request-URI is reused as the +/// digest `uri`. +pub(crate) fn build_retry( + original: &Request, + response: &Response, + creds: Credentials, +) -> Option { + let (kind, challenge) = extract_challenge(response)?; + let authorization = build_authorization(&challenge, &creds, original.method(), &original.uri); + + let mut retry = original.clone(); + bump_via_branch(&mut retry)?; + bump_cseq(&mut retry)?; + + let header = match kind { + ChallengeKind::Www => Header::Authorization(authorization.into()), + ChallengeKind::Proxy => { + let proxy = rsip::headers::typed::ProxyAuthorization(authorization); + Header::ProxyAuthorization(proxy.into()) + } + }; + retry.headers.unique_push(header); + Some(retry) +} + +/// Pull the digest challenge out of a `401`/`407`. +fn extract_challenge(response: &Response) -> Option<(ChallengeKind, WwwAuthenticate)> { + match response.status_code().code() { + 401 => { + let www = response.www_authenticate_header()?.typed().ok()?; + Some((ChallengeKind::Www, www)) + } + 407 => { + // `HeadersExt` has no proxy-authenticate accessor; find it directly. + let proxy = response.headers.iter().find_map(|h| match h { + Header::ProxyAuthenticate(p) => Some(p.clone()), + _ => None, + })?; + let typed = proxy.typed().ok()?; + Some((ChallengeKind::Proxy, typed.0)) + } + _ => None, + } +} + +/// Compute the `Authorization` payload that answers `challenge`. +fn build_authorization( + challenge: &WwwAuthenticate, + creds: &Credentials, + method: &Method, + uri: &Uri, +) -> Authorization { + let algorithm = challenge.algorithm.unwrap_or(Algorithm::Md5); + // If the server offered a quality-of-protection, answer with `qop=auth` + // and a fresh client nonce. `nc` is 1: each retry is a new nonce here, so + // we never reuse a server nonce across counts. + let qop = challenge.qop.as_ref().map(|_| AuthQop::Auth { + cnonce: gen_cnonce(), + nc: 1, + }); + + let response = DigestGenerator { + username: creds.username, + password: creds.password, + nonce: &challenge.nonce, + uri, + realm: &challenge.realm, + method, + qop: qop.as_ref(), + algorithm, + } + .compute(); + + Authorization { + scheme: Scheme::Digest, + username: creds.username.to_string(), + realm: challenge.realm.clone(), + nonce: challenge.nonce.clone(), + uri: uri.clone(), + response, + algorithm: Some(algorithm), + opaque: challenge.opaque.clone(), + qop, + } +} + +/// Replace the top `Via`'s branch with a fresh one (the retry is a new +/// transaction). +fn bump_via_branch(request: &mut Request) -> Option<()> { + use rsip::common::uri::param::{Branch, Param}; + + let mut via = request.via_header().ok()?.typed().ok()?; + via.params.retain(|p| !matches!(p, Param::Branch(_))); + via.params.push(Param::Branch(Branch::new(gen_branch()))); + request.headers.unique_push(Header::Via(via.into())); + Some(()) +} + +/// Increment the request's `CSeq` sequence number, keeping the method. +fn bump_cseq(request: &mut Request) -> Option<()> { + let cseq = request.cseq_header().ok()?.typed().ok()?; + let next = rsip::typed::CSeq { + seq: cseq.seq + 1, + method: cseq.method, + }; + request.headers.unique_push(Header::CSeq(next.into())); + Some(()) +} + +/// Process-wide counter feeding [`gen_cnonce`]. +static CNONCE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Generate a client nonce: a hard-to-predict hex token, unique per call. +/// Same randomly-seeded-hash trick as branch generation — no `rand` dep. +fn gen_cnonce() -> String { + use std::hash::BuildHasher; + let n = CNONCE_COUNTER.fetch_add(1, Ordering::Relaxed); + let seed = std::collections::hash_map::RandomState::new().hash_one(n); + format!("{n:x}{seed:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn register() -> Request { + let raw = "REGISTER sip:example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-orig\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-reg\r\n\ + CSeq: 1 REGISTER\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn challenge_401(qop: &str, algorithm: &str) -> Response { + let qop_param = if qop.is_empty() { + String::new() + } else { + format!(", qop=\"{qop}\"") + }; + let raw = format!( + "SIP/2.0 401 Unauthorized\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-orig\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=srv\r\n\ + Call-ID: call-reg\r\n\ + CSeq: 1 REGISTER\r\n\ + WWW-Authenticate: Digest realm=\"example.com\", \ + nonce=\"abc123\", algorithm={algorithm}{qop_param}\r\n\ + Content-Length: 0\r\n\r\n" + ); + Response::try_from(raw.as_bytes()).unwrap() + } + + fn creds() -> Credentials<'static> { + Credentials { + username: "alice", + password: "secret", + } + } + + #[test] + fn retry_adds_authorization_and_freshens_transaction() { + let retry = build_retry(®ister(), &challenge_401("auth", "MD5"), creds()).unwrap(); + + // CSeq bumped, method preserved. + let cseq = retry.cseq_header().unwrap().typed().unwrap(); + assert_eq!(cseq.seq, 2); + assert_eq!(cseq.method, Method::Register); + + // Fresh branch (new transaction). + let branch = retry + .via_header() + .unwrap() + .typed() + .unwrap() + .branch() + .unwrap() + .to_string(); + assert!(branch.starts_with("z9hG4bK")); + assert_ne!(branch, "z9hG4bK-orig"); + + // Authorization present, realm/nonce copied from the challenge. + let auth = retry.authorization_header().unwrap().typed().unwrap(); + assert_eq!(auth.realm, "example.com"); + assert_eq!(auth.nonce, "abc123"); + assert!(!auth.response.is_empty()); + } + + #[test] + fn computed_response_verifies_against_rsip() { + // Self-consistency: the response we emit must be what rsip recomputes + // from the same Authorization (this catches qop/cnonce/algorithm mixups). + // We verify at the typed level. NB: rsip spells the algorithm token + // "SHA256" (no hyphen) for both parse and Display, unlike RFC 7616's + // "SHA-256"; MD5 — the near-universal SIP digest — is unaffected. + let (_, challenge) = extract_challenge(&challenge_401("auth", "SHA256")).unwrap(); + let uri = register().uri; + let auth = build_authorization(&challenge, &creds(), &Method::Register, &uri); + assert_eq!(auth.algorithm, Some(Algorithm::Sha256)); + let generator = DigestGenerator::from(&auth, "secret", &Method::Register); + assert!(generator.verify(&auth.response)); + } + + #[test] + fn no_qop_still_authorizes() { + let (_, challenge) = extract_challenge(&challenge_401("", "MD5")).unwrap(); + let uri = register().uri; + let auth = build_authorization(&challenge, &creds(), &Method::Register, &uri); + assert!(auth.qop.is_none()); + let generator = DigestGenerator::from(&auth, "secret", &Method::Register); + assert!(generator.verify(&auth.response)); + } + + #[test] + fn authorization_header_serializes_with_expected_params() { + // The on-the-wire form the server will parse (rsip emits it via Display). + let (_, challenge) = extract_challenge(&challenge_401("auth", "SHA256")).unwrap(); + let uri = register().uri; + let auth = build_authorization(&challenge, &creds(), &Method::Register, &uri); + let wire = auth.to_string(); + assert!(wire.contains("Digest username=\"alice\"")); + assert!(wire.contains("realm=\"example.com\"")); + assert!(wire.contains("algorithm=SHA256")); + assert!(wire.contains("qop=\"auth\"")); + assert!(wire.contains("cnonce=")); + } + + #[test] + fn proxy_challenge_uses_proxy_authorization() { + let raw = "SIP/2.0 407 Proxy Authentication Required\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-orig\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=srv\r\n\ + Call-ID: call-reg\r\n\ + CSeq: 1 REGISTER\r\n\ + Proxy-Authenticate: Digest realm=\"proxy\", nonce=\"xyz\", qop=\"auth\"\r\n\ + Content-Length: 0\r\n\r\n"; + let response = Response::try_from(raw.as_bytes()).unwrap(); + let retry = build_retry(®ister(), &response, creds()).unwrap(); + + let has_proxy_auth = retry + .headers + .iter() + .any(|h| matches!(h, Header::ProxyAuthorization(_))); + assert!(has_proxy_auth); + assert!(!retry + .headers + .iter() + .any(|h| matches!(h, Header::Authorization(_)))); + } + + #[test] + fn non_challenge_response_yields_no_retry() { + let raw = "SIP/2.0 200 OK\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-orig\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=srv\r\n\ + Call-ID: call-reg\r\n\ + CSeq: 1 REGISTER\r\n\ + Content-Length: 0\r\n\r\n"; + let response = Response::try_from(raw.as_bytes()).unwrap(); + assert!(build_retry(®ister(), &response, creds()).is_none()); + } + + #[test] + fn cnonce_is_unique_per_call() { + assert_ne!(gen_cnonce(), gen_cnonce()); + } +} diff --git a/crates/wavekat-sip/src/stack/call.rs b/crates/wavekat-sip/src/stack/call.rs new file mode 100644 index 0000000..86e64ee --- /dev/null +++ b/crates/wavekat-sip/src/stack/call.rs @@ -0,0 +1,271 @@ +//! Outbound INVITE request builder + result types — RFC 3261 §13 (UAC side). +//! +//! Pure composition: `build_invite` assembles the INVITE (with the SDP offer) +//! and `CallConfig`/`CallOutcome` describe the inputs and result. The driving +//! (send, follow provisionals, answer a challenge, build the dialog and ACK) +//! lives in [`ua`](super::ua). + +use std::net::SocketAddr; + +use rsip::headers::{ToTypedHeader, UntypedHeader}; +use rsip::message::HeadersExt; +use rsip::{Header, Headers, Method, Request, StatusCode, Uri}; + +use super::auth::Credentials; +use super::dialog::Dialog; +use super::transaction::{gen_branch, via_value}; + +/// Everything needed to place a call and answer a challenge. +pub(crate) struct CallConfig { + /// Request-URI and `To` — the callee, e.g. `sip:bob@example.com`. + pub target: Uri, + /// `From` — our address of record. + pub from: Uri, + /// Our contact — Request-URI of in-dialog requests the peer sends us. + pub contact: Uri, + pub from_tag: String, + pub call_id: String, + /// SDP offer carried in the INVITE body (empty for a late offer). + pub sdp: Vec, + /// Extra headers added to the INVITE (e.g. `Supported: timer` and + /// `Session-Expires` for RFC 4028). + pub extra_headers: Vec
, + pub username: String, + pub password: String, +} + +impl CallConfig { + fn creds(&self) -> Credentials<'_> { + Credentials { + username: &self.username, + password: &self.password, + } + } + + /// Credentials for answering a challenge (used by the `ua` router). + pub(crate) fn creds_for_retry(&self) -> Credentials<'_> { + self.creds() + } +} + +/// The result of placing a call. +pub(crate) enum CallOutcome { + /// The callee answered; the confirmed dialog plus the 2xx response (whose + /// body carries the SDP answer). Boxed because both are large. + Answered { + dialog: Box, + response: Box, + }, + /// The callee (or proxy) rejected the call with this final status. + Rejected(StatusCode), + /// Credentials were rejected. + Unauthorized, + /// No final response before the transaction timed out. + TimedOut, + /// The engine stopped before a result was reached. + EngineStopped, +} + +/// Compose an INVITE bound to `local_addr`, carrying the SDP offer. +pub(crate) fn build_invite(cfg: &CallConfig, cseq: u32, local_addr: SocketAddr) -> Request { + let mut headers = Headers::default(); + headers.push(Header::Via(rsip::headers::Via::new(via_value( + local_addr, + &gen_branch(), + )))); + headers.push(Header::MaxForwards(rsip::headers::MaxForwards::default())); + + let from = rsip::typed::From { + display_name: None, + uri: cfg.from.clone(), + params: vec![rsip::common::uri::param::Param::Tag( + rsip::common::uri::param::Tag::new(cfg.from_tag.clone()), + )], + }; + let to = rsip::typed::To { + display_name: None, + uri: cfg.target.clone(), + params: vec![], + }; + let contact = rsip::typed::Contact { + display_name: None, + uri: cfg.contact.clone(), + params: vec![], + }; + headers.push(Header::From(from.into())); + headers.push(Header::To(to.into())); + headers.push(Header::Contact(contact.into())); + headers.push(Header::CallId(rsip::headers::CallId::new( + cfg.call_id.clone(), + ))); + headers.push(Header::CSeq( + rsip::typed::CSeq { + seq: cseq, + method: Method::Invite, + } + .into(), + )); + for header in &cfg.extra_headers { + headers.push(header.clone()); + } + if !cfg.sdp.is_empty() { + headers.push(Header::ContentType(rsip::headers::ContentType::new( + "application/sdp", + ))); + } + headers.push(Header::ContentLength(rsip::headers::ContentLength::from( + cfg.sdp.len() as u32, + ))); + + Request { + method: Method::Invite, + uri: cfg.target.clone(), + version: rsip::Version::V2, + headers, + body: cfg.sdp.clone(), + } +} + +/// Build the `CANCEL` for an in-flight INVITE (RFC 3261 §9.1). +/// +/// CANCEL matches the INVITE hop-by-hop, so it copies the INVITE's **top Via** +/// (same branch), Request-URI, `From`, `To` (no tag added), and `Call-ID`, with +/// the same `CSeq` number but method `CANCEL`. Returns `None` if the INVITE is +/// missing a header CANCEL must echo. +pub(crate) fn build_cancel(invite: &Request) -> Option { + let via = invite.via_header().ok()?.clone(); + let from = invite.from_header().ok()?.clone(); + let to = invite.to_header().ok()?.clone(); + let call_id = invite.call_id_header().ok()?.clone(); + let seq = cseq_of(invite); + + let mut headers = Headers::default(); + headers.push(Header::Via(via)); + headers.push(Header::MaxForwards(rsip::headers::MaxForwards::default())); + headers.push(Header::From(from)); + headers.push(Header::To(to)); + headers.push(Header::CallId(call_id)); + headers.push(Header::CSeq( + rsip::typed::CSeq { + seq, + method: Method::Cancel, + } + .into(), + )); + headers.push(Header::ContentLength( + rsip::headers::ContentLength::default(), + )); + + Some(Request { + method: Method::Cancel, + uri: invite.uri.clone(), + version: rsip::Version::V2, + headers, + body: Vec::new(), + }) +} + +pub(crate) fn cseq_of(request: &Request) -> u32 { + request + .cseq_header() + .ok() + .and_then(|c| c.typed().ok()) + .map(|c| c.seq) + .unwrap_or(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> CallConfig { + CallConfig { + target: Uri::try_from("sip:bob@example.com").unwrap(), + from: Uri::try_from("sip:alice@example.com").unwrap(), + contact: Uri::try_from("sip:alice@127.0.0.1:5060").unwrap(), + from_tag: "alicetag".into(), + call_id: "call-xyz".into(), + sdp: b"v=0\r\n".to_vec(), + extra_headers: Vec::new(), + username: "alice".into(), + password: "secret".into(), + } + } + + #[test] + fn build_invite_carries_sdp() { + let cfg = config(); + let invite = build_invite(&cfg, 1, "127.0.0.1:5060".parse().unwrap()); + assert_eq!(*invite.method(), Method::Invite); + assert_eq!(invite.body, b"v=0\r\n"); + assert!(invite + .headers + .iter() + .any(|h| matches!(h, Header::ContentType(_)))); + } + + #[test] + fn build_invite_via_advertises_rport() { + // RFC 3581: the INVITE's Via must carry `rport` so a NAT-fronting proxy + // learns our public address and can route in-dialog requests back. The + // CANCEL clones the INVITE's Via, so it inherits rport for free. + let cfg = config(); + let invite = build_invite(&cfg, 1, "127.0.0.1:5060".parse().unwrap()); + let via = invite.via_header().unwrap().to_string(); + assert!(via.contains(";rport"), "INVITE Via lacks rport: {via}"); + + let cancel = build_cancel(&invite).expect("cancel built"); + let cancel_via = cancel.via_header().unwrap().to_string(); + assert!( + cancel_via.contains(";rport"), + "CANCEL Via lacks rport: {cancel_via}" + ); + } + + #[test] + fn build_invite_carries_extra_headers() { + let mut cfg = config(); + cfg.extra_headers = vec![Header::Supported("timer".into())]; + let invite = build_invite(&cfg, 1, "127.0.0.1:5060".parse().unwrap()); + assert!(invite.to_string().contains("Supported: timer")); + } + + #[test] + fn build_cancel_matches_invite_branch_and_cseq() { + let cfg = config(); + let invite = build_invite(&cfg, 4, "127.0.0.1:5060".parse().unwrap()); + let cancel = build_cancel(&invite).expect("cancel built"); + + assert_eq!(*cancel.method(), Method::Cancel); + // Same Request-URI and same top Via branch as the INVITE (hop-by-hop). + assert_eq!(cancel.uri, invite.uri); + let inv_branch = invite + .via_header() + .unwrap() + .typed() + .unwrap() + .branch() + .unwrap() + .to_string(); + let can_branch = cancel + .via_header() + .unwrap() + .typed() + .unwrap() + .branch() + .unwrap() + .to_string(); + assert_eq!(can_branch, inv_branch, "CANCEL reuses the INVITE branch"); + // Same CSeq number, method CANCEL. + let cseq = cancel.cseq_header().unwrap().typed().unwrap(); + assert_eq!(cseq.seq, 4); + assert_eq!(cseq.method, Method::Cancel); + } + + #[test] + fn cseq_of_reads_sequence() { + let cfg = config(); + let invite = build_invite(&cfg, 7, "127.0.0.1:5060".parse().unwrap()); + assert_eq!(cseq_of(&invite), 7); + } +} diff --git a/crates/wavekat-sip/src/stack/dialog.rs b/crates/wavekat-sip/src/stack/dialog.rs new file mode 100644 index 0000000..e6b17b4 --- /dev/null +++ b/crates/wavekat-sip/src/stack/dialog.rs @@ -0,0 +1,503 @@ +//! Dialogs — RFC 3261 §12. +//! +//! A dialog is the peer-to-peer relationship established by an INVITE/2xx: it +//! pins the Call-ID, the local/remote tags and CSeqs, the remote target +//! (peer Contact), and — crucially — the **route set** captured from +//! `Record-Route`. Every in-dialog request (BYE, re-INVITE, INFO) is built +//! from this state. +//! +//! ## The route-set bug this layer fixes +//! +//! The defect that motivated the whole clean-room engine was an in-dialog +//! re-INVITE's ACK/route being addressed straight to the Contact instead of +//! through the stored route set, breaking hold/resume behind a proxy/SBC. +//! Here that cannot happen: the route set is captured **once** at dialog +//! establishment and [`Dialog::new_request`] replays it on *every* in-dialog +//! request, regardless of what later responses carry (a re-INVITE 2xx with no +//! `Record-Route` does not erase it). The remote target is the Request-URI; +//! the route set is the `Route` headers — never conflated. +//! +//! Loose routing (`;lr`, universal in modern proxies) is assumed: Request-URI +//! = remote target, `Route` = the stored set. Strict routing (legacy) is out +//! of scope per the plan. + +use rsip::common::uri::param::{Param, Tag}; +use rsip::common::uri::{UriWithParams, UriWithParamsList}; +use rsip::headers::{ToTypedHeader, UntypedHeader}; +use rsip::message::HeadersExt; +use rsip::{Header, Headers, Method, Request, Response, Uri}; + +use super::transaction::{gen_branch, via_value}; + +/// One end of a dialog: identity (display name + URI) plus its dialog tag. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Party { + display_name: Option, + uri: Uri, + tag: String, +} + +/// Identifies a dialog: Call-ID + local tag + remote tag (RFC 3261 §12). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DialogId { + pub call_id: String, + pub local_tag: String, + pub remote_tag: String, +} + +impl DialogId { + /// The dialog an inbound in-dialog request belongs to, from our point of + /// view: our tag is the request's `To` tag, the peer's is its `From` tag. + pub(crate) fn from_request(request: &Request) -> Option { + let to = request.to_header().ok()?.typed().ok()?; + let from = request.from_header().ok()?.typed().ok()?; + Some(Self { + call_id: request.call_id_header().ok()?.value().to_string(), + local_tag: to.tag()?.value().to_string(), + remote_tag: from.tag()?.value().to_string(), + }) + } +} + +/// A confirmed (or early) dialog and the state needed to build in-dialog +/// requests through it. +pub(crate) struct Dialog { + call_id: String, + /// Us — placed in `From` on requests we send. + local: Party, + /// The peer — placed in `To` on requests we send. + remote: Party, + /// Our Contact: the `Contact` header and `Via` sent-by on our requests. + local_contact: Uri, + /// The peer's Contact: the Request-URI of our in-dialog requests. + remote_target: Uri, + /// Captured route set, already oriented for sending (UAC: reversed). + route_set: Vec, + /// CSeq of the last request we sent in this dialog. + local_seq: u32, + /// `true` once established by a 2xx; `false` for an early (1xx) dialog. + confirmed: bool, +} + +impl Dialog { + /// Create the UAC-side dialog from the INVITE we sent and the response + /// that established it (a 2xx, or a 1xx carrying a To tag for an early + /// dialog). Returns `None` if the response lacks the remote tag or Contact + /// a dialog requires. + pub(crate) fn uac(invite: &Request, response: &Response, local_contact: Uri) -> Option { + let from = invite.from_header().ok()?.typed().ok()?; + let to = response.to_header().ok()?.typed().ok()?; + let cseq = invite.cseq_header().ok()?.typed().ok()?; + + // UAC stores the route set in reverse of the response's Record-Route. + let mut route_set = collect_routes(response.headers.iter()); + route_set.reverse(); + + Some(Self { + call_id: invite.call_id_header().ok()?.value().to_string(), + local: Party { + display_name: from.display_name.clone(), + uri: from.uri.clone(), + tag: tag_of(&from.params)?, + }, + remote: Party { + display_name: to.display_name.clone(), + uri: to.uri.clone(), + tag: tag_of(&to.params)?, + }, + local_contact, + remote_target: response.contact_header().ok()?.typed().ok()?.uri, + route_set, + local_seq: cseq.seq, + confirmed: response.status_code().code() >= 200, + }) + } + + /// Create the UAS-side dialog from an inbound INVITE and the local tag we + /// place on our answer. We are the callee, so the request's `From` is the + /// peer and its `To` (plus our tag) is us. + pub(crate) fn uas(invite: &Request, local_tag: String, local_contact: Uri) -> Option { + let from = invite.from_header().ok()?.typed().ok()?; + let to = invite.to_header().ok()?.typed().ok()?; + + // UAS stores the route set in the order of the request's Record-Route. + let route_set = collect_routes(invite.headers.iter()); + + Some(Self { + call_id: invite.call_id_header().ok()?.value().to_string(), + local: Party { + display_name: to.display_name.clone(), + uri: to.uri.clone(), + tag: local_tag, + }, + remote: Party { + display_name: from.display_name.clone(), + uri: from.uri.clone(), + tag: tag_of(&from.params)?, + }, + local_contact, + remote_target: invite.contact_header().ok()?.typed().ok()?.uri, + route_set, + // Our first in-dialog request will be CSeq 1. + local_seq: 0, + confirmed: true, + }) + } + + /// This dialog's identity. + pub(crate) fn id(&self) -> DialogId { + DialogId { + call_id: self.call_id.clone(), + local_tag: self.local.tag.clone(), + remote_tag: self.remote.tag.clone(), + } + } + + pub(crate) fn is_confirmed(&self) -> bool { + self.confirmed + } + + /// Promote an early dialog to confirmed when its 2xx arrives. + pub(crate) fn confirm(&mut self) { + self.confirmed = true; + } + + pub(crate) fn remote_target(&self) -> &Uri { + &self.remote_target + } + + /// Build the next in-dialog request (BYE, re-INVITE, INFO, …). + /// + /// Increments the local CSeq, addresses the Request-URI to the remote + /// target, and replays the stored route set as `Route` headers. The caller + /// adds any body (e.g. SDP for a re-INVITE) and content headers. + pub(crate) fn new_request(&mut self, method: Method) -> Request { + self.local_seq += 1; + let seq = self.local_seq; + self.compose(method, seq, Vec::new(), Vec::new()) + } + + /// Build the next in-dialog request carrying `extra_headers` (e.g. a + /// `Content-Type` or `Session-Expires`) and a `body` (e.g. an SDP re-offer + /// or a DTMF `INFO` payload). The `Content-Length` is set from `body`. + pub(crate) fn new_request_with( + &mut self, + method: Method, + extra_headers: Vec
, + body: Vec, + ) -> Request { + self.local_seq += 1; + let seq = self.local_seq; + self.compose(method, seq, extra_headers, body) + } + + /// Build the ACK for a 2xx answer to our INVITE (RFC 3261 §13.2.2.4). + /// + /// Unlike other in-dialog requests this is **not** a new transaction-CSeq: + /// it reuses the INVITE's sequence number with method ACK, and is sent + /// outside any transaction. It still rides the dialog route set and target. + pub(crate) fn ack_2xx(&self, invite_cseq: u32) -> Request { + self.compose(Method::Ack, invite_cseq, Vec::new(), Vec::new()) + } + + /// Shared request composer: Via (fresh branch) + target + tags + route set, + /// then any `extra_headers`, a `Content-Length` derived from `body`, and the + /// `body` itself. + fn compose( + &self, + method: Method, + seq: u32, + extra_headers: Vec
, + body: Vec, + ) -> Request { + let branch = gen_branch(); + + let mut headers = Headers::default(); + // Via sent-by is our own contact address; UDP is our only transport. + let host = self.local_contact.host_with_port.to_string(); + headers.push(Header::Via(rsip::headers::Via::new(via_value( + &host, &branch, + )))); + headers.push(Header::MaxForwards(rsip::headers::MaxForwards::default())); + + let from = rsip::typed::From { + display_name: self.local.display_name.clone(), + uri: self.local.uri.clone(), + params: vec![Param::Tag(Tag::new(self.local.tag.clone()))], + }; + let to = rsip::typed::To { + display_name: self.remote.display_name.clone(), + uri: self.remote.uri.clone(), + params: vec![Param::Tag(Tag::new(self.remote.tag.clone()))], + }; + headers.push(Header::From(from.into())); + headers.push(Header::To(to.into())); + headers.push(Header::CallId(rsip::headers::CallId::new( + self.call_id.clone(), + ))); + headers.push(Header::CSeq(rsip::typed::CSeq { seq, method }.into())); + + // Replay the stored route set — this is the bug fix: it is reused on + // *every* in-dialog request, never recomputed from a Contact. + if !self.route_set.is_empty() { + let list: UriWithParamsList = self.route_set.clone().into(); + headers.push(Header::Route(rsip::typed::Route(list).into())); + } + + let contact = rsip::typed::Contact { + display_name: None, + uri: self.local_contact.clone(), + params: vec![], + }; + headers.push(Header::Contact(contact.into())); + + // Caller-supplied headers (e.g. Content-Type, Session-Expires) precede + // the Content-Length so the latter stays adjacent to the body. + for header in extra_headers { + headers.push(header); + } + headers.push(Header::ContentLength(rsip::headers::ContentLength::from( + body.len() as u32, + ))); + + Request { + method, + uri: self.remote_target.clone(), + version: rsip::Version::V2, + headers, + body, + } + } +} + +/// Pull the tag value out of a header's params. +fn tag_of(params: &[Param]) -> Option { + params.iter().find_map(|p| match p { + Param::Tag(tag) => Some(tag.value().to_string()), + _ => None, + }) +} + +/// Collect every `Record-Route` entry across the message's headers, in order. +fn collect_routes<'a>(headers: impl Iterator) -> Vec { + let mut routes = Vec::new(); + for header in headers { + if let Header::RecordRoute(rr) = header { + if let Ok(typed) = rr.typed() { + routes.extend(typed.uris().iter().cloned()); + } + } + } + routes +} + +#[cfg(test)] +mod tests { + use super::*; + + fn invite() -> Request { + let raw = "INVITE sip:bob@biloxi.example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-inv\r\n\ + Record-Route: \r\n\ + From: \"Alice\" ;tag=alice\r\n\ + To: \r\n\ + Contact: \r\n\ + Call-ID: call-dlg\r\n\ + CSeq: 314 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn ok_response() -> Response { + // Two Record-Route entries: UAC must reverse them. + let raw = "SIP/2.0 200 OK\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-inv\r\n\ + Record-Route: \r\n\ + Record-Route: \r\n\ + From: \"Alice\" ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Contact: \r\n\ + Call-ID: call-dlg\r\n\ + CSeq: 314 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + Response::try_from(raw.as_bytes()).unwrap() + } + + fn local_contact() -> Uri { + Uri::try_from("sip:alice@10.0.0.1:5060").unwrap() + } + + #[test] + fn uac_dialog_captures_identity_and_target() { + let dialog = Dialog::uac(&invite(), &ok_response(), local_contact()).unwrap(); + assert!(dialog.is_confirmed()); + assert_eq!( + dialog.id(), + DialogId { + call_id: "call-dlg".into(), + local_tag: "alice".into(), + remote_tag: "bob".into(), + } + ); + assert_eq!( + dialog.remote_target().to_string(), + "sip:bob@192.168.9.9:5060" + ); + } + + #[test] + fn in_dialog_request_uses_target_route_set_and_tags() { + let mut dialog = Dialog::uac(&invite(), &ok_response(), local_contact()).unwrap(); + let bye = dialog.new_request(Method::Bye); + + // Request-URI is the remote target, not a route. + assert_eq!(bye.uri.to_string(), "sip:bob@192.168.9.9:5060"); + assert_eq!(*bye.method(), Method::Bye); + + // CSeq advanced from the INVITE's 314 → 315, method BYE. + let cseq = bye.cseq_header().unwrap().typed().unwrap(); + assert_eq!(cseq.seq, 315); + assert_eq!(cseq.method, Method::Bye); + + // From carries our tag, To the remote tag. + assert_eq!( + bye.from_header() + .unwrap() + .typed() + .unwrap() + .tag() + .unwrap() + .value(), + "alice" + ); + assert_eq!( + bye.to_header() + .unwrap() + .typed() + .unwrap() + .tag() + .unwrap() + .value(), + "bob" + ); + + // Route set present and REVERSED for the UAC: p2 before p1. + let route = bye + .headers + .iter() + .find_map(|h| match h { + Header::Route(r) => Some(r.to_string()), + _ => None, + }) + .expect("Route header present"); + let p2 = route.find("p2").expect("p2 in route"); + let p1 = route.find("p1").expect("p1 in route"); + assert!(p2 < p1, "UAC route set must be reversed: {route}"); + + // Fresh transaction branch. + let branch = bye + .via_header() + .unwrap() + .typed() + .unwrap() + .branch() + .unwrap() + .to_string(); + assert!(branch.starts_with("z9hG4bK")); + } + + #[test] + fn in_dialog_request_via_advertises_rport() { + // RFC 3581: in-dialog requests (BYE / re-INVITE / INFO) must also carry + // rport — this is the path that the peer's BYE travels in reverse, and + // it must route back to our public address when we are behind NAT. + let mut dialog = Dialog::uac(&invite(), &ok_response(), local_contact()).unwrap(); + let bye = dialog.new_request(Method::Bye); + let via = bye.via_header().unwrap().to_string(); + assert!(via.contains(";rport"), "in-dialog Via lacks rport: {via}"); + } + + #[test] + fn route_set_is_replayed_on_every_request() { + // The regression guard: a second in-dialog request still carries the + // route set captured at establishment (never dropped to the Contact). + let mut dialog = Dialog::uac(&invite(), &ok_response(), local_contact()).unwrap(); + let _bye = dialog.new_request(Method::Bye); + let reinvite = dialog.new_request(Method::Invite); + assert!(reinvite + .headers + .iter() + .any(|h| matches!(h, Header::Route(_)))); + // And the CSeq keeps advancing. + assert_eq!(reinvite.cseq_header().unwrap().typed().unwrap().seq, 316); + } + + #[test] + fn new_request_with_attaches_body_headers_and_content_length() { + let mut dialog = Dialog::uac(&invite(), &ok_response(), local_contact()).unwrap(); + let body = b"Signal=5\nDuration=160".to_vec(); + let info = dialog.new_request_with( + Method::Info, + vec![Header::ContentType(rsip::headers::ContentType::new( + "application/dtmf-relay", + ))], + body.clone(), + ); + + assert_eq!(*info.method(), Method::Info); + assert_eq!(info.body, body); + // Content-Length reflects the body, not the default 0. + let len = info + .headers + .iter() + .find_map(|h| match h { + Header::ContentLength(c) => Some(c.value().to_string()), + _ => None, + }) + .expect("Content-Length present"); + assert_eq!(len, body.len().to_string()); + // The caller's Content-Type rode along. + assert!(info.headers.iter().any(|h| matches!( + h, + Header::ContentType(ct) if ct.value() == "application/dtmf-relay" + ))); + } + + #[test] + fn uas_dialog_orients_parties_and_matches_inbound() { + let dialog = Dialog::uas(&invite(), "ourtag".into(), local_contact()).unwrap(); + // We are the callee: remote is the caller (alice), local is us (bob)+tag. + let id = dialog.id(); + assert_eq!(id.local_tag, "ourtag"); + assert_eq!(id.remote_tag, "alice"); + + // An inbound in-dialog request (the peer's BYE) maps to this dialog: + // its To tag is ours, its From tag is theirs. + let bye_raw = "BYE sip:bob@10.0.0.1:5060 SIP/2.0\r\n\ + Via: SIP/2.0/UDP 1.2.3.4:5060;branch=z9hG4bK-bye\r\n\ + From: \"Alice\" ;tag=alice\r\n\ + To: ;tag=ourtag\r\n\ + Call-ID: call-dlg\r\n\ + CSeq: 1 BYE\r\n\ + Content-Length: 0\r\n\r\n"; + let inbound = Request::try_from(bye_raw.as_bytes()).unwrap(); + assert_eq!(DialogId::from_request(&inbound), Some(dialog.id())); + } + + #[test] + fn early_dialog_from_provisional_is_not_confirmed() { + let raw = "SIP/2.0 180 Ringing\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-inv\r\n\ + From: \"Alice\" ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Contact: \r\n\ + Call-ID: call-dlg\r\n\ + CSeq: 314 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + let ringing = Response::try_from(raw.as_bytes()).unwrap(); + let mut dialog = Dialog::uac(&invite(), &ringing, local_contact()).unwrap(); + assert!(!dialog.is_confirmed()); + dialog.confirm(); + assert!(dialog.is_confirmed()); + } +} diff --git a/crates/wavekat-sip/src/stack/engine.rs b/crates/wavekat-sip/src/stack/engine.rs new file mode 100644 index 0000000..cddc8d4 --- /dev/null +++ b/crates/wavekat-sip/src/stack/engine.rs @@ -0,0 +1,613 @@ +//! Transaction engine: the async runner that drives the sans-IO §17 state +//! machines over a real UDP socket. +//! +//! A single task owns the socket, the transaction table, and the timers, and +//! `select!`s over three sources: +//! +//! - **inbound datagrams** — parsed and demuxed by [`TransactionKey`] to an +//! existing transaction, or used to open a new server transaction; +//! - **fired timers** — looked up and fed back into the owning machine; +//! - **TU commands** — start a client transaction, hand a server transaction +//! its response, or send a message outside any transaction (the 2xx ACK). +//! +//! Each machine returns [`TxAction`]s, which this runner applies: put bytes on +//! the wire, arm/cancel a timer, or publish an [`Event`] to the TU. Because +//! the machines are sans-IO, all the protocol logic stays unit-tested in their +//! own modules; this file is only plumbing, exercised by the loopback tests at +//! the bottom. +//! +//! Timers use a generation counter rather than cancellable handles: arming a +//! timer bumps the `(transaction, TimerId)` generation and spawns a sleeper +//! tagged with it; a fired timer is ignored unless its generation still +//! matches, so `StopTimer` (and re-arming) is just an increment. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use rsip::{Method, Request, Response, SipMessage}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::{debug, warn}; + +use super::transaction::client_invite::ClientInvite; +use super::transaction::client_non_invite::ClientNonInvite; +use super::transaction::server_invite::ServerInvite; +use super::transaction::server_non_invite::ServerNonInvite; +use super::transaction::{Reliability, TimerId, Timers, Transaction, TransactionKey, TxAction}; +use super::transport::UdpTransport; + +/// A request from the transaction user (TU) to the engine. +pub(crate) enum Command { + /// Open a client transaction: send `request` to `peer` and report its + /// responses, timeout, or termination back as [`Event`]s. + StartClient { request: Request, peer: SocketAddr }, + /// Hand a server transaction the response the TU wants it to send. + SendResponse { + key: TransactionKey, + response: Response, + }, + /// Send a message that belongs to no transaction — specifically the ACK + /// for a 2xx, which is a dialog-layer concern, not a transaction one. + SendOutOfDialog { + message: SipMessage, + peer: SocketAddr, + }, +} + +/// Something the engine surfaces up to the TU. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Event { + /// A new inbound request opened a server transaction. + IncomingRequest { + key: TransactionKey, + request: Request, + peer: SocketAddr, + }, + /// A response was delivered for one of our client transactions. + Response { + key: TransactionKey, + response: Response, + }, + /// A request matched no transaction (a 2xx ACK, or a stray in-dialog + /// request) — handed up for the dialog layer to route. + UnmatchedRequest { request: Request, peer: SocketAddr }, + /// A transaction timed out without a final response. + TimedOut { key: TransactionKey }, + /// A transaction terminated; any TU state keyed on it can be dropped. + Terminated { key: TransactionKey }, +} + +impl Event { + /// The transaction key this event belongs to, for routing. `None` only for + /// [`Event::UnmatchedRequest`], which has no owning transaction. + pub(crate) fn key(&self) -> Option<&TransactionKey> { + match self { + Event::IncomingRequest { key, .. } + | Event::Response { key, .. } + | Event::TimedOut { key } + | Event::Terminated { key } => Some(key), + Event::UnmatchedRequest { .. } => None, + } + } +} + +/// Handle the TU uses to drive a running engine. +pub(crate) struct EngineHandle { + cmd_tx: mpsc::Sender, + local_addr: SocketAddr, +} + +impl EngineHandle { + /// The local address the engine's socket is bound to. + pub(crate) fn local_addr(&self) -> SocketAddr { + self.local_addr + } + + /// Queue a client transaction. Returns `false` if the engine has stopped. + pub(crate) async fn start_client(&self, request: Request, peer: SocketAddr) -> bool { + self.cmd_tx + .send(Command::StartClient { request, peer }) + .await + .is_ok() + } + + /// Hand a server transaction its response. + pub(crate) async fn send_response(&self, key: TransactionKey, response: Response) -> bool { + self.cmd_tx + .send(Command::SendResponse { key, response }) + .await + .is_ok() + } + + /// Send a message outside any transaction (the 2xx ACK). + pub(crate) async fn send_out_of_dialog(&self, message: SipMessage, peer: SocketAddr) -> bool { + self.cmd_tx + .send(Command::SendOutOfDialog { message, peer }) + .await + .is_ok() + } +} + +/// A fired timer, tagged with the generation that armed it. +struct TimerFire { + key: TransactionKey, + id: TimerId, + generation: u64, +} + +/// A live transaction plus the routing state the engine keeps for it. +struct Entry { + tx: Transaction, + /// Where this transaction's messages go (client: the target; server: the + /// source the request arrived from). + peer: SocketAddr, + /// Current generation per armed timer; a fired timer with a stale + /// generation has been cancelled or superseded. + timers: HashMap, +} + +/// The engine task's owned state. +struct Engine { + transport: Arc, + reliability: Reliability, + timers: Timers, + txns: HashMap, + timer_tx: mpsc::Sender, + event_tx: mpsc::Sender, +} + +/// Bind a UDP socket and spawn the engine task. +/// +/// Returns a handle for issuing commands and the stream of [`Event`]s the +/// engine produces. The task runs until `cancel` fires or the socket errors. +pub(crate) async fn start( + local: SocketAddr, + cancel: CancellationToken, +) -> io::Result<(EngineHandle, mpsc::Receiver)> { + start_with_timers(local, Timers::default(), cancel).await +} + +/// Like [`start`], but with explicit base timers. Used by tests to shrink the +/// RFC timer table so timeouts and soak periods fire in milliseconds. +pub(crate) async fn start_with_timers( + local: SocketAddr, + timers: Timers, + cancel: CancellationToken, +) -> io::Result<(EngineHandle, mpsc::Receiver)> { + let transport = Arc::new(UdpTransport::bind(local).await?); + let local_addr = transport.local_addr()?; + let reliability = transport.reliability(); + + let (cmd_tx, cmd_rx) = mpsc::channel(64); + let (event_tx, event_rx) = mpsc::channel(64); + let (timer_tx, timer_rx) = mpsc::channel(256); + + let engine = Engine { + transport, + reliability, + timers, + txns: HashMap::new(), + timer_tx, + event_tx, + }; + tokio::spawn(engine.run(cmd_rx, timer_rx, cancel)); + + Ok((EngineHandle { cmd_tx, local_addr }, event_rx)) +} + +impl Engine { + async fn run( + mut self, + mut cmd_rx: mpsc::Receiver, + mut timer_rx: mpsc::Receiver, + cancel: CancellationToken, + ) { + let transport = self.transport.clone(); + loop { + tokio::select! { + biased; + _ = cancel.cancelled() => break, + recvd = transport.recv() => match recvd { + Ok((msg, src)) => self.on_inbound(msg, src).await, + Err(e) => { warn!(error = %e, "UDP receive failed; stopping engine"); break; } + }, + Some(fire) = timer_rx.recv() => self.on_timer_fire(fire).await, + Some(cmd) = cmd_rx.recv() => self.on_command(cmd).await, + } + } + } + + async fn on_inbound(&mut self, msg: SipMessage, src: SocketAddr) { + match msg { + SipMessage::Request(req) => self.on_request(req, src).await, + SipMessage::Response(resp) => self.on_response(resp).await, + } + } + + async fn on_response(&mut self, resp: Response) { + let Some(key) = TransactionKey::from_response(&resp) else { + return; + }; + match self.txns.get_mut(&key) { + Some(entry) => { + let actions = entry.tx.on_response(&resp); + self.apply(&key, actions).await; + } + // A response with no matching transaction is a late/duplicate final + // (e.g. a retransmitted 2xx after the client INVITE tx terminated); + // the dialog layer owns 2xx retransmits, so there is nothing to do + // at this layer yet. + None => debug!("response matched no transaction; dropping"), + } + } + + async fn on_request(&mut self, req: Request, src: SocketAddr) { + let Some(key) = TransactionKey::from_request(&req) else { + return; + }; + + // Retransmission or ACK for an existing transaction. + if let Some(entry) = self.txns.get_mut(&key) { + let actions = entry.tx.on_request(&req); + self.apply(&key, actions).await; + return; + } + + // No transaction yet — open one, or hand up an out-of-transaction ACK. + let (tx, actions) = match req.method() { + Method::Ack => { + // An ACK matching no server INVITE transaction is the ACK for a + // 2xx: a dialog concern, not a transaction one. + let _ = self + .event_tx + .send(Event::UnmatchedRequest { + request: req, + peer: src, + }) + .await; + return; + } + Method::Invite => { + let (t, a) = ServerInvite::start(&req, self.timers, self.reliability); + (Transaction::ServerInvite(t), a) + } + _ => { + let (t, a) = ServerNonInvite::start(&req, self.timers, self.reliability); + (Transaction::ServerNonInvite(t), a) + } + }; + self.txns.insert( + key.clone(), + Entry { + tx, + peer: src, + timers: HashMap::new(), + }, + ); + self.apply(&key, actions).await; + } + + async fn on_timer_fire(&mut self, fire: TimerFire) { + // Ignore a timer that has been cancelled, re-armed, or whose + // transaction is already gone. + let current = self + .txns + .get(&fire.key) + .and_then(|e| e.timers.get(&fire.id).copied()); + if current != Some(fire.generation) { + return; + } + let actions = self + .txns + .get_mut(&fire.key) + .map(|e| e.tx.on_timer(fire.id)) + .unwrap_or_default(); + self.apply(&fire.key, actions).await; + } + + async fn on_command(&mut self, cmd: Command) { + match cmd { + Command::StartClient { request, peer } => { + let Some(key) = TransactionKey::from_request(&request) else { + return; + }; + let is_invite = *request.method() == Method::Invite; + let (tx, actions) = if is_invite { + let (t, a) = ClientInvite::start(request, self.timers, self.reliability); + (Transaction::ClientInvite(t), a) + } else { + let (t, a) = ClientNonInvite::start(request, self.timers, self.reliability); + (Transaction::ClientNonInvite(t), a) + }; + self.txns.insert( + key.clone(), + Entry { + tx, + peer, + timers: HashMap::new(), + }, + ); + self.apply(&key, actions).await; + } + Command::SendResponse { key, response } => { + let actions = match self.txns.get_mut(&key).map(|e| &mut e.tx) { + Some(Transaction::ServerInvite(t)) => t.send_response(response), + Some(Transaction::ServerNonInvite(t)) => t.send_response(response), + _ => Vec::new(), + }; + self.apply(&key, actions).await; + } + Command::SendOutOfDialog { message, peer } => { + if let Err(e) = self.transport.send_to(&message, peer).await { + warn!(error = %e, "out-of-dialog send failed"); + } + } + } + } + + /// Apply the actions a state machine returned. + async fn apply(&mut self, key: &TransactionKey, actions: Vec) { + for action in actions { + match action { + TxAction::Send(msg) => { + if let Some(peer) = self.txns.get(key).map(|e| e.peer) { + if let Err(e) = self.transport.send_to(&msg, peer).await { + warn!(error = %e, "transport send failed"); + } + } + } + TxAction::StartTimer { id, after } => self.arm_timer(key, id, after), + TxAction::StopTimer(id) => self.stop_timer(key, id), + TxAction::DeliverResponse(response) => { + let _ = self + .event_tx + .send(Event::Response { + key: key.clone(), + response, + }) + .await; + } + TxAction::DeliverRequest(request) => { + if let Some(peer) = self.txns.get(key).map(|e| e.peer) { + let _ = self + .event_tx + .send(Event::IncomingRequest { + key: key.clone(), + request, + peer, + }) + .await; + } + } + TxAction::TimedOut => { + let _ = self + .event_tx + .send(Event::TimedOut { key: key.clone() }) + .await; + } + TxAction::Terminated => { + self.txns.remove(key); + let _ = self + .event_tx + .send(Event::Terminated { key: key.clone() }) + .await; + } + } + } + } + + fn arm_timer(&mut self, key: &TransactionKey, id: TimerId, after: Duration) { + let Some(entry) = self.txns.get_mut(key) else { + return; + }; + let generation = { + let g = entry.timers.entry(id).or_insert(0); + *g += 1; + *g + }; + let timer_tx = self.timer_tx.clone(); + let key = key.clone(); + tokio::spawn(async move { + tokio::time::sleep(after).await; + let _ = timer_tx + .send(TimerFire { + key, + id, + generation, + }) + .await; + }); + } + + fn stop_timer(&mut self, key: &TransactionKey, id: TimerId) { + if let Some(entry) = self.txns.get_mut(key) { + // Bump the generation so any outstanding sleeper for this timer is + // ignored when it fires. + if let Some(g) = entry.timers.get_mut(&id) { + *g += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::{timeout, Duration}; + + const BRANCH: &str = "z9hG4bK-engine"; + + /// Tiny timers so soak/timeout timers fire in milliseconds: + /// Timer F/timeout = 64·T1 = ~64ms, Timer K = T4 = 5ms. + fn fast_timers() -> Timers { + Timers { + t1: Duration::from_millis(1), + t2: Duration::from_millis(4), + t4: Duration::from_millis(5), + } + } + + async fn recv_event(rx: &mut mpsc::Receiver) -> Event { + timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("event within timeout") + .expect("channel open") + } + + fn options_to(peer: SocketAddr) -> Request { + let raw = format!( + "OPTIONS sip:bob@{peer} SIP/2.0\r\n\ + Via: SIP/2.0/UDP 127.0.0.1:5060;branch={BRANCH}\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-eng\r\n\ + CSeq: 4 OPTIONS\r\n\ + Content-Length: 0\r\n\r\n" + ); + Request::try_from(raw.as_bytes()).unwrap() + } + + fn response(code: u16, method: &str) -> Response { + let raw = format!( + "SIP/2.0 {code} X\r\n\ + Via: SIP/2.0/UDP 127.0.0.1:5060;branch={BRANCH}\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-eng\r\n\ + CSeq: 4 {method}\r\n\ + Content-Length: 0\r\n\r\n" + ); + Response::try_from(raw.as_bytes()).unwrap() + } + + fn invite_to(peer: SocketAddr) -> Request { + let raw = format!( + "INVITE sip:bob@{peer} SIP/2.0\r\n\ + Via: SIP/2.0/UDP 127.0.0.1:5060;branch={BRANCH}\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-eng\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n" + ); + Request::try_from(raw.as_bytes()).unwrap() + } + + #[tokio::test] + async fn client_transaction_delivers_response_then_terminates() { + let cancel = CancellationToken::new(); + let (handle, mut events) = start_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + cancel.clone(), + ) + .await + .unwrap(); + + // A fake peer that answers the OPTIONS. + let server = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let server_addr = server.local_addr().unwrap(); + + assert!( + handle + .start_client(options_to(server_addr), server_addr) + .await + ); + + // The engine sends the OPTIONS; reply 200 to its source. + let (got, engine_src) = server.recv().await.unwrap(); + assert!(matches!(got, SipMessage::Request(_))); + server + .send_to(&response(200, "OPTIONS").into(), engine_src) + .await + .unwrap(); + + match recv_event(&mut events).await { + Event::Response { response, .. } => assert_eq!(response.status_code().code(), 200), + other => panic!("expected Response, got {other:?}"), + } + assert!(matches!( + recv_event(&mut events).await, + Event::Terminated { .. } + )); + cancel.cancel(); + } + + #[tokio::test] + async fn inbound_invite_opens_server_transaction_and_sends_response() { + let cancel = CancellationToken::new(); + let (handle, mut events) = start_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + cancel.clone(), + ) + .await + .unwrap(); + let engine_addr = handle.local_addr(); + + let peer = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + + // Peer sends an INVITE into the engine. + peer.send_to(&invite_to(engine_addr).into(), engine_addr) + .await + .unwrap(); + + let key = match recv_event(&mut events).await { + Event::IncomingRequest { key, request, .. } => { + assert_eq!(*request.method(), Method::Invite); + key + } + other => panic!("expected IncomingRequest, got {other:?}"), + }; + + // TU answers 486; the engine puts it on the wire toward the peer. + assert!(handle.send_response(key, response(486, "INVITE")).await); + let (got, _) = peer.recv().await.unwrap(); + match got { + SipMessage::Response(r) => assert_eq!(r.status_code().code(), 486), + other => panic!("expected response, got {other:?}"), + } + cancel.cancel(); + } + + #[tokio::test] + async fn no_final_response_times_out() { + let cancel = CancellationToken::new(); + // Short timers so Timer F fires quickly: 64·T1 with T1 = 1ms ≈ 64ms. + let (handle, mut events) = start_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + cancel.clone(), + ) + .await + .unwrap(); + + // Send to a bound-but-silent socket that never answers. + let sink = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let sink_addr = sink.local_addr().unwrap(); + + assert!(handle.start_client(options_to(sink_addr), sink_addr).await); + + // Timer F fires (~64ms) → the transaction reports timeout, then + // terminates. (Exact timer-table values are checked sans-IO in the + // transaction unit tests; here we prove the engine wires them through.) + assert!(matches!( + recv_event(&mut events).await, + Event::TimedOut { .. } + )); + assert!(matches!( + recv_event(&mut events).await, + Event::Terminated { .. } + )); + cancel.cancel(); + } +} diff --git a/crates/wavekat-sip/src/stack/mod.rs b/crates/wavekat-sip/src/stack/mod.rs new file mode 100644 index 0000000..538b62d --- /dev/null +++ b/crates/wavekat-sip/src/stack/mod.rs @@ -0,0 +1,47 @@ +//! Clean-room SIP transaction/dialog/transport engine — internal only. +//! +//! This module is the from-scratch SIP engine described in +//! `docs/08-own-sip-stack.md`. It is built flow-by-flow and is **entirely +//! `pub(crate)`**: none of its types ever appear in `wavekat_sip::*` +//! signatures, so it can evolve or be replaced without breaking downstream +//! consumers. The crate's existing public wrappers (`Caller`, `Callee`, +//! `Registrar`, …) own the public API; this engine sits behind them. +//! +//! Scope is the **User Agent** path this crate actually drives — REGISTER, +//! INVITE/re-INVITE, BYE, CANCEL, INFO, OPTIONS — and nothing else. No +//! proxy/registrar-server/B2BUA/SBC roles (see the plan's non-goals). +//! +//! We keep the `rsip` crate for SIP *message* types (`Request`, `Response`, +//! headers, digest math) and write only the **stateful** layer on top: +//! transactions (RFC 3261 §17), dialogs (§12), transport, and digest +//! orchestration. +//! +//! ## Build status +//! +//! Phase 1 (this module today): the RFC 3261 §17 transaction state +//! machines, expressed sans-IO — each machine maps an input event +//! (received message, fired timer) to a list of [`transaction::TxAction`]s +//! (send a message, arm/stop a timer, hand a message to the transaction +//! user). That keeps the timer-heavy core fully unit-testable against the +//! RFC timer tables with no sockets or wall-clock, and lets a thin +//! transport runner drive it in a later phase. +//! +//! Later phases add the dialog layer, digest orchestration, and the +//! transport serve loop, then migrate each flow off the external stack. + +// The engine is built out flow-by-flow ahead of the wrappers that will +// consume it, so during the migration many `pub(crate)` items are exercised +// only by this module's own unit tests. Allow dead code here (and only here) +// rather than sprinkling per-item attributes; it is removed phase by phase as +// `endpoint`/`caller`/`callee`/`registrar` are re-pointed at the engine. +#![allow(dead_code)] + +pub(crate) mod auth; +pub(crate) mod call; +pub(crate) mod dialog; +pub(crate) mod engine; +pub(crate) mod registration; +pub(crate) mod response; +pub(crate) mod transaction; +pub(crate) mod transport; +pub(crate) mod ua; diff --git a/crates/wavekat-sip/src/stack/registration.rs b/crates/wavekat-sip/src/stack/registration.rs new file mode 100644 index 0000000..a521c61 --- /dev/null +++ b/crates/wavekat-sip/src/stack/registration.rs @@ -0,0 +1,174 @@ +//! REGISTER request builder + result types — RFC 3261 §10. +//! +//! Pure composition: `build_register` assembles the request and the +//! `RegisterConfig`/`RegisterOutcome` types describe the inputs and result. +//! The driving (send, await, answer a challenge over the shared engine) lives +//! in [`ua`](super::ua); the digest math is [`auth`](super::auth). + +use std::net::SocketAddr; + +use rsip::headers::UntypedHeader; +use rsip::message::HeadersExt; +use rsip::{Header, Headers, Method, Request, StatusCode, Uri}; + +use super::auth::Credentials; +use super::transaction::{gen_branch, via_value}; + +/// Everything needed to compose a REGISTER and answer a challenge. +pub(crate) struct RegisterConfig { + /// Request-URI — the registrar domain, e.g. `sip:example.com`. + pub registrar_uri: Uri, + /// Address of record — `From`/`To`, e.g. `sip:alice@example.com`. + pub aor: Uri, + /// Our contact — where we receive calls, e.g. `sip:alice@10.0.0.1:5060`. + pub contact: Uri, + /// Stable `From` tag for this registration. + pub from_tag: String, + /// Stable `Call-ID` for this registration. + pub call_id: String, + /// Requested registration lifetime in seconds. + pub expires: u32, + pub username: String, + pub password: String, +} + +impl RegisterConfig { + fn creds(&self) -> Credentials<'_> { + Credentials { + username: &self.username, + password: &self.password, + } + } + + /// Credentials for answering a challenge (used by the `ua` router). + pub(crate) fn creds_for_retry(&self) -> Credentials<'_> { + self.creds() + } +} + +/// The result of a register attempt. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RegisterOutcome { + /// Registered; the server granted this lifetime (seconds). + Registered { expires: u32 }, + /// Credentials rejected (a second challenge, or an unanswerable one). + Unauthorized, + /// The server returned a non-2xx, non-auth final response. + Failed(StatusCode), + /// No final response before the transaction timed out. + TimedOut, + /// The engine stopped before a result was reached. + EngineStopped, +} + +/// Build a REGISTER request bound to `local_addr` (for the `Via` sent-by) with +/// the given CSeq. +pub(crate) fn build_register(cfg: &RegisterConfig, cseq: u32, local_addr: SocketAddr) -> Request { + let mut headers = Headers::default(); + headers.push(Header::Via(rsip::headers::Via::new(via_value( + local_addr, + &gen_branch(), + )))); + headers.push(Header::MaxForwards(rsip::headers::MaxForwards::default())); + + let from = rsip::typed::From { + display_name: None, + uri: cfg.aor.clone(), + params: vec![rsip::common::uri::param::Param::Tag( + rsip::common::uri::param::Tag::new(cfg.from_tag.clone()), + )], + }; + let to = rsip::typed::To { + display_name: None, + uri: cfg.aor.clone(), + params: vec![], + }; + let contact = rsip::typed::Contact { + display_name: None, + uri: cfg.contact.clone(), + params: vec![], + }; + headers.push(Header::From(from.into())); + headers.push(Header::To(to.into())); + headers.push(Header::Contact(contact.into())); + headers.push(Header::CallId(rsip::headers::CallId::new( + cfg.call_id.clone(), + ))); + headers.push(Header::CSeq( + rsip::typed::CSeq { + seq: cseq, + method: Method::Register, + } + .into(), + )); + headers.push(Header::Expires(rsip::headers::Expires::from(cfg.expires))); + headers.push(Header::ContentLength( + rsip::headers::ContentLength::default(), + )); + + Request { + method: Method::Register, + uri: cfg.registrar_uri.clone(), + version: rsip::Version::V2, + headers, + body: Vec::new(), + } +} + +/// The lifetime the server granted, from the response `Expires` header. +pub(crate) fn granted_expires(response: &rsip::Response) -> Option { + response.expires_header()?.seconds().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use rsip::headers::ToTypedHeader; + + fn config() -> RegisterConfig { + RegisterConfig { + registrar_uri: Uri::try_from("sip:example.com").unwrap(), + aor: Uri::try_from("sip:alice@example.com").unwrap(), + contact: Uri::try_from("sip:alice@10.0.0.1:5060").unwrap(), + from_tag: "alicetag".into(), + call_id: "reg-call".into(), + expires: 60, + username: "alice".into(), + password: "secret".into(), + } + } + + #[test] + fn build_register_has_expected_shape() { + let cfg = config(); + let req = build_register(&cfg, 1, "10.0.0.1:5060".parse().unwrap()); + assert_eq!(*req.method(), Method::Register); + assert_eq!(req.uri.to_string(), "sip:example.com"); + assert_eq!(req.cseq_header().unwrap().typed().unwrap().seq, 1); + assert_eq!( + req.from_header() + .unwrap() + .typed() + .unwrap() + .tag() + .unwrap() + .value(), + "alicetag" + ); + assert_eq!(req.expires_header().unwrap().seconds().unwrap(), 60); + // RFC 3581: REGISTER advertises rport so the registrar binds our public + // address — the NAT pinhole that later inbound calls and in-dialog + // requests are routed through. + let via = req.via_header().unwrap().to_string(); + assert!(via.contains(";rport"), "REGISTER Via lacks rport: {via}"); + } + + #[test] + fn granted_expires_reads_the_header() { + let raw = "SIP/2.0 200 OK\r\nVia: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-x\r\n\ + From: ;tag=a\r\nTo: ;tag=s\r\nCall-ID: c\r\nCSeq: 1 REGISTER\r\n\ + Expires: 120\r\nContent-Length: 0\r\n\r\n"; + let resp = rsip::Response::try_from(raw.as_bytes()).unwrap(); + assert_eq!(granted_expires(&resp), Some(120)); + } +} diff --git a/crates/wavekat-sip/src/stack/response.rs b/crates/wavekat-sip/src/stack/response.rs new file mode 100644 index 0000000..c910628 --- /dev/null +++ b/crates/wavekat-sip/src/stack/response.rs @@ -0,0 +1,212 @@ +//! Build a SIP response to an inbound request — RFC 3261 §8.2.6. +//! +//! A UAS response echoes the request's `Via`, `From`, `Call-ID` and `CSeq` +//! unchanged, copies `To` (adding a local tag if the request had none), and +//! optionally carries a `Contact` and a body. This is the small bit of +//! response composition the callee and the endpoint's auto-answer need. + +use rsip::headers::{ToTypedHeader, UntypedHeader}; +use rsip::message::HeadersExt; +use rsip::{Header, Headers, Request, Response, StatusCode, Uri}; + +/// A body to attach to a response: its content type and bytes. +pub(crate) struct ResponseBody<'a> { + pub content_type: &'a str, + pub bytes: Vec, +} + +/// Build a response to `request` with `status`. +/// +/// - `to_tag`: if the request's `To` had no tag, this one is added (a UAS must +/// tag its responses to establish a dialog); ignored if `To` already carries +/// one. +/// - `contact`: added as a `Contact` header (e.g. on a 2xx so the peer can +/// route in-dialog requests back). +/// - `body`: optional SDP (or other) body with its content type. +pub(crate) fn build_response( + request: &Request, + status: StatusCode, + to_tag: Option<&str>, + contact: Option<&Uri>, + body: Option>, +) -> Option { + let mut headers = Headers::default(); + + // Via(s), From, Call-ID, CSeq are echoed verbatim. Record-Route is also + // copied unchanged and in order (RFC 3261 §12.1.1): a UAS MUST mirror every + // Record-Route from the request into its 2xx so the peer's reversed route + // set (§12.1.2) sends in-dialog requests — crucially the terminating BYE — + // back through the proxies that recorded themselves, not straight to our + // Contact. Behind NAT our Contact is a private address, so dropping these + // strands the peer's BYE and the call never tears down on remote hangup. + for header in request.headers.iter() { + match header { + Header::Via(_) + | Header::From(_) + | Header::CallId(_) + | Header::CSeq(_) + | Header::RecordRoute(_) => { + headers.push(header.clone()); + } + _ => {} + } + } + + // To, with a local tag added when absent. + let mut to = request.to_header().ok()?.typed().ok()?; + if to.tag().is_none() { + if let Some(tag) = to_tag { + to.params.push(rsip::common::uri::param::Param::Tag( + rsip::common::uri::param::Tag::new(tag.to_string()), + )); + } + } + headers.push(Header::To(to.into())); + + if let Some(uri) = contact { + let contact = rsip::typed::Contact { + display_name: None, + uri: uri.clone(), + params: vec![], + }; + headers.push(Header::Contact(contact.into())); + } + + let body_bytes = match body { + Some(b) => { + headers.push(Header::ContentType(rsip::headers::ContentType::new( + b.content_type, + ))); + b.bytes + } + None => Vec::new(), + }; + headers.push(Header::ContentLength(rsip::headers::ContentLength::from( + body_bytes.len() as u32, + ))); + + Some(Response { + status_code: status, + version: request.version.clone(), + headers, + body: body_bytes, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn invite() -> Request { + let raw = "INVITE sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-r\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-r\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn ok_echoes_dialog_headers_and_adds_to_tag() { + let resp = build_response( + &invite(), + StatusCode::OK, + Some("srvtag"), + Some(&Uri::try_from("sip:bob@10.0.0.2:5060").unwrap()), + Some(ResponseBody { + content_type: "application/sdp", + bytes: b"v=0\r\n".to_vec(), + }), + ) + .unwrap(); + + assert_eq!(resp.status_code.code(), 200); + assert_eq!( + resp.to_header() + .unwrap() + .typed() + .unwrap() + .tag() + .unwrap() + .value(), + "srvtag" + ); + assert_eq!( + resp.from_header() + .unwrap() + .typed() + .unwrap() + .tag() + .unwrap() + .value(), + "alice" + ); + assert_eq!(resp.cseq_header().unwrap().typed().unwrap().seq, 1); + assert_eq!(resp.body, b"v=0\r\n"); + assert!(resp.headers.iter().any(|h| matches!(h, Header::Contact(_)))); + } + + #[test] + fn ok_echoes_record_route_in_order() { + // RFC 3261 §12.1.1: every Record-Route the proxy chain inserted must be + // mirrored into the 2xx, in the same order, so the peer can build its + // route set and send the in-dialog BYE back through the proxies. The + // 2talk gateway behind a NAT exposed this: without the echo the peer's + // BYE targeted our private Contact and the call never tore down. + let raw = "INVITE sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-r\r\n\ + Record-Route: \r\n\ + Record-Route: \r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-r\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + let invite = Request::try_from(raw.as_bytes()).unwrap(); + let resp = build_response(&invite, StatusCode::OK, Some("srvtag"), None, None).unwrap(); + + let record_routes: Vec = resp + .headers + .iter() + .filter_map(|h| match h { + Header::RecordRoute(rr) => Some(rr.value().to_string()), + _ => None, + }) + .collect(); + assert_eq!( + record_routes, + vec![ + "".to_string(), + "".to_string(), + ], + "2xx must mirror Record-Route values verbatim and in order" + ); + } + + #[test] + fn reject_keeps_existing_to_tag() { + let raw = "BYE sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-b\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-r\r\n\ + CSeq: 2 BYE\r\n\ + Content-Length: 0\r\n\r\n"; + let bye = Request::try_from(raw.as_bytes()).unwrap(); + let resp = build_response(&bye, StatusCode::OK, Some("ignored"), None, None).unwrap(); + // Existing tag is preserved, not overwritten. + assert_eq!( + resp.to_header() + .unwrap() + .typed() + .unwrap() + .tag() + .unwrap() + .value(), + "bob" + ); + assert_eq!(resp.body.len(), 0); + } +} diff --git a/crates/wavekat-sip/src/stack/transaction/client_invite.rs b/crates/wavekat-sip/src/stack/transaction/client_invite.rs new file mode 100644 index 0000000..bea92b0 --- /dev/null +++ b/crates/wavekat-sip/src/stack/transaction/client_invite.rs @@ -0,0 +1,331 @@ +//! Client INVITE transaction — RFC 3261 §17.1.1. +//! +//! ```text +//! |INVITE sent +//! Calling -------+--- Timer A: resend, A = 2·A +//! | \ +--- Timer B: inform TU (timeout) +//! 1xx | \ 2xx → deliver, terminate +//! v \ 300-699 → ACK, deliver, Completed +//! Proceeding ----- 2xx → deliver, terminate +//! | 300-699 → ACK, deliver, Completed +//! v +//! Completed --- retransmitted 300-699: resend ACK +//! | Timer D: terminate +//! v +//! Terminated +//! ``` +//! +//! The ACK for a **non-2xx** final response is built and sent *inside* this +//! transaction (it shares the INVITE's branch). The ACK for a **2xx** is the +//! TU/dialog's job — a separate transaction — so on a 2xx this machine just +//! hands the response up and terminates. + +use super::{build_non_2xx_ack, Reliability, TimerId, Timers, TxAction}; +use rsip::{Request, Response, SipMessage}; + +/// State of a client INVITE transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum State { + /// INVITE sent, no response yet; retransmitting on Timer A. + Calling, + /// A provisional response arrived; waiting for the final. + Proceeding, + /// A non-2xx final arrived; absorbing its retransmissions (Timer D). + Completed, + /// Done; the runner may drop the transaction. + Terminated, +} + +/// A client INVITE transaction state machine. +pub(crate) struct ClientInvite { + state: State, + timers: Timers, + rel: Reliability, + /// The original INVITE — kept to retransmit it and to build the non-2xx ACK. + invite: Request, + /// Current Timer A interval; doubles on each retransmission. + timer_a: std::time::Duration, +} + +impl ClientInvite { + /// Begin the transaction: send the INVITE and arm Timers A (unreliable + /// only) and B. + pub(crate) fn start( + invite: Request, + timers: Timers, + rel: Reliability, + ) -> (Self, Vec) { + let mut actions = vec![TxAction::Send(SipMessage::Request(invite.clone()))]; + if !rel.is_reliable() { + actions.push(TxAction::StartTimer { + id: TimerId::A, + after: timers.t1, + }); + } + actions.push(TxAction::StartTimer { + id: TimerId::B, + after: timers.timeout(), + }); + let tx = Self { + state: State::Calling, + timers, + rel, + invite, + timer_a: timers.t1, + }; + (tx, actions) + } + + pub(crate) fn state(&self) -> State { + self.state + } + + /// Feed a response received for this transaction. + pub(crate) fn on_response(&mut self, resp: &Response) -> Vec { + let code = resp.status_code().code(); + match self.state { + State::Calling | State::Proceeding => { + if code < 200 { + self.on_provisional(resp) + } else if code < 300 { + // 2xx: hand up; the TU sends the ACK and owns the dialog. + self.state = State::Terminated; + vec![ + TxAction::DeliverResponse(resp.clone()), + TxAction::Terminated, + ] + } else { + self.on_final_failure(resp) + } + } + // Retransmitted non-2xx final: resend the ACK, do not re-deliver. + State::Completed if code >= 300 => match build_non_2xx_ack(&self.invite, resp) { + Some(ack) => vec![TxAction::Send(SipMessage::Request(ack))], + None => Vec::new(), + }, + State::Completed | State::Terminated => Vec::new(), + } + } + + /// Feed a fired timer. + pub(crate) fn on_timer(&mut self, id: TimerId) -> Vec { + match (self.state, id) { + // Retransmit the INVITE, doubling the interval (unreliable only). + (State::Calling, TimerId::A) => { + self.timer_a *= 2; + vec![ + TxAction::Send(SipMessage::Request(self.invite.clone())), + TxAction::StartTimer { + id: TimerId::A, + after: self.timer_a, + }, + ] + } + // No response in time. + (State::Calling, TimerId::B) => { + self.state = State::Terminated; + vec![TxAction::TimedOut, TxAction::Terminated] + } + // Done absorbing retransmitted finals. + (State::Completed, TimerId::D) => { + self.state = State::Terminated; + vec![TxAction::Terminated] + } + _ => Vec::new(), + } + } + + fn on_provisional(&mut self, resp: &Response) -> Vec { + if self.state == State::Calling { + // Leave Calling: stop retransmitting (A) and the timeout (B); a + // call may legitimately stay in Proceeding (ringing) for minutes. + self.state = State::Proceeding; + vec![ + TxAction::StopTimer(TimerId::A), + TxAction::StopTimer(TimerId::B), + TxAction::DeliverResponse(resp.clone()), + ] + } else { + vec![TxAction::DeliverResponse(resp.clone())] + } + } + + fn on_final_failure(&mut self, resp: &Response) -> Vec { + let Some(ack) = build_non_2xx_ack(&self.invite, resp) else { + return Vec::new(); + }; + let mut actions = vec![ + TxAction::Send(SipMessage::Request(ack)), + TxAction::DeliverResponse(resp.clone()), + ]; + if self.rel.is_reliable() { + // Timer D is zero on reliable transports: terminate at once. + self.state = State::Terminated; + actions.push(TxAction::Terminated); + } else { + self.state = State::Completed; + actions.push(TxAction::StartTimer { + id: TimerId::D, + after: self.timers.d(self.rel), + }); + } + actions + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rsip::Method; + + fn invite() -> Request { + let raw = "INVITE sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-inv\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-abc\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn response(code: u16) -> Response { + let raw = format!( + "SIP/2.0 {code} X\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-inv\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-abc\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n" + ); + Response::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn start_sends_invite_and_arms_a_and_b_on_udp() { + let (tx, actions) = + ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + assert_eq!(tx.state(), State::Calling); + assert!(matches!(actions[0], TxAction::Send(_))); + assert!(matches!( + actions[1], + TxAction::StartTimer { id: TimerId::A, .. } + )); + assert!(matches!( + actions[2], + TxAction::StartTimer { id: TimerId::B, .. } + )); + } + + #[test] + fn start_skips_timer_a_on_reliable_transport() { + let (_tx, actions) = + ClientInvite::start(invite(), Timers::default(), Reliability::Reliable); + assert!(!actions + .iter() + .any(|a| matches!(a, TxAction::StartTimer { id: TimerId::A, .. }))); + assert!(actions + .iter() + .any(|a| matches!(a, TxAction::StartTimer { id: TimerId::B, .. }))); + } + + #[test] + fn timer_a_retransmits_and_doubles() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + let a = tx.on_timer(TimerId::A); + assert!(matches!(a[0], TxAction::Send(_))); + match a[1] { + TxAction::StartTimer { + id: TimerId::A, + after, + } => assert_eq!(after, Timers::default().t1 * 2), + _ => panic!("expected Timer A reschedule"), + } + // Second fire doubles again → 4·T1. + let a2 = tx.on_timer(TimerId::A); + match a2[1] { + TxAction::StartTimer { + id: TimerId::A, + after, + } => assert_eq!(after, Timers::default().t1 * 4), + _ => panic!("expected Timer A reschedule"), + } + } + + #[test] + fn timer_b_times_out_the_transaction() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + let out = tx.on_timer(TimerId::B); + assert_eq!(out, vec![TxAction::TimedOut, TxAction::Terminated]); + assert_eq!(tx.state(), State::Terminated); + } + + #[test] + fn provisional_moves_to_proceeding_and_stops_retransmits() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + let out = tx.on_response(&response(180)); + assert_eq!(tx.state(), State::Proceeding); + assert!(out.contains(&TxAction::StopTimer(TimerId::A))); + assert!(out.contains(&TxAction::StopTimer(TimerId::B))); + assert!(matches!(out.last(), Some(TxAction::DeliverResponse(_)))); + // A retransmit timer firing in Proceeding is ignored. + assert!(tx.on_timer(TimerId::A).is_empty()); + } + + #[test] + fn success_delivers_and_terminates_without_acking() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + let out = tx.on_response(&response(200)); + assert_eq!(tx.state(), State::Terminated); + assert!(matches!(out[0], TxAction::DeliverResponse(_))); + assert_eq!(out[1], TxAction::Terminated); + // No ACK is sent by the transaction for a 2xx. + assert!(!out.iter().any(|a| matches!(a, TxAction::Send(_)))); + } + + #[test] + fn non_2xx_final_acks_delivers_and_enters_completed_on_udp() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + let out = tx.on_response(&response(486)); + assert_eq!(tx.state(), State::Completed); + // ACK first, then deliver, then arm Timer D. + match &out[0] { + TxAction::Send(SipMessage::Request(r)) => assert_eq!(*r.method(), Method::Ack), + other => panic!("expected ACK send, got {other:?}"), + } + assert!(matches!(out[1], TxAction::DeliverResponse(_))); + assert!(matches!( + out[2], + TxAction::StartTimer { id: TimerId::D, .. } + )); + } + + #[test] + fn retransmitted_non_2xx_resends_ack_only() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + tx.on_response(&response(486)); + let out = tx.on_response(&response(486)); + assert_eq!(out.len(), 1); + assert!(matches!(out[0], TxAction::Send(SipMessage::Request(_)))); + } + + #[test] + fn non_2xx_final_terminates_immediately_on_reliable() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Reliable); + let out = tx.on_response(&response(500)); + assert_eq!(tx.state(), State::Terminated); + assert!(matches!(out[0], TxAction::Send(_))); + assert!(matches!(out[1], TxAction::DeliverResponse(_))); + assert_eq!(out[2], TxAction::Terminated); + } + + #[test] + fn timer_d_terminates_from_completed() { + let (mut tx, _) = ClientInvite::start(invite(), Timers::default(), Reliability::Unreliable); + tx.on_response(&response(404)); + let out = tx.on_timer(TimerId::D); + assert_eq!(out, vec![TxAction::Terminated]); + assert_eq!(tx.state(), State::Terminated); + } +} diff --git a/crates/wavekat-sip/src/stack/transaction/client_non_invite.rs b/crates/wavekat-sip/src/stack/transaction/client_non_invite.rs new file mode 100644 index 0000000..e4ed423 --- /dev/null +++ b/crates/wavekat-sip/src/stack/transaction/client_non_invite.rs @@ -0,0 +1,291 @@ +//! Client non-INVITE transaction — RFC 3261 §17.1.2. +//! +//! Drives REGISTER, BYE, CANCEL, INFO and OPTIONS. Simpler than the INVITE +//! machine: there is no ACK, and a final response of any class ends the +//! request/response exchange. +//! +//! ```text +//! Trying ----- Timer E: resend, E = min(2·E, T2) +//! | \ Timer F: inform TU (timeout) +//! 1xx | \ 200-699 → deliver, Completed +//! v \ +//! Proceeding - Timer E: resend, E = T2 +//! | Timer F: inform TU (timeout) +//! | 200-699 → deliver, Completed +//! v +//! Completed -- retransmitted final: absorb +//! | Timer K: terminate +//! v +//! Terminated +//! ``` + +use super::{Reliability, TimerId, Timers, TxAction}; +use rsip::{Request, Response, SipMessage}; + +/// State of a client non-INVITE transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum State { + /// Request sent, nothing back yet; retransmitting on Timer E. + Trying, + /// A provisional response arrived; still retransmitting (capped at T2). + Proceeding, + /// A final response arrived; absorbing its retransmissions (Timer K). + Completed, + /// Done. + Terminated, +} + +/// A client non-INVITE transaction state machine. +pub(crate) struct ClientNonInvite { + state: State, + timers: Timers, + rel: Reliability, + request: Request, + /// Current Timer E interval; doubles up to T2. + timer_e: std::time::Duration, +} + +impl ClientNonInvite { + /// Begin the transaction: send the request and arm Timers E (unreliable + /// only) and F. + pub(crate) fn start( + request: Request, + timers: Timers, + rel: Reliability, + ) -> (Self, Vec) { + let mut actions = vec![TxAction::Send(SipMessage::Request(request.clone()))]; + if !rel.is_reliable() { + actions.push(TxAction::StartTimer { + id: TimerId::E, + after: timers.t1, + }); + } + actions.push(TxAction::StartTimer { + id: TimerId::F, + after: timers.timeout(), + }); + let tx = Self { + state: State::Trying, + timers, + rel, + request, + timer_e: timers.t1, + }; + (tx, actions) + } + + pub(crate) fn state(&self) -> State { + self.state + } + + /// Feed a response received for this transaction. + pub(crate) fn on_response(&mut self, resp: &Response) -> Vec { + let code = resp.status_code().code(); + match self.state { + State::Trying | State::Proceeding => { + if code < 200 { + // Provisional: deliver and (from Trying) enter Proceeding. + self.state = State::Proceeding; + vec![TxAction::DeliverResponse(resp.clone())] + } else { + self.enter_completed(resp) + } + } + // Retransmitted final: absorb silently (no ACK for non-INVITE). + State::Completed | State::Terminated => Vec::new(), + } + } + + /// Feed a fired timer. + pub(crate) fn on_timer(&mut self, id: TimerId) -> Vec { + match (self.state, id) { + (State::Trying, TimerId::E) => { + // Back off, capped at T2. + self.timer_e = (self.timer_e * 2).min(self.timers.t2); + self.retransmit() + } + (State::Proceeding, TimerId::E) => { + // In Proceeding the interval is pinned at T2. + self.timer_e = self.timers.t2; + self.retransmit() + } + (State::Trying | State::Proceeding, TimerId::F) => { + self.state = State::Terminated; + vec![TxAction::TimedOut, TxAction::Terminated] + } + (State::Completed, TimerId::K) => { + self.state = State::Terminated; + vec![TxAction::Terminated] + } + _ => Vec::new(), + } + } + + fn retransmit(&self) -> Vec { + vec![ + TxAction::Send(SipMessage::Request(self.request.clone())), + TxAction::StartTimer { + id: TimerId::E, + after: self.timer_e, + }, + ] + } + + fn enter_completed(&mut self, resp: &Response) -> Vec { + if self.rel.is_reliable() { + // Timer K is zero: terminate immediately. + self.state = State::Terminated; + return vec![ + TxAction::DeliverResponse(resp.clone()), + TxAction::Terminated, + ]; + } + self.state = State::Completed; + vec![ + TxAction::StopTimer(TimerId::E), + TxAction::StopTimer(TimerId::F), + TxAction::DeliverResponse(resp.clone()), + TxAction::StartTimer { + id: TimerId::K, + after: self.timers.k(self.rel), + }, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> Request { + let raw = "OPTIONS sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-opt\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-opt\r\n\ + CSeq: 4 OPTIONS\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn response(code: u16) -> Response { + let raw = format!( + "SIP/2.0 {code} X\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-opt\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-opt\r\n\ + CSeq: 4 OPTIONS\r\n\ + Content-Length: 0\r\n\r\n" + ); + Response::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn start_sends_request_and_arms_e_and_f_on_udp() { + let (tx, actions) = + ClientNonInvite::start(request(), Timers::default(), Reliability::Unreliable); + assert_eq!(tx.state(), State::Trying); + assert!(matches!(actions[0], TxAction::Send(_))); + assert!(matches!( + actions[1], + TxAction::StartTimer { id: TimerId::E, .. } + )); + assert!(matches!( + actions[2], + TxAction::StartTimer { id: TimerId::F, .. } + )); + } + + #[test] + fn timer_e_backs_off_capped_at_t2() { + let timers = Timers::default(); + let (mut tx, _) = ClientNonInvite::start(request(), timers, Reliability::Unreliable); + // T1=500ms → 1s → 2s → 4s(=T2) → stays 4s. + let expected = [ + timers.t1 * 2, + timers.t1 * 4, + timers.t2, // capped + timers.t2, + ]; + for want in expected { + let out = tx.on_timer(TimerId::E); + match out[1] { + TxAction::StartTimer { + id: TimerId::E, + after, + } => assert_eq!(after, want), + _ => panic!("expected Timer E reschedule"), + } + } + } + + #[test] + fn provisional_enters_proceeding_and_pins_e_to_t2() { + let timers = Timers::default(); + let (mut tx, _) = ClientNonInvite::start(request(), timers, Reliability::Unreliable); + let out = tx.on_response(&response(100)); + assert_eq!(tx.state(), State::Proceeding); + assert!(matches!(out[0], TxAction::DeliverResponse(_))); + // Timer E in Proceeding fires at exactly T2 regardless of prior backoff. + match tx.on_timer(TimerId::E)[1] { + TxAction::StartTimer { + id: TimerId::E, + after, + } => assert_eq!(after, timers.t2), + _ => panic!("expected Timer E reschedule"), + } + } + + #[test] + fn final_response_completes_and_arms_k_on_udp() { + let (mut tx, _) = + ClientNonInvite::start(request(), Timers::default(), Reliability::Unreliable); + let out = tx.on_response(&response(200)); + assert_eq!(tx.state(), State::Completed); + assert!(out.contains(&TxAction::StopTimer(TimerId::E))); + assert!(out.contains(&TxAction::StopTimer(TimerId::F))); + assert!(out + .iter() + .any(|a| matches!(a, TxAction::DeliverResponse(_)))); + assert!(matches!( + out.last(), + Some(TxAction::StartTimer { id: TimerId::K, .. }) + )); + } + + #[test] + fn final_response_terminates_immediately_on_reliable() { + let (mut tx, _) = + ClientNonInvite::start(request(), Timers::default(), Reliability::Reliable); + let out = tx.on_response(&response(404)); + assert_eq!(tx.state(), State::Terminated); + assert!(matches!(out[0], TxAction::DeliverResponse(_))); + assert_eq!(out[1], TxAction::Terminated); + } + + #[test] + fn retransmitted_final_is_absorbed() { + let (mut tx, _) = + ClientNonInvite::start(request(), Timers::default(), Reliability::Unreliable); + tx.on_response(&response(200)); + assert!(tx.on_response(&response(200)).is_empty()); + } + + #[test] + fn timer_f_times_out() { + let (mut tx, _) = + ClientNonInvite::start(request(), Timers::default(), Reliability::Unreliable); + let out = tx.on_timer(TimerId::F); + assert_eq!(out, vec![TxAction::TimedOut, TxAction::Terminated]); + } + + #[test] + fn timer_k_terminates_from_completed() { + let (mut tx, _) = + ClientNonInvite::start(request(), Timers::default(), Reliability::Unreliable); + tx.on_response(&response(200)); + assert_eq!(tx.on_timer(TimerId::K), vec![TxAction::Terminated]); + assert_eq!(tx.state(), State::Terminated); + } +} diff --git a/crates/wavekat-sip/src/stack/transaction/mod.rs b/crates/wavekat-sip/src/stack/transaction/mod.rs new file mode 100644 index 0000000..cdd6fda --- /dev/null +++ b/crates/wavekat-sip/src/stack/transaction/mod.rs @@ -0,0 +1,585 @@ +//! RFC 3261 §17 transaction layer, expressed sans-IO. +//! +//! A SIP transaction is a short-lived state machine that sits between the +//! transaction user (TU — our dialog/registration logic) and the transport. +//! Its whole job is timers and retransmissions: hide packet loss on +//! unreliable transports and absorb duplicates, so the TU sees each logical +//! request/response once. +//! +//! ## Sans-IO +//! +//! These machines never touch a socket or the clock. Each one maps an input +//! event — a received [`SipMessage`], a fired [`TimerId`], or (server side) a +//! response the TU wants to send — to an ordered list of [`TxAction`]s the +//! caller must perform: put a message on the wire, arm or cancel a timer, +//! hand a message up to the TU, or report that the transaction finished. A +//! thin transport runner (a later phase) owns the UDP/TCP socket and a timer +//! wheel and simply applies whatever actions come back. +//! +//! This is what makes the timer-heavy core testable: a unit test feeds events +//! and asserts on the action list and the timer durations, with no sleeping +//! and no sockets — exactly the RFC §17 timer tables, checked directly. +//! +//! ## What we implement +//! +//! All four machines, because our UA drives all four: +//! +//! | Machine | RFC | Methods | Module | +//! |---------|-----|---------|--------| +//! | Client INVITE | §17.1.1 | INVITE | [`client_invite`] | +//! | Client non-INVITE | §17.1.2 | REGISTER, BYE, CANCEL, INFO, OPTIONS | [`client_non_invite`] | +//! | Server INVITE | §17.2.1 | inbound INVITE | [`server_invite`] | +//! | Server non-INVITE | §17.2.2 | inbound BYE, INFO, OPTIONS, CANCEL | [`server_non_invite`] | + +use std::hash::{BuildHasher, Hash, Hasher}; +use std::mem::discriminant; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use rsip::headers::ToTypedHeader; +use rsip::message::HeadersExt; +use rsip::{Header, Headers, Method, Request, Response, SipMessage}; + +pub(crate) mod client_invite; +pub(crate) mod client_non_invite; +pub(crate) mod server_invite; +pub(crate) mod server_non_invite; + +/// RFC 3261 magic cookie that prefixes every `branch` parameter we generate, +/// marking the value as RFC 3261-compliant (§8.1.1.7). +pub(crate) const MAGIC_COOKIE: &str = "z9hG4bK"; + +/// Whether the underlying transport delivers reliably (TCP/TLS) or may drop +/// and reorder (UDP). This single bit selects every transaction timer that +/// differs between the two: retransmit timers run only on unreliable +/// transports, and the post-final "soak" timers (D/I/J/K) collapse to zero on +/// reliable ones because there is nothing to retransmit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Reliability { + /// TCP / TLS — no retransmissions, soak timers fire immediately. + Reliable, + /// UDP — retransmit on Timer A/E/G, soak after the final response. + Unreliable, +} + +impl Reliability { + pub(crate) fn is_reliable(self) -> bool { + matches!(self, Reliability::Reliable) + } +} + +impl From for Reliability { + fn from(transport: crate::account::Transport) -> Self { + match transport { + crate::account::Transport::Udp => Reliability::Unreliable, + crate::account::Transport::Tcp => Reliability::Reliable, + } + } +} + +/// The RFC 3261 §17 base timer values (T1/T2/T4) from which every transaction +/// timer is derived. Defaults are the RFC's recommended values; tests can +/// shrink them to keep wall-clock assertions fast, and the transport runner +/// can tune T1 to a measured RTT. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Timers { + /// Round-trip time estimate (default 500 ms). Seeds Timer A/E/G and, at + /// ×64, the transaction timeout Timer B/F/H. + pub t1: Duration, + /// Maximum retransmit interval (default 4 s) for non-INVITE Timer E and + /// INVITE response Timer G. + pub t2: Duration, + /// Maximum time a message lingers in the network (default 5 s). Sets the + /// Confirmed/Completed soak timers I and K. + pub t4: Duration, +} + +impl Default for Timers { + fn default() -> Self { + Self { + t1: Duration::from_millis(500), + t2: Duration::from_secs(4), + t4: Duration::from_secs(5), + } + } +} + +impl Timers { + /// Timer B/F/H = 64·T1 — the overall transaction timeout. + pub(crate) fn timeout(&self) -> Duration { + self.t1 * 64 + } + + /// Timer D — wait in client-INVITE Completed to absorb retransmitted + /// final responses. "≥ 32 s" on unreliable transports, zero on reliable. + pub(crate) fn d(&self, rel: Reliability) -> Duration { + if rel.is_reliable() { + Duration::ZERO + } else { + Duration::from_secs(32) + } + } + + /// Timer I — wait in server-INVITE Confirmed to absorb retransmitted ACKs. + /// T4 on unreliable, zero on reliable. + pub(crate) fn i(&self, rel: Reliability) -> Duration { + if rel.is_reliable() { + Duration::ZERO + } else { + self.t4 + } + } + + /// Timer J — wait in server-non-INVITE Completed to absorb retransmitted + /// requests. 64·T1 on unreliable, zero on reliable. + pub(crate) fn j(&self, rel: Reliability) -> Duration { + if rel.is_reliable() { + Duration::ZERO + } else { + self.timeout() + } + } + + /// Timer K — wait in client-non-INVITE Completed to absorb retransmitted + /// final responses. T4 on unreliable, zero on reliable. + pub(crate) fn k(&self, rel: Reliability) -> Duration { + if rel.is_reliable() { + Duration::ZERO + } else { + self.t4 + } + } +} + +/// Identifies a transaction timer. The variants map one-to-one onto the +/// letters used in RFC 3261 §17, so the FSMs and their tests read like the +/// spec's timer tables. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum TimerId { + /// Client INVITE request retransmit (unreliable only). + A, + /// Client INVITE transaction timeout. + B, + /// Client INVITE Completed soak. + D, + /// Client non-INVITE request retransmit (unreliable only). + E, + /// Client non-INVITE transaction timeout. + F, + /// Server INVITE final-response retransmit (unreliable only). + G, + /// Server INVITE wait-for-ACK timeout. + H, + /// Server INVITE Confirmed soak. + I, + /// Server non-INVITE Completed soak. + J, + /// Client non-INVITE Completed soak. + K, +} + +/// One unit of work a transaction asks its caller to perform. The FSMs return +/// these in order; the caller (a future transport runner, or a unit test) +/// applies them against a real socket and timer wheel, or simply inspects +/// them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TxAction { + /// Put this message on the wire (initial send, retransmit, or ACK). + Send(SipMessage), + /// Arm a timer; if already running, reschedule it to fire `after` from now. + StartTimer { id: TimerId, after: Duration }, + /// Cancel a running timer. + StopTimer(TimerId), + /// Deliver a received response up to the TU (client transactions). + DeliverResponse(Response), + /// Deliver a received request up to the TU (server transactions: the + /// initial request and any CANCEL; ACK retransmits are absorbed here). + DeliverRequest(Request), + /// No final response arrived before the timeout timer fired. + TimedOut, + /// The transaction reached Terminated; the caller may drop it. + Terminated, +} + +/// Identifies the transaction a message belongs to, for demultiplexing +/// inbound traffic to the right state machine (RFC 3261 §17.1.3 / §17.2.3). +/// +/// The match key is the top `Via` branch plus the `sent-by` host, combined +/// with the method. `ACK` is folded onto `INVITE` so a non-2xx ACK reaches +/// its server INVITE transaction; `CANCEL` keeps its own identity so it forms +/// a separate (non-INVITE) transaction from the INVITE it cancels. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TransactionKey { + branch: String, + sent_by: String, + method: Method, +} + +impl Hash for TransactionKey { + fn hash(&self, state: &mut H) { + self.branch.hash(state); + self.sent_by.hash(state); + // `rsip::Method` is a field-less enum but does not derive `Hash`; its + // discriminant is hashable and uniquely identifies the variant. + discriminant(&self.method).hash(state); + } +} + +impl TransactionKey { + /// Build the match key from an inbound (or outbound) request. Uses the + /// request line method, with `ACK` folded onto `INVITE`. + pub(crate) fn from_request(req: &Request) -> Option { + Self::build(req, normalize(*req.method())) + } + + /// Build the match key from a response. The method comes from the + /// response's `CSeq` (a response carries the request's `Via` and `CSeq`). + pub(crate) fn from_response(resp: &Response) -> Option { + let method = resp.cseq_header().ok()?.typed().ok()?.method; + Self::build(resp, normalize(method)) + } + + fn build(msg: &impl HeadersExt, method: Method) -> Option { + let via = msg.via_header().ok()?.typed().ok()?; + let branch = via.branch()?.value().to_string(); + let sent_by = via.sent_by().host_with_port.to_string(); + Some(Self { + branch, + sent_by, + method, + }) + } + + /// The key of the INVITE transaction a CANCEL targets: same `branch` and + /// `sent-by`, with the method folded to `INVITE`. A CANCEL shares the top + /// `Via` branch of the request it cancels (RFC 3261 §9.1), so this maps a + /// received CANCEL's key onto the server INVITE transaction it must 487. + pub(crate) fn invite_target(&self) -> TransactionKey { + TransactionKey { + branch: self.branch.clone(), + sent_by: self.sent_by.clone(), + method: Method::Invite, + } + } +} + +/// Fold `ACK` onto `INVITE` for transaction matching; every other method +/// keeps its identity. (An ACK for a non-2xx response is absorbed by the +/// server INVITE transaction that sent the final response.) +fn normalize(method: Method) -> Method { + match method { + Method::Ack => Method::Invite, + other => other, + } +} + +/// Process-wide counter feeding [`gen_branch`]; guarantees uniqueness within +/// a process without locking. +static BRANCH_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Generate a globally-unique `Via` branch value carrying the RFC 3261 magic +/// cookie (§8.1.1.7). Uniqueness comes from a monotonic per-process counter +/// mixed with a randomly-seeded hash, so values don't collide within a +/// process or across restarts — without taking a `rand` dependency. +pub(crate) fn gen_branch() -> String { + let n = BRANCH_COUNTER.fetch_add(1, Ordering::Relaxed); + let seed = std::collections::hash_map::RandomState::new().hash_one(n); + format!("{MAGIC_COOKIE}{n:x}{seed:x}") +} + +/// Build the `Via` header value for one of our outgoing requests: a UDP +/// sent-by of `sent_by`, the given transaction `branch`, and a bare `;rport` +/// (RFC 3581). +/// +/// `rport` is what lets us be reached behind NAT. Without it, a proxy records +/// our private sent-by / `Contact` and routes a later in-dialog request (most +/// importantly the peer's `BYE`) to an address that never arrives — so the call +/// never tears down on a remote hangup. With it, the proxy stamps `received` / +/// `rport` from the packet's real source and routes back there instead. The +/// value is empty in a request (RFC 3581 §3); the server fills it in its +/// response. +pub(crate) fn via_value(sent_by: impl std::fmt::Display, branch: &str) -> String { + format!("SIP/2.0/UDP {sent_by};rport;branch={branch}") +} + +/// Generate a unique dialog tag (`From`/`To` tag). Like [`gen_branch`] but +/// without the transaction magic cookie — a tag is just an opaque token. +pub(crate) fn gen_tag() -> String { + use std::hash::BuildHasher; + let n = BRANCH_COUNTER.fetch_add(1, Ordering::Relaxed); + let seed = std::collections::hash_map::RandomState::new().hash_one(n); + format!("wk{n:x}{seed:x}") +} + +/// Build the ACK for a non-2xx final response, per RFC 3261 §17.1.1.3. +/// +/// This ACK is part of the client INVITE *transaction* (unlike the ACK for a +/// 2xx, which the TU/dialog sends as a separate transaction). It reuses the +/// INVITE's top `Via` (same branch), `From`, `Call-ID`, request-URI, and +/// `CSeq` sequence number; takes `To` from the response (so the remote tag is +/// echoed); copies any `Route` set; and carries an empty body. +pub(crate) fn build_non_2xx_ack(invite: &Request, response: &Response) -> Option { + let mut headers = Headers::default(); + headers.push(Header::Via(invite.via_header().ok()?.clone())); + headers.push(Header::From(invite.from_header().ok()?.clone())); + // To with the remote tag the server placed on its final response. + headers.push(Header::To(response.to_header().ok()?.clone())); + headers.push(Header::CallId(invite.call_id_header().ok()?.clone())); + + let seq = invite.cseq_header().ok()?.typed().ok()?.seq; + let cseq: rsip::headers::CSeq = rsip::typed::CSeq { + seq, + method: Method::Ack, + } + .into(); + headers.push(Header::CSeq(cseq)); + + // Preserve the request's route set on the ACK (§17.1.1.3). + for header in invite.headers.iter() { + if let Header::Route(route) = header { + headers.push(Header::Route(route.clone())); + } + } + headers.push(Header::MaxForwards(rsip::headers::MaxForwards::default())); + headers.push(Header::ContentLength( + rsip::headers::ContentLength::default(), + )); + + Some(Request { + method: Method::Ack, + uri: invite.uri.clone(), + version: invite.version.clone(), + headers, + body: Vec::new(), + }) +} + +/// A live transaction of any of the four RFC 3261 §17 kinds, so the engine +/// can hold them in one table and dispatch events without knowing the kind. +// The variant names mirror the RFC's four machine names (client/server × +// INVITE/non-INVITE); the shared "Invite" suffix is intentional and clearer +// than any rename. +#[allow(clippy::enum_variant_names)] +pub(crate) enum Transaction { + ClientInvite(client_invite::ClientInvite), + ClientNonInvite(client_non_invite::ClientNonInvite), + ServerInvite(server_invite::ServerInvite), + ServerNonInvite(server_non_invite::ServerNonInvite), +} + +impl Transaction { + /// Feed a received response. Only client transactions react; server + /// transactions never receive responses, so they return no actions. + pub(crate) fn on_response(&mut self, resp: &Response) -> Vec { + match self { + Transaction::ClientInvite(t) => t.on_response(resp), + Transaction::ClientNonInvite(t) => t.on_response(resp), + Transaction::ServerInvite(_) | Transaction::ServerNonInvite(_) => Vec::new(), + } + } + + /// Feed a received request (a retransmission, or the ACK for a non-2xx). + /// Only server transactions react. + pub(crate) fn on_request(&mut self, req: &Request) -> Vec { + match self { + Transaction::ServerInvite(t) => t.on_request(req), + Transaction::ServerNonInvite(t) => t.on_request(req), + Transaction::ClientInvite(_) | Transaction::ClientNonInvite(_) => Vec::new(), + } + } + + /// Feed a fired timer. + pub(crate) fn on_timer(&mut self, id: TimerId) -> Vec { + match self { + Transaction::ClientInvite(t) => t.on_timer(id), + Transaction::ClientNonInvite(t) => t.on_timer(id), + Transaction::ServerInvite(t) => t.on_timer(id), + Transaction::ServerNonInvite(t) => t.on_timer(id), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal but well-formed request for tests, with a single Via, From, + /// To, Call-ID and CSeq. `with_tag` controls whether To carries a tag. + fn request(method: Method, branch: &str, seq: u32) -> Request { + let raw = format!( + "{m} sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch={b}\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-abc\r\n\ + CSeq: {s} {m}\r\n\ + Content-Length: 0\r\n\r\n", + m = method, + b = branch, + s = seq, + ); + Request::try_from(raw.as_bytes()).expect("valid request") + } + + fn response(status: u16, branch: &str, seq: u32, method: Method) -> Response { + let raw = format!( + "SIP/2.0 {code} Testing\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch={b}\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-abc\r\n\ + CSeq: {s} {m}\r\n\ + Content-Length: 0\r\n\r\n", + code = status, + b = branch, + s = seq, + m = method, + ); + Response::try_from(raw.as_bytes()).expect("valid response") + } + + #[test] + fn via_value_advertises_rport_and_keeps_branch_parseable() { + let v = via_value("192.168.1.46:54991", "z9hG4bK-rp"); + // RFC 3581: the request carries a bare `rport` (no value). + assert!(v.contains(";rport"), "Via must advertise rport: {v}"); + assert!( + !v.contains("rport="), + "request rport must be valueless: {v}" + ); + assert!(v.contains("branch=z9hG4bK-rp")); + assert!(v.contains("192.168.1.46:54991")); + + // Regression: the bare `rport` param must not break branch extraction — + // transaction matching keys off the branch, so a request built with this + // Via must still yield a key with the right branch. + let raw = format!( + "BYE sip:bob@example.com SIP/2.0\r\n\ + Via: {v}\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-abc\r\n\ + CSeq: 2 BYE\r\n\ + Content-Length: 0\r\n\r\n" + ); + let req = Request::try_from(raw.as_bytes()).expect("valid request"); + let key = TransactionKey::from_request(&req).expect("branch parses despite bare rport"); + assert_eq!(key.branch, "z9hG4bK-rp"); + } + + #[test] + fn gen_branch_carries_magic_cookie_and_is_unique() { + let a = gen_branch(); + let b = gen_branch(); + assert!(a.starts_with(MAGIC_COOKIE)); + assert!(b.starts_with(MAGIC_COOKIE)); + assert_ne!(a, b); + } + + #[test] + fn timeout_is_64_t1() { + let t = Timers::default(); + assert_eq!(t.timeout(), Duration::from_millis(500) * 64); + } + + #[test] + fn soak_timers_collapse_on_reliable_transport() { + let t = Timers::default(); + assert_eq!(t.d(Reliability::Reliable), Duration::ZERO); + assert_eq!(t.i(Reliability::Reliable), Duration::ZERO); + assert_eq!(t.j(Reliability::Reliable), Duration::ZERO); + assert_eq!(t.k(Reliability::Reliable), Duration::ZERO); + + assert_eq!(t.d(Reliability::Unreliable), Duration::from_secs(32)); + assert_eq!(t.i(Reliability::Unreliable), t.t4); + assert_eq!(t.j(Reliability::Unreliable), t.timeout()); + assert_eq!(t.k(Reliability::Unreliable), t.t4); + } + + #[test] + fn request_and_response_share_a_key() { + let req = request(Method::Invite, "z9hG4bK-1", 1); + let resp = response(180, "z9hG4bK-1", 1, Method::Invite); + assert_eq!( + TransactionKey::from_request(&req), + TransactionKey::from_response(&resp) + ); + } + + #[test] + fn ack_folds_onto_invite_but_cancel_does_not() { + let invite = request(Method::Invite, "z9hG4bK-1", 1); + let ack = request(Method::Ack, "z9hG4bK-1", 1); + let cancel = request(Method::Cancel, "z9hG4bK-1", 1); + + assert_eq!( + TransactionKey::from_request(&invite), + TransactionKey::from_request(&ack), + "ACK must reach the INVITE server transaction" + ); + assert_ne!( + TransactionKey::from_request(&invite), + TransactionKey::from_request(&cancel), + "CANCEL forms its own transaction" + ); + } + + #[test] + fn cancel_invite_target_matches_the_invite_key() { + // RFC 3261 §9.1: a CANCEL carries the same top-`Via` branch as the + // INVITE it cancels. `invite_target` maps a received CANCEL's key onto + // that INVITE server transaction so the UAS can 487 it (§9.2). If this + // ever drifts, a cancelled INVITE would never be terminated and the + // call would ring forever. + let invite = request(Method::Invite, "z9hG4bK-cxl", 1); + let cancel = request(Method::Cancel, "z9hG4bK-cxl", 1); + + let invite_key = TransactionKey::from_request(&invite).unwrap(); + let cancel_key = TransactionKey::from_request(&cancel).unwrap(); + + // The CANCEL is its own transaction (method kept distinct)... + assert_ne!(cancel_key, invite_key); + // ...but its target is exactly the INVITE's key. + assert_eq!(cancel_key.invite_target(), invite_key); + // Branch + sent-by carried over verbatim; only the method is folded. + let target = cancel_key.invite_target(); + assert_eq!(target.branch, cancel_key.branch); + assert_eq!(target.sent_by, cancel_key.sent_by); + assert_eq!(target.method, Method::Invite); + } + + #[test] + fn different_branches_yield_different_keys() { + let a = request(Method::Bye, "z9hG4bK-aaa", 2); + let b = request(Method::Bye, "z9hG4bK-bbb", 2); + assert_ne!( + TransactionKey::from_request(&a), + TransactionKey::from_request(&b) + ); + } + + #[test] + fn non_2xx_ack_copies_dialog_identity_and_echoes_remote_tag() { + let invite = request(Method::Invite, "z9hG4bK-9", 7); + let resp = response(486, "z9hG4bK-9", 7, Method::Invite); + let ack = build_non_2xx_ack(&invite, &resp).expect("ack built"); + + assert_eq!(*ack.method(), Method::Ack); + assert_eq!(ack.uri, invite.uri); + // Same branch as the INVITE: the ACK is part of the same transaction. + assert_eq!( + ack.via_header().unwrap().typed().unwrap().branch(), + invite.via_header().unwrap().typed().unwrap().branch() + ); + // CSeq number copied, method rewritten to ACK. + let cseq = ack.cseq_header().unwrap().typed().unwrap(); + assert_eq!(cseq.seq, 7); + assert_eq!(cseq.method, Method::Ack); + // To tag taken from the response, not the (tagless) request To. + let to = ack.to_header().unwrap().typed().unwrap(); + assert_eq!( + to.tag().map(|t| t.value().to_string()), + Some("bob".to_string()) + ); + } +} diff --git a/crates/wavekat-sip/src/stack/transaction/server_invite.rs b/crates/wavekat-sip/src/stack/transaction/server_invite.rs new file mode 100644 index 0000000..9051dfb --- /dev/null +++ b/crates/wavekat-sip/src/stack/transaction/server_invite.rs @@ -0,0 +1,341 @@ +//! Server INVITE transaction — RFC 3261 §17.2.1. +//! +//! Handles an inbound INVITE: relays the TU's responses, retransmits the +//! final on loss, and absorbs the ACK for a non-2xx. +//! +//! ```text +//! Proceeding -- INVITE retransmit: resend last provisional +//! | \ 1xx from TU: send +//! 300 | \ 2xx from TU: send, terminate (TU owns 2xx + ACK) +//! -699| \ +//! v +//! Completed -- INVITE retransmit / Timer G: resend final (G = min(2·G,T2)) +//! | Timer H: no ACK → inform TU, terminate +//! ACK | +//! v +//! Confirmed -- ACK retransmit: absorb +//! | Timer I: terminate +//! v +//! Terminated +//! ``` +//! +//! A **2xx** is special: the server transaction sends it and terminates at +//! once. Retransmitting the 2xx until the ACK arrives is the TU/dialog's job +//! (RFC 3261 §13.3.1.4), because that ACK is a separate transaction. + +use super::{Reliability, TimerId, Timers, TxAction}; +use rsip::{Method, Request, Response, SipMessage}; + +/// State of a server INVITE transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum State { + /// INVITE received; relaying provisional responses from the TU. + Proceeding, + /// A non-2xx final was sent; retransmitting it and waiting for the ACK. + Completed, + /// The ACK arrived; absorbing its retransmissions (Timer I). + Confirmed, + /// Done. + Terminated, +} + +/// A server INVITE transaction state machine. +pub(crate) struct ServerInvite { + state: State, + timers: Timers, + rel: Reliability, + last_provisional: Option, + last_final: Option, + /// Current Timer G interval; doubles up to T2. + timer_g: std::time::Duration, +} + +impl ServerInvite { + /// Construct from the received INVITE. Hands the request up to the TU so + /// it can produce responses; no timers run until a final is sent. + pub(crate) fn start( + invite: &Request, + timers: Timers, + rel: Reliability, + ) -> (Self, Vec) { + let tx = Self { + state: State::Proceeding, + timers, + rel, + last_provisional: None, + last_final: None, + timer_g: timers.t1, + }; + (tx, vec![TxAction::DeliverRequest(invite.clone())]) + } + + pub(crate) fn state(&self) -> State { + self.state + } + + /// The TU wants to send `resp` for this transaction. + pub(crate) fn send_response(&mut self, resp: Response) -> Vec { + if self.state != State::Proceeding { + return Vec::new(); + } + let code = resp.status_code().code(); + if code < 200 { + self.last_provisional = Some(resp.clone()); + vec![TxAction::Send(SipMessage::Response(resp))] + } else if code < 300 { + // 2xx: emit and terminate; the TU now owns 2xx retransmission. + self.state = State::Terminated; + vec![ + TxAction::Send(SipMessage::Response(resp)), + TxAction::Terminated, + ] + } else { + self.last_final = Some(resp.clone()); + let mut actions = vec![TxAction::Send(SipMessage::Response(resp))]; + if !self.rel.is_reliable() { + actions.push(TxAction::StartTimer { + id: TimerId::G, + after: self.timers.t1, + }); + } + actions.push(TxAction::StartTimer { + id: TimerId::H, + after: self.timers.timeout(), + }); + self.state = State::Completed; + actions + } + } + + /// A request was received for this transaction (a retransmitted INVITE, + /// or the ACK for a non-2xx final). + pub(crate) fn on_request(&mut self, req: &Request) -> Vec { + match (*req.method(), self.state) { + // Retransmitted INVITE: resend whatever we last sent. + (Method::Invite, State::Proceeding) => self.resend(&self.last_provisional), + (Method::Invite, State::Completed) => self.resend(&self.last_final), + // ACK for our non-2xx final: stop retransmitting, soak in Confirmed. + (Method::Ack, State::Completed) => { + if self.rel.is_reliable() { + self.state = State::Terminated; + vec![ + TxAction::StopTimer(TimerId::G), + TxAction::StopTimer(TimerId::H), + TxAction::Terminated, + ] + } else { + self.state = State::Confirmed; + vec![ + TxAction::StopTimer(TimerId::G), + TxAction::StopTimer(TimerId::H), + TxAction::StartTimer { + id: TimerId::I, + after: self.timers.i(self.rel), + }, + ] + } + } + // Retransmitted ACK while soaking: absorb. + (Method::Ack, State::Confirmed) => Vec::new(), + _ => Vec::new(), + } + } + + /// Feed a fired timer. + pub(crate) fn on_timer(&mut self, id: TimerId) -> Vec { + match (self.state, id) { + (State::Completed, TimerId::G) => { + self.timer_g = (self.timer_g * 2).min(self.timers.t2); + let mut actions = self.resend(&self.last_final); + actions.push(TxAction::StartTimer { + id: TimerId::G, + after: self.timer_g, + }); + actions + } + (State::Completed, TimerId::H) => { + // ACK never arrived. + self.state = State::Terminated; + vec![TxAction::TimedOut, TxAction::Terminated] + } + (State::Confirmed, TimerId::I) => { + self.state = State::Terminated; + vec![TxAction::Terminated] + } + _ => Vec::new(), + } + } + + fn resend(&self, stored: &Option) -> Vec { + match stored { + Some(resp) => vec![TxAction::Send(SipMessage::Response(resp.clone()))], + None => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn invite() -> Request { + let raw = "INVITE sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.2:5060;branch=z9hG4bK-sin\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-sin\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn ack() -> Request { + let raw = "ACK sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.2:5060;branch=z9hG4bK-sin\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-sin\r\n\ + CSeq: 1 ACK\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn response(code: u16) -> Response { + let raw = format!( + "SIP/2.0 {code} X\r\n\ + Via: SIP/2.0/UDP 10.0.0.2:5060;branch=z9hG4bK-sin\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-sin\r\n\ + CSeq: 1 INVITE\r\n\ + Content-Length: 0\r\n\r\n" + ); + Response::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn start_delivers_request_to_tu() { + let (tx, actions) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + assert_eq!(tx.state(), State::Proceeding); + assert!(matches!(actions[0], TxAction::DeliverRequest(_))); + } + + #[test] + fn provisional_is_sent_and_remembered() { + let (mut tx, _) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + let out = tx.send_response(response(180)); + assert_eq!( + out, + vec![TxAction::Send(SipMessage::Response(response(180)))] + ); + // A retransmitted INVITE now replays the 180. + let replay = tx.on_request(&invite()); + assert_eq!( + replay, + vec![TxAction::Send(SipMessage::Response(response(180)))] + ); + } + + #[test] + fn two_xx_sends_and_terminates() { + let (mut tx, _) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + let out = tx.send_response(response(200)); + assert_eq!(tx.state(), State::Terminated); + assert!(matches!(out[0], TxAction::Send(_))); + assert_eq!(out[1], TxAction::Terminated); + } + + #[test] + fn non_2xx_final_arms_g_and_h_on_udp() { + let (mut tx, _) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + let out = tx.send_response(response(486)); + assert_eq!(tx.state(), State::Completed); + assert!(matches!(out[0], TxAction::Send(_))); + assert!(out + .iter() + .any(|a| matches!(a, TxAction::StartTimer { id: TimerId::G, .. }))); + assert!(out + .iter() + .any(|a| matches!(a, TxAction::StartTimer { id: TimerId::H, .. }))); + } + + #[test] + fn non_2xx_final_skips_g_on_reliable() { + let (mut tx, _) = ServerInvite::start(&invite(), Timers::default(), Reliability::Reliable); + let out = tx.send_response(response(500)); + assert!(!out + .iter() + .any(|a| matches!(a, TxAction::StartTimer { id: TimerId::G, .. }))); + assert!(out + .iter() + .any(|a| matches!(a, TxAction::StartTimer { id: TimerId::H, .. }))); + } + + #[test] + fn timer_g_retransmits_final_and_doubles_capped_at_t2() { + let timers = Timers::default(); + let (mut tx, _) = ServerInvite::start(&invite(), timers, Reliability::Unreliable); + tx.send_response(response(486)); + let expected = [timers.t1 * 2, timers.t1 * 4, timers.t2, timers.t2]; + for want in expected { + let out = tx.on_timer(TimerId::G); + assert!(matches!(out[0], TxAction::Send(_))); + match out[1] { + TxAction::StartTimer { + id: TimerId::G, + after, + } => assert_eq!(after, want), + _ => panic!("expected Timer G reschedule"), + } + } + } + + #[test] + fn ack_moves_completed_to_confirmed_and_arms_i() { + let (mut tx, _) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + tx.send_response(response(486)); + let out = tx.on_request(&ack()); + assert_eq!(tx.state(), State::Confirmed); + assert!(out.contains(&TxAction::StopTimer(TimerId::G))); + assert!(out.contains(&TxAction::StopTimer(TimerId::H))); + assert!(matches!( + out.last(), + Some(TxAction::StartTimer { id: TimerId::I, .. }) + )); + // Retransmitted ACK is absorbed. + assert!(tx.on_request(&ack()).is_empty()); + } + + #[test] + fn ack_terminates_immediately_on_reliable() { + let (mut tx, _) = ServerInvite::start(&invite(), Timers::default(), Reliability::Reliable); + tx.send_response(response(486)); + let out = tx.on_request(&ack()); + assert_eq!(tx.state(), State::Terminated); + assert!(out.contains(&TxAction::Terminated)); + } + + #[test] + fn timer_h_times_out_waiting_for_ack() { + let (mut tx, _) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + tx.send_response(response(486)); + let out = tx.on_timer(TimerId::H); + assert_eq!(out, vec![TxAction::TimedOut, TxAction::Terminated]); + } + + #[test] + fn timer_i_terminates_from_confirmed() { + let (mut tx, _) = + ServerInvite::start(&invite(), Timers::default(), Reliability::Unreliable); + tx.send_response(response(486)); + tx.on_request(&ack()); + assert_eq!(tx.on_timer(TimerId::I), vec![TxAction::Terminated]); + assert_eq!(tx.state(), State::Terminated); + } +} diff --git a/crates/wavekat-sip/src/stack/transaction/server_non_invite.rs b/crates/wavekat-sip/src/stack/transaction/server_non_invite.rs new file mode 100644 index 0000000..9ea872c --- /dev/null +++ b/crates/wavekat-sip/src/stack/transaction/server_non_invite.rs @@ -0,0 +1,211 @@ +//! Server non-INVITE transaction — RFC 3261 §17.2.2. +//! +//! Handles an inbound BYE, INFO, OPTIONS or CANCEL: relays the TU's +//! responses and replays the last one on a retransmitted request. +//! +//! ```text +//! Trying ----- request retransmit: absorb (nothing sent yet) +//! | \ 1xx from TU → send, Proceeding +//! | \ 200-699 from TU → send, Completed +//! v +//! Proceeding - request retransmit: resend last provisional +//! | 1xx from TU: send +//! | 200-699 from TU → send, Completed +//! v +//! Completed -- request retransmit: resend final +//! | Timer J: terminate +//! v +//! Terminated +//! ``` + +use super::{Reliability, TimerId, Timers, TxAction}; +use rsip::{Request, Response, SipMessage}; + +/// State of a server non-INVITE transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum State { + /// Request received; the TU has not responded yet. + Trying, + /// A provisional response was sent. + Proceeding, + /// A final response was sent; absorbing retransmissions (Timer J). + Completed, + /// Done. + Terminated, +} + +/// A server non-INVITE transaction state machine. +pub(crate) struct ServerNonInvite { + state: State, + timers: Timers, + rel: Reliability, + last_response: Option, +} + +impl ServerNonInvite { + /// Construct from the received request and hand it up to the TU. + pub(crate) fn start( + request: &Request, + timers: Timers, + rel: Reliability, + ) -> (Self, Vec) { + let tx = Self { + state: State::Trying, + timers, + rel, + last_response: None, + }; + (tx, vec![TxAction::DeliverRequest(request.clone())]) + } + + pub(crate) fn state(&self) -> State { + self.state + } + + /// The TU wants to send `resp` for this transaction. + pub(crate) fn send_response(&mut self, resp: Response) -> Vec { + if matches!(self.state, State::Completed | State::Terminated) { + return Vec::new(); + } + self.last_response = Some(resp.clone()); + let code = resp.status_code().code(); + if code < 200 { + self.state = State::Proceeding; + vec![TxAction::Send(SipMessage::Response(resp))] + } else { + let send = TxAction::Send(SipMessage::Response(resp)); + if self.rel.is_reliable() { + // Timer J is zero: terminate immediately after sending. + self.state = State::Terminated; + vec![send, TxAction::Terminated] + } else { + self.state = State::Completed; + vec![ + send, + TxAction::StartTimer { + id: TimerId::J, + after: self.timers.j(self.rel), + }, + ] + } + } + } + + /// A retransmitted request was received for this transaction. + pub(crate) fn on_request(&mut self, _req: &Request) -> Vec { + match self.state { + // Nothing sent yet — there is nothing to replay. + State::Trying => Vec::new(), + State::Proceeding | State::Completed => match &self.last_response { + Some(resp) => vec![TxAction::Send(SipMessage::Response(resp.clone()))], + None => Vec::new(), + }, + State::Terminated => Vec::new(), + } + } + + /// Feed a fired timer. + pub(crate) fn on_timer(&mut self, id: TimerId) -> Vec { + match (self.state, id) { + (State::Completed, TimerId::J) => { + self.state = State::Terminated; + vec![TxAction::Terminated] + } + _ => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> Request { + let raw = "BYE sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.2:5060;branch=z9hG4bK-bye\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-bye\r\n\ + CSeq: 9 BYE\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + fn response(code: u16) -> Response { + let raw = format!( + "SIP/2.0 {code} X\r\n\ + Via: SIP/2.0/UDP 10.0.0.2:5060;branch=z9hG4bK-bye\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-bye\r\n\ + CSeq: 9 BYE\r\n\ + Content-Length: 0\r\n\r\n" + ); + Response::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn start_delivers_request() { + let (tx, actions) = + ServerNonInvite::start(&request(), Timers::default(), Reliability::Unreliable); + assert_eq!(tx.state(), State::Trying); + assert!(matches!(actions[0], TxAction::DeliverRequest(_))); + } + + #[test] + fn retransmit_in_trying_is_absorbed() { + let (mut tx, _) = + ServerNonInvite::start(&request(), Timers::default(), Reliability::Unreliable); + assert!(tx.on_request(&request()).is_empty()); + } + + #[test] + fn provisional_then_replays_on_retransmit() { + let (mut tx, _) = + ServerNonInvite::start(&request(), Timers::default(), Reliability::Unreliable); + let out = tx.send_response(response(100)); + assert_eq!(tx.state(), State::Proceeding); + assert!(matches!(out[0], TxAction::Send(_))); + assert_eq!( + tx.on_request(&request()), + vec![TxAction::Send(SipMessage::Response(response(100)))] + ); + } + + #[test] + fn final_completes_and_arms_j_on_udp() { + let (mut tx, _) = + ServerNonInvite::start(&request(), Timers::default(), Reliability::Unreliable); + let out = tx.send_response(response(200)); + assert_eq!(tx.state(), State::Completed); + assert!(matches!(out[0], TxAction::Send(_))); + assert!(matches!( + out[1], + TxAction::StartTimer { id: TimerId::J, .. } + )); + // Retransmitted request replays the final. + assert_eq!( + tx.on_request(&request()), + vec![TxAction::Send(SipMessage::Response(response(200)))] + ); + } + + #[test] + fn final_terminates_immediately_on_reliable() { + let (mut tx, _) = + ServerNonInvite::start(&request(), Timers::default(), Reliability::Reliable); + let out = tx.send_response(response(404)); + assert_eq!(tx.state(), State::Terminated); + assert!(matches!(out[0], TxAction::Send(_))); + assert_eq!(out[1], TxAction::Terminated); + } + + #[test] + fn timer_j_terminates_from_completed() { + let (mut tx, _) = + ServerNonInvite::start(&request(), Timers::default(), Reliability::Unreliable); + tx.send_response(response(200)); + assert_eq!(tx.on_timer(TimerId::J), vec![TxAction::Terminated]); + assert_eq!(tx.state(), State::Terminated); + } +} diff --git a/crates/wavekat-sip/src/stack/transport.rs b/crates/wavekat-sip/src/stack/transport.rs new file mode 100644 index 0000000..926edbb --- /dev/null +++ b/crates/wavekat-sip/src/stack/transport.rs @@ -0,0 +1,178 @@ +//! Transport: SIP message (de)serialization and a bound UDP socket. +//! +//! RFC 3261 §18. This is the bytes-on-the-wire layer the transaction engine +//! drives. UDP is the primary path (one datagram = one SIP message); TCP +//! framing is a later addition (the plan keeps it out of the initial cut). +//! +//! Message parsing/serialization is delegated to `rsip` — we own only the +//! socket plumbing. + +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; + +use rsip::SipMessage; +use tokio::net::UdpSocket; +use tracing::{debug, trace}; + +use super::transaction::Reliability; + +/// Largest datagram we will read. SIP-over-UDP messages must fit in a single +/// datagram (RFC 3261 §18.1.1 caps them below the path MTU); 64 KiB is the +/// IPv4 datagram ceiling and leaves ample headroom. +const MAX_DATAGRAM: usize = 65_535; + +/// Parse a received buffer into a SIP message, or `None` if it is malformed. +pub(crate) fn parse(bytes: &[u8]) -> Option { + SipMessage::try_from(bytes).ok() +} + +/// Serialize a SIP message to its on-the-wire bytes. +pub(crate) fn serialize(msg: &SipMessage) -> Vec { + msg.clone().into() +} + +/// A bound UDP transport. +pub(crate) struct UdpTransport { + socket: Arc, +} + +impl UdpTransport { + /// Bind a UDP socket to `local` (use port 0 to let the OS choose). + pub(crate) async fn bind(local: SocketAddr) -> io::Result { + let socket = UdpSocket::bind(local).await?; + Ok(Self { + socket: Arc::new(socket), + }) + } + + /// The address the socket is bound to (with the OS-assigned port resolved). + pub(crate) fn local_addr(&self) -> io::Result { + self.socket.local_addr() + } + + /// UDP is always unreliable — retransmission timers apply. + pub(crate) fn reliability(&self) -> Reliability { + Reliability::Unreliable + } + + /// Send one SIP message to `dst`. + pub(crate) async fn send_to(&self, msg: &SipMessage, dst: SocketAddr) -> io::Result<()> { + let bytes = serialize(msg); + debug!( + %dst, + bytes = bytes.len(), + "\n>>> SEND to {dst} >>>\n{}", + String::from_utf8_lossy(&bytes).trim_end(), + ); + self.socket.send_to(&bytes, dst).await?; + Ok(()) + } + + /// Receive the next parseable SIP message and its source address. + /// + /// Malformed datagrams are dropped (logged at debug — a peer message that + /// fails to parse is exactly the kind of thing we want visible when + /// debugging) and reception continues, so a single bad packet cannot stall + /// the engine. + pub(crate) async fn recv(&self) -> io::Result<(SipMessage, SocketAddr)> { + let mut buf = vec![0u8; MAX_DATAGRAM]; + loop { + let (n, src) = self.socket.recv_from(&mut buf).await?; + debug!( + %src, + bytes = n, + "\n<<< RECV from {src} <<<\n{}", + String::from_utf8_lossy(&buf[..n]).trim_end(), + ); + match parse(&buf[..n]) { + Some(msg) => return Ok((msg, src)), + None => { + trace!(%src, bytes = n, "dropping unparseable datagram"); + debug!(%src, bytes = n, "^ datagram above was unparseable; dropped"); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rsip::{Request, Response}; + + fn options() -> Request { + let raw = "OPTIONS sip:bob@example.com SIP/2.0\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-opt\r\n\ + From: ;tag=alice\r\n\ + To: \r\n\ + Call-ID: call-opt\r\n\ + CSeq: 4 OPTIONS\r\n\ + Content-Length: 0\r\n\r\n"; + Request::try_from(raw.as_bytes()).unwrap() + } + + #[test] + fn parse_round_trips_serialize() { + let msg: SipMessage = options().into(); + let bytes = serialize(&msg); + let back = parse(&bytes).expect("parses"); + assert_eq!(back, msg); + } + + #[test] + fn parse_rejects_garbage() { + assert!(parse(b"not a sip message\r\n\r\n").is_none()); + } + + #[tokio::test] + async fn udp_round_trip_between_two_sockets() { + let a = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let b = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let b_addr = b.local_addr().unwrap(); + + let sent: SipMessage = options().into(); + a.send_to(&sent, b_addr).await.unwrap(); + + let (got, src) = b.recv().await.unwrap(); + assert_eq!(got, sent); + assert_eq!(src, a.local_addr().unwrap()); + } + + #[tokio::test] + async fn recv_skips_malformed_then_delivers() { + let a = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let b = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let b_addr = b.local_addr().unwrap(); + + // Raw garbage datagram, then a valid message. + a.socket.send_to(b"garbage", b_addr).await.unwrap(); + let good: SipMessage = options().into(); + a.send_to(&good, b_addr).await.unwrap(); + + let (got, _) = b.recv().await.unwrap(); + assert_eq!(got, good); + } + + #[test] + fn response_serializes_and_parses() { + let raw = "SIP/2.0 200 OK\r\n\ + Via: SIP/2.0/UDP 10.0.0.1:5060;branch=z9hG4bK-opt\r\n\ + From: ;tag=alice\r\n\ + To: ;tag=bob\r\n\ + Call-ID: call-opt\r\n\ + CSeq: 4 OPTIONS\r\n\ + Content-Length: 0\r\n\r\n"; + let resp = Response::try_from(raw.as_bytes()).unwrap(); + let msg: SipMessage = resp.into(); + assert_eq!(parse(&serialize(&msg)).unwrap(), msg); + } +} diff --git a/crates/wavekat-sip/src/stack/ua.rs b/crates/wavekat-sip/src/stack/ua.rs new file mode 100644 index 0000000..669b566 --- /dev/null +++ b/crates/wavekat-sip/src/stack/ua.rs @@ -0,0 +1,822 @@ +//! User-agent facade: one engine, many concurrent flows. +//! +//! The composed flows in [`registration`](super::registration) and +//! [`call`](super::call) each assume they own the engine's single event +//! stream. Real use needs a registration, several calls, and keepalives +//! sharing one socket at once, so this module owns the event stream and +//! **routes** each [`Event`] to the flow that owns its transaction. +//! +//! A small router task keeps a `TransactionKey → Sender` table. +//! Before starting a client transaction a flow *subscribes* its key (and +//! waits for the router to confirm, closing the race against a fast reply); +//! inbound requests with no matching subscription — new INVITEs and the 2xx +//! ACK — go to the `incoming` stream for the callee/dialog layer. +//! +//! This is the layer the public wrappers (`Registrar`, `Caller`, `Callee`) +//! are built on. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; + +use rsip::{Header, Method, Request, Response, SipMessage, StatusCode}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio_util::sync::CancellationToken; + +use super::auth; +use super::call::{build_cancel, build_invite, cseq_of, CallConfig, CallOutcome}; +use super::dialog::Dialog; +use super::engine::{self, EngineHandle, Event}; +use super::registration::{build_register, granted_expires, RegisterConfig, RegisterOutcome}; +use super::transaction::{Timers, TransactionKey}; + +/// A request the router hands up because it matched no client transaction: +/// a brand-new inbound request (INVITE/BYE/…) or the ACK for a 2xx we sent. +pub(crate) struct Incoming { + pub key: TransactionKey, + pub request: Request, + pub peer: SocketAddr, +} + +/// A subscription request from a flow to the router. +struct Subscribe { + key: TransactionKey, + tx: mpsc::Sender, + ack: oneshot::Sender<()>, +} + +/// A bound user agent: a shared engine plus the router that fans events out. +pub(crate) struct Ua { + engine: EngineHandle, + subscribe_tx: mpsc::Sender, + incoming: Mutex>, + /// Product token emitted as `User-Agent` on outbound requests, if set. + user_agent: Option, +} + +impl Ua { + /// Bind a UDP socket and start the engine + router with default timers. + pub(crate) async fn bind(local: SocketAddr, cancel: CancellationToken) -> io::Result { + Self::bind_full(local, Timers::default(), None, cancel).await + } + + /// Bind with explicit base timers (tests shrink them). + pub(crate) async fn bind_with_timers( + local: SocketAddr, + timers: Timers, + cancel: CancellationToken, + ) -> io::Result { + Self::bind_full(local, timers, None, cancel).await + } + + /// Bind advertising `user_agent` as the `User-Agent` on outbound requests. + pub(crate) async fn bind_with_app( + local: SocketAddr, + user_agent: Option, + cancel: CancellationToken, + ) -> io::Result { + Self::bind_full(local, Timers::default(), user_agent, cancel).await + } + + async fn bind_full( + local: SocketAddr, + timers: Timers, + user_agent: Option, + cancel: CancellationToken, + ) -> io::Result { + let (engine, events) = engine::start_with_timers(local, timers, cancel).await?; + let (subscribe_tx, subscribe_rx) = mpsc::channel(32); + let (incoming_tx, incoming_rx) = mpsc::channel(32); + tokio::spawn(router(events, subscribe_rx, incoming_tx)); + Ok(Self { + engine, + subscribe_tx, + incoming: Mutex::new(incoming_rx), + user_agent, + }) + } + + pub(crate) fn local_addr(&self) -> SocketAddr { + self.engine.local_addr() + } + + /// Register, answering a single `401`/`407` digest challenge. + pub(crate) async fn register( + &self, + cfg: &RegisterConfig, + peer: SocketAddr, + first_cseq: u32, + ) -> RegisterOutcome { + let mut request = build_register(cfg, first_cseq, self.local_addr()); + let mut challenged = false; + loop { + let Some(mut rx) = self.start_client(&request, peer).await else { + return RegisterOutcome::EngineStopped; + }; + match self.await_final(&mut rx).await { + Some(response) => { + let code = response.status_code().code(); + if (200..300).contains(&code) { + return RegisterOutcome::Registered { + expires: granted_expires(&response).unwrap_or(cfg.expires), + }; + } + if (code == 401 || code == 407) && !challenged { + match auth::build_retry(&request, &response, cfg.creds_for_retry()) { + Some(retry) => { + request = retry; + challenged = true; + continue; + } + None => return RegisterOutcome::Unauthorized, + } + } + if code == 401 || code == 407 { + return RegisterOutcome::Unauthorized; + } + return RegisterOutcome::Failed(response.status_code().clone()); + } + None => return RegisterOutcome::TimedOut, + } + } + } + + /// Place a call: send INVITE, follow provisionals, answer one challenge, + /// and on a 2xx build the dialog and ACK it. + pub(crate) async fn call( + &self, + cfg: &CallConfig, + peer: SocketAddr, + first_cseq: u32, + ) -> CallOutcome { + self.call_cancellable(cfg, peer, first_cseq, &CancellationToken::new(), None) + .await + } + + /// Like [`Ua::call`], but `cancel` aborts a still-ringing INVITE with a + /// `CANCEL` (RFC 3261 §9): once a provisional has arrived, firing the token + /// sends the CANCEL and the call resolves `Rejected(487 Request + /// Terminated)`. Each provisional status (e.g. `180 Ringing`) is forwarded + /// to `progress` when present. + pub(crate) async fn call_cancellable( + &self, + cfg: &CallConfig, + peer: SocketAddr, + first_cseq: u32, + cancel: &CancellationToken, + progress: Option<&mpsc::Sender>, + ) -> CallOutcome { + let mut request = build_invite(cfg, first_cseq, self.local_addr()); + let mut challenged = false; + loop { + let Some(mut rx) = self.start_client(&request, peer).await else { + return CallOutcome::EngineStopped; + }; + match self + .await_final_cancellable(&mut rx, peer, &request, cancel, progress) + .await + { + Some(response) => { + let code = response.status_code().code(); + if (200..300).contains(&code) { + let Some(dialog) = Dialog::uac(&request, &response, cfg.contact.clone()) + else { + return CallOutcome::Rejected(response.status_code().clone()); + }; + let ack = dialog.ack_2xx(cseq_of(&request)); + self.engine + .send_out_of_dialog(SipMessage::Request(ack), peer) + .await; + return CallOutcome::Answered { + dialog: Box::new(dialog), + response: Box::new(response), + }; + } + if (code == 401 || code == 407) && !challenged { + match auth::build_retry(&request, &response, cfg.creds_for_retry()) { + Some(retry) => { + request = retry; + challenged = true; + continue; + } + None => return CallOutcome::Unauthorized, + } + } + if code == 401 || code == 407 { + return CallOutcome::Unauthorized; + } + return CallOutcome::Rejected(response.status_code().clone()); + } + None => return CallOutcome::TimedOut, + } + } + } + + /// Tear down a confirmed dialog with an in-dialog BYE; `true` on a 2xx. + pub(crate) async fn hangup(&self, peer: SocketAddr, dialog: &mut Dialog) -> bool { + let bye = dialog.new_request(Method::Bye); + let Some(mut rx) = self.start_client(&bye, peer).await else { + return false; + }; + match self.await_final(&mut rx).await { + Some(response) => (200..300).contains(&response.status_code().code()), + None => false, + } + } + + /// Send a request through the engine and (optionally) an in-dialog request + /// the caller built; returns its final response, or `None` on timeout. + pub(crate) async fn send_in_dialog( + &self, + peer: SocketAddr, + request: Request, + ) -> Option { + let mut rx = self.start_client(&request, peer).await?; + self.await_final(&mut rx).await + } + + /// Send an in-dialog re-INVITE carrying `extra_headers` + `body` (typically + /// an SDP re-offer) and return its final response, or `None` on timeout. + /// + /// A re-INVITE is an INVITE transaction, so on a 2xx the TU **must** send + /// the ACK (RFC 3261 §13.2.2.4) — done here, out of transaction, reusing the + /// re-INVITE's CSeq. Non-2xx finals are returned as-is (their ACK is handled + /// by the transaction itself). + pub(crate) async fn reinvite( + &self, + peer: SocketAddr, + dialog: &mut Dialog, + extra_headers: Vec
, + body: Vec, + ) -> Option { + let request = dialog.new_request_with(Method::Invite, extra_headers, body); + let cseq = cseq_of(&request); + let mut rx = self.start_client(&request, peer).await?; + let response = self.await_final(&mut rx).await?; + if (200..300).contains(&response.status_code().code()) { + let ack = dialog.ack_2xx(cseq); + self.engine + .send_out_of_dialog(SipMessage::Request(ack), peer) + .await; + } + Some(response) + } + + /// Send an in-dialog `INFO` carrying `extra_headers` + `body` and return its + /// final response, or `None` on timeout. `INFO` is a non-INVITE request, so + /// no ACK follows its 2xx. + pub(crate) async fn info( + &self, + peer: SocketAddr, + dialog: &mut Dialog, + extra_headers: Vec
, + body: Vec, + ) -> Option { + let request = dialog.new_request_with(Method::Info, extra_headers, body); + self.send_in_dialog(peer, request).await + } + + /// Have a server transaction send `response` for the request keyed by `key`. + pub(crate) async fn answer(&self, key: TransactionKey, response: Response) -> bool { + self.engine.send_response(key, response).await + } + + /// The next inbound request with no matching client transaction. + pub(crate) async fn next_incoming(&self) -> Option { + self.incoming.lock().await.recv().await + } + + /// Subscribe `request`'s key, then start its client transaction. Returns a + /// receiver of that transaction's events, or `None` if the engine stopped. + async fn start_client( + &self, + request: &Request, + peer: SocketAddr, + ) -> Option> { + let key = TransactionKey::from_request(request)?; + let (tx, rx) = mpsc::channel(16); + let (ack, ack_rx) = oneshot::channel(); + self.subscribe_tx + .send(Subscribe { key, tx, ack }) + .await + .ok()?; + // Wait until the router has the subscription before we send, so a fast + // reply can't arrive before we're listening. + ack_rx.await.ok()?; + let mut request = request.clone(); + self.apply_user_agent(&mut request); + if !self.engine.start_client(request, peer).await { + return None; + } + Some(rx) + } + + /// Add our `User-Agent` to an outbound request if a product token is set and + /// the request doesn't already carry one. The header rides every client + /// transaction (INVITE / REGISTER / BYE / re-INVITE / INFO); it does not + /// affect the transaction key, which is keyed on Via + method. + fn apply_user_agent(&self, request: &mut Request) { + let Some(product) = &self.user_agent else { + return; + }; + let has_ua = request + .headers + .iter() + .any(|h| matches!(h, Header::UserAgent(_))); + if !has_ua { + request + .headers + .push(Header::UserAgent(product.clone().into())); + } + } + + /// Pump a transaction's events until its final response, or `None` on + /// timeout / engine stop. Provisionals and Terminated are skipped. + async fn await_final(&self, rx: &mut mpsc::Receiver) -> Option { + while let Some(event) = rx.recv().await { + match event { + Event::Response { response, .. } => { + if response.status_code().code() >= 200 { + return Some(response); + } + } + Event::TimedOut { .. } => return None, + _ => continue, + } + } + None + } + + /// Like [`Ua::await_final`], but also watches `cancel`: once a provisional + /// has arrived, firing the token sends a `CANCEL` for `invite` (RFC 3261 + /// §9.1 forbids CANCEL before the first provisional). The INVITE's final + /// response — a `487` after a successful CANCEL — is still returned. + async fn await_final_cancellable( + &self, + rx: &mut mpsc::Receiver, + peer: SocketAddr, + invite: &Request, + cancel: &CancellationToken, + progress: Option<&mpsc::Sender>, + ) -> Option { + let mut cancel_requested = false; + let mut cancel_sent = false; + let mut provisional_seen = false; + loop { + tokio::select! { + event = rx.recv() => match event { + Some(Event::Response { response, .. }) => { + if response.status_code().code() >= 200 { + return Some(response); + } + if let Some(tx) = progress { + let _ = tx.send(response.status_code().clone()).await; + } + provisional_seen = true; + if cancel_requested && !cancel_sent { + self.send_cancel(invite, peer).await; + cancel_sent = true; + } + } + Some(Event::TimedOut { .. }) => return None, + Some(_) => continue, + None => return None, + }, + _ = cancel.cancelled(), if !cancel_requested => { + cancel_requested = true; + if provisional_seen && !cancel_sent { + self.send_cancel(invite, peer).await; + cancel_sent = true; + } + } + } + } + } + + /// Fire a `CANCEL` for an in-flight INVITE as its own (non-INVITE) + /// transaction; we don't wait on its 2xx — the cancelled INVITE's `487` is + /// what resolves the call. + async fn send_cancel(&self, invite: &Request, peer: SocketAddr) { + if let Some(request) = build_cancel(invite) { + self.engine.start_client(request, peer).await; + } + } +} + +/// The router task: fan engine events out to subscribers, and hand +/// unmatched inbound requests to the `incoming` stream. +async fn router( + mut events: mpsc::Receiver, + mut subscribe_rx: mpsc::Receiver, + incoming_tx: mpsc::Sender, +) { + let mut subs: HashMap> = HashMap::new(); + loop { + tokio::select! { + sub = subscribe_rx.recv() => { + let Some(sub) = sub else { continue }; + subs.insert(sub.key, sub.tx); + let _ = sub.ack.send(()); + } + ev = events.recv() => { + let Some(ev) = ev else { break }; + match ev { + Event::IncomingRequest { key, request, peer } => { + let _ = incoming_tx.send(Incoming { key, request, peer }).await; + } + Event::UnmatchedRequest { request, peer } => { + if let Some(key) = TransactionKey::from_request(&request) { + let _ = incoming_tx.send(Incoming { key, request, peer }).await; + } + } + other => { + if let Some(key) = other.key().cloned() { + let terminated = matches!(other, Event::Terminated { .. }); + if let Some(tx) = subs.get(&key) { + let _ = tx.send(other).await; + } + if terminated { + subs.remove(&key); + } + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::stack::transport::UdpTransport; + use rsip::Uri; + use std::time::Duration; + use tokio::time::timeout; + + fn fast_timers() -> Timers { + Timers { + t1: Duration::from_millis(1), + t2: Duration::from_millis(4), + t4: Duration::from_millis(5), + } + } + + fn reg_config() -> RegisterConfig { + RegisterConfig { + registrar_uri: Uri::try_from("sip:example.com").unwrap(), + aor: Uri::try_from("sip:alice@example.com").unwrap(), + contact: Uri::try_from("sip:alice@127.0.0.1:5060").unwrap(), + from_tag: "alicetag".into(), + call_id: "reg-call".into(), + expires: 60, + username: "alice".into(), + password: "secret".into(), + } + } + + fn call_config() -> CallConfig { + CallConfig { + target: Uri::try_from("sip:bob@example.com").unwrap(), + from: Uri::try_from("sip:alice@example.com").unwrap(), + contact: Uri::try_from("sip:alice@127.0.0.1:5060").unwrap(), + from_tag: "alicetag".into(), + call_id: "call-xyz".into(), + sdp: b"v=0\r\n".to_vec(), + extra_headers: Vec::new(), + username: "alice".into(), + password: "secret".into(), + } + } + + fn echo(req: &Request) -> String { + use rsip::message::HeadersExt; + format!( + "{}\r\n{}\r\n{}\r\n{}\r\n", + req.via_header().unwrap(), + req.from_header().unwrap(), + req.call_id_header().unwrap(), + req.cseq_header().unwrap(), + ) + } + + #[tokio::test] + async fn register_then_call_share_one_engine() { + let cancel = CancellationToken::new(); + let ua = Ua::bind_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + cancel.clone(), + ) + .await + .unwrap(); + + // One peer plays registrar then callee. + let peer = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let peer_addr = peer.local_addr().unwrap(); + + let server = tokio::spawn(async move { + // REGISTER → 401 then 200. + let (m, src) = peer.recv().await.unwrap(); + let SipMessage::Request(r) = m else { panic!() }; + let h = echo(&r); + let c = format!("SIP/2.0 401 Unauthorized\r\n{h}To: ;tag=s\r\nWWW-Authenticate: Digest realm=\"example.com\", nonce=\"n\", qop=\"auth\"\r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(c.as_bytes()).unwrap(), src) + .await + .unwrap(); + let (m, src) = peer.recv().await.unwrap(); + let SipMessage::Request(r) = m else { panic!() }; + let h = echo(&r); + let ok = format!("SIP/2.0 200 OK\r\n{h}To: ;tag=s\r\nExpires: 60\r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(ok.as_bytes()).unwrap(), src) + .await + .unwrap(); + + // INVITE → 200 (Contact + tag), then absorb the ACK. + let (m, src) = peer.recv().await.unwrap(); + let SipMessage::Request(r) = m else { panic!() }; + assert_eq!(*r.method(), Method::Invite); + let h = echo(&r); + let ok = format!("SIP/2.0 200 OK\r\n{h}To: ;tag=b\r\nContact: \r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(ok.as_bytes()).unwrap(), src) + .await + .unwrap(); + let (m, _) = peer.recv().await.unwrap(); + assert!(matches!(m, SipMessage::Request(a) if *a.method() == Method::Ack)); + }); + + let reg = timeout( + Duration::from_secs(3), + ua.register(®_config(), peer_addr, 1), + ) + .await + .unwrap(); + assert_eq!(reg, RegisterOutcome::Registered { expires: 60 }); + + let call = timeout( + Duration::from_secs(3), + ua.call(&call_config(), peer_addr, 1), + ) + .await + .unwrap(); + assert!(matches!(call, CallOutcome::Answered { .. })); + + server.await.unwrap(); + cancel.cancel(); + } + + #[tokio::test] + async fn inbound_invite_reaches_incoming_and_can_be_answered() { + let cancel = CancellationToken::new(); + let ua = Ua::bind_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + cancel.clone(), + ) + .await + .unwrap(); + let ua_addr = ua.local_addr(); + + let caller = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let invite = format!( + "INVITE sip:alice@{ua_addr} SIP/2.0\r\n\ + Via: SIP/2.0/UDP {caller};branch=z9hG4bK-in\r\n\ + From: ;tag=bob\r\n\ + To: \r\n\ + Contact: \r\n\ + Call-ID: inbound\r\n\ + CSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n", + caller = caller.local_addr().unwrap() + ); + caller + .send_to(&SipMessage::try_from(invite.as_bytes()).unwrap(), ua_addr) + .await + .unwrap(); + + let incoming = timeout(Duration::from_secs(2), ua.next_incoming()) + .await + .unwrap() + .unwrap(); + assert_eq!(*incoming.request.method(), Method::Invite); + + // Answer 200 and confirm the caller sees it. + let ok = format!( + "SIP/2.0 200 OK\r\nVia: SIP/2.0/UDP {c};branch=z9hG4bK-in\r\n\ + From: ;tag=bob\r\nTo: ;tag=alice\r\n\ + Call-ID: inbound\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n", + c = caller.local_addr().unwrap() + ); + let response = Response::try_from(ok.as_bytes()).unwrap(); + assert!(ua.answer(incoming.key, response).await); + + let (m, _) = timeout(Duration::from_secs(2), caller.recv()) + .await + .unwrap() + .unwrap(); + match m { + SipMessage::Response(r) => assert_eq!(r.status_code().code(), 200), + _ => panic!("expected the 200"), + } + cancel.cancel(); + } + + #[tokio::test] + async fn user_agent_injected_only_when_configured() { + let cancel = CancellationToken::new(); + let with_app = Ua::bind_with_app( + "127.0.0.1:0".parse().unwrap(), + Some("wavekat-test/9.9".into()), + cancel.clone(), + ) + .await + .unwrap(); + let plain = Ua::bind("127.0.0.1:0".parse().unwrap(), cancel.clone()) + .await + .unwrap(); + + let mut req = build_invite(&call_config(), 1, with_app.local_addr()); + with_app.apply_user_agent(&mut req); + assert!( + req.to_string().contains("User-Agent: wavekat-test/9.9"), + "configured product token must ride the request", + ); + // Idempotent: a second pass does not duplicate the header. + with_app.apply_user_agent(&mut req); + assert_eq!( + req.to_string().matches("User-Agent:").count(), + 1, + "User-Agent must not be duplicated", + ); + + let mut plain_req = build_invite(&call_config(), 1, plain.local_addr()); + plain.apply_user_agent(&mut plain_req); + assert!( + !plain_req.to_string().contains("User-Agent"), + "no token configured → no header", + ); + cancel.cancel(); + } + + #[tokio::test] + async fn reinvite_acks_the_2xx() { + let cancel = CancellationToken::new(); + let ua = Ua::bind_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + cancel.clone(), + ) + .await + .unwrap(); + + let peer = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let peer_addr = peer.local_addr().unwrap(); + + let server = tokio::spawn(async move { + // Initial INVITE → 200 → ACK. + let (m, src) = peer.recv().await.unwrap(); + let SipMessage::Request(r) = m else { panic!() }; + assert_eq!(*r.method(), Method::Invite); + let h = echo(&r); + let ok = format!("SIP/2.0 200 OK\r\n{h}To: ;tag=b\r\nContact: \r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(ok.as_bytes()).unwrap(), src) + .await + .unwrap(); + let (m, _) = peer.recv().await.unwrap(); + assert!(matches!(m, SipMessage::Request(a) if *a.method() == Method::Ack)); + + // Re-INVITE → 200, then the engine MUST send the ACK. + let (m, src) = peer.recv().await.unwrap(); + let SipMessage::Request(r) = m else { panic!() }; + assert_eq!(*r.method(), Method::Invite, "re-INVITE is an INVITE"); + // Its CSeq advanced past the initial INVITE. + assert!(cseq_of(&r) > 1, "re-INVITE CSeq should advance"); + let h = echo(&r); + let ok = format!("SIP/2.0 200 OK\r\n{h}To: ;tag=b\r\nContact: \r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(ok.as_bytes()).unwrap(), src) + .await + .unwrap(); + let (m, _) = peer.recv().await.unwrap(); + assert!( + matches!(m, SipMessage::Request(a) if *a.method() == Method::Ack), + "engine must ACK the re-INVITE 2xx", + ); + }); + + let outcome = timeout( + Duration::from_secs(3), + ua.call(&call_config(), peer_addr, 1), + ) + .await + .unwrap(); + let CallOutcome::Answered { dialog, .. } = outcome else { + panic!("call should be answered"); + }; + let mut dialog = *dialog; + + let response = timeout( + Duration::from_secs(3), + ua.reinvite(peer_addr, &mut dialog, Vec::new(), b"v=0\r\n".to_vec()), + ) + .await + .unwrap(); + assert_eq!(response.unwrap().status_code().code(), 200); + + server.await.unwrap(); + cancel.cancel(); + } + + #[tokio::test] + async fn cancel_after_ringing_terminates_call() { + let shutdown = CancellationToken::new(); + let ua = Ua::bind_with_timers( + "127.0.0.1:0".parse().unwrap(), + fast_timers(), + shutdown.clone(), + ) + .await + .unwrap(); + + let peer = UdpTransport::bind("127.0.0.1:0".parse().unwrap()) + .await + .unwrap(); + let peer_addr = peer.local_addr().unwrap(); + + let server = tokio::spawn(async move { + let mut invite_echo: Option = None; + let mut sent_ringing = false; + loop { + let (m, src) = peer.recv().await.unwrap(); + let SipMessage::Request(r) = m else { continue }; + match r.method() { + // Ring once; tolerate INVITE retransmissions. + Method::Invite if !sent_ringing => { + let h = echo(&r); + invite_echo = Some(h.clone()); + let ringing = format!("SIP/2.0 180 Ringing\r\n{h}To: ;tag=b\r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(ringing.as_bytes()).unwrap(), src) + .await + .unwrap(); + sent_ringing = true; + } + Method::Invite => {} + Method::Cancel => { + // 200 to the CANCEL, then 487 to the INVITE, then ACK. + let ch = echo(&r); + let ok = format!("SIP/2.0 200 OK\r\n{ch}To: ;tag=b\r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(ok.as_bytes()).unwrap(), src) + .await + .unwrap(); + let h = invite_echo.clone().unwrap(); + let term = format!("SIP/2.0 487 Request Terminated\r\n{h}To: ;tag=b\r\nContent-Length: 0\r\n\r\n"); + peer.send_to(&SipMessage::try_from(term.as_bytes()).unwrap(), src) + .await + .unwrap(); + let (m, _) = peer.recv().await.unwrap(); + assert!( + matches!(m, SipMessage::Request(a) if *a.method() == Method::Ack), + "engine ACKs the 487", + ); + return; + } + _ => {} + } + } + }); + + // Request cancellation up front; the engine defers the CANCEL until the + // 180 arrives (RFC 3261 §9.1). Observe provisionals on `progress`. + let dial_cancel = CancellationToken::new(); + dial_cancel.cancel(); + let (progress_tx, mut progress_rx) = mpsc::channel(8); + let outcome = timeout( + Duration::from_secs(3), + ua.call_cancellable( + &call_config(), + peer_addr, + 1, + &dial_cancel, + Some(&progress_tx), + ), + ) + .await + .unwrap(); + assert!( + matches!(outcome, CallOutcome::Rejected(s) if s.code() == 487), + "cancel-while-ringing resolves to 487 Request Terminated", + ); + assert_eq!( + progress_rx.recv().await.map(|s| s.code()), + Some(180), + "the 180 Ringing was observed on the progress channel", + ); + + server.await.unwrap(); + shutdown.cancel(); + } +} diff --git a/crates/wavekat-sip/tests/caller_dial.rs b/crates/wavekat-sip/tests/caller_dial.rs deleted file mode 100644 index 96e8d02..0000000 --- a/crates/wavekat-sip/tests/caller_dial.rs +++ /dev/null @@ -1,233 +0,0 @@ -//! Integration tests for the outbound [`Caller`] surface. -//! -//! Pairs Alice ([`Caller`]) with Bob ([`Callee`]) on two loopback -//! endpoints and exercises: -//! -//! - **`caller_dial_confirms_with_remote_media`** — full dial → accept -//! → on_confirmed → bye lifecycle. Asserts the [`AcceptedDial`] -//! carries the negotiated SDP answer and that the BYE shows up as -//! `Terminated(UasBye)` on Bob's state stream. -//! - **`caller_cancel_is_idempotent_after_terminated`** — Alice dials, -//! never gets answered, CANCELs. Calling `cancel()` again after the -//! dialog has terminated must return `Ok(())` without resending. - -use std::sync::Arc; -use std::time::Duration; - -use rsip::Method; -use tokio::sync::{mpsc, oneshot}; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; -use wavekat_sip::re_exports::{DialogState, SipAddr, TerminatedReason}; -use wavekat_sip::{Callee, Caller, PendingCall, SipAccount, SipEndpoint, Transport}; - -fn account(name: &str) -> SipAccount { - SipAccount { - display_name: name.to_string(), - username: name.to_string(), - password: "secret".to_string(), - domain: "127.0.0.1".to_string(), - auth_username: None, - server: Some("127.0.0.1".to_string()), - port: Some(5060), - transport: Transport::Udp, - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn caller_dial_confirms_with_remote_media() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - let bob_local_ip = callee_ep.local_ip(); - - // Bob: take the INVITE, accept it with an SDP answer. Forward his - // dialog state to a channel so the test can assert UasBye after - // Alice hangs up. - let (bob_state_tx, mut bob_state_rx) = mpsc::unbounded_channel::(); - let callee = Callee::new(account("bob"), callee_ep.clone()); - let bob_ep_for_dispatch = callee_ep.clone(); - tokio::spawn(async move { - let mut accepted_hold = None; - while let Some(tx) = callee_rx.recv().await { - match tx.original.method { - Method::Invite if accepted_hold.is_none() => { - let pending: PendingCall = - callee.handle_pending(tx).await.expect("handle_pending"); - let mut accepted = pending.accept().await.expect("Bob accept"); - let mut srx = - std::mem::replace(&mut accepted.state_rx, mpsc::unbounded_channel().1); - let forward_tx = bob_state_tx.clone(); - tokio::spawn(async move { - while let Some(state) = srx.recv().await { - if forward_tx.send(state).is_err() { - break; - } - } - }); - accepted_hold = Some(accepted); - } - _ => { - // In-dialog BYE etc. — dispatch so Bob's dialog state - // machine advances. - let _ = bob_ep_for_dispatch.dispatch_in_dialog(tx).await; - } - } - } - drop(accepted_hold); - }); - - // Drain Alice's incoming side. - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let caller = Caller::new(account("alice"), caller_ep.clone()); - let target: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid target"); - let destination: SipAddr = callee_addr.into(); - let pending = caller - .dial_with_destination(target, Some(destination)) - .await - .expect("dial"); - - let accepted = timeout(Duration::from_secs(5), pending.on_confirmed()) - .await - .expect("on_confirmed didn't resolve within 5s") - .expect("on_confirmed failed"); - - // Bob's PendingCall::accept built an SDP answer with Bob's local - // IP. Alice should have parsed that. - assert_eq!( - accepted.remote_media.addr, bob_local_ip, - "remote_media.addr should be Bob's local IP from the SDP answer" - ); - assert_eq!( - accepted.remote_media.payload_type, 0, - "expected PCMU as the preferred payload type" - ); - assert_ne!( - accepted.remote_media.port, 0, - "remote_media.port should be Bob's bound RTP port" - ); - assert_ne!( - accepted.local_rtp_addr.port(), - 0, - "local_rtp_addr should be bound" - ); - - // Alice (UAC) hangs up. The BYE comes from the UAC, so both sides - // see Terminated(UacBye). (Symmetry with UacCancel in - // pending_call_cancel.rs — the variant names the originator.) - accepted.dialog.bye().await.expect("alice BYE"); - - let saw_bye = timeout(Duration::from_secs(5), async { - loop { - match bob_state_rx.recv().await { - Some(DialogState::Terminated(_, TerminatedReason::UacBye)) => return true, - Some(_) => continue, - None => return false, - } - } - }) - .await - .unwrap_or(false); - assert!(saw_bye, "Bob should see Terminated(UacBye) after Alice BYE"); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn caller_cancel_is_idempotent_after_terminated() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - - // Bob holds the PendingCall without ever calling accept/reject; - // signal back as soon as he's on the wire so we don't race the - // CANCEL against the INVITE handler. - let (pending_ready_tx, pending_ready_rx) = oneshot::channel::<()>(); - let callee = Callee::new(account("bob"), callee_ep.clone()); - tokio::spawn(async move { - let mut pending_ready_tx = Some(pending_ready_tx); - let mut hold: Option = None; - while let Some(tx) = callee_rx.recv().await { - if hold.is_none() && tx.original.method == Method::Invite { - hold = Some(callee.handle_pending(tx).await.expect("handle_pending")); - if let Some(tx) = pending_ready_tx.take() { - let _ = tx.send(()); - } - } - } - drop(hold); - }); - - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let caller = Caller::new(account("alice"), caller_ep.clone()); - let target: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid target"); - let destination: SipAddr = callee_addr.into(); - let mut pending = caller - .dial_with_destination(target, Some(destination)) - .await - .expect("dial"); - - timeout(Duration::from_secs(5), pending_ready_rx) - .await - .expect("Bob never reached handle_pending") - .expect("pending_ready dropped"); - - // First cancel: actually sends CANCEL. - pending.cancel().await.expect("first cancel"); - - // Wait for Alice's dialog to enter Terminated so we exercise the - // "settled dialog" branch on the second call. - let saw_terminated = timeout(Duration::from_secs(5), async { - loop { - match pending.state_rx.recv().await { - Some(DialogState::Terminated(_, _)) => return true, - Some(_) => continue, - None => return false, - } - } - }) - .await - .unwrap_or(false); - assert!( - saw_terminated, - "Alice's dialog should reach Terminated after CANCEL" - ); - - // Second cancel after Terminated must be a silent no-op, not an - // error. This is the contract the consumer relies on when the user - // mashes the End button or two code paths both try to cancel. - pending - .cancel() - .await - .expect("second cancel after Terminated should be Ok"); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} diff --git a/crates/wavekat-sip/tests/dispatch_in_dialog.rs b/crates/wavekat-sip/tests/dispatch_in_dialog.rs deleted file mode 100644 index d2949f2..0000000 --- a/crates/wavekat-sip/tests/dispatch_in_dialog.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Integration tests for [`SipEndpoint::dispatch_in_dialog`]. -//! -//! Two endpoints bind loopback UDP sockets and exchange real SIP -//! messages: Alice sends an INVITE, Bob accepts via [`Callee`], Alice -//! sends BYE. The test asserts that Bob's `dispatch_in_dialog` returns -//! [`DispatchOutcome::Handled`] and that the callee dialog's state -//! stream observes `Terminated(UacBye)`. -//! -//! These tests are *not* `#[ignore]`d: they use only loopback UDP, no -//! external SIP server, and complete in tens of milliseconds. They run -//! as part of `cargo test --workspace` in CI. - -use std::sync::Arc; -use std::time::Duration; - -use rsip::Method; -use rsipstack::dialog::invitation::InviteOption; -use tokio::sync::mpsc; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; -use wavekat_sip::re_exports::{DialogState, TerminatedReason}; -use wavekat_sip::{build_sdp, Callee, DispatchOutcome, SipAccount, SipEndpoint, Transport}; - -fn account(name: &str) -> SipAccount { - SipAccount { - display_name: name.to_string(), - username: name.to_string(), - password: "secret".to_string(), - domain: "127.0.0.1".to_string(), - auth_username: None, - server: Some("127.0.0.1".to_string()), - port: Some(5060), - transport: Transport::Udp, - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn remote_bye_dispatches_to_dialog_and_terminates() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - let caller_addr = caller_ep.local_addr().expect("caller bound"); - - // Bob: accept the first INVITE, then route in-dialog requests - // (BYE in this test) through dispatch_in_dialog. - let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::(); - let (state_tx, mut state_rx) = mpsc::unbounded_channel::(); - let callee = Callee::new(account("bob"), callee_ep.clone()); - let callee_ep_task = callee_ep.clone(); - tokio::spawn(async move { - let mut accepted: Option = None; - while let Some(tx) = callee_rx.recv().await { - if accepted.is_none() && tx.original.method == Method::Invite { - let mut call = callee - .accept_transaction(tx) - .await - .expect("accept_transaction"); - let state_tx_inner = state_tx.clone(); - let mut srx = std::mem::replace(&mut call.state_rx, mpsc::unbounded_channel().1); - tokio::spawn(async move { - while let Some(state) = srx.recv().await { - if state_tx_inner.send(state).is_err() { - break; - } - } - }); - accepted = Some(call); - } else { - let outcome = callee_ep_task - .dispatch_in_dialog(tx) - .await - .expect("dispatch_in_dialog"); - let _ = outcome_tx.send(outcome); - } - } - }); - - // Drain Alice's incoming stream — the client INVITE/BYE transactions - // are consumed internally by rsipstack, but unrelated inbounds would - // otherwise back up the channel. - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let local_ip = caller_ep.local_ip(); - let alice_contact: rsip::Uri = format!("sip:alice@{caller_addr}") - .try_into() - .expect("valid contact uri"); - let alice_from: rsip::Uri = format!("sip:alice@{caller_addr}") - .try_into() - .expect("valid from uri"); - let bob_to: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid to uri"); - - let opt = InviteOption { - caller: alice_from, - callee: bob_to, - destination: Some(callee_addr.into()), - content_type: Some("application/sdp".into()), - offer: Some(build_sdp(local_ip, 30000)), - contact: alice_contact, - ..Default::default() - }; - - let (caller_state_sender, _caller_state_rx) = caller_ep.dialog_layer.new_dialog_state_channel(); - let (client_dialog, resp) = timeout( - Duration::from_secs(5), - caller_ep.dialog_layer.do_invite(opt, caller_state_sender), - ) - .await - .expect("INVITE round-trip timed out") - .expect("do_invite failed"); - - let resp = resp.expect("expected a final response to INVITE"); - assert_eq!( - resp.status_code.kind(), - rsip::StatusCodeKind::Successful, - "INVITE was not accepted: {}", - resp.status_code - ); - - client_dialog.bye().await.expect("bye failed"); - - let outcome = timeout(Duration::from_secs(5), outcome_rx.recv()) - .await - .expect("no dispatch outcome within 5s") - .expect("outcome channel closed"); - assert_eq!(outcome, DispatchOutcome::Handled); - - let saw_terminated = timeout(Duration::from_secs(5), async { - loop { - match state_rx.recv().await { - Some(DialogState::Terminated(_, TerminatedReason::UacBye)) => return true, - Some(_) => continue, - None => return false, - } - } - }) - .await - .unwrap_or(false); - assert!( - saw_terminated, - "expected Terminated(UacBye) on state stream" - ); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn dispatch_returns_no_dialog_for_unknown_call_id() { - // Alice sends an INVITE to Bob, but Bob never accepts it via Callee - // — instead, he hands every inbound transaction straight to - // dispatch_in_dialog. Since no dialog is registered for the - // INVITE's Call-ID, match_dialog returns None, and the dispatcher - // should reply 481 and surface DispatchOutcome::NoDialog. - // - // (Yes, an INVITE is the initial dialog request — but - // dispatch_in_dialog doesn't special-case it; it just runs - // match_dialog. Using INVITE here keeps the test simple and - // exercises the unmatched-tx → 481 → NoDialog path the helper - // promises.) - - let cancel = CancellationToken::new(); - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, _caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - let caller_addr = caller_ep.local_addr().expect("caller bound"); - - let (outcome_tx, mut outcome_rx) = mpsc::unbounded_channel::(); - let callee_ep_task = callee_ep.clone(); - tokio::spawn(async move { - while let Some(tx) = callee_rx.recv().await { - let outcome = callee_ep_task - .dispatch_in_dialog(tx) - .await - .expect("dispatch_in_dialog"); - let _ = outcome_tx.send(outcome); - } - }); - - // Send an OPTIONS from Alice to Bob via rsipstack's invitation - // helper — OPTIONS isn't part of any dialog Bob knows about, so - // match_dialog will return None. - let alice_contact: rsip::Uri = format!("sip:alice@{caller_addr}") - .try_into() - .expect("valid contact uri"); - let alice_from: rsip::Uri = format!("sip:alice@{caller_addr}") - .try_into() - .expect("valid from uri"); - let bob_to: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid to uri"); - - // Re-use do_invite: it sends an INVITE for a dialog Bob has never - // accepted. Bob's dispatcher gets the INVITE as an unmatched - // transaction and replies 481. (do_invite will then surface the - // 481 as the final response — we ignore the outcome here, we just - // need Bob's `dispatch_in_dialog` to see an unmatched transaction.) - let opt = InviteOption { - caller: alice_from, - callee: bob_to, - destination: Some(callee_addr.into()), - content_type: Some("application/sdp".into()), - offer: Some(build_sdp(caller_ep.local_ip(), 30000)), - contact: alice_contact, - ..Default::default() - }; - let (sender, _rx) = caller_ep.dialog_layer.new_dialog_state_channel(); - // do_invite will error out once Bob replies 481; we only care that - // Bob's dispatcher returned NoDialog. - let _ = timeout( - Duration::from_secs(5), - caller_ep.dialog_layer.do_invite(opt, sender), - ) - .await; - - let outcome = timeout(Duration::from_secs(5), outcome_rx.recv()) - .await - .expect("no dispatch outcome within 5s") - .expect("outcome channel closed"); - assert_eq!(outcome, DispatchOutcome::NoDialog); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} diff --git a/crates/wavekat-sip/tests/end_to_end_call.rs b/crates/wavekat-sip/tests/end_to_end_call.rs new file mode 100644 index 0000000..14b0326 --- /dev/null +++ b/crates/wavekat-sip/tests/end_to_end_call.rs @@ -0,0 +1,735 @@ +//! End-to-end exercise of the public call API over loopback, on the in-house +//! engine (no external SIP stack). One endpoint dials another; the callee +//! accepts with an SDP answer; the caller confirms the negotiated media and +//! hangs up with a BYE that the callee's router auto-answers. +//! +//! This is the integration counterpart to the in-crate `stack::ua` loopback +//! tests: it drives the *public* surface (`SipEndpoint` / `Caller` / +//! `IncomingCall` / `Call`) exactly as a consumer would. + +use std::net::SocketAddr; +use std::time::Duration; + +use tokio::net::UdpSocket; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use wavekat_sip::re_exports::Uri; +use wavekat_sip::{build_sdp, Caller, DtmfDigit, SipAccount, SipEndpoint, Transport}; + +fn account(server: &str, port: u16) -> SipAccount { + SipAccount { + display_name: "Test".into(), + username: "1001".into(), + password: "secret".into(), + domain: "127.0.0.1".into(), + auth_username: None, + server: Some(server.into()), + port: Some(port), + transport: Transport::Udp, + } +} + +/// A raw INVITE carrying a real G.711 SDP offer, from `peer` to `callee`. Set +/// `with_contact` for tests that go on to `accept()` — `Dialog::uas` needs the +/// `Contact` to learn the remote target; omit it when the call only ever rings. +/// The `Call-ID` is derived from `branch` so the matching `raw_cancel` shares it. +fn raw_invite(callee: SocketAddr, peer: SocketAddr, branch: &str, with_contact: bool) -> Vec { + let sdp = String::from_utf8(build_sdp("127.0.0.1".parse().unwrap(), 40000)).unwrap(); + let contact = if with_contact { + format!("Contact: \r\n") + } else { + String::new() + }; + format!( + "INVITE sip:1001@{callee} SIP/2.0\r\n\ + Via: SIP/2.0/UDP {peer};branch={branch}\r\n\ + From: ;tag=caller\r\n\ + To: \r\n\ + Call-ID: {branch}-call\r\n\ + CSeq: 1 INVITE\r\n\ + Max-Forwards: 70\r\n\ + {contact}\ + Content-Type: application/sdp\r\n\ + Content-Length: {len}\r\n\r\n{sdp}", + len = sdp.len(), + ) + .into_bytes() +} + +/// A raw CANCEL sharing `branch` (and thus `Call-ID`) with the INVITE it +/// cancels — the §9.1 correlation the endpoint relies on to 487 the right +/// transaction. +fn raw_cancel(callee: SocketAddr, peer: SocketAddr, branch: &str) -> Vec { + format!( + "CANCEL sip:1001@{callee} SIP/2.0\r\n\ + Via: SIP/2.0/UDP {peer};branch={branch}\r\n\ + From: ;tag=caller\r\n\ + To: \r\n\ + Call-ID: {branch}-call\r\n\ + CSeq: 1 CANCEL\r\n\ + Max-Forwards: 70\r\n\ + Content-Length: 0\r\n\r\n" + ) + .into_bytes() +} + +/// A raw INVITE like [`raw_invite`] but carrying a `Record-Route` — the header a +/// routing proxy inserts so in-dialog requests traverse it — and always a +/// `Contact` (the call is accepted). Used to prove the callee's 2xx mirrors the +/// Record-Route back (RFC 3261 §12.1.1). +fn raw_invite_via_proxy( + callee: SocketAddr, + peer: SocketAddr, + branch: &str, + record_route: &str, +) -> Vec { + let sdp = String::from_utf8(build_sdp("127.0.0.1".parse().unwrap(), 40000)).unwrap(); + format!( + "INVITE sip:1001@{callee} SIP/2.0\r\n\ + Record-Route: {record_route}\r\n\ + Via: SIP/2.0/UDP {peer};branch={branch}\r\n\ + From: ;tag=caller\r\n\ + To: \r\n\ + Call-ID: {branch}-call\r\n\ + CSeq: 1 INVITE\r\n\ + Max-Forwards: 70\r\n\ + Contact: \r\n\ + Content-Type: application/sdp\r\n\ + Content-Length: {len}\r\n\r\n{sdp}", + len = sdp.len(), + ) + .into_bytes() +} + +/// A raw in-dialog request (`ACK` or `BYE`) addressed to the established dialog: +/// the peer's `From` tag plus the callee's `To` tag learned from the 2xx. `cseq` +/// and a `-{method}` branch suffix keep each its own transaction. +fn raw_in_dialog( + method: &str, + cseq: u32, + callee: SocketAddr, + peer: SocketAddr, + branch: &str, + to_tag: &str, +) -> Vec { + format!( + "{method} sip:1001@{callee} SIP/2.0\r\n\ + Via: SIP/2.0/UDP {peer};branch={branch}-{lower}\r\n\ + From: ;tag=caller\r\n\ + To: ;tag={to_tag}\r\n\ + Call-ID: {branch}-call\r\n\ + CSeq: {cseq} {method}\r\n\ + Max-Forwards: 70\r\n\ + Content-Length: 0\r\n\r\n", + lower = method.to_lowercase(), + ) + .into_bytes() +} + +/// A routing proxy (modeled here by the raw peer itself) inserts a +/// `Record-Route` into the INVITE. RFC 3261 §12.1.1 requires the callee to +/// mirror it, verbatim, into its 2xx so the peer's reversed route set (§12.1.2) +/// sends the terminating BYE back *through the proxy* rather than straight to the +/// callee's `Contact`. Behind NAT that Contact is a private, unroutable address, +/// so a dropped Record-Route stranded the peer's BYE and the call never tore down +/// on remote hangup — the live-gateway bug this guards. +/// +/// The loopback `remote_bye_*` tests cannot catch it, for two independent +/// reasons that both have to hold to trigger the bug: with no proxy there is no +/// Record-Route to drop, and a directly-reachable `Contact` masks the empty +/// route set even when there is. This test supplies both: a recorded route, and +/// an assertion on the echo itself rather than on (locally-always-reachable) BYE +/// delivery. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn proxy_record_route_is_echoed_and_inbound_bye_terminates() { + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_addr: SocketAddr = format!("127.0.0.1:{}", callee.local_addr().port()) + .parse() + .unwrap(); + + // The raw peer doubles as the far-end UAC and the Record-Route'ing proxy: + // the route it records points at its own address, params and all. + let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer_addr = peer.local_addr().unwrap(); + let branch = "z9hG4bK-proxy-rr"; + let record_route = format!(""); + + peer.send_to( + &raw_invite_via_proxy(callee_addr, peer_addr, branch, &record_route), + callee_addr, + ) + .await + .unwrap(); + + // Callee accepts; hold the Call so the dialog stays established for the BYE. + let incoming = timeout(Duration::from_secs(10), callee.next_incoming_call()) + .await + .expect("inbound INVITE arrives within 10s") + .expect("inbound call"); + let callee_call = incoming.accept().await.expect("accept inbound call"); + let term = callee_call.terminated(); + assert!(!term.is_cancelled(), "termination must not fire before BYE"); + + // The peer (proxy) receives the 200 OK. It MUST echo the Record-Route, + // verbatim — the regression assertion; without the fix the 2xx omits it. + let mut buf = [0u8; 8192]; + let ok = loop { + let (n, _) = timeout(Duration::from_secs(5), peer.recv_from(&mut buf)) + .await + .expect("a 200 OK to the INVITE") + .unwrap(); + let text = String::from_utf8_lossy(&buf[..n]).to_string(); + if text.starts_with("SIP/2.0 200") { + break text; + } + // Anything else (provisional retransmits, etc.) — keep waiting. + }; + assert!( + ok.contains(&format!("Record-Route: {record_route}")), + "200 OK must echo the proxy's Record-Route verbatim; got:\n{ok}" + ); + + // The callee's local tag, from the 2xx `To`, identifies the dialog the BYE + // must address. + let to_tag = ok + .lines() + .find_map(|l| l.strip_prefix("To:").and_then(|v| v.split("tag=").nth(1))) + .map(|t| t.trim().to_string()) + .expect("200 OK carries a To-tag"); + + // ACK the 2xx, then hang up with an in-dialog BYE — exactly what a UAC sitting + // behind the proxy sends once its route set is built from the echoed header. + peer.send_to( + &raw_in_dialog("ACK", 1, callee_addr, peer_addr, branch, &to_tag), + callee_addr, + ) + .await + .unwrap(); + peer.send_to( + &raw_in_dialog("BYE", 2, callee_addr, peer_addr, branch, &to_tag), + callee_addr, + ) + .await + .unwrap(); + + // The callee learns of the remote hangup and the dialog tears down. + timeout(Duration::from_secs(10), term.cancelled()) + .await + .expect("callee learns of the remote BYE via terminated()"); + + cancel.cancel(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dial_accept_hangup_over_loopback() { + let cancel = CancellationToken::new(); + + // Callee: server field is unused (it never dials out), but it must point + // somewhere routable so the endpoint can pick a source IP. + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_port = callee.local_addr().port(); + + // Caller: its resolved server is the callee's bound address. + let caller_account = account("127.0.0.1", callee_port); + let caller_ep = SipEndpoint::new(&caller_account, cancel.clone()) + .await + .expect("bind caller"); + + // Callee accept loop: answer the first inbound call, then hold the Call so + // the dialog stays alive for the caller's BYE. + let callee_for_task = callee.clone(); + let accepted = tokio::spawn(async move { + let incoming = callee_for_task + .next_incoming_call() + .await + .expect("inbound call arrives"); + // The caller offered RTP; we should have parsed its media. + assert!(incoming.remote_media.port > 0); + let call = incoming.accept().await.expect("accept inbound call"); + // Keep the Call alive until the test drops the JoinHandle's output. + call + }); + + // Place the call. + let caller = Caller::new(caller_account, caller_ep.clone()); + let target: Uri = "sip:1001@127.0.0.1".try_into().unwrap(); + + let mut call = timeout(Duration::from_secs(10), caller.dial(target)) + .await + .expect("dial completes within 10s") + .expect("call is answered"); + + // The caller negotiated real remote media from the SDP answer. + assert!(call.remote_media.port > 0); + assert_eq!(call.remote_media.payload_type, 0); // PCMU + + // RFC 4028: the caller advertised Supported: timer + Session-Expires, the + // callee echoed it, so the caller negotiated a timer and (peer refresher + // unset → us) takes the refresher role. + let caller_timer = call + .session_timer() + .expect("caller negotiated a session timer"); + assert_eq!(caller_timer.interval_secs, 1800); + assert!(caller_timer.we_are_refresher, "caller should refresh"); + + // The callee side finished accepting. + let callee_call = timeout(Duration::from_secs(10), accepted) + .await + .expect("accept finishes within 10s") + .expect("accept task did not panic"); + + // The callee negotiated the same interval but, as the answerer of a timer + // the caller supports, is the watchdog (peer/UAC refreshes). + let callee_timer = callee_call + .session_timer() + .expect("callee negotiated a session timer"); + assert_eq!(callee_timer.interval_secs, 1800); + assert!( + !callee_timer.we_are_refresher, + "callee should watch, not refresh" + ); + + // DTMF over SIP INFO: the press is sent in-dialog and the callee's router + // auto-answers 200 OK, which classifies as accepted. + let outcome = timeout( + Duration::from_secs(10), + call.send_dtmf_info(DtmfDigit::D5, 160), + ) + .await + .expect("INFO completes within 10s"); + assert!(outcome.is_accepted(), "INFO DTMF accepted, got {outcome:?}"); + + // Hold then resume: each is an in-dialog re-INVITE the callee's router + // auto-answers 200, so the local hold state flips on success. + timeout(Duration::from_secs(10), call.set_hold(true)) + .await + .expect("hold re-INVITE completes within 10s") + .expect("hold accepted"); + assert!(call.is_held(), "call should be on hold"); + timeout(Duration::from_secs(10), call.set_hold(false)) + .await + .expect("resume re-INVITE completes within 10s") + .expect("resume accepted"); + assert!(!call.is_held(), "call should be resumed"); + + // Hang up: BYE is sent and the callee's router auto-answers 200. + timeout(Duration::from_secs(10), call.hangup()) + .await + .expect("hangup completes within 10s") + .expect("BYE acknowledged"); + + cancel.cancel(); +} + +/// When a `Call` opts in via `inbound_requests`, the endpoint stops +/// auto-answering that dialog's re-INVITE / INFO and surfaces them instead — so +/// the *only* way the peer gets a 200 is the consumer answering. This drives +/// the callee-initiated direction: the callee sends INFO + a hold re-INVITE, +/// the caller's stream receives both and answers them. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opted_in_call_receives_inbound_in_dialog_requests() { + use std::sync::{Arc, Mutex}; + use wavekat_sip::re_exports::Method; + + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_port = callee.local_addr().port(); + + let caller_account = account("127.0.0.1", callee_port); + let caller_ep = SipEndpoint::new(&caller_account, cancel.clone()) + .await + .expect("bind caller"); + + let callee_for_task = callee.clone(); + let accepted = tokio::spawn(async move { + let incoming = callee_for_task + .next_incoming_call() + .await + .expect("inbound call arrives"); + incoming.accept().await.expect("accept inbound call") + }); + + let caller = Caller::new(caller_account, caller_ep.clone()); + let target: Uri = "sip:1001@127.0.0.1".try_into().unwrap(); + let call = timeout(Duration::from_secs(10), caller.dial(target)) + .await + .expect("dial completes within 10s") + .expect("call is answered"); + + let mut callee_call = timeout(Duration::from_secs(10), accepted) + .await + .expect("accept finishes within 10s") + .expect("accept task did not panic"); + + // Caller opts in and answers every inbound in-dialog request, recording the + // methods it saw. + let mut inbound = call.inbound_requests(); + let seen = Arc::new(Mutex::new(Vec::::new())); + let seen_in_task = seen.clone(); + let responder = tokio::spawn(async move { + while let Some(req) = inbound.recv().await { + seen_in_task.lock().unwrap().push(*req.method()); + req.ok().await; + } + }); + + // Callee sends INFO to the caller; it must be answered by the responder. + let outcome = timeout( + Duration::from_secs(10), + callee_call.send_dtmf_info(DtmfDigit::D7, 120), + ) + .await + .expect("INFO completes within 10s"); + assert!(outcome.is_accepted(), "surfaced INFO answered: {outcome:?}"); + + // Callee holds the caller via a re-INVITE; the responder answers that too. + timeout(Duration::from_secs(10), callee_call.set_hold(true)) + .await + .expect("hold re-INVITE completes within 10s") + .expect("surfaced re-INVITE answered"); + + let methods = seen.lock().unwrap().clone(); + assert!( + methods.contains(&Method::Info), + "caller's stream saw the INFO: {methods:?}" + ); + assert!( + methods.contains(&Method::Invite), + "caller's stream saw the re-INVITE: {methods:?}" + ); + + responder.abort(); + cancel.cancel(); +} + +/// A remote `BYE` on an established call fires the callee's +/// [`Call::terminated`](wavekat_sip::Call::terminated) signal — the endpoint +/// auto-answers the BYE `200 OK` and notifies the owning `Call` so a consumer +/// can tear down audio and finalize a recording. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_bye_fires_call_terminated() { + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_port = callee.local_addr().port(); + + let caller_account = account("127.0.0.1", callee_port); + let caller_ep = SipEndpoint::new(&caller_account, cancel.clone()) + .await + .expect("bind caller"); + + let callee_for_task = callee.clone(); + let accepted = tokio::spawn(async move { + let incoming = callee_for_task + .next_incoming_call() + .await + .expect("inbound call arrives"); + incoming.accept().await.expect("accept inbound call") + }); + + let caller = Caller::new(caller_account, caller_ep.clone()); + let target: Uri = "sip:1001@127.0.0.1".try_into().unwrap(); + let mut call = timeout(Duration::from_secs(10), caller.dial(target)) + .await + .expect("dial completes within 10s") + .expect("call is answered"); + + let callee_call = timeout(Duration::from_secs(10), accepted) + .await + .expect("accept finishes within 10s") + .expect("accept task did not panic"); + + // Before the BYE, the callee's termination signal is unfired. + let term = callee_call.terminated(); + assert!(!term.is_cancelled(), "termination must not fire before BYE"); + + // Caller hangs up: the callee's router auto-answers the BYE and fires the + // termination signal. + timeout(Duration::from_secs(10), call.hangup()) + .await + .expect("hangup completes within 10s") + .expect("BYE acknowledged"); + + timeout(Duration::from_secs(10), term.cancelled()) + .await + .expect("callee learns of the remote BYE via terminated()"); + + cancel.cancel(); +} + +/// The reverse of [`remote_bye_fires_call_terminated`]: when the **callee** +/// hangs up, the **caller**'s [`Call::terminated`](wavekat_sip::Call::terminated) +/// fires. This is the everyday product case — we place an outbound call and the +/// far end (a PSTN gateway) ends it — so the caller's own endpoint must route +/// the inbound BYE to the owning UAC `Call`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn callee_bye_fires_caller_terminated() { + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_port = callee.local_addr().port(); + + let caller_account = account("127.0.0.1", callee_port); + let caller_ep = SipEndpoint::new(&caller_account, cancel.clone()) + .await + .expect("bind caller"); + + let callee_for_task = callee.clone(); + let accepted = tokio::spawn(async move { + let incoming = callee_for_task + .next_incoming_call() + .await + .expect("inbound call arrives"); + incoming.accept().await.expect("accept inbound call") + }); + + let caller = Caller::new(caller_account, caller_ep.clone()); + let target: Uri = "sip:1001@127.0.0.1".try_into().unwrap(); + let call = timeout(Duration::from_secs(10), caller.dial(target)) + .await + .expect("dial completes within 10s") + .expect("call is answered"); + + let mut callee_call = timeout(Duration::from_secs(10), accepted) + .await + .expect("accept finishes within 10s") + .expect("accept task did not panic"); + + // Before the BYE, the caller's termination signal is unfired. + let term = call.terminated(); + assert!(!term.is_cancelled(), "termination must not fire before BYE"); + + // Callee hangs up: the caller's router must auto-answer the BYE and fire the + // caller's termination signal. + timeout(Duration::from_secs(10), callee_call.hangup()) + .await + .expect("hangup completes within 10s") + .expect("BYE acknowledged"); + + timeout(Duration::from_secs(10), term.cancelled()) + .await + .expect("caller learns of the callee's BYE via terminated()"); + + cancel.cancel(); +} + +/// A `CANCEL` for a still-ringing inbound INVITE fires +/// [`IncomingCall::cancelled`](wavekat_sip::IncomingCall::cancelled) and `487`s +/// the INVITE — otherwise the call would ring forever. Driven with a raw UDP +/// peer because the public `Caller` only emits a CANCEL after a provisional, +/// which the bare endpoint doesn't send. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn inbound_cancel_fires_incoming_cancelled_and_487s_the_invite() { + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_addr: SocketAddr = format!("127.0.0.1:{}", callee.local_addr().port()) + .parse() + .unwrap(); + + let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer_addr = peer.local_addr().unwrap(); + let branch = "z9hG4bK-cancel-it"; + + // The INVITE carries a real G.711 SDP offer so the endpoint surfaces it. + peer.send_to( + &raw_invite(callee_addr, peer_addr, branch, false), + callee_addr, + ) + .await + .unwrap(); + + let incoming = timeout(Duration::from_secs(10), callee.next_incoming_call()) + .await + .expect("inbound INVITE arrives within 10s") + .expect("inbound call"); + let cancelled = incoming.cancelled(); + assert!(!cancelled.is_cancelled(), "must not be cancelled yet"); + + // The caller hangs up while ringing: a CANCEL sharing the INVITE's branch. + peer.send_to(&raw_cancel(callee_addr, peer_addr, branch), callee_addr) + .await + .unwrap(); + + // The pending call surfaces the cancellation. + timeout(Duration::from_secs(10), cancelled.cancelled()) + .await + .expect("CANCEL surfaces via IncomingCall::cancelled()"); + // `incoming` is intentionally never accepted — the caller hung up. + drop(incoming); + + // And the peer sees both a 200 (to the CANCEL) and a 487 (to the INVITE), + // so the INVITE transaction is torn down rather than ringing forever. + let mut saw_200 = false; + let mut saw_487 = false; + let mut buf = [0u8; 4096]; + while !(saw_200 && saw_487) { + let (n, _) = match timeout(Duration::from_secs(5), peer.recv_from(&mut buf)).await { + Ok(res) => res.unwrap(), + Err(_) => break, + }; + let text = String::from_utf8_lossy(&buf[..n]); + let first_line = text.lines().next().unwrap_or_default(); + if first_line.contains("200") { + saw_200 = true; + } + if first_line.contains("487") { + saw_487 = true; + } + } + assert!(saw_200, "peer should receive 200 OK to its CANCEL"); + assert!( + saw_487, + "peer should receive 487 Request Terminated for the INVITE" + ); + + cancel.cancel(); +} + +/// A `CANCEL` that races in *after* the INVITE was accepted must not `487` the +/// now-established call, nor fire `cancelled()`: `accept` unregisters the pending +/// INVITE, so the late CANCEL is answered `200` and otherwise ignored. Guards the +/// unregister-on-decision contract — without it, a straggling CANCEL would tear +/// down a call the user already answered. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_after_accept_does_not_487_the_established_call() { + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_addr: SocketAddr = format!("127.0.0.1:{}", callee.local_addr().port()) + .parse() + .unwrap(); + + let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer_addr = peer.local_addr().unwrap(); + let branch = "z9hG4bK-late-cancel"; + + // Ring, then accept — the INVITE needs a Contact so the dialog can form. + peer.send_to( + &raw_invite(callee_addr, peer_addr, branch, true), + callee_addr, + ) + .await + .unwrap(); + let incoming = timeout(Duration::from_secs(10), callee.next_incoming_call()) + .await + .expect("inbound INVITE arrives within 10s") + .expect("inbound call"); + let cancelled = incoming.cancelled(); + // Hold the Call so the dialog stays established for the duration. + let _call = incoming.accept().await.expect("accept inbound call"); + + // A CANCEL now races in on the same branch. + peer.send_to(&raw_cancel(callee_addr, peer_addr, branch), callee_addr) + .await + .unwrap(); + + // Drain responses until the wire goes quiet (there is no TU-side 2xx + // retransmit, so this terminates). We expect the 200 OK to the INVITE and a + // 200 to the CANCEL — and crucially never a 487. + let mut saw_200 = false; + let mut saw_487 = false; + let mut buf = [0u8; 4096]; + while let Ok(Ok((n, _))) = timeout(Duration::from_millis(600), peer.recv_from(&mut buf)).await { + let text = String::from_utf8_lossy(&buf[..n]); + let first_line = text.lines().next().unwrap_or_default(); + if first_line.contains("200") { + saw_200 = true; + } + if first_line.contains("487") { + saw_487 = true; + } + } + assert!( + saw_200, + "the CANCEL (and the INVITE) should be answered 200" + ); + assert!( + !saw_487, + "a late CANCEL must not 487 an already-accepted call" + ); + assert!( + !cancelled.is_cancelled(), + "cancelled() must not fire once the call has been accepted" + ); + + cancel.cancel(); +} + +/// A stray `CANCEL` with no INVITE behind it (late duplicate, or a peer bug) is +/// answered `200` and otherwise a no-op — it must not `487` anything or panic +/// the router. Guards the `None`-match arm of the §9.2 handling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stray_cancel_is_answered_200_without_487() { + let cancel = CancellationToken::new(); + + let callee_account = account("127.0.0.1", 5060); + let callee = SipEndpoint::new(&callee_account, cancel.clone()) + .await + .expect("bind callee"); + let callee_addr: SocketAddr = format!("127.0.0.1:{}", callee.local_addr().port()) + .parse() + .unwrap(); + + let peer = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer_addr = peer.local_addr().unwrap(); + + // A CANCEL whose branch was never seen as an INVITE. + peer.send_to( + &raw_cancel(callee_addr, peer_addr, "z9hG4bK-orphan"), + callee_addr, + ) + .await + .unwrap(); + + // The first response is the 200 to the CANCEL. + let mut buf = [0u8; 4096]; + let (n, _) = timeout(Duration::from_secs(5), peer.recv_from(&mut buf)) + .await + .expect("a response to the stray CANCEL") + .unwrap(); + let first_line = String::from_utf8_lossy(&buf[..n]) + .lines() + .next() + .unwrap_or_default() + .to_string(); + assert!( + first_line.contains("200"), + "stray CANCEL should be answered 200, got: {first_line}" + ); + + // Nothing else should follow — in particular no 487. + if let Ok(Ok((n, _))) = timeout(Duration::from_millis(400), peer.recv_from(&mut buf)).await { + let text = String::from_utf8_lossy(&buf[..n]); + assert!( + !text.contains("487"), + "a stray CANCEL must not 487 anything: {text}" + ); + } + + cancel.cancel(); +} diff --git a/crates/wavekat-sip/tests/pending_call_cancel.rs b/crates/wavekat-sip/tests/pending_call_cancel.rs deleted file mode 100644 index 545b75a..0000000 --- a/crates/wavekat-sip/tests/pending_call_cancel.rs +++ /dev/null @@ -1,192 +0,0 @@ -//! Integration test for [`Callee::handle_pending`] + pre-answer CANCEL. -//! -//! Alice INVITEs Bob; Bob calls `handle_pending` and waits without -//! accepting; Alice immediately CANCELs the INVITE. The test asserts: -//! -//! - Bob's `state_rx` observes `Terminated(UacCancel)` (so a UI watcher -//! can dismiss the ringing indicator). -//! - Alice's `do_invite` resolves with a `487 Request Terminated` final -//! response (the canonical CANCEL outcome). -//! -//! This covers the regression where a consumer's pending-invite map -//! had no path to surface a pre-answer cancel — the INVITE just sat -//! there until the caller's own timeout, and the UI stayed ringing. - -use std::sync::Arc; -use std::time::Duration; - -use rsip::Method; -use rsipstack::dialog::invitation::InviteOption; -use tokio::sync::{mpsc, oneshot}; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; -use wavekat_sip::re_exports::{DialogState, TerminatedReason}; -use wavekat_sip::{build_sdp, Callee, PendingCall, SipAccount, SipEndpoint, Transport}; - -fn account(name: &str) -> SipAccount { - SipAccount { - display_name: name.to_string(), - username: name.to_string(), - password: "secret".to_string(), - domain: "127.0.0.1".to_string(), - auth_username: None, - server: Some("127.0.0.1".to_string()), - port: Some(5060), - transport: Transport::Udp, - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn pre_answer_cancel_terminates_pending_call() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - let caller_addr = caller_ep.local_addr().expect("caller bound"); - - // Bob: receive the INVITE, call handle_pending, surface the resulting - // state_rx so the test can assert Terminated(UacCancel). Hold the - // PendingCall until the CANCEL arrives — never call accept / reject. - let (state_tx, mut state_rx) = mpsc::unbounded_channel::(); - let (pending_ready_tx, pending_ready_rx) = oneshot::channel::<()>(); - let callee = Callee::new(account("bob"), callee_ep.clone()); - tokio::spawn(async move { - let mut pending_ready_tx = Some(pending_ready_tx); - let mut hold: Option = None; - while let Some(tx) = callee_rx.recv().await { - if hold.is_none() && tx.original.method == Method::Invite { - let mut call = callee.handle_pending(tx).await.expect("handle_pending"); - let state_tx_inner = state_tx.clone(); - let mut srx = std::mem::replace(&mut call.state_rx, mpsc::unbounded_channel().1); - tokio::spawn(async move { - while let Some(state) = srx.recv().await { - if state_tx_inner.send(state).is_err() { - break; - } - } - }); - hold = Some(call); - if let Some(tx) = pending_ready_tx.take() { - let _ = tx.send(()); - } - } - // Anything else is uninteresting for this test. - } - drop(hold); - }); - - // Alice doesn't care about her inbound stream — drain it. - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let local_ip = caller_ep.local_ip(); - let alice_contact: rsip::Uri = format!("sip:alice@{caller_addr}") - .try_into() - .expect("valid contact uri"); - let alice_from: rsip::Uri = format!("sip:alice@{caller_addr}") - .try_into() - .expect("valid from uri"); - let bob_to: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid to uri"); - - let opt = InviteOption { - caller: alice_from, - callee: bob_to, - destination: Some(callee_addr.into()), - content_type: Some("application/sdp".into()), - offer: Some(build_sdp(local_ip, 30000)), - contact: alice_contact, - ..Default::default() - }; - - // Watch Alice's dialog state stream to learn the Call-ID once the - // client dialog is created; then we can look up the ClientInviteDialog - // out of the dialog_layer and call `.cancel()`. - let (caller_state_sender, mut caller_state_rx) = - caller_ep.dialog_layer.new_dialog_state_channel(); - - // Fire the INVITE in the background — it will sit unanswered until - // we send CANCEL, at which point it should resolve with 487. - let caller_ep_invite = caller_ep.clone(); - let invite_task = tokio::spawn(async move { - caller_ep_invite - .dialog_layer - .do_invite(opt, caller_state_sender) - .await - }); - - // Find Alice's client dialog by waiting for the first Calling state. - let call_id = timeout(Duration::from_secs(5), async { - loop { - match caller_state_rx.recv().await { - Some(DialogState::Calling(id)) => return Some(id.call_id), - Some(_) => continue, - None => return None, - } - } - }) - .await - .expect("no Calling state within 5s") - .expect("caller state channel closed"); - - // Bob must have his PendingCall set up before we CANCEL — otherwise - // the CANCEL races the INVITE on his side. Both arrive in order on - // a single UDP socket, but `handle_pending` is async; this oneshot - // makes the ordering explicit. - timeout(Duration::from_secs(5), pending_ready_rx) - .await - .expect("Bob never reached handle_pending within 5s") - .expect("pending_ready channel dropped"); - - let client_dialogs = caller_ep - .dialog_layer - .get_client_dialog_by_call_id(&call_id); - let client_dialog = client_dialogs - .into_iter() - .next() - .expect("client dialog should exist after Calling"); - client_dialog.cancel().await.expect("client cancel failed"); - - // Bob should see Terminated(UacCancel). - let saw_cancel = timeout(Duration::from_secs(5), async { - loop { - match state_rx.recv().await { - Some(DialogState::Terminated(_, TerminatedReason::UacCancel)) => return true, - Some(_) => continue, - None => return false, - } - } - }) - .await - .unwrap_or(false); - assert!( - saw_cancel, - "expected Terminated(UacCancel) on callee state stream" - ); - - // Alice's INVITE resolves with 487. - let (_dialog, resp) = timeout(Duration::from_secs(5), invite_task) - .await - .expect("INVITE task didn't finish within 5s") - .expect("INVITE task panicked") - .expect("do_invite failed"); - let resp = resp.expect("expected a final response to the cancelled INVITE"); - assert_eq!( - u16::from(resp.status_code.clone()), - 487, - "expected 487 Request Terminated, got {}", - resp.status_code - ); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} diff --git a/crates/wavekat-sip/tests/register_permanent_failure.rs b/crates/wavekat-sip/tests/register_permanent_failure.rs deleted file mode 100644 index e9edbb3..0000000 --- a/crates/wavekat-sip/tests/register_permanent_failure.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Regression test for the registrar's handling of a *permanent* REGISTER -//! rejection. -//! -//! Before the fix, [`Registrar::register`] looped on every non-200 final -//! response — including `403 Forbidden`, `401 Unauthorized` (bad password), -//! and other rejections a retry can never satisfy — re-sending every 10s -//! forever. The caller's `register().await` never returned, so a misconfigured -//! account was pinned in "connecting" and the user never learned why. -//! -//! This test stands up a fake SIP server that answers every REGISTER with -//! `403 Forbidden` and asserts `register()` *returns the error* promptly -//! instead of hanging. Before the fix it would never resolve and the test's -//! timeout would fire. - -use std::sync::Arc; -use std::time::Duration; - -use rsip::{SipMessage, StatusCode}; -use tokio::net::UdpSocket; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; -use wavekat_sip::{Registrar, SipAccount, SipEndpoint, Transport}; - -/// An account whose SIP server points at `127.0.0.1:` — the fake -/// server we stand up below. -fn account(server_port: u16) -> SipAccount { - SipAccount { - display_name: "Test".to_string(), - username: "1001".to_string(), - password: "secret".to_string(), - domain: "127.0.0.1".to_string(), - auth_username: None, - server: Some("127.0.0.1".to_string()), - port: Some(server_port), - transport: Transport::Udp, - } -} - -/// Build a `403 Forbidden` response for `req`, echoing the headers the -/// client transaction matches a response against (Via carries the branch; -/// From/To/Call-ID/CSeq complete the match). -fn forbidden_for(req: &rsip::Request) -> rsip::Response { - let mut headers = rsip::Headers::default(); - for h in req.headers.iter() { - match h { - rsip::Header::Via(_) - | rsip::Header::From(_) - | rsip::Header::To(_) - | rsip::Header::CallId(_) - | rsip::Header::CSeq(_) => headers.push(h.clone()), - _ => {} - } - } - headers.push(rsip::Header::ContentLength( - rsip::headers::ContentLength::from(0u32), - )); - rsip::Response { - status_code: StatusCode::Forbidden, - version: rsip::Version::V2, - headers, - body: Vec::new(), - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn register_returns_on_permanent_rejection() { - // Fake SIP server: answer every datagram that parses as a REGISTER with - // 403 Forbidden, echoing the transaction-matching headers. - let server = UdpSocket::bind("127.0.0.1:0") - .await - .expect("bind fake server"); - let server_addr = server.local_addr().expect("server addr"); - let server = Arc::new(server); - - let responder = server.clone(); - tokio::spawn(async move { - let mut buf = vec![0u8; 65_535]; - loop { - let (n, src) = match responder.recv_from(&mut buf).await { - Ok(v) => v, - Err(_) => break, - }; - if let Ok(SipMessage::Request(req)) = SipMessage::try_from(&buf[..n]) { - if req.method == rsip::Method::Register { - let resp = forbidden_for(&req); - let _ = responder.send_to(resp.to_string().as_bytes(), src).await; - } - } - } - }); - - let cancel = CancellationToken::new(); - let acct = account(server_addr.port()); - let (endpoint, _incoming) = SipEndpoint::new(&acct, cancel.clone()) - .await - .expect("bind local endpoint"); - let endpoint = Arc::new(endpoint); - let registrar = - Registrar::new(acct, endpoint.clone(), cancel.clone(), 60, 50).expect("build registrar"); - - // The crux: register() must return — not spin on the 10s retry loop. - let outcome = timeout(Duration::from_secs(5), registrar.register()).await; - - endpoint.shutdown(); - cancel.cancel(); - - let result = - outcome.expect("register() should return on a permanent rejection, not hang on retries"); - let err = result.expect_err("a 403 Forbidden REGISTER must surface as an error"); - assert!( - err.to_string().contains("403"), - "the surfaced error should name the rejecting status, got: {err}" - ); - - // The failure should also be visible in diagnostics for the UI panel. - let diag = registrar.diagnostics(); - assert_eq!(diag.last_status, Some(403)); - assert_eq!(diag.failure_count, 1); - assert!(diag.last_success_at.is_none()); -} diff --git a/crates/wavekat-sip/tests/session_timer_refresh.rs b/crates/wavekat-sip/tests/session_timer_refresh.rs deleted file mode 100644 index 48cea20..0000000 --- a/crates/wavekat-sip/tests/session_timer_refresh.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! Integration tests for RFC 4028 session timers over loopback. -//! -//! - **`session_timer_negotiates_and_refreshes_over_loopback`** — full -//! negotiation round-trip plus one real refresh re-INVITE: Alice's -//! INVITE advertises `Supported: timer` + `Session-Expires`, Bob -//! ([`Callee`]) negotiates and echoes in his 200 OK, Alice parses the -//! grant from the 2xx. Alice then sends a refresh re-INVITE through -//! the [`SessionDialogOps`] impl; Bob answers it via the -//! [`TransactionHandle`] carried in `DialogState::Updated` — the -//! exact wiring the `session_timer` module docs prescribe. Loopback -//! UDP only, completes in milliseconds, not `#[ignore]`d. -//! - **`watchdog_tears_down_lapsed_session`** — `#[ignore]`d (~60 s of -//! wall clock): a confirmed call whose peer never refreshes; the -//! watchdog half of [`session_timer_loop`] must BYE the dialog at -//! `interval - min(32, interval/3)` and the remote must observe -//! `Terminated(UacBye)`. - -use std::sync::Arc; -use std::time::Duration; - -use rsip::prelude::HasHeaders; -use rsip::Method; -use tokio::sync::{mpsc, Notify}; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; -use wavekat_sip::re_exports::{DialogState, SipAddr, StatusCode, TerminatedReason}; -use wavekat_sip::{ - build_sdp, session_expires_in, session_timer_loop, supported_timer_header, supports_timer, - Callee, Caller, Refresher, SessionDialogOps, SessionExpires, SessionTimer, SessionTimerOutcome, - SipAccount, SipEndpoint, Transport, UasSessionTimer, DEFAULT_SESSION_EXPIRES_SECS, -}; - -fn account(name: &str) -> SipAccount { - SipAccount { - display_name: name.to_string(), - username: name.to_string(), - password: "secret".to_string(), - domain: "127.0.0.1".to_string(), - auth_username: None, - server: Some("127.0.0.1".to_string()), - port: Some(5060), - transport: Transport::Udp, - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn session_timer_negotiates_and_refreshes_over_loopback() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - let bob_local_ip = callee_ep.local_ip(); - - // Bob: accept the INVITE, report his negotiated UasSessionTimer, - // and answer any refresh re-INVITE through the TransactionHandle — - // reporting the refresh request's Session-Expires back to the test. - let (bob_timer_tx, mut bob_timer_rx) = mpsc::unbounded_channel::>(); - let (bob_refresh_tx, mut bob_refresh_rx) = mpsc::unbounded_channel::>(); - let (bob_term_tx, mut bob_term_rx) = mpsc::unbounded_channel::(); - let callee = Callee::new(account("bob"), callee_ep.clone()); - let bob_ep_for_dispatch = callee_ep.clone(); - tokio::spawn(async move { - let mut accepted_hold = None; - while let Some(tx) = callee_rx.recv().await { - match tx.original.method { - Method::Invite if accepted_hold.is_none() => { - let pending = callee.handle_pending(tx).await.expect("handle_pending"); - let _ = bob_timer_tx.send(pending.session_timer); - let uas = pending.session_timer; - let mut accepted = pending.accept().await.expect("Bob accept"); - let answer = build_sdp(bob_local_ip, accepted.local_rtp_addr.port()); - let mut srx = - std::mem::replace(&mut accepted.state_rx, mpsc::unbounded_channel().1); - let refresh_tx = bob_refresh_tx.clone(); - let term_tx = bob_term_tx.clone(); - tokio::spawn(async move { - while let Some(state) = srx.recv().await { - match state { - DialogState::Updated(_, request, handle) => { - // The module-doc wiring: echo the - // negotiated Session-Expires in the - // 200 to the peer's refresh. - let echo = uas.expect("timer was negotiated").echo; - handle - .respond( - StatusCode::OK, - Some(vec![ - rsip::Header::ContentType("application/sdp".into()), - supported_timer_header(), - echo.header(), - ]), - Some(answer.clone()), - ) - .await - .expect("respond to refresh"); - let _ = refresh_tx.send(session_expires_in(request.headers())); - } - DialogState::Terminated(_, reason) => { - let _ = term_tx.send(reason); - } - _ => {} - } - } - }); - accepted_hold = Some(accepted); - } - _ => { - // Refresh re-INVITE / BYE — route to the dialog so - // its state machine advances. - let _ = bob_ep_for_dispatch.dispatch_in_dialog(tx).await; - } - } - } - drop(accepted_hold); - }); - - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let caller = Caller::new(account("alice"), caller_ep.clone()); - let target: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid target"); - let destination: SipAddr = callee_addr.into(); - let pending = caller - .dial_with_destination(target, Some(destination)) - .await - .expect("dial"); - - let accepted = timeout(Duration::from_secs(5), pending.on_confirmed()) - .await - .expect("on_confirmed didn't resolve within 5s") - .expect("on_confirmed failed"); - - // Bob (UAS) saw Alice's Supported: timer + Session-Expires and - // assigned the refresher role to her (the UAC). - let bob_timer = timeout(Duration::from_secs(5), bob_timer_rx.recv()) - .await - .expect("Bob never reported his session timer") - .expect("channel closed") - .expect("Bob should have negotiated a session timer"); - assert_eq!(bob_timer.timer.interval_secs, DEFAULT_SESSION_EXPIRES_SECS); - assert!( - !bob_timer.timer.we_are_refresher, - "Bob (UAS) should rely on Alice to refresh" - ); - assert_eq!(bob_timer.echo.refresher, Some(Refresher::Uac)); - assert!(bob_timer.require_timer); - - // Alice (UAC) parsed Bob's echo from the 2xx. - assert_eq!( - accepted.session_timer, - Some(SessionTimer { - interval_secs: DEFAULT_SESSION_EXPIRES_SECS, - we_are_refresher: true, - }), - "Alice should know she is the refresher for the granted interval" - ); - - // One real refresh re-INVITE through the SessionDialogOps impl — - // exactly what session_timer_loop sends on its tick. - let refresh_headers = vec![ - supported_timer_header(), - SessionExpires { - interval_secs: DEFAULT_SESSION_EXPIRES_SECS, - refresher: Some(Refresher::Uac), - } - .header(), - ]; - let offer = build_sdp(caller_ep.local_ip(), accepted.local_rtp_addr.port()); - let resp = timeout( - Duration::from_secs(5), - accepted.dialog.refresh(refresh_headers, Some(offer)), - ) - .await - .expect("refresh timed out") - .expect("refresh failed") - .expect("dialog should still be confirmed"); - assert_eq!( - resp.status_code.kind(), - rsip::StatusCodeKind::Successful, - "refresh re-INVITE should get a 2xx, got {}", - resp.status_code - ); - assert_eq!( - session_expires_in(&resp.headers), - Some(bob_timer.echo), - "Bob's 200 to the refresh should echo the negotiated Session-Expires" - ); - - // Bob's state pump saw the refresh as DialogState::Updated and the - // request carried the session-timer headers. - let seen = timeout(Duration::from_secs(5), bob_refresh_rx.recv()) - .await - .expect("Bob never observed the refresh re-INVITE") - .expect("channel closed"); - assert_eq!( - seen, - Some(SessionExpires { - interval_secs: DEFAULT_SESSION_EXPIRES_SECS, - refresher: Some(Refresher::Uac), - }) - ); - - // Tear down normally. - accepted.dialog.bye().await.expect("alice BYE"); - let reason = timeout(Duration::from_secs(5), bob_term_rx.recv()) - .await - .expect("Bob never saw termination") - .expect("channel closed"); - assert!( - matches!(reason, TerminatedReason::UacBye), - "expected Terminated(UacBye), got {reason:?}" - ); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} - -/// Sanity check that Alice's initial INVITE really carries the -/// session-timer headers on the wire (not just in the InviteOption): -/// Bob inspects the raw request before answering. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn outbound_invite_carries_session_timer_headers() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - - let (invite_tx, mut invite_rx) = mpsc::unbounded_channel::<(bool, Option)>(); - tokio::spawn(async move { - while let Some(tx) = callee_rx.recv().await { - if tx.original.method == Method::Invite { - let headers = tx.original.headers(); - let _ = invite_tx.send((supports_timer(headers), session_expires_in(headers))); - // Leave the INVITE unanswered; the test only inspects it. - } - } - }); - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let caller = Caller::new(account("alice"), caller_ep.clone()); - let target: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid target"); - let destination: SipAddr = callee_addr.into(); - let pending = caller - .dial_with_destination(target, Some(destination)) - .await - .expect("dial"); - - let (timer_supported, session_expires) = timeout(Duration::from_secs(5), invite_rx.recv()) - .await - .expect("Bob never received the INVITE") - .expect("channel closed"); - assert!(timer_supported, "INVITE must carry Supported: timer"); - assert_eq!( - session_expires, - Some(SessionExpires { - interval_secs: DEFAULT_SESSION_EXPIRES_SECS, - refresher: None, - }), - "INVITE must request the default interval, refresher unpinned" - ); - - pending.cancel().await.ok(); - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -#[ignore = "watchdog expiry needs ~60s of wall clock (90s interval minus 30s headroom)"] -async fn watchdog_tears_down_lapsed_session() { - let cancel = CancellationToken::new(); - - let (callee_ep, mut callee_rx) = SipEndpoint::new(&account("bob"), cancel.clone()) - .await - .expect("bind callee endpoint"); - let (caller_ep, mut caller_rx) = SipEndpoint::new(&account("alice"), cancel.clone()) - .await - .expect("bind caller endpoint"); - let callee_ep = Arc::new(callee_ep); - let caller_ep = Arc::new(caller_ep); - - let callee_addr = callee_ep.local_addr().expect("callee bound"); - - // Bob accepts and then never refreshes — the dead-peer scenario. - let (bob_term_tx, mut bob_term_rx) = mpsc::unbounded_channel::(); - let callee = Callee::new(account("bob"), callee_ep.clone()); - let bob_ep_for_dispatch = callee_ep.clone(); - tokio::spawn(async move { - let mut accepted_hold = None; - while let Some(tx) = callee_rx.recv().await { - match tx.original.method { - Method::Invite if accepted_hold.is_none() => { - let mut accepted = callee - .accept_transaction(tx) - .await - .expect("accept_transaction"); - let mut srx = - std::mem::replace(&mut accepted.state_rx, mpsc::unbounded_channel().1); - let term_tx = bob_term_tx.clone(); - tokio::spawn(async move { - while let Some(state) = srx.recv().await { - if let DialogState::Terminated(_, reason) = state { - let _ = term_tx.send(reason); - } - } - }); - accepted_hold = Some(accepted); - } - _ => { - let _ = bob_ep_for_dispatch.dispatch_in_dialog(tx).await; - } - } - } - drop(accepted_hold); - }); - tokio::spawn(async move { while caller_rx.recv().await.is_some() {} }); - - let caller = Caller::new(account("alice"), caller_ep.clone()); - let target: rsip::Uri = format!("sip:bob@{callee_addr}") - .try_into() - .expect("valid target"); - let destination: SipAddr = callee_addr.into(); - let pending = caller - .dial_with_destination(target, Some(destination)) - .await - .expect("dial"); - let accepted = timeout(Duration::from_secs(5), pending.on_confirmed()) - .await - .expect("on_confirmed didn't resolve within 5s") - .expect("on_confirmed failed"); - - // Pretend the negotiation put the refresh burden on Bob (who will - // never deliver): Alice runs the watchdog half with the minimum - // 90s interval → BYE at 90 - min(32, 30) = 60s. - let watchdog = SessionTimer { - interval_secs: 90, - we_are_refresher: false, - }; - let outcome = timeout( - Duration::from_secs(80), - session_timer_loop( - &accepted.dialog, - watchdog, - None, - Arc::new(Notify::new()), - cancel.clone(), - ), - ) - .await - .expect("watchdog never fired"); - assert_eq!(outcome, SessionTimerOutcome::Expired); - - let reason = timeout(Duration::from_secs(5), bob_term_rx.recv()) - .await - .expect("Bob never saw the watchdog BYE") - .expect("channel closed"); - assert!( - matches!(reason, TerminatedReason::UacBye), - "expected Terminated(UacBye) from the watchdog BYE, got {reason:?}" - ); - - callee_ep.shutdown(); - caller_ep.shutdown(); - cancel.cancel(); -} diff --git a/docs/08-own-sip-stack.md b/docs/08-own-sip-stack.md new file mode 100644 index 0000000..6233a48 --- /dev/null +++ b/docs/08-own-sip-stack.md @@ -0,0 +1,244 @@ +# From-scratch SIP stack (internal `stack` module) — plan + +Date: 2026-06-27 + +## Why + +Today the crate's call control (transactions, dialogs, transport, digest +auth) is provided by an external SIP stack. That bet was the right one +to get a working softphone quickly — it spared us RFC 3261 §17 timers, +dialog matching, and ACK/CANCEL races. But three problems have surfaced: + +1. **We don't control our own bugs.** A real, call-breaking defect — an + in-dialog re-INVITE's 2xx ACK addressed straight to the Contact + instead of through the dialog route set — bites hold/resume through a + proxy/SBC. We are already carrying a local patch for it. Every such + fix is gated on an upstream we don't own. + +2. **The stack leaks through our public API.** `Caller`, `Callee`, and + `session_timer` expose the underlying stack's `ClientInviteDialog`, + `ServerInviteDialog`, and `DialogStateReceiver` directly. Any change + to that stack — including a version bump — is a *breaking* change for + downstream consumers. The current major-version line of the upstream + stack is a hard fork of its own message layer (it dropped the `rsip` + crate and vendored its own SIP types), so even a routine upgrade is a + breaking migration that touches every module here — and it still + ships the re-INVITE ACK bug. + +3. **We carry far more than we use.** The upstream stack is a general + SIP toolkit (proxy/SBC roles, benchmark binaries, TLS, examples). We + drive a narrow UA path: REGISTER, outbound INVITE, inbound INVITE, + re-INVITE (hold/resume + session-timer refresh), BYE, CANCEL, INFO, + OPTIONS. A purpose-built engine for *that* surface is small. + +This plan proposes a **clean-room** SIP transaction/dialog/transport +engine, written from the ground up against our actual needs — **not** a +copy or fork of any existing stack — living as an **internal module** +inside `wavekat-sip`, exposed to consumers only through our own public +types. + +**One crate, on purpose.** `wavekat-sip` stays a single crate. A Rust +user who wants SIP depends on `wavekat-sip` and nothing else — no +choice between a "stack" crate and an "API" crate, no version-matching +across two crates. The engine is an implementation detail behind the +crate boundary, not a separate published artifact. + +## Non-goals + +- **Not** a general SIP library. No proxy, registrar-server, B2BUA, or + SBC roles. We are a User Agent only. +- **Not** a rewrite of SIP *message* parsing. We keep the crates.io + `rsip` crate for `Uri`/`Request`/`Response`/headers/digest math: it is + pure, stateless parsing with no product value to reimplement, and a + hand-rolled RFC 3261 grammar is a large, bug-prone slog. We write only + the **stateful** layer (transactions, dialogs, transport, auth + orchestration) that sits on top of `rsip`. +- **Not** a big-bang replacement. The existing stack stays wired (via a + `[patch.crates-io]` bridge to a locally-fixed fork) until our engine + reaches parity on each flow, one flow at a time. +- **No** new dependencies that touch audio, files, or LLM APIs — this + crate is SIP signaling + transport only, same scope rule as + `wavekat-sip`. + +## Scope — what the engine must do + +Derived from the flows `wavekat-sip` actually drives today: + +### Transport +- UDP bind + send/receive loop (primary path). +- TCP outbound connections (framing per RFC 3261 §7.5 — Content-Length + delimited). +- Source-address selection (already solved in `endpoint.rs::detect_local_ip`; + reuse). +- Inbound message demux to the right transaction / dialog. + +### Transactions (RFC 3261 §17) +- **Client INVITE** FSM: Calling → Proceeding → Completed → Terminated, + timers A (retransmit), B (timeout), D (wait for retransmits). +- **Server INVITE** FSM: Proceeding → Completed → Confirmed → Terminated, + timers G, H, I; ACK absorption for non-2xx; 2xx ACK handled at the + dialog/TU layer (separate transaction). +- **Non-INVITE client/server** FSM (REGISTER, BYE, INFO, OPTIONS, + CANCEL): Trying → Proceeding → Completed → Terminated, timers E, F, K. +- Via `branch` generation (RFC 3261 magic cookie), CSeq handling, + retransmission on unreliable transports only. + +### Dialogs (RFC 3261 §12) +- Dialog creation from 2xx (UAC) and from request + local tag (UAS). +- Dialog ID = Call-ID + local tag + remote tag; matching of in-dialog + requests to existing dialogs. +- **Route set** capture from Record-Route (establishing response) and + reuse on every in-dialog request — including the case the upstream bug + got wrong: a re-INVITE 2xx with no Record-Route must reuse the stored + dialog route set, not fall back to the Contact. +- Remote target (Contact) tracking; local/remote CSeq. +- State machine: Calling → Early → Confirmed → (Updated)\* → Terminated, + with explicit terminated reasons (UAC/UAS BYE, CANCEL, decline, busy, + timeout) so consumers can render call outcomes. + +### Call control (the TU surface we expose) +- Outbound INVITE with SDP offer; CANCEL; ACK for 2xx; BYE; re-INVITE + (carrying new SDP for hold/resume and session-timer refresh); INFO + (DTMF); OPTIONS keepalive. +- Inbound INVITE accept (200 + SDP answer) / reject (4xx–6xx with + Reason); inbound BYE/INFO/OPTIONS/re-INVITE handling. + +### Authentication (RFC 3261 §22 + RFC 2617 / 8760) +- Digest challenge/response on 401/407, MD5 and SHA-256, `qop=auth`, + nonce/cnonce/nc. Reuse `rsip`'s digest primitives where they exist; + own only the challenge→retry orchestration. + +### Out of initial scope (add only if a flow needs it) +- TLS/WSS transport, IPv6-only edge cases beyond what UDP/TCP give us, + forking/multiple early dialogs per INVITE, PRACK/100rel, UPDATE method + (we refresh via re-INVITE), SUBSCRIBE/NOTIFY/PUBLISH. + +## Where it lives — one crate, internal module + +`wavekat-sip` stays a single crate. The engine is a private module +subtree; only `wavekat-sip`'s existing public types appear in the API: + +``` +crates/wavekat-sip/src/ + stack/ # new: clean-room SIP engine, pub(crate) only + mod.rs # engine entry point + the trait surface (below) + transport.rs # UDP/TCP bind, serve loop, inbound demux + transaction.rs # RFC 3261 §17 client/server FSMs + timers + dialog.rs # RFC 3261 §12 dialog state, route set, matching + auth.rs # digest challenge -> retry orchestration + endpoint.rs # existing: re-pointed at stack::* + caller.rs # existing: wraps stack dialog handles + callee.rs # existing + registrar.rs # existing + session_timer.rs # existing (already trait-abstracted) + sdp.rs rtp/ ... # unchanged +``` + +Rationale: + +- **One crate for consumers (decisive).** A Rust user who wants SIP uses + `wavekat-sip` and only `wavekat-sip` — no choosing between an "API" + crate and a "stack" crate, no cross-crate version matching. The engine + is an internal detail, not a separate published artifact. This also + matches the crate's existing "single-crate workspace" structure. +- **Concern separation without a crate split.** The engine is a focused + module *subtree* (`stack/transport.rs`, `transaction.rs`, `dialog.rs`, + `auth.rs`), each file one concern under the >300-line rule — the same + discipline a separate crate would give, at module granularity. +- **It stops the leak for good — and this is a `pub` question, not a + crate question.** Keep every engine type `pub(crate)`; expose only + `wavekat-sip`'s own wrapper types. The engine then never appears in + `wavekat_sip::*` signatures, so evolving or replacing it is no longer + a breaking change for downstream consumers (motivation 2 above). A + second crate was never required for this; module privacy delivers it. +- **Engine-level tests still fit.** Unit tests live at the bottom of each + `stack/*.rs` (per project policy); RFC-conformance and interop suites + live in `wavekat-sip`'s `tests/` (the `#[ignore]`'d integration tier). + +## The boundary — the public API wraps the engine, never exposes it + +The key design rule: the engine lives behind a **small internal +trait/type surface** (`stack::mod`), and `wavekat-sip`'s public types +(`Caller`, `Callee`, `PendingDial`, …) wrap it. The engine is +swappable and never leaks into `wavekat_sip::*`. Sketch (names +provisional, all `pub(crate)`): + +- `Endpoint` — bind transport, serve loop, yield inbound transactions. +- `ClientInvite` / `ServerInvite` — the two dialog handles, with + `bye()`, `cancel()`, `reinvite(headers, body)`, `info(...)`, + `accept(...)`, `reject(...)`, `state()`. +- `DialogEvents` — async stream of dialog state transitions + (`Calling`/`Early`/`Confirmed`/`Updated`/`Terminated{reason}`). +- Re-export the `rsip` message types we already pass around, so SDP/ + header construction in `sdp.rs` and `registrar.rs` is unchanged. + +`session_timer.rs` already abstracts over a `SessionDialogOps` trait for +exactly this reason; that pattern generalizes to the whole surface. + +## Migration — phased, behind the bridge + +Parity is reached one flow at a time. Until a flow is at parity it keeps +running on the existing stack via `[patch.crates-io]` → locally-fixed +fork. + +1. **Phase 0 — unblock now.** Keep the existing stack; point it at the + locally-patched fork via `[patch.crates-io]` so hold/resume works + today. (Separately: upstream the re-INVITE ACK fix.) +2. **Phase 1 — transport + transactions.** UDP/TCP, the three + transaction FSMs, Via/branch/CSeq, retransmission. Conformance tests + against the timer tables. No dialog layer yet. +3. **Phase 2 — REGISTER.** Non-INVITE client transaction + digest + orchestration end-to-end. Swap `Registrar`'s internals to the new + engine; `Registrar`'s public API is unchanged. +4. **Phase 3 — outbound INVITE.** Client INVITE + dialog creation + + 2xx ACK + BYE/CANCEL. Swap `Caller`; keep its public type names, + re-pointed at engine-backed wrappers. +5. **Phase 4 — inbound INVITE.** Server INVITE + accept/reject. Swap + `Callee`. +6. **Phase 5 — re-INVITE + INFO.** Hold/resume, session-timer refresh, + DTMF INFO. This is where the upstream bug lived; the route-set reuse + is a first-class requirement here with a direct regression test. +7. **Phase 6 — remove the old dependency.** Drop the external stack and + the `[patch]` once every flow is at parity and the external stack's + types no longer appear anywhere in `wavekat_sip::*`. `wavekat-sip` + remains a single crate throughout — no crate is added or published at + any phase. + +Each phase lands with its own tests in the same PR (per project policy), +and updates `RFC-COVERAGE.md` to point at the new engine instead of the +external stack. + +## Risks & honest caveats + +- **This is the hard core.** Transaction timers, ACK absorption, dialog + matching, CANCEL/re-INVITE races, and digest edge cases are exactly + where homegrown SIP stacks bleed. The phased approach with the bridge + is the mitigation: never lose a working call path, validate each flow + against the real server before cutting over. +- **Interop surface is wide.** Proxies/SBCs (Kamailio, FreeSWITCH, + Plivo, etc.) vary in Record-Route, loose vs. strict routing, and + `;lr` quirks. Keep an `#[ignore]`'d integration suite in `tests/` + exercising each against a real endpoint. +- **Effort is non-trivial.** This is weeks, not days. It is justified by + owning our bug-fix loop and permanently de-leaking the public API — not + by a single fix, which the `[patch]` bridge already delivers. + +## Open questions + +- Internal module path: `stack/` subtree as sketched, vs. a flatter + `stack.rs` + a few siblings. (Either way it stays `pub(crate)` inside + the one crate.) +- Async runtime surface: keep the current `tokio` + `CancellationToken` + shape, or define a thinner internal abstraction. +- How much of `rsip`'s digest helpers to reuse vs. own. +- Feature-gating: should the engine sit behind a default-on feature so + the old-stack path can be A/B-compiled during migration, or is the + `[patch]` bridge enough? + +## Decision sought + +Approve (a) keeping `wavekat-sip` a **single crate** with the engine as +an internal `pub(crate)` module, (b) keeping `rsip` for messages while +writing the stateful engine from scratch, and (c) the phased, +bridge-backed migration order above. Implementation starts at Phase 1 +only after sign-off. diff --git a/docs/09-stack-transactions.md b/docs/09-stack-transactions.md new file mode 100644 index 0000000..bdf3e14 --- /dev/null +++ b/docs/09-stack-transactions.md @@ -0,0 +1,102 @@ +# Internal `stack` engine, Phase 1 — transaction layer + +> Status: implemented · Date: 2026-06-27 + +Implements the first slice of the clean-room engine planned in +[08-own-sip-stack.md](08-own-sip-stack.md): the RFC 3261 §17 transaction +state machines. This is the conformance-critical "hard core" the plan +flags — the timers, retransmissions and ACK absorption where homegrown +SIP stacks bleed — so it lands first, fully unit-tested, ahead of the +transport runner and dialog layer. + +Everything here is `pub(crate)` inside the single `wavekat-sip` crate and +appears in no public signature, per the plan's one-crate, no-leak rule. + +## What landed + +`src/stack/`: + +``` +mod.rs engine entry point + build-status doc +transaction/ + mod.rs Reliability, Timers (T1/T2/T4), TimerId, + TxAction, TransactionKey (demux), gen_branch, + build_non_2xx_ack + client_invite.rs §17.1.1 Calling/Proceeding/Completed + client_non_invite.rs §17.1.2 Trying/Proceeding/Completed + server_invite.rs §17.2.1 Proceeding/Completed/Confirmed + server_non_invite.rs §17.2.2 Trying/Proceeding/Completed +``` + +All four machines are driven, because our UA drives all four: + +| Machine | Methods | +|---------|---------| +| Client INVITE | outbound INVITE | +| Client non-INVITE | REGISTER, BYE, CANCEL, INFO, OPTIONS | +| Server INVITE | inbound INVITE | +| Server non-INVITE | inbound BYE, INFO, OPTIONS, CANCEL | + +Only what we use: no PRACK/100rel, no UPDATE (we refresh via re-INVITE), +no forking/multiple early dialogs, no TLS-specific timing — matching the +plan's non-goals. + +## Design — sans-IO + +The machines never touch a socket or the clock. Each maps an input event +to an ordered list of actions the caller must perform: + +- **Events in:** a received `rsip` message, a fired `TimerId`, or (server + side) a response the TU wants to send. +- **Actions out (`TxAction`):** `Send`, `StartTimer{id, after}`, + `StopTimer`, `DeliverResponse`/`DeliverRequest` (hand up to the TU), + `TimedOut`, `Terminated`. + +A later phase adds a thin transport runner that owns the UDP/TCP socket +and a timer wheel and simply applies whatever actions come back. Until +then the design pays off immediately: the timer-heavy core is tested by +feeding events and asserting on the action list and the exact timer +durations — the RFC §17 timer tables checked directly, with no sleeping +and no flakiness. + +### Timers + +`Timers { t1, t2, t4 }` defaults to the RFC's 500 ms / 4 s / 5 s; tests +shrink nothing (durations are asserted symbolically), and a future +runner can tune T1 to a measured RTT. A single `Reliability` bit +(UDP = `Unreliable`, TCP = `Reliable`) selects every timer that differs +between transports: the retransmit timers (A, E, G) run only on +unreliable transports, and the post-final soak timers (D, I, J, K) +collapse to zero on reliable ones, so those transactions terminate +immediately instead of lingering. + +### The re-INVITE ACK bug, structurally avoided + +The defect that motivated the whole effort — a non-2xx ACK addressed to +the Contact instead of through the transaction — cannot occur here: +`build_non_2xx_ack` reuses the INVITE's own top `Via` (same branch), +`From`, `Call-ID`, request-URI and `CSeq` number, takes `To` (with the +remote tag) from the response, and copies the request's `Route` set. The +2xx ACK is deliberately *not* this machine's job — on a 2xx the client +INVITE transaction hands the response up and terminates, leaving the ACK +and dialog routing to the (future) dialog/TU layer, where the route set +will live. + +## Tests + +41 unit tests at the bottoms of the `stack/` files cover, per machine: +initial send + timer arming, retransmit backoff and caps (A doubles; +E/G double capped at T2), the timeout timers (B/F/H), provisional → +proceeding transitions, final-response handling, ACK absorption +(server INVITE → Confirmed), the soak timers (D/I/J/K), and the +reliable-transport fast paths. Shared helpers cover `gen_branch` +(magic-cookie prefix + uniqueness), `TransactionKey` matching +(ACK folds onto INVITE, CANCEL does not), and the non-2xx ACK builder. + +## Not in this slice + +Transport sockets + serve loop / inbound demux, the dialog layer +(§12 route sets, dialog matching), digest orchestration, and wiring the +existing wrappers onto the engine. Those are the next phases in +[08-own-sip-stack.md](08-own-sip-stack.md); the external stack stays +wired until each flow reaches parity. diff --git a/docs/10-stack-transport-engine.md b/docs/10-stack-transport-engine.md new file mode 100644 index 0000000..64f8da6 --- /dev/null +++ b/docs/10-stack-transport-engine.md @@ -0,0 +1,77 @@ +# Internal `stack` engine, Phase 2 — transport + async runner + +> Status: implemented · Date: 2026-06-27 + +Builds on [09-stack-transactions.md](09-stack-transactions.md). Adds the +UDP transport and the async **engine** that drives the sans-IO §17 state +machines over a real socket — the half of "Phase 1: transport + +transactions" in [08-own-sip-stack.md](08-own-sip-stack.md) that the +first slice deferred. + +Still entirely `pub(crate)`; nothing here appears in `wavekat_sip::*`. + +## What landed + +`src/stack/`: + +``` +transport.rs SIP (de)serialization (via rsip) + a bound UDP socket +engine.rs the async runner: one task owns the socket, the + transaction table and the timers; Command/Event API +transaction/mod.rs + Transaction dispatch enum, Reliability::from(Transport) +``` + +### `transport.rs` + +`parse`/`serialize` delegate to `rsip` (we own only socket plumbing). +`UdpTransport` binds a `tokio` UDP socket, sends one message per +datagram, and `recv`s the next *parseable* message — malformed datagrams +are dropped (trace-logged) so one bad packet can't stall the engine. UDP +is `Reliability::Unreliable`; TCP framing is a later addition (kept out +of the initial cut per the plan). + +### `engine.rs` + +A single task `select!`s over three sources and owns all mutable state, +so there are no locks: + +- **inbound datagrams** → demuxed by `TransactionKey` to an existing + transaction, or used to open a new server transaction (INVITE → + server-INVITE machine, else server-non-INVITE). An ACK matching no + transaction is surfaced as `UnmatchedRequest` — it's the 2xx ACK, a + dialog concern. +- **fired timers** → looked up and fed back into the owning machine. +- **TU commands** (`Command`) → start a client transaction, hand a + server transaction its response, or send out-of-dialog (the 2xx ACK). + +Each machine returns `TxAction`s, which the runner applies: send bytes, +arm/cancel a timer, or publish an `Event` (`IncomingRequest`, +`Response`, `UnmatchedRequest`, `TimedOut`, `Terminated`) up to the TU. + +**Timers without handles.** Arming a timer bumps a per-`(transaction, +TimerId)` generation and spawns a `tokio::time::sleep` tagged with it; a +fired timer is ignored unless its generation still matches. So +`StopTimer` and re-arming are just increments — no `DelayQueue`, no +handle bookkeeping, and a terminated transaction's stale timers are +dropped for free. + +## Tests + +49 stack tests (41 sans-IO + 8 new). The new ones run on **loopback UDP +sockets** with shrunk timers (`start_with_timers`, T1 = 1 ms) so soak and +timeout timers fire in milliseconds: + +- transport: parse/serialize round-trip, garbage rejection, two-socket + send/recv, malformed-then-valid recovery, response round-trip; +- engine: a client transaction delivering a 200 then terminating; an + inbound INVITE opening a server transaction and emitting the TU's 486 + to the wire; and a silent peer driving Timer F to `TimedOut` + + `Terminated`. + +## Next + +Dialog layer (§12 route sets, dialog matching, the 2xx-ACK path that +`UnmatchedRequest` feeds), digest orchestration, then migrating +`Registrar`/`Caller`/`Callee` onto the engine and dropping the external +stack — the remaining phases in +[08-own-sip-stack.md](08-own-sip-stack.md). diff --git a/docs/11-stack-auth.md b/docs/11-stack-auth.md new file mode 100644 index 0000000..4cb2f77 --- /dev/null +++ b/docs/11-stack-auth.md @@ -0,0 +1,48 @@ +# Internal `stack` engine, Phase 3 — digest auth orchestration + +> Status: implemented · Date: 2026-06-27 + +Adds `src/stack/auth.rs` — the digest challenge→retry orchestration the +engine needs for REGISTER (and any challenged request). Continues +[08-own-sip-stack.md](08-own-sip-stack.md). + +## What we own vs. reuse + +The digest **math** (HA1/HA2, MD5/SHA-256/SHA-512, `qop=auth` with +`cnonce`/`nc`) is `rsip`'s `DigestGenerator`. Reimplementing it has no +product value and is easy to get subtly wrong, so we reuse it — per the +plan's non-goal of not rewriting message-layer primitives. + +We own only the **orchestration**: + +- `build_retry(original, response, creds)` — given the request we sent + and the `401`/`407` we got back, produce the retried request: a fresh + `Via` branch and incremented `CSeq` (it's a new transaction), the + original otherwise, plus the credential header. +- `401` → `WWW-Authenticate` answered with `Authorization`; `407` → + `Proxy-Authenticate` answered with `Proxy-Authorization`. +- `qop=auth` handling: a fresh client nonce (`cnonce`, generated with the + same no-`rand` seeded-hash trick as branch values) and `nc=1`. + +## Tests + +7 unit tests: retry adds the credential header with a bumped CSeq and a +fresh branch; the computed response self-verifies via `DigestGenerator` +(catches qop/cnonce/algorithm mixups) for both `qop=auth` and no-qop; the +wire form carries the expected params; `407` uses `Proxy-Authorization`; +and a non-challenge response yields no retry. + +## Known limitation + +`rsip` spells the algorithm token `SHA256` (no hyphen) for both parsing +and `Display`, where RFC 7616 uses `SHA-256`. So SHA-256 interop with a +strict server is limited by `rsip`, not by this layer. **MD5 — the +near-universal SIP digest — is fully correct.** Since we own the +orchestration, the token can be normalized here later if a SHA-256 +deployment needs it. + +## Next + +The dialog layer (§12 route sets + matching), then migrating `Registrar` +(REGISTER + this auth path), `Caller` and `Callee` onto the engine, and +finally dropping the external stack. diff --git a/docs/12-stack-dialog.md b/docs/12-stack-dialog.md new file mode 100644 index 0000000..584add5 --- /dev/null +++ b/docs/12-stack-dialog.md @@ -0,0 +1,55 @@ +# Internal `stack` engine, Phase 4 — dialog layer + +> Status: implemented · Date: 2026-06-27 + +Adds `src/stack/dialog.rs` — RFC 3261 §12 dialogs: the state that turns an +INVITE/2xx into a relationship through which BYE, re-INVITE and INFO are +sent. Continues [08-own-sip-stack.md](08-own-sip-stack.md). + +## What landed + +- `Dialog::uac(invite, response, local_contact)` — build the caller-side + dialog from the INVITE we sent and the establishing response (2xx, or a + 1xx-with-tag early dialog). +- `Dialog::uas(invite, local_tag, local_contact)` — build the callee-side + dialog from an inbound INVITE and the tag we answer with. +- `Dialog::new_request(method)` — the next in-dialog request: incremented + CSeq, Request-URI = remote target, From/To with the right tags, a fresh + Via branch, our Contact, and the replayed route set. +- `DialogId` (Call-ID + local tag + remote tag) and + `DialogId::from_request` to route inbound in-dialog requests. + +## The route-set bug, fixed by construction + +The defect that motivated the clean-room engine: an in-dialog re-INVITE +addressed straight to the Contact instead of through the stored route +set, breaking hold/resume behind a proxy/SBC. Here it **cannot** recur: + +- the route set is captured **once** at establishment (UAC: the response's + `Record-Route`, reversed; UAS: the request's, in order); +- `new_request` replays it as `Route` headers on **every** in-dialog + request, with the remote target as the Request-URI — the two are never + conflated, and a later re-INVITE 2xx with no `Record-Route` cannot + erase it. + +A dedicated test (`route_set_is_replayed_on_every_request`) guards this: +a second in-dialog request still carries the captured route set. + +## Tests + +5 unit tests: UAC dialog identity/target capture; an in-dialog BYE using +the target + reversed route set + correct tags + advancing CSeq; the +route-set regression guard; UAS party orientation + inbound-request +matching; and early (1xx) vs confirmed (2xx) state. + +## Scope + +Loose routing (`;lr`) only — universal in modern proxies; strict routing +(legacy) is out of scope per the plan. UDP transport in the Via. + +## Next + +With transactions, transport/engine, auth and dialogs in place, the +remaining work is wiring: migrate `Registrar`, `Caller` and `Callee` onto +the engine + dialog layer behind their existing public types, replace the +`rsipstack` re-exports with crate-owned types, and drop the dependency. diff --git a/docs/13-stack-register-flow.md b/docs/13-stack-register-flow.md new file mode 100644 index 0000000..ce47ec6 --- /dev/null +++ b/docs/13-stack-register-flow.md @@ -0,0 +1,49 @@ +# Internal `stack` engine, Phase 5 — REGISTER flow (first composed flow) + +> Status: implemented · Date: 2026-06-27 + +Adds `src/stack/registration.rs` — the first *composed* flow on the +clean-room stack, and the first end-to-end proof it works over the wire. +Continues [08-own-sip-stack.md](08-own-sip-stack.md). + +## What landed + +- `build_register(cfg, cseq, local_addr)` — compose a REGISTER (Via with + a fresh branch + sent-by, From/To/Contact, CSeq, Call-ID, Expires). +- `drive_register(engine, peer, events, cfg, cseq)` — send the REGISTER + through the engine, answer a single `401`/`407` with `auth::build_retry`, + and report a `RegisterOutcome` (`Registered{expires}` / `Unauthorized` / + `Failed(status)` / `TimedOut` / `EngineStopped`). + +This stitches together every prior phase: the non-INVITE client +transaction (§17.1.2), the UDP engine, and the digest orchestration. It +is the logic the migrated `Registrar` will sit on. + +## Tests + +3 tests, two of them **full loopback round-trips against a fake +registrar**: + +- `register_succeeds_after_digest_challenge` — the fake registrar + challenges the first REGISTER (401 + `WWW-Authenticate`) and accepts the + second once it carries `Authorization`; the flow reports + `Registered { expires: 60 }`. +- `rejected_credentials_yield_unauthorized` — a registrar that always + challenges yields `Unauthorized` after one retry (no auth loop). +- `build_register_has_expected_shape` — header/CSeq/tag/expires checks. + +These are real SIP messages on real UDP sockets, parsed by `rsip` on the +server side — the strongest evidence yet that the engine interoperates. + +## Note on event routing + +`drive_register` consumes the engine's event stream directly and assumes +it is the only flow in flight. The migrated endpoint will add a small +per-transaction/per-dialog router so REGISTER, calls, and keepalives +share one engine — that router is the heart of the next phase. + +## Next + +Wire the public wrappers (`Registrar`/`Caller`/`Callee`) and a new +engine-backed endpoint onto these flows, replace the `rsipstack` +re-exports with crate-owned types, and drop the dependency. diff --git a/docs/14-stack-call-flow.md b/docs/14-stack-call-flow.md new file mode 100644 index 0000000..82bd673 --- /dev/null +++ b/docs/14-stack-call-flow.md @@ -0,0 +1,63 @@ +# Internal `stack` engine, Phase 6 — outbound INVITE call flow + +> Status: implemented · Date: 2026-06-27 + +Adds `src/stack/call.rs` — the outbound call flow (RFC 3261 §13, UAC +side), the second composed flow on the clean-room stack. Continues +[08-own-sip-stack.md](08-own-sip-stack.md). + +## What landed + +- `build_invite(cfg, cseq, local_addr)` — compose an INVITE carrying the + SDP offer. +- `place_call(engine, peer, events, cfg, cseq)` — send the INVITE, follow + provisional responses (100/180/183), answer one `401`/`407` via + `auth::build_retry`, and on a 2xx build the `Dialog` and send the ACK + (which, for a 2xx, rides **outside** any transaction per §13.2.2.4, + reusing the INVITE's CSeq). Returns a `CallOutcome` + (`Answered(Dialog)` / `Rejected(status)` / `Unauthorized` / `TimedOut`). +- `hangup(engine, peer, events, dialog)` — tear the call down with an + in-dialog BYE. + +Ties together the client INVITE transaction (§17.1.1, including the +transaction-owned non-2xx ACK), the dialog layer's route-set reuse, and +the 2xx-ACK path the engine surfaces as out-of-dialog. + +## Tests + +3 tests, two of them **full loopback round-trips against a fake callee**: + +- `call_is_answered_acked_and_hung_up` — INVITE → 180 → 200 (with Contact + + To tag); the flow ACKs, returns a confirmed dialog, then BYEs and the + callee 200s. +- `rejected_call_reports_status` — INVITE → 486; the client INVITE + transaction ACKs the non-2xx itself and the flow reports `Rejected(486)`. +- `build_invite_carries_sdp` — body + `Content-Type` checks. + +## State of the engine + +Both signaling flows our UA needs — REGISTER and INVITE — now run +end-to-end on the clean-room stack with real SIP over real UDP. The +engine (transactions, transport, dialogs, auth) is functionally complete +for the UA path. + +## Remaining for full `rsipstack` removal + +The engine is done; what's left is **wiring and a public-API swap** — the +big-bang the plan flags: + +1. An engine-backed endpoint with a per-dialog/transaction event router + (so REGISTER, calls, and keepalives share one engine), plus inbound + INVITE (UAS) acceptance. +2. Re-point the public wrappers — `Registrar`, `Caller`, `Callee`, + `SipEndpoint`, `session_timer`, `dtmf_info` — onto the engine flows, + keeping their public type names. +3. Replace the `rsipstack` re-exports in `lib.rs::re_exports` + (`DialogState`, `DialogStateReceiver`, `TerminatedReason`, + `TransactionHandle`, `DialogId`, `Transaction`, `SipAddr`) with + crate-owned types. +4. Drop `rsipstack` from `Cargo.toml` and the crate docs. + +Steps 2–4 are a single cutover (the public surface changes together), so +they land as one focused phase rather than the incremental, always-green +slices used so far. diff --git a/docs/15-stack-ua-router.md b/docs/15-stack-ua-router.md new file mode 100644 index 0000000..6fd34d2 --- /dev/null +++ b/docs/15-stack-ua-router.md @@ -0,0 +1,51 @@ +# Internal `stack` engine, Phase 7 — UA router (one engine, many flows) + +> Status: implemented · Date: 2026-06-27 · Branch: `feat/drop-rsipstack` + +Adds `src/stack/ua.rs` — the multiplexing layer that lets a single engine +serve a registration, several calls, and keepalives at once. This is the +foundation the public-wrapper cutover sits on. Continues +[08-own-sip-stack.md](08-own-sip-stack.md). + +## What landed + +- A **router task** owning the engine's single event stream and a + `TransactionKey → Sender` table. A flow subscribes its key + (with a oneshot ack, so a fast reply can't arrive before the + subscription is live), then starts its transaction; events are fanned + to the owning flow, and a `Terminated` event drops the subscription. +- Unmatched inbound requests — new INVITEs and the 2xx ACK — are routed + to an `incoming` stream for the callee / dialog layer. +- `Ua::{register, call, hangup, send_in_dialog, answer, next_incoming}` + as the **single canonical drivers**. The earlier standalone + `drive_register` / `place_call` / `hangup` are removed; + `registration.rs` / `call.rs` keep the request builders and the + `*Config` / `*Outcome` types. + +## Tests + +- `register_then_call_share_one_engine` — a REGISTER (with digest + challenge) and an INVITE complete over one `Ua` against one peer. +- `inbound_invite_reaches_incoming_and_can_be_answered` — an inbound + INVITE surfaces on `next_incoming` and the answer reaches the caller. + +## Remaining work to drop `rsipstack` + +The engine (transactions, transport, dialogs, auth) and this router are +complete. The final phase is the **breaking public-API cutover**: + +1. Replace `SipEndpoint` with a `Ua`-backed type (bind + the inbound + stream). +2. Re-point `Registrar` onto `Ua::register` + keepalive. +3. Re-point `Caller` / `PendingDial` / `AcceptedDial` onto `Ua::call`, + exposing crate-owned dialog/state types (replacing the re-exported + `DialogState` / `DialogStateReceiver`) plus the RTP socket + parsed + `RemoteMedia` (already crate-owned in `sdp.rs`). +4. Re-point `Callee` / `PendingCall` / `AcceptedCall` onto + `Ua::next_incoming` + `Ua::answer` + `Dialog::uas`. +5. Adapt `session_timer` and `dtmf_info` onto the new dialog handle. +6. Replace the `rsipstack` re-exports in `lib.rs::re_exports` with + crate-owned types; drop `rsipstack` from `Cargo.toml`. + +Steps 1–6 change the public surface together, so unlike phases 1–7 +(always green) they land as one cutover commit. diff --git a/docs/16-drop-rsipstack.md b/docs/16-drop-rsipstack.md new file mode 100644 index 0000000..aa779b6 --- /dev/null +++ b/docs/16-drop-rsipstack.md @@ -0,0 +1,72 @@ +# 16 · Cutover: drop `rsipstack`, run on the in-house engine + +> Status: Done · Phase 8 (final) of the own-SIP-stack plan (`docs/08`). + +## What changed + +The public wrappers were re-pointed off `rsipstack` and onto the clean-room +engine built across phases 1–7 (`docs/09`–`docs/15`). `rsipstack` is removed +from `Cargo.toml` and no longer appears in the dependency tree. `rsip` (SIP +message types) is the only SIP crate left. + +This is a **breaking** API change — accepted deliberately: the old wrappers +exposed `rsipstack` types (`DialogState`, `TransactionHandle`, `DialogId`, …) +across the public surface, so they could not survive the dependency's removal. + +## New public surface + +The engine is internal (`pub(crate) mod stack`). The public API is a thin layer +over the `Ua` router (`stack::ua`): + +- **`SipEndpoint::new(account, cancel) -> Arc`** — binds the UDP + transport, starts the engine, resolves the next-hop server (RFC 3263 subset), + and spawns an **inbound router** that drains `Ua::next_incoming()`: + - a fresh `INVITE` (no `To` tag) → an `IncomingCall` on + `next_incoming_call()`; + - any in-dialog request (`BYE` / `OPTIONS` / `INFO` / re-`INVITE`) → + auto-answered `200 OK`; + - the 2xx `ACK` → absorbed. +- **`Caller::dial(target) -> Call`** — binds an RTP socket, offers G.711 SDP, + places the INVITE, answers a single `401`/`407` digest challenge, and parses + the SDP answer from the 2xx. +- **`IncomingCall::accept() -> Call`** / **`reject(status)`** — answers `200 OK` + with an SDP answer (building a UAS dialog), or sends a non-2xx final. +- **`Call`** — the established-call handle (`remote_media`, `rtp_socket`, + `local_rtp_addr`); `hangup()` sends an in-dialog `BYE`. +- **`Registrar`** — `register()` / `keepalive_loop()` / `unregister()` / + `diagnostics()` over `Ua::register`, preserving `RegistrarDiagnostics`. +- **`re_exports`** now exposes only `rsip` types (`Header`, `Headers`, `Method`, + `StatusCode`, `Uri`). + +Two small engine additions landed to support the wrappers: + +- `stack/response.rs` — `build_response()`, a UAS response builder (echoes + Via/From/Call-ID/CSeq, adds a local `To` tag, optional Contact + body). +- `stack::transaction::gen_tag()` — opaque dialog-tag generator. + +## Feature deltas (deferred to follow-ups on the new API) + +To land the cutover as one coherent change, these `rsipstack`-coupled features +were dropped for now and their modules removed. Each is reintroducible on the +engine without further breaking changes: + +- **Session timers (RFC 4028)** — `session_timer.rs` removed. The engine + auto-answers in-dialog re-INVITEs; periodic refresh is not yet driven. +- **DTMF over SIP INFO** — `dtmf_info.rs` removed. In-band RTP telephone-event + DTMF (`rtp::dtmf`) is unaffected. +- **Hold / resume re-INVITE** — not yet wired on the new `Call`. +- **DialogState streaming / cancel-while-ringing** — the old `DialogState` + receiver UX is gone; `dial()` resolves directly to an answered `Call`. +- **`User-Agent` header** — not currently emitted. + +## Tests + +- The old `rsipstack`-based integration tests were removed. +- `tests/end_to_end_call.rs` (new) drives the **public** API over loopback on + the in-house engine: one endpoint dials another, the callee accepts with an + SDP answer, the caller confirms negotiated media, and hangs up with a BYE the + callee's router auto-answers. No external SIP server required. +- The engine's own loopback coverage (`stack::ua`) continues to exercise + REGISTER-with-digest and the full INVITE/200/ACK/BYE flow. + +All four gates pass with zero warnings; `rsipstack` is absent from `Cargo.lock`. diff --git a/docs/17-reinstate-deferred-features.md b/docs/17-reinstate-deferred-features.md new file mode 100644 index 0000000..52f5524 --- /dev/null +++ b/docs/17-reinstate-deferred-features.md @@ -0,0 +1,196 @@ +# 17 · Reinstate the deferred call features on the in-house engine + +> Status: Done · Follows `docs/16` (the `rsipstack` cutover). + +## Why + +`docs/16` swapped the public wrappers off `rsipstack` and onto the +clean-room engine (`stack`, built across `docs/09`–`docs/15`). To land the +cutover as one coherent change, a set of `rsipstack`-coupled features were +**dropped** and their modules removed, with a promise that each was +"reintroducible on the engine without further breaking changes". + +A downstream consumer drives long, unattended voice calls and relies on +those features in production: without them a migrated build does not just +lose nice-to-haves, it fails to compile and — where it would compile — +drops calls. This change reinstates the genuinely-missing capabilities on +the engine. + +## Scope: what's missing vs. what merely moved + +The deferred items split into two kinds. Only the first is in scope here. + +### In scope — capabilities the engine cannot currently perform + +These are not present anywhere in the engine; a consumer cannot reach them +under any spelling. Each must be implemented. + +| # | Capability | Why it's genuinely missing | +|---|------------|----------------------------| +| 1 | **In-dialog re-INVITE with 2xx ACK** | `Ua::send_in_dialog` exists but never ACKs a 2xx. A re-INVITE is an INVITE transaction (RFC 3261 §13/§14) whose 2xx **must** be ACKed by the TU, or the peer retransmits and ultimately tears the dialog down. No code path does this. *Foundation for #4 and #5.* | +| 2 | **`User-Agent` header** | Not emitted on any outbound request; the engine has no product-token plumbing. | +| 3 | **DTMF over SIP INFO** | No way to send an in-dialog `INFO`; `dtmf_info.rs` was removed. Inbound `INFO` is blindly auto-answered `200` and dropped. | +| 4 | **Hold / resume re-INVITE** | No `a=sendonly`/`a=inactive`/`a=sendrecv` SDP builder, no `o=` version bump, no re-INVITE call control. | +| 5 | **Session timers (RFC 4028)** | No `Session-Expires`/`Min-SE`/`Supported: timer` negotiation, no refresh/watchdog loop; `session_timer.rs` was removed. | +| 6 | **Surfacing inbound in-dialog requests** | The router auto-answers every in-dialog request (re-INVITE / INFO) with a bare `200 OK` and discards it. A peer's session-timer **refresh** re-INVITE needs an SDP answer + watchdog reset; an inbound `INFO` DTMF needs to reach the consumer. Neither is reachable. | +| 7 | **CANCEL a pending outbound INVITE** | RFC 3261 §9. The transaction layer routes CANCEL but nothing builds/sends it; a ringing outbound call cannot be cancelled. | +| 8 | **Provisional / terminated state observation** | `await_final` silently skips 1xx and never reports *why* a dialog ended. A "ringing" UX and cancel-while-ringing both need the 1xx stream + a terminated reason. | + +### Out of scope — present, only the API shape changed + +These capabilities **exist** on the engine; adopting them is a matter of the +consumer changing its calling code, not the crate adding anything. Per the +instruction driving this change, they are deliberately left alone: + +- The inbound-call types renamed (`Callee` / `PendingCall` / `AcceptedCall` + → `IncomingCall` → `accept()` → `Call`). +- The outbound-call types renamed (`PendingDial` / `AcceptedDial` → + `Caller::dial() -> Call`). +- The exact `DialogState` enum spelling and `TransactionHandle` / + `DispatchOutcome` types. (The *capabilities* they carried — provisional + observation, cancel, inbound in-dialog surfacing — are in scope as #6–#8; + only their old type names are not restored verbatim.) + +## Engine seams these build on + +The relevant primitives already exist (`stack/dialog.rs`, `stack/ua.rs`, +`stack/response.rs`): + +- `Dialog::new_request(method) -> Request` — composes an in-dialog request + with a fresh Via branch, incremented `CSeq`, and the captured route set. +- `Dialog::ack_2xx(invite_cseq) -> Request` — builds the out-of-transaction + ACK for a 2xx (already used by the initial INVITE in `Ua::call`). +- `Ua::send_in_dialog(peer, request) -> Option` — sends a + caller-built request and awaits its final response (no ACK). +- `Ua::answer(key, response)` + `build_response(request, status, to_tag, + contact, body)` — answer an inbound server transaction with any + status/body. Reusable for re-INVITE / INFO answers. +- `Ua::next_incoming() -> Incoming { key, request, peer }` and the router in + `ua.rs` that currently auto-answers in-dialog requests in + `endpoint.rs::route_inbound`. + +## Design, phase by phase + +Each phase lands as its own commit, green on all four gates, with tests in +the same commit (per `CLAUDE.md`). + +### Phase 1 — in-dialog re-INVITE seam (foundation) + +Add `Ua::reinvite(peer, dialog, headers, body) -> Option`: +build `dialog.new_request(Method::Invite)`, attach the extra headers and +SDP body, send, await the final response, and **on a 2xx send the ACK** +(`dialog.ack_2xx(cseq_of(&reinvite))` via `send_out_of_dialog`). Non-2xx +finals are returned as-is (no ACK; the transaction's own ACK covers them). +`Ua::send_in_dialog` stays for non-INVITE in-dialog requests (BYE/INFO). + +Expose on the public `Call` as `pub(crate)`-flavored helpers the feature +phases call: `Call::reinvite(...)` and `Call::send_info(...)`. + +Tests: loopback in `stack::ua` — a re-INVITE gets a 200, the test peer +observes the ACK; a rejected re-INVITE (e.g. 488) returns the status and +sends no ACK. + +### Phase 2 — `User-Agent` header + +Carry an optional product token on the `Ua` (set at bind time) and inject a +`User-Agent` header in the shared request builders (`build_invite`, +`build_register`, `Dialog::new_request`). Restore the public entry point as +`SipEndpoint::new_with_app(account, product, cancel)` with `new` delegating +to it with `None`. A `None` token emits no header (byte-identical to today). + +Tests: `build_invite` / `build_register` include `User-Agent: ` +when set and omit it when `None`. + +### Phase 3 — DTMF over SIP INFO + +Restore `dtmf_info.rs`. The pure parts return verbatim: `CONTENT_TYPE`, +`build_info_body`, `content_type_header`, `InfoOutcome` (+ `is_accepted` +/ `should_stop`), and `classify(Option) -> InfoOutcome` +(`classify` already takes exactly what `send_in_dialog` returns). Replace +the two `rsipstack` send functions with one `Call::send_dtmf_info(digit, +duration_ms) -> InfoOutcome` over the Phase-1 INFO seam. The existing tests +(body format, classifier) port unchanged. + +### Phase 4 — hold / resume re-INVITE + +Generalize the SDP builder: `build_sdp_with(local_ip, rtp_port, direction, +version)` where `direction ∈ {SendRecv, SendOnly, Inactive}` controls the +`a=` attribute and `version` feeds `o=wavekat 0 …` (RFC 3264 +requires the same session-id and an **incremented** version on each +re-offer). `build_sdp` becomes `build_sdp_with(.., SendRecv, 0)`. + +`Call` tracks an SDP `o=` version and current direction; `Call::set_hold( +on: bool)` builds the next re-offer (sendonly on hold, sendrecv on resume), +sends it via the Phase-1 re-INVITE seam, and only flips local audio gating +once the peer 2xxs. A non-2xx final surfaces the server's reason (status) +without changing local state. + +Tests: SDP builder emits the right `a=` line and a monotonic `o=` version; +`set_hold` round-trips direction state; rejection leaves state unchanged +(driven by a loopback peer in `tests/`). + +### Phase 5 — session timers (RFC 4028) + +Restore `session_timer.rs`. Almost all of it is pure `rsip` + tokio and +returns verbatim: `Refresher`, `SessionExpires` (parse/build), `min_se_in`, +`supports_timer`, `SessionTimer` (`refresh_after`/`expiry_after`), +`negotiate_uac` / `negotiate_uas`, `SessionTimerOutcome`, the +`SessionDialogOps` trait, and `session_timer_loop` (with its full +paused-clock test suite). The only change: drop the two +`impl SessionDialogOps for ClientInviteDialog/ServerInviteDialog` and +implement the trait for the engine call handle instead — `refresh` = +Phase-1 re-INVITE, `send_bye` = in-dialog BYE. + +Negotiate in `Caller::dial` (UAC, from the 2xx) and `IncomingCall::accept` +(UAS, from the INVITE, echoing `Session-Expires` + optional `Require: +timer` in the 200). Expose the negotiated `SessionTimer` on `Call` so the +consumer can spawn `session_timer_loop`. The UAC-refresher path is fully +functional on Phases 1–5; the **watchdog** path that resets on a peer +refresh depends on Phase 6 (the peer's refresh re-INVITE must be surfaced). + +### Phase 6 — inbound in-dialog surfacing + CANCEL + provisional states + +The deepest, most invasive phase; may be split into sub-commits. + +- **Surface inbound in-dialog requests.** Instead of `route_inbound` + blindly auto-answering, deliver in-dialog re-INVITE / INFO to the owning + `Call` (keyed by dialog id) as events the consumer can read, so a peer + session refresh can be answered with SDP + the watchdog pinged, and an + inbound INFO DTMF body reaches the consumer. Requests with no matching + dialog keep the safe auto-`200` fallback. +- **CANCEL a pending INVITE.** Track the in-flight INVITE branch and add a + cancel path (RFC 3261 §9: same branch/Call-ID, `CSeq` method `CANCEL`), + so a ringing outbound call can be aborted before answer. +- **Provisional + terminated observation.** Surface 1xx (notably `180 + Ringing`) and a terminated reason on the call's event stream, replacing + the silent `await_final` skip for callers that opt into the stream. + +This phase is where the old `DialogState` UX is reconstructed in spirit +(not by name) on the engine. + +## Testing + +Per `CLAUDE.md`: every phase ships unit tests in the same commit; pure +helpers (SDP direction/version, INFO body, session-timer math/negotiation) +are tested directly, and the dialog-coupled paths get loopback coverage in +`stack::ua` or an `#[ignore]`-free loopback test in `tests/` where no +external server is needed. All four gates (`fmt`, `clippy -D warnings`, +`test`, `doc`) stay green at every commit. + +## Status checklist + +- [x] Phase 1 — in-dialog re-INVITE seam (ACK the 2xx) — landed with Phase 3/4 +- [x] Phase 2 — `User-Agent` header (`SipEndpoint::new_with_app`) +- [x] Phase 3 — DTMF over SIP INFO (`Call::send_dtmf_info`) +- [x] Phase 4 — hold / resume re-INVITE (`Call::set_hold` / `is_held`) +- [x] Phase 5 — session timers (RFC 4028) — `Call::session_timer()` + + `Call::session_handle()` + `session_timer_loop`. The watchdog + (peer-refresh) path is completed by Phase 6 (the peer's refresh re-INVITE + is now surfaced so the consumer can answer it and reset the deadline). +- [x] Phase 6 — inbound in-dialog surfacing + CANCEL + provisional states: + - `Call::inbound_requests()` → `InboundRequests`/`InboundRequest` opt-in + stream of peer re-INVITE / INFO (auto-answer remains the default). + - `Caller::dial_cancellable(target, cancel)` CANCELs a ringing INVITE + (RFC 3261 §9), resolving to `487`. + - `Caller::dial_with_progress(target, cancel, tx)` forwards provisional + statuses (e.g. `180 Ringing`). diff --git a/docs/README.md b/docs/README.md index 5c612be..0b933a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,3 +25,13 @@ Two kinds of documents live here: | [05-dtmf-receive.md](05-dtmf-receive.md) | DTMF receiving: decoding incoming telephone-event packets | | [06-srv-lookup.md](06-srv-lookup.md) | RFC 3263 SRV-based server location | | [07-session-timers.md](07-session-timers.md) | RFC 4028 session timers | +| [08-own-sip-stack.md](08-own-sip-stack.md) | Clean-room SIP transaction/dialog/transport engine as an internal `stack` module | +| [09-stack-transactions.md](09-stack-transactions.md) | `stack` engine Phase 1: the RFC 3261 §17 transaction state machines (sans-IO) | +| [10-stack-transport-engine.md](10-stack-transport-engine.md) | `stack` engine Phase 2: UDP transport + the async runner that drives the §17 machines | +| [11-stack-auth.md](11-stack-auth.md) | `stack` engine Phase 3: digest authentication challenge→retry orchestration | +| [12-stack-dialog.md](12-stack-dialog.md) | `stack` engine Phase 4: RFC 3261 §12 dialogs + route-set reuse (the bug fix) | +| [13-stack-register-flow.md](13-stack-register-flow.md) | `stack` engine Phase 5: REGISTER-with-digest, the first composed end-to-end flow | +| [14-stack-call-flow.md](14-stack-call-flow.md) | `stack` engine Phase 6: outbound INVITE call flow (place / answer / ACK / BYE) | +| [15-stack-ua-router.md](15-stack-ua-router.md) | `stack` engine Phase 7: UA router so one engine serves register + many calls | +| [16-drop-rsipstack.md](16-drop-rsipstack.md) | `stack` engine Phase 8 (final): re-point the public wrappers onto the engine and remove the `rsipstack` dependency | +| [17-reinstate-deferred-features.md](17-reinstate-deferred-features.md) | Re-add the call features deferred by the cutover (re-INVITE seam, User-Agent, DTMF INFO, hold/resume, session timers, inbound surfacing + CANCEL) on the engine | diff --git a/docs/RFC-COVERAGE.md b/docs/RFC-COVERAGE.md index 4f4f7d9..44a17a5 100644 --- a/docs/RFC-COVERAGE.md +++ b/docs/RFC-COVERAGE.md @@ -1,15 +1,18 @@ # RFC coverage -> Status: living document · Last audited: 2026-06-07 (post-v0.0.14 main) +> Status: living document · Last audited: 2026-06-28 (deferred features reinstated — see `docs/17`) What standards this crate's **public API** implements, which parts of each, and what is knowingly absent. The yardstick is the surface a -consumer can actually reach through `wavekat_sip::*` — capabilities -that exist in the underlying [`rsipstack`] but are not exposed here are -listed under [Not exposed](#in-the-underlying-stack-but-not-exposed), -not under "implemented". +consumer can actually reach through `wavekat_sip::*`. -[`rsipstack`]: https://crates.io/crates/rsipstack +Since `docs/16`, the SIP transaction/dialog/transport machinery is a +from-scratch in-house engine (internal `stack` module; see `docs/08`–`docs/16`). +The only SIP crate dependency is [`rsip`], used for message types. There is no +longer an "underlying stack" with extra latent capabilities — what the engine +does is exactly what this document lists. + +[`rsip`]: https://crates.io/crates/rsip ## Implemented @@ -17,21 +20,22 @@ not under "implemented". | Area | Coverage | Public surface | |------|----------|----------------| -| §10 Registrations | REGISTER, digest-auth retry on 401/407, re-registration driven by the server-granted `Expires`, unregister (`Expires: 0`), permanent-vs-transient failure classification | `Registrar` | -| §9 Cancelling | CANCEL for a pending outbound INVITE (idempotent once settled) | `PendingDial::cancel` | -| §13.2 UAC INVITE | Outbound INVITE with SDP offer, early dialog states (`Calling` / `Early` / `Confirmed` / `Terminated`), digest credentials | `Caller::dial`, `PendingDial`, `AcceptedDial` | -| §13.3 UAS INVITE | `100 Trying`, deferred accept/reject, `487` auto-reply to pre-answer CANCEL, non-2xx final rejects (`486`, `603`, …) | `Callee`, `PendingCall`, `AcceptedCall` | -| §15 Terminating | BYE in both directions — local hangup via the dialog handle, remote BYE surfaced as `Terminated` on the state channel | `dialog.bye()`, `DialogStateReceiver` | -| §12.2.2 In-dialog requests | Route inbound in-dialog transactions to their dialog; `481 Call/Transaction Does Not Exist` when nothing matches, `501 Not Implemented` for non-INVITE dialog kinds | `SipEndpoint::dispatch_in_dialog`, `DispatchOutcome` | -| §20.41 User-Agent | Library product token plus optional consumer-prepended app token, most-significant-first per RFC 7231 §5.5.3 | `SipEndpoint::new_with_app` | -| §8.1.1.4 Call-ID | Random-prefix Call-ID generation (suffix overridden from rsipstack's default) | `SipEndpoint::new` | -| §18 Transports | UDP and TCP | `Transport` | +| §10 Registrations | REGISTER, digest-auth retry on 401/407, re-registration on an interval, unregister (`Expires: 0`), outcome classification (registered / unauthorized / failed / timed out) | `Registrar` | +| §13.2 UAC INVITE | Outbound INVITE with SDP offer, follows provisional responses to a final, answers one digest challenge, parses the SDP answer from the 2xx, sends the 2xx ACK; provisional statuses can be observed (`dial_with_progress`) | `Caller::dial`, `Call` | +| §9 CANCEL | Cancel a still-ringing outbound INVITE (CANCEL after the first provisional; resolves `487 Request Terminated`) | `Caller::dial_cancellable` | +| §13.3 UAS INVITE | Automatic `100 Trying`, deferred accept (`200 OK` + SDP answer) or reject (non-2xx final, e.g. `486`/`603`) | `SipEndpoint::next_incoming_call`, `IncomingCall` | +| §14 Re-INVITE | Outbound in-dialog re-INVITE with SDP re-offer and the mandatory 2xx ACK, used for hold/resume and session refresh | `Call::set_hold`, `Call::session_handle` | +| §15 Terminating | BYE — local hangup via the call handle; an inbound in-dialog BYE is auto-answered `200 OK` by the endpoint router | `Call::hangup`, `SipEndpoint` | +| §12 Dialogs | Dialog establishment with route-set capture/reuse (UAC reverses Record-Route, UAS keeps order), in-dialog request composition addressed to the route set | internal (`Call`) | +| §12.2.2 In-dialog requests | Outbound in-dialog re-INVITE / INFO carrying a body. Inbound re-INVITE / INFO are auto-answered `200 OK` by default, or surfaced to the owning `Call` when it opts in (to answer a session refresh / peer hold, or read INFO DTMF); BYE / OPTIONS stay auto-answered | `Call`, `Call::inbound_requests`, `SipEndpoint` router | +| §8.1.1.4 Call-ID | Random Call-ID generation | `Caller`, `Registrar` | +| §17 Transactions | Full RFC 3261 §17 client/server INVITE & non-INVITE state machines with T1/T2/T4 timers and UDP retransmission | internal (`stack::transaction`) | +| §20.41 User-Agent | Optional product token emitted on every outbound request | `SipEndpoint::new_with_app` | | §22 Authentication | Digest challenge/response as a client, on both REGISTER and INVITE | `Registrar`, `Caller` (credentials threaded internally) | -Not covered from RFC 3261: acting as a proxy/registrar/redirect -server, SIPS/TLS (§26.2), multicast (§18.1.1), `OPTIONS` self-handling -(inbound OPTIONS outside a dialog is left to the consumer via the -incoming-transaction stream). +Not covered from RFC 3261: acting as a proxy/registrar/redirect server, +SIPS/TLS (§26.2), and multicast (§18.1.1). Provisional responses are +observed but not acknowledged reliably (PRACK / 100rel is RFC 3262, below). ### RFC 3264 — SDP offer/answer model (minimal subset) @@ -42,9 +46,14 @@ Single `m=audio` line, one round of offer/answer: - Offer parsed from the inbound INVITE, answer generated in the 200 OK — UAS direction. -No re-INVITE renegotiation of media, no hold (`a=sendonly` / -`a=inactive` transitions), no multiple media descriptions, no -rejected-stream (`port 0`) handling. +Hold/resume **is** supported: `Call::set_hold` sends a re-INVITE with an +`a=sendonly`/`a=sendrecv` re-offer and a bumped `o=` version (§5), +`build_sdp_with` exposing the direction + version. Still absent: multiple +media descriptions, `a=inactive` two-way hold as a first-class call state +(the `MediaDirection::Inactive` variant exists but `set_hold` only toggles +sendonly/sendrecv), and rejected-stream (`port 0`) handling. Inbound +re-INVITE re-offers are auto-answered `200 OK` without a renegotiated SDP +answer body — see the gap noted in `docs/17`. ### RFC 8866 (ex-4566) — SDP (minimal subset) @@ -108,19 +117,6 @@ Not covered: tones (§3), trunk/line events beyond DTMF, RFC 2198 redundancy encoding. (RFC 2833 is the obsoleted predecessor — we implement the 4733 revision.) -### SIP INFO method (RFC 6086 / ex-2976) — DTMF relay only - -Sending in-dialog `INFO` carrying `application/dtmf-relay` -(`Signal=` / `Duration=`, the Cisco de-facto body — not itself an RFC -format) on both client and server dialogs, with 2xx / 415 / other -outcome classification — `send_dtmf_info_client`, -`send_dtmf_info_server`, `InfoOutcome`. - -Not covered: the RFC 6086 Info Package negotiation framework -(`Recv-Info`), and surfacing *inbound* INFO bodies to the consumer — -an incoming INFO is absorbed by the dialog state machine via -`dispatch_in_dialog` but its payload is not exposed. - ### RFC 3263 — Locating SIP servers (SRV subset) - SRV lookup (`_sip._udp.` / `_sip._tcp.` per the account transport) @@ -138,35 +134,32 @@ configured transport instead), `_sips._tcp` / TLS targets, and failover across multiple SRV targets on connection failure — only the first candidate is used today. -### RFC 4028 — Session timers (partial) - -- `Session-Expires` / `Min-SE` / `Supported: timer` parsing and - building (manual, header-shape tolerant) — `SessionExpires`, - `Refresher`, `session_expires_in`, `min_se_in`, `supports_timer`. -- Outbound INVITEs advertise `Supported: timer` + - `Session-Expires: 1800`; the negotiated result is surfaced as - `AcceptedDial::session_timer` (UAC, from the 2xx) and - `PendingCall::session_timer` / `AcceptedCall::session_timer` (UAS, - echoed into the 200 OK with `Require: timer` when the caller asked). -- 90 s `Min-SE` floor (§4) — `MIN_SESSION_EXPIRES_SECS`. -- `session_timer_loop` drives one confirmed dialog in either role: - refresher sends re-INVITE refreshes at interval/2 (repeating the - original SDP — a no-op offer per RFC 3264); non-refresher runs the - expiry watchdog and tears the call down with BYE if no refresh lands - in time — `SessionTimerOutcome`. - -Not covered: UPDATE-based refreshes (re-INVITE only), `422 Session -Interval Too Small` retry, initiating timers as the UAS when the -caller didn't offer them, and answering the *peer's* refresh -re-INVITEs in-crate — the consumer answers those from its dialog-state -pump and pings the loop's `peer_refreshed` notifier. - -### RFC 3581 — Symmetric response routing (`rport`) - -Client side only, inherited from the underlying stack: every request -we send carries `;rport` in its Via, so responses come back to the -source port. We do not implement the server-side behavior (we are not -a proxy). +### RFC 4028 — Session timers + +- Header logic: `Session-Expires` (with `;refresher=uac|uas`) and `Min-SE` + parse/build, `Supported`/`Require: timer` — `SessionExpires`, + `min_se_in`, `supports_timer`. +- Negotiation: `negotiate_uac` (from the 2xx) and `negotiate_uas` (from the + INVITE, echoing the agreed interval + `Require: timer` in the 200). The + outbound INVITE advertises `Supported: timer` + a 30-minute + `Session-Expires` by default. +- Runtime: `session_timer_loop` drives the §10 schedule — the refresher + sends a refresh re-INVITE every `interval/2`; the non-refresher runs a + BYE watchdog at `interval − min(32 s, interval/3)`. Surfaced via + `Call::session_timer()` + `Call::session_handle()`. + +Both roles work: the watchdog resets on the peer's refresh re-INVITE, +which the consumer receives via `Call::inbound_requests` (answer it with a +fresh SDP + echoed `Session-Expires`, then ping the loop's `Notify`). + +### RFC 6086 (ex-2976) — SIP INFO (DTMF relay) + +Outbound `INFO` with the Cisco `application/dtmf-relay` body +(`Signal=…\nDuration=…`) as a DTMF fallback when the remote did not +negotiate RFC 4733 `telephone-event` — `Call::send_dtmf_info`, +`build_info_body`, `InfoOutcome` (incl. the `415 Unsupported Media Type` +stop signal). Inbound `INFO` is still auto-answered `200 OK` and not yet +surfaced (see `docs/17`). ## Not implemented @@ -176,46 +169,39 @@ typically a PBX/SBC on the same network or a trunk that latches): | RFC | What it is | Status | |-----|------------|--------| +| 3581 | Symmetric response routing (`rport`) | Not currently added to outgoing Via; responses route to the Via sent-by address. | | 3311 | UPDATE method | Not exposed | | 3326 | Reason header | Not emitted or parsed | | 3515 / 3891 / 3892 | REFER, Replaces, Referred-By (call transfer) | Not implemented | | 3428 | MESSAGE (pager-mode IM) | Not implemented | -| 6665 (ex-3265) | SUBSCRIBE/NOTIFY event framework | Matching dialogs answered `501 Not Implemented` | +| 6665 (ex-3265) | SUBSCRIBE/NOTIFY event framework | Not implemented | | 3856 / 3863 | Presence, PIDF | Not implemented | | 5626 / 5627 | Outbound connection reuse, GRUU | Not implemented | | 3711 / 5763 / 5764 | SRTP, DTLS-SRTP | No media encryption | | 8489 / 8445 / 8656 | STUN, ICE, TURN | No NAT traversal; local address discovery is a UDP-connect trick only | | 3605 / 5761 | RTCP attribute in SDP, RTP/RTCP mux | No RTCP at all | | 7587 et al. | Wideband codecs (Opus, …) | G.711 only by design (see scope in `CLAUDE.md`) | +| 3262 | PRACK / 100rel | Provisional responses are not acknowledged reliably | | 7118 | SIP over WebSocket | Not exposed (`Transport` is UDP/TCP only) | -## In the underlying stack but not exposed - -`rsipstack` ships these, but `wavekat-sip` does not surface them — a -consumer holding only this crate's API cannot reach them: - -- **TLS and WebSocket transports** — `Transport` deliberately offers - `Udp | Tcp` only. -- **RFC 3262 PRACK / 100rel** — provisional responses are surfaced as - dialog states but never acknowledged reliably. -- **Proxy / registrar server roles** — this crate is a UA toolkit. +### Transports -If a consumer needs one of these, the path is to widen this crate's -API (new `Transport` variant, etc.), not to reach into `rsipstack` -directly — the `re_exports` module pins the only upstream types we -consider public. +The engine currently implements a **UDP** transport only. The `Transport` +enum still carries a `Tcp` variant (and the transaction timers collapse their +retransmission soak for a reliable transport), but a TCP transport +implementation is not yet wired into the engine. TLS and WebSocket are out of +scope. ## Known gaps worth closing first Ranked by how soon a real deployment trips over them: -1. **RTCP receiver reports** — without them, neither side gets loss or - jitter feedback; fine on a LAN, blind over the open internet. -2. **TLS transport (SIPS)** — credentials currently ride plaintext - except for the digest exchange itself. -3. **422 retry + UPDATE refreshes (RFC 4028)** — a server that rejects - our 1800 s `Session-Expires` with `422` currently just gets no - timer; UPDATE would refresh without touching media. -4. **SRV failover (RFC 3263)** — we order the candidates correctly but - only ever try the first; a dead primary should fall through to the - next target. +1. **`rport` (RFC 3581)** — needed for responses to come back through NAT/PAT; + add `;rport` to outgoing Via and honor it on responses. +2. **TCP transport** — the `Transport::Tcp` variant is currently inert. +3. **RTCP receiver reports** — without them, neither side gets loss or jitter + feedback; fine on a LAN, blind over the open internet. +4. **TLS transport (SIPS)** — credentials currently ride plaintext except for + the digest exchange itself. +5. **SRV failover (RFC 3263)** — we order the candidates correctly but only ever + try the first; a dead primary should fall through to the next target.