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..23ad4a0 100644 --- a/crates/wavekat-sip/src/callee.rs +++ b/crates/wavekat-sip/src/callee.rs @@ -1,359 +1,128 @@ -//! 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::{Request, StatusCode, Uri}; use tokio::net::UdpSocket; -use tracing::{debug, info, warn}; +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::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, + /// 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, + ) -> Self { + Self { + endpoint, + key, + peer, + request, + 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, -} - -impl Callee { - pub fn new(account: SipAccount, endpoint: Arc) -> Self { - Self { account, endpoint } - } + /// 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 { + 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"); - /// 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", - ); + let answer = build_sdp(local_ip, local_rtp_addr.port()); + debug!("SDP answer:\n{}", String::from_utf8_lossy(&answer)); - let (state_sender, state_rx) = self.endpoint.dialog_layer.new_dialog_state_channel(); - let contact_uri: rsip::Uri = format!( + 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}"); - } - }); + let 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")?; - 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, + 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> { 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..21c8c80 100644 --- a/crates/wavekat-sip/src/caller.rs +++ b/crates/wavekat-sip/src/caller.rs @@ -1,158 +1,72 @@ -//! 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::Uri; use tokio::net::UdpSocket; -use tokio::task::JoinHandle; use tracing::{debug, info}; use crate::account::SipAccount; use crate::endpoint::SipEndpoint; use crate::sdp::{build_sdp, parse_sdp, RemoteMedia}; -use crate::session_timer::{ - negotiate_uac, supported_timer_header, SessionExpires, SessionTimer, - DEFAULT_SESSION_EXPIRES_SECS, -}; +use crate::stack::call::{CallConfig, CallOutcome}; +use crate::stack::dialog::Dialog; +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, + dialog: Dialog, + peer: SocketAddr, + /// 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 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"); - Ok(()) - } +impl Call { + pub(crate) fn new( + endpoint: Arc, + dialog: Dialog, + peer: SocketAddr, + remote_media: RemoteMedia, + rtp_socket: Arc, + local_rtp_addr: SocketAddr, + ) -> Self { + Self { + endpoint, + dialog, + peer, + remote_media, + rtp_socket, + local_rtp_addr, } } - /// Wait for the INVITE transaction to complete and assemble the - /// [`AcceptedDial`] from the negotiated SDP answer plus the - /// already-bound local RTP socket. - /// - /// 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()); + /// 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> { + if self.endpoint.ua().hangup(self.peer, &mut self.dialog).await { + info!("call hung up (BYE acknowledged)"); + 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 +82,70 @@ 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 - } - - /// 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( - &self, - target: rsip::Uri, - destination: Option, - ) -> Result { + /// 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 { 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(), + 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()?; + + let cfg = CallConfig { target, - offer, - destination, - )?; - let (state_sender, state_rx) = self.endpoint.dialog_layer.new_dialog_state_channel(); - let (dialog, invite_task) = self + from, + contact, + from_tag: gen_tag(), + call_id: format!("{}@wavekat.com", gen_tag()), + sdp: offer, + username: self.account.auth_username().to_string(), + password: self.account.password.clone(), + }; + + match 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, - }) - } -} - -/// 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)) -} - -/// 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, + .ua() + .call(&cfg, self.endpoint.server(), 1) + .await + { + CallOutcome::Answered { dialog, response } => { + let remote_media = parse_sdp(&response.body)?; + info!( + remote_addr = %remote_media.addr, + remote_port = remote_media.port, + payload_type = remote_media.payload_type, + "call answered; parsed SDP answer", + ); + Ok(Call::new( + self.endpoint.clone(), + *dialog, + self.endpoint.server(), + 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 +167,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 deleted file mode 100644 index b625ca6..0000000 --- a/crates/wavekat-sip/src/dtmf_info.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! SIP INFO fallback transport for DTMF (`application/dtmf-relay`). -//! -//! When the SDP answer omits `telephone-event/8000` -//! ([`crate::RemoteMedia::dtmf_payload_type`] is `None`), the remote -//! 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`]. -//! -//! ## Wire format -//! -//! ```text -//! INFO sip:carrier@example.com SIP/2.0 -//! ... -//! Content-Type: application/dtmf-relay -//! Content-Length: 22 -//! -//! Signal=5 -//! Duration=160 -//! ``` -//! -//! `Signal` is the digit character (`0`-`9`, `*`, `#`, `A`-`D`). -//! `Duration` is in milliseconds (typical: 100-200). Lines are -//! separated by a single `\n` per the de-facto format — no CR. -//! -//! ## 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 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()`. -pub fn build_info_body(digit: DtmfDigit, duration_ms: u32) -> String { - format!("Signal={}\nDuration={duration_ms}", digit.as_char()) -} - -/// Build the `Content-Type: application/dtmf-relay` header to attach to -/// the `INFO` request. -pub fn content_type_header() -> Header { - Header::ContentType(CONTENT_TYPE.into()) -} - -/// Possible outcomes of one INFO-DTMF attempt. -#[derive(Debug)] -pub enum InfoOutcome { - /// The remote returned a 2xx response. Press accepted. - Accepted(StatusCode), - /// The remote returned 415 (Unsupported Media Type) — it does not - /// accept `application/dtmf-relay`. Consumers should stop sending - /// further INFO presses on this dialog and surface a user-facing - /// error. - UnsupportedMedia, - /// 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. - DialogNotConfirmed, -} - -impl InfoOutcome { - /// `true` if the press was accepted by the remote. - pub fn is_accepted(&self) -> bool { - matches!(self, Self::Accepted(_)) - } - - /// `true` if the remote signalled this transport is unusable and - /// further presses on this dialog should not be attempted. - pub fn should_stop(&self) -> bool { - matches!(self, Self::UnsupportedMedia) - } -} - -fn classify(response: Option) -> InfoOutcome { - let Some(r) = response else { - return InfoOutcome::DialogNotConfirmed; - }; - let code = r.status_code.code(); - if (200..300).contains(&code) { - InfoOutcome::Accepted(r.status_code) - } else if code == 415 { - warn!( - "remote rejected application/dtmf-relay (415); \ - stop sending INFO DTMF on this dialog" - ); - InfoOutcome::UnsupportedMedia - } else { - warn!(status = %r.status_code, "INFO DTMF failed"); - InfoOutcome::OtherFailure(r.status_code) - } -} - -/// 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::*; - - #[test] - fn body_format_matches_application_dtmf_relay() { - // Cisco's original spec — `Signal=\nDuration=`. No CR, - // no trailing newline. Verbatim wire bytes get checked here - // because any deviation can quietly break a PBX's parser. - let body = build_info_body(DtmfDigit::D5, 160); - assert_eq!(body, "Signal=5\nDuration=160"); - } - - #[test] - fn body_uses_digit_canonical_char_for_star_pound() { - assert_eq!( - build_info_body(DtmfDigit::Star, 100), - "Signal=*\nDuration=100" - ); - assert_eq!( - build_info_body(DtmfDigit::Pound, 100), - "Signal=#\nDuration=100" - ); - } - - #[test] - fn body_uppercases_letter_digits() { - // RFC 4733 digit codes 12-15 correspond to the AUTOVON A/B/C/D - // keys; `DtmfDigit::as_char` returns uppercase. The body must - // carry that uppercase form so any remote that whitelists the - // letters case-sensitively still accepts the press. - assert_eq!(build_info_body(DtmfDigit::A, 160), "Signal=A\nDuration=160"); - assert_eq!(build_info_body(DtmfDigit::D, 160), "Signal=D\nDuration=160"); - } - - #[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), - "header should contain {CONTENT_TYPE}, got {s:?}" - ); - } - - #[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), - other => panic!("expected Accepted, got {other:?}"), - } - } - - #[test] - fn classify_415_response_is_unsupported_media() { - let resp = make_response(415); - assert!(matches!( - classify(Some(resp)), - InfoOutcome::UnsupportedMedia - )); - } - - #[test] - fn classify_500_response_is_other_failure() { - let resp = make_response(500); - match classify(Some(resp)) { - InfoOutcome::OtherFailure(code) => assert_eq!(code.code(), 500), - other => panic!("expected OtherFailure, got {other:?}"), - } - } - - #[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. - assert!(matches!(classify(None), InfoOutcome::DialogNotConfirmed)); - } - - #[test] - fn outcome_helpers() { - let accepted = InfoOutcome::Accepted(StatusCode::from(200u16)); - assert!(accepted.is_accepted()); - assert!(!accepted.should_stop()); - - let unsupported = InfoOutcome::UnsupportedMedia; - assert!(!unsupported.is_accepted()); - assert!(unsupported.should_stop()); - - let other = InfoOutcome::OtherFailure(StatusCode::from(500u16)); - assert!(!other.is_accepted()); - assert!(!other.should_stop()); - } - - /// Minimal rsip::Response builder for the classifier tests. The - /// classifier only inspects `status_code`, so the rest stays empty. - fn make_response(code: u16) -> rsip::Response { - rsip::Response { - status_code: StatusCode::from(code), - version: rsip::Version::V2, - headers: rsip::Headers::default(), - body: Vec::new(), - } - } -} diff --git a/crates/wavekat-sip/src/endpoint.rs b/crates/wavekat-sip/src/endpoint.rs index c7880c5..f9ca243 100644 --- a/crates/wavekat-sip/src/endpoint.rs +++ b/crates/wavekat-sip/src/endpoint.rs @@ -1,322 +1,179 @@ -//! 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::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 rsip::headers::ToTypedHeader; +use rsip::message::HeadersExt; +use rsip::{Method, 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::resolve::resolve_sip_server; +use crate::sdp::parse_sdp; +use crate::stack::response::build_response; +use crate::stack::transaction::gen_tag; +use crate::stack::ua::{Incoming, Ua}; -/// 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"; +type BoxError = Box; -/// 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>, } 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> { - 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 - /// ``` - pub async fn new_with_app( - account: &SipAccount, - app_product: Option<&str>, - _cancel: CancellationToken, - ) -> Result<(Self, TransactionReceiver), Box> { + ) -> 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()); - - 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. - } - } - - 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(), - ); - - 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 ua = Arc::new(Ua::bind(bind_addr, 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 endpoint = Arc::new(Self { + ua: ua.clone(), + account: account.clone(), + server, + local_ip, + transport: account.transport, + cancel, + incoming_calls: Mutex::new(calls_rx), + }); - 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}"); - } + // Inbound router: new INVITE → calls stream; in-dialog → auto-answer. + tokio::spawn(async move { + while let Some(inc) = ua.next_incoming().await { + route_inbound(&ua, inc, &calls_tx).await; } }); - 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, - )) + Ok(endpoint) } - /// Local IP address this endpoint is bound to. + /// 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(); - } - - /// 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) + self.cancel.cancel(); + } + + /// 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) => { + return Some(IncomingCall::new( + self.clone(), + incoming.key, + incoming.peer, + incoming.request, + remote_media, + )); + } + 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 + } -/// 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}") -} + pub(crate) fn server(&self) -> SocketAddr { + self.server + } -/// 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}"); - } - } + pub(crate) fn account(&self) -> &SipAccount { + &self.account } - #[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(); - } - } +} + +/// Route one inbound request: new call, in-dialog auto-answer, or drop. +async fn route_inbound(ua: &Ua, 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; } - } - #[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 ACK for a 2xx we sent: it confirms our dialog; nothing to reply. + Method::Ack => debug!("absorbing 2xx ACK"), + // Any other in-dialog request (BYE / OPTIONS / INFO / re-INVITE): + // acknowledge it so the peer's transaction completes. + _ => { + if let Some(response) = + build_response(&inc.request, StatusCode::OK, Some(&gen_tag()), None, None) + { + let _ = ua.answer(inc.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 +193,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/lib.rs b/crates/wavekat-sip/src/lib.rs index 3f3a0bb..1c55f19 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?; +//! +//! // Wire RTP to your audio / AI pipeline using call.rtp_socket and +//! // call.remote_media. Hang up locally with: +//! call.hangup().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Answering inbound calls //! -//! // 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?; +//! ```no_run +//! use std::sync::Arc; +//! use wavekat_sip::SipEndpoint; //! -//! // Wire RTP to your audio / AI pipeline using accepted.rtp_socket -//! // and accepted.remote_media. Hang up locally with: -//! accepted.dialog.bye().await?; +//! # 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 //! @@ -154,22 +151,19 @@ pub mod account; pub mod callee; pub mod caller; -pub mod dtmf_info; pub mod endpoint; 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, Caller}; +pub use endpoint::SipEndpoint; pub use registrar::{Registrar, RegistrarDiagnostics}; pub use resolve::{order_candidates, resolve_sip_server, SrvRecord}; pub use rtp::dtmf::{ @@ -179,24 +173,11 @@ 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 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, - SessionExpires, SessionTimer, SessionTimerOutcome, UasSessionTimer, - 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/session_timer.rs b/crates/wavekat-sip/src/session_timer.rs deleted file mode 100644 index 3b595b8..0000000 --- a/crates/wavekat-sip/src/session_timer.rs +++ /dev/null @@ -1,1094 +0,0 @@ -//! RFC 4028 session timers — keep long calls from outliving a dead dialog. -//! -//! Without session timers, a call whose dialog silently died (peer -//! crashed, NAT binding dropped, proxy lost state) lives forever: -//! nothing on the signaling path re-validates the dialog, so an -//! unattended consumer (voice bot, AI agent) keeps streaming RTP into -//! the void. RFC 4028 bounds that window: one side periodically -//! refreshes the session with a re-INVITE, and the other side tears the -//! call down with BYE if no refresh arrives before the negotiated -//! session interval lapses. -//! -//! This module has three layers: -//! -//! 1. **Pure header logic** — [`SessionExpires`] parse/build for the -//! `Session-Expires` header (with its `;refresher=uac|uas` param), -//! [`min_se_in`] for `Min-SE`, and [`supports_timer`] for the -//! `Supported: timer` option tag. rsip 0.4 has no typed variants for -//! these, so they are parsed manually from -//! [`rsip::Header::Other`] — same style as the crate's SDP and RTP -//! parsing. -//! 2. **Negotiation** — [`negotiate_uac`] (from a 2xx response, caller -//! side) and [`negotiate_uas`] (from an INVITE, callee side) decide -//! the [`SessionTimer`]: the negotiated interval and whether *we* -//! are the refresher. [`SessionTimer::refresh_after`] / -//! [`SessionTimer::expiry_after`] give the RFC 4028 §10 schedule. -//! 3. **Runtime** — [`session_timer_loop`], shaped like -//! [`crate::Registrar::keepalive_loop`]: a `select!` over sleeps and -//! a [`CancellationToken`] that either sends the periodic refresh -//! re-INVITE (when we are the refresher) or watches for the peer's -//! refreshes and sends BYE when the session lapses. -//! -//! # 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: -//! -//! ```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()); -//! 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"); -//! } -//! }); -//! } -//! ``` -//! -//! 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(); -//! } -//! ``` - -use std::future::Future; -use std::sync::Arc; -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; -use tracing::{debug, info, warn}; - -type BoxError = Box; - -/// Smallest session interval we will run a timer at, in seconds. -/// RFC 4028 §4 fixes 90 s as the absolute minimum (and the default -/// `Min-SE`); anything shorter would churn re-INVITEs. -pub const MIN_SESSION_EXPIRES_SECS: u32 = 90; - -/// Session interval requested on outbound INVITEs, in seconds — the -/// RFC 4028 recommended default of 30 minutes. -pub const DEFAULT_SESSION_EXPIRES_SECS: u32 = 1800; - -/// Cap on how far *before* the session expiry the non-refresher fires -/// its BYE watchdog (RFC 4028 §10: `min(32 s, interval / 3)`). -const MAX_EXPIRY_HEADROOM_SECS: u32 = 32; - -/// Which side of the original INVITE transaction performs refreshes — -/// the value space of the `Session-Expires` `refresher` parameter. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Refresher { - /// The party that sent the request refreshes. - Uac, - /// The party that answered the request refreshes. - Uas, -} - -impl Refresher { - /// Canonical lowercase parameter value (`"uac"` / `"uas"`). - pub fn as_str(&self) -> &'static str { - match self { - Refresher::Uac => "uac", - Refresher::Uas => "uas", - } - } - - fn parse(s: &str) -> Result { - if s.eq_ignore_ascii_case("uac") { - Ok(Refresher::Uac) - } else if s.eq_ignore_ascii_case("uas") { - Ok(Refresher::Uas) - } else { - Err(format!("invalid refresher value {s:?} (want uac|uas)")) - } - } -} - -/// Parsed `Session-Expires` header value: interval in seconds plus the -/// optional `refresher` parameter. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SessionExpires { - /// Session interval in seconds. - pub interval_secs: u32, - /// Who refreshes, if pinned. `None` means "answerer's choice". - pub refresher: Option, -} - -impl SessionExpires { - /// Parse a `Session-Expires` header *value* (not the full header - /// line), e.g. `"1800"` or `"1800;refresher=uas"`. Parameter names - /// and values are case-insensitive; unknown parameters are ignored - /// per RFC 4028 §4. - pub fn parse(value: &str) -> Result { - let mut parts = value.split(';'); - let interval = parts.next().unwrap_or("").trim(); - let interval_secs: u32 = interval - .parse() - .map_err(|e| format!("invalid Session-Expires interval {interval:?}: {e}"))?; - let mut refresher = None; - for param in parts { - if let Some((name, val)) = param.split_once('=') { - if name.trim().eq_ignore_ascii_case("refresher") { - refresher = Some(Refresher::parse(val.trim())?); - } - } - } - Ok(Self { - interval_secs, - refresher, - }) - } - - /// Serialize back to a header value, e.g. `"1800;refresher=uac"`. - pub fn header_value(&self) -> String { - match self.refresher { - Some(r) => format!("{};refresher={}", self.interval_secs, r.as_str()), - None => self.interval_secs.to_string(), - } - } - - /// Build the untyped `Session-Expires` header (rsip 0.4 has no - /// typed variant). - pub fn header(&self) -> Header { - Header::Other("Session-Expires".into(), self.header_value()) - } -} - -/// `Supported: timer` — advertise RFC 4028 support on a request or -/// response. -pub fn supported_timer_header() -> Header { - Header::Supported("timer".into()) -} - -/// `Require: timer` — placed in a 2xx by the answerer when the offerer -/// advertised timer support (RFC 4028 §9). -pub fn require_timer_header() -> Header { - Header::Require("timer".into()) -} - -/// Find the value of the first untyped header matching any of `names` -/// (case-insensitive). -fn other_header_value<'a>(headers: &'a rsip::Headers, names: &[&str]) -> Option<&'a str> { - headers.iter().find_map(|h| match h { - Header::Other(name, value) if names.iter().any(|n| name.eq_ignore_ascii_case(n)) => { - Some(value.as_str()) - } - _ => None, - }) -} - -/// Extract and parse the `Session-Expires` header (long or compact `x` -/// form) from a header list. Malformed values are logged and treated as -/// absent — a broken peer header must not kill the call. -pub fn session_expires_in(headers: &rsip::Headers) -> Option { - let raw = other_header_value(headers, &["Session-Expires", "x"])?; - match SessionExpires::parse(raw) { - Ok(se) => Some(se), - Err(e) => { - warn!("ignoring malformed Session-Expires header: {e}"); - None - } - } -} - -/// Extract the `Min-SE` interval (seconds) from a header list, ignoring -/// any generic parameters. Malformed values are treated as absent. -pub fn min_se_in(headers: &rsip::Headers) -> Option { - let raw = other_header_value(headers, &["Min-SE"])?; - let interval = raw.split(';').next().unwrap_or("").trim(); - match interval.parse() { - Ok(v) => Some(v), - Err(e) => { - warn!("ignoring malformed Min-SE header {raw:?}: {e}"); - None - } - } -} - -fn has_timer_token(value: &str) -> bool { - value - .split(',') - .any(|tag| tag.trim().eq_ignore_ascii_case("timer")) -} - -/// `true` if any `Supported` header (typed, untyped, or compact `k` -/// form) carries the `timer` option tag. -pub fn supports_timer(headers: &rsip::Headers) -> bool { - headers.iter().any(|h| match h { - Header::Supported(s) => has_timer_token(s.value()), - Header::Other(name, value) - if name.eq_ignore_ascii_case("Supported") || name.eq_ignore_ascii_case("k") => - { - has_timer_token(value) - } - _ => false, - }) -} - -/// Negotiated session-timer state for one dialog, from our side's -/// perspective. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SessionTimer { - /// Negotiated session interval in seconds. - pub interval_secs: u32, - /// `true` if we send the periodic refresh re-INVITEs; `false` if we - /// only watch for the peer's refreshes and BYE on expiry. - pub we_are_refresher: bool, -} - -impl SessionTimer { - /// When the refresher sends its refresh: half the session interval - /// after the last refresh (RFC 4028 §10). - pub fn refresh_after(&self) -> Duration { - Duration::from_secs(u64::from(self.interval_secs / 2)) - } - - /// When the non-refresher gives up and sends BYE: the session - /// interval minus `min(32 s, interval / 3)` after the last refresh - /// (RFC 4028 §10). - pub fn expiry_after(&self) -> Duration { - let headroom = (self.interval_secs / 3).min(MAX_EXPIRY_HEADROOM_SECS); - Duration::from_secs(u64::from(self.interval_secs.saturating_sub(headroom))) - } -} - -/// UAC-side negotiation: decide the [`SessionTimer`] from the 2xx -/// response to our INVITE. -/// -/// Returns `None` when the response carries no `Session-Expires` — the -/// answerer declined (or doesn't support) session timers, so no timer -/// runs. When the (mandatory per RFC 4028 §9) `refresher` parameter is -/// missing we defensively take the refresher role ourselves: refreshing -/// when the peer also refreshes is redundant but harmless, while *not* -/// refreshing when the peer expects us to would drop the call. -/// -/// The interval is floored at [`MIN_SESSION_EXPIRES_SECS`] so a bogus -/// tiny grant can't spin the refresh loop. -pub fn negotiate_uac(response_headers: &rsip::Headers) -> Option { - let se = session_expires_in(response_headers)?; - Some(SessionTimer { - interval_secs: se.interval_secs.max(MIN_SESSION_EXPIRES_SECS), - we_are_refresher: !matches!(se.refresher, Some(Refresher::Uas)), - }) -} - -/// UAS-side negotiation result: the timer to run plus what to put in -/// our 2xx so the peer agrees on it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct UasSessionTimer { - /// Timer state from our (UAS) perspective. - pub timer: SessionTimer, - /// `Session-Expires` to echo in the 2xx (interval + pinned - /// refresher). - pub echo: SessionExpires, - /// `true` if the peer advertised `Supported: timer`, in which case - /// the 2xx must also carry `Require: timer` (RFC 4028 §9). - pub require_timer: bool, -} - -/// UAS-side negotiation: decide the session timer from an inbound -/// INVITE's headers. -/// -/// Returns `None` when the INVITE carries no `Session-Expires` — we do -/// not insert timers into sessions the caller didn't ask for (allowed -/// by RFC 4028, deliberately deferred; see -/// `docs/07-session-timers.md`). -/// -/// The interval is floored at `max(90, Min-SE)`. The refresher is the -/// request's `refresher` parameter when the peer advertised timer -/// support, defaulting to `uac` (peer refreshes). When the peer did -/// *not* advertise `Supported: timer` — e.g. a proxy inserted the -/// `Session-Expires` — the peer cannot refresh, so we take the -/// refresher role and skip `Require: timer`. -pub fn negotiate_uas(invite_headers: &rsip::Headers) -> Option { - let se = session_expires_in(invite_headers)?; - let floor = min_se_in(invite_headers) - .unwrap_or(0) - .max(MIN_SESSION_EXPIRES_SECS); - let interval_secs = se.interval_secs.max(floor); - let peer_supports = supports_timer(invite_headers); - let refresher = if peer_supports { - se.refresher.unwrap_or(Refresher::Uac) - } else { - Refresher::Uas - }; - Some(UasSessionTimer { - timer: SessionTimer { - interval_secs, - we_are_refresher: refresher == Refresher::Uas, - }, - echo: SessionExpires { - interval_secs, - refresher: Some(refresher), - }, - require_timer: peer_supports, - }) -} - -/// 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. -pub trait SessionDialogOps { - /// Send a session-refresh re-INVITE with the given extra headers - /// and (typically SDP) body. Returns the final response, or - /// `Ok(None)` if the dialog is no longer confirmed. - fn refresh( - &self, - headers: Vec
, - body: Option>, - ) -> impl Future, BoxError>> + Send; - - /// Hang up the dialog with BYE. - 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 { - /// The [`CancellationToken`] fired — the call ended through the - /// normal path (local/remote BYE) and the loop just stood down. - Cancelled, - /// We were the watchdog and no refresh arrived before the session - /// interval lapsed. A BYE was sent (best effort). - Expired, - /// We were the refresher and a refresh re-INVITE failed (non-2xx or - /// transport error). A BYE was sent (best effort). - RefreshFailed, - /// The dialog was no longer confirmed when we tried to refresh — - /// it already terminated through another path. No BYE needed. - DialogGone, -} - -/// Drive RFC 4028 session keepalive for one confirmed dialog. Runs -/// until cancelled or the session dies; same shape as -/// [`crate::Registrar::keepalive_loop`]. -/// -/// - When `timer.we_are_refresher`, sends a refresh re-INVITE every -/// `interval / 2` carrying `refresh_body` (repeat the SDP you sent -/// when the call was set up — an identical offer is a no-op per -/// RFC 3264). A 2xx resets the clock (adopting any `Session-Expires` -/// the peer granted); failure tears the call down with BYE. -/// - 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. -/// -/// All failures are folded into the returned [`SessionTimerOutcome`]; -/// the loop never panics and never returns early without standing the -/// session down. -pub async fn session_timer_loop( - dialog: &D, - timer: SessionTimer, - refresh_body: Option>, - peer_refreshed: Arc, - cancel: CancellationToken, -) -> SessionTimerOutcome { - let mut interval_secs = timer.interval_secs.max(MIN_SESSION_EXPIRES_SECS); - if timer.we_are_refresher { - loop { - let current = SessionTimer { - interval_secs, - we_are_refresher: true, - }; - select! { - _ = tokio::time::sleep(current.refresh_after()) => {} - _ = cancel.cancelled() => return SessionTimerOutcome::Cancelled, - } - - // In this refresh re-INVITE we are the UAC of the new - // transaction, so the refresher param says `uac`. - let headers = vec![ - supported_timer_header(), - SessionExpires { - interval_secs, - refresher: Some(Refresher::Uac), - } - .header(), - ]; - match dialog.refresh(headers, refresh_body.clone()).await { - Ok(Some(resp)) if resp.status_code.kind() == rsip::StatusCodeKind::Successful => { - // Adopt a re-granted interval (a peer may shorten or - // lengthen mid-call), floored to avoid a hot loop. - if let Some(granted) = session_expires_in(&resp.headers) { - interval_secs = granted.interval_secs.max(MIN_SESSION_EXPIRES_SECS); - } - debug!(interval_secs, "session refresh accepted"); - } - Ok(Some(resp)) => { - warn!( - status = %resp.status_code, - "session refresh rejected; hanging up" - ); - if let Err(e) = dialog.send_bye().await { - warn!("BYE after rejected refresh failed: {e}"); - } - return SessionTimerOutcome::RefreshFailed; - } - Ok(None) => { - debug!("dialog no longer confirmed; session timer standing down"); - return SessionTimerOutcome::DialogGone; - } - Err(e) => { - warn!("session refresh error: {e}; hanging up"); - if let Err(e) = dialog.send_bye().await { - warn!("BYE after failed refresh failed: {e}"); - } - return SessionTimerOutcome::RefreshFailed; - } - } - } - } else { - let current = SessionTimer { - interval_secs, - we_are_refresher: false, - }; - loop { - select! { - _ = tokio::time::sleep(current.expiry_after()) => { - info!( - interval_secs, - "session lapsed without refresh; sending BYE" - ); - if let Err(e) = dialog.send_bye().await { - warn!("BYE after session expiry failed: {e}"); - } - return SessionTimerOutcome::Expired; - } - _ = peer_refreshed.notified() => { - debug!("peer refreshed session; watchdog deadline reset"); - } - _ = cancel.cancelled() => return SessionTimerOutcome::Cancelled, - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - - // ---- SessionExpires parse / build ---- - - #[test] - fn parse_bare_interval() { - let se = SessionExpires::parse("1800").unwrap(); - assert_eq!(se.interval_secs, 1800); - assert_eq!(se.refresher, None); - } - - #[test] - fn parse_with_refresher_param() { - let se = SessionExpires::parse("1800;refresher=uas").unwrap(); - assert_eq!(se.interval_secs, 1800); - assert_eq!(se.refresher, Some(Refresher::Uas)); - let se = SessionExpires::parse("90;refresher=uac").unwrap(); - assert_eq!(se.refresher, Some(Refresher::Uac)); - } - - #[test] - fn parse_is_case_insensitive_and_whitespace_tolerant() { - let se = SessionExpires::parse(" 600 ; Refresher = UAS ").unwrap(); - assert_eq!(se.interval_secs, 600); - assert_eq!(se.refresher, Some(Refresher::Uas)); - } - - #[test] - fn parse_ignores_unknown_params() { - let se = SessionExpires::parse("1800;foo=bar;refresher=uac;baz").unwrap(); - assert_eq!(se.refresher, Some(Refresher::Uac)); - } - - #[test] - fn parse_rejects_garbage() { - assert!(SessionExpires::parse("").is_err()); - assert!(SessionExpires::parse("soon").is_err()); - assert!(SessionExpires::parse("1800;refresher=bogus").is_err()); - assert!(SessionExpires::parse("-5").is_err()); - } - - #[test] - fn header_value_round_trips() { - for se in [ - SessionExpires { - interval_secs: 1800, - refresher: None, - }, - SessionExpires { - interval_secs: 90, - refresher: Some(Refresher::Uac), - }, - SessionExpires { - interval_secs: 7200, - refresher: Some(Refresher::Uas), - }, - ] { - let parsed = SessionExpires::parse(&se.header_value()).unwrap(); - assert_eq!(parsed, se, "round-trip via {:?}", se.header_value()); - } - } - - #[test] - fn header_builds_untyped_session_expires() { - let h = SessionExpires { - interval_secs: 1800, - refresher: Some(Refresher::Uac), - } - .header(); - assert_eq!(h.to_string(), "Session-Expires: 1800;refresher=uac"); - } - - // ---- header extraction ---- - - fn headers(items: Vec
) -> rsip::Headers { - let mut h = rsip::Headers::default(); - for item in items { - h.push(item); - } - h - } - - #[test] - fn session_expires_in_finds_header_case_insensitively() { - let h = headers(vec![Header::Other( - "session-expires".into(), - "600;refresher=uas".into(), - )]); - let se = session_expires_in(&h).unwrap(); - assert_eq!(se.interval_secs, 600); - assert_eq!(se.refresher, Some(Refresher::Uas)); - } - - #[test] - fn session_expires_in_accepts_compact_form() { - // RFC 4028 §4 defines `x` as the compact form of Session-Expires. - let h = headers(vec![Header::Other("x".into(), "300".into())]); - assert_eq!( - session_expires_in(&h), - Some(SessionExpires { - interval_secs: 300, - refresher: None - }) - ); - } - - #[test] - fn session_expires_in_absent_or_malformed_is_none() { - assert_eq!(session_expires_in(&headers(vec![])), None); - let h = headers(vec![Header::Other("Session-Expires".into(), "soon".into())]); - assert_eq!(session_expires_in(&h), None); - } - - #[test] - fn min_se_in_parses_and_ignores_params() { - let h = headers(vec![Header::Other("Min-SE".into(), "120".into())]); - assert_eq!(min_se_in(&h), Some(120)); - let h = headers(vec![Header::Other("min-se".into(), "240;lr".into())]); - assert_eq!(min_se_in(&h), Some(240)); - assert_eq!(min_se_in(&headers(vec![])), None); - let h = headers(vec![Header::Other("Min-SE".into(), "never".into())]); - assert_eq!(min_se_in(&h), None); - } - - #[test] - fn supports_timer_scans_typed_untyped_and_compact() { - assert!(supports_timer(&headers(vec![supported_timer_header()]))); - assert!(supports_timer(&headers(vec![Header::Supported( - "100rel, timer".into() - )]))); - assert!(supports_timer(&headers(vec![Header::Other( - "k".into(), - "timer".into() - )]))); - assert!(supports_timer(&headers(vec![Header::Other( - "Supported".into(), - "TIMER".into() - )]))); - assert!(!supports_timer(&headers(vec![]))); - assert!(!supports_timer(&headers(vec![Header::Supported( - "100rel".into() - )]))); - // `timer` must be a whole option tag, not a substring. - assert!(!supports_timer(&headers(vec![Header::Supported( - "timers".into() - )]))); - } - - // ---- interval math (RFC 4028 §10) ---- - - #[test] - fn refresh_fires_at_half_the_interval() { - let t = SessionTimer { - interval_secs: 1800, - we_are_refresher: true, - }; - assert_eq!(t.refresh_after(), Duration::from_secs(900)); - let t = SessionTimer { - interval_secs: 90, - we_are_refresher: true, - }; - assert_eq!(t.refresh_after(), Duration::from_secs(45)); - } - - #[test] - fn expiry_keeps_min_of_32s_or_a_third_headroom() { - // Long interval: headroom caps at 32 s. - let t = SessionTimer { - interval_secs: 1800, - we_are_refresher: false, - }; - assert_eq!(t.expiry_after(), Duration::from_secs(1768)); - // Short interval: a third of 90 s = 30 s < 32 s. - let t = SessionTimer { - interval_secs: 90, - we_are_refresher: false, - }; - assert_eq!(t.expiry_after(), Duration::from_secs(60)); - } - - // ---- negotiation: UAC side ---- - - #[test] - fn uac_no_session_expires_means_no_timer() { - assert_eq!(negotiate_uac(&headers(vec![])), None); - } - - #[test] - fn uac_refresher_uas_means_peer_refreshes() { - let h = headers(vec![Header::Other( - "Session-Expires".into(), - "1800;refresher=uas".into(), - )]); - assert_eq!( - negotiate_uac(&h), - Some(SessionTimer { - interval_secs: 1800, - we_are_refresher: false - }) - ); - } - - #[test] - fn uac_refresher_uac_or_missing_means_we_refresh() { - let h = headers(vec![Header::Other( - "Session-Expires".into(), - "600;refresher=uac".into(), - )]); - assert!(negotiate_uac(&h).unwrap().we_are_refresher); - // RFC 4028 §9 says the 2xx MUST pin the refresher; a peer that - // omits it gets the defensive default: we refresh. - let h = headers(vec![Header::Other("Session-Expires".into(), "600".into())]); - assert!(negotiate_uac(&h).unwrap().we_are_refresher); - } - - #[test] - fn uac_floors_tiny_grants_at_90s() { - let h = headers(vec![Header::Other( - "Session-Expires".into(), - "20;refresher=uac".into(), - )]); - assert_eq!(negotiate_uac(&h).unwrap().interval_secs, 90); - } - - // ---- negotiation: UAS side ---- - - fn invite_headers(session_expires: &str, min_se: Option<&str>, timer: bool) -> rsip::Headers { - let mut items = vec![Header::Other( - "Session-Expires".into(), - session_expires.into(), - )]; - if let Some(m) = min_se { - items.push(Header::Other("Min-SE".into(), m.into())); - } - if timer { - items.push(supported_timer_header()); - } - headers(items) - } - - #[test] - fn uas_no_session_expires_means_no_timer() { - assert_eq!( - negotiate_uas(&headers(vec![supported_timer_header()])), - None - ); - } - - #[test] - fn uas_default_makes_supporting_peer_the_refresher() { - let uas = negotiate_uas(&invite_headers("1800", None, true)).unwrap(); - assert_eq!(uas.timer.interval_secs, 1800); - assert!(!uas.timer.we_are_refresher, "peer (UAC) should refresh"); - assert_eq!( - uas.echo, - SessionExpires { - interval_secs: 1800, - refresher: Some(Refresher::Uac) - } - ); - assert!(uas.require_timer); - } - - #[test] - fn uas_honors_requested_refresher_uas() { - let uas = negotiate_uas(&invite_headers("1800;refresher=uas", None, true)).unwrap(); - assert!(uas.timer.we_are_refresher, "we (UAS) were asked to refresh"); - assert_eq!(uas.echo.refresher, Some(Refresher::Uas)); - } - - #[test] - fn uas_without_peer_support_takes_refresher_role() { - // A proxy inserted Session-Expires but the UAC itself never - // advertised `Supported: timer` — it can't refresh, so we must, - // and we must not Require: timer in the 2xx. - let uas = negotiate_uas(&invite_headers("1800;refresher=uac", None, false)).unwrap(); - assert!(uas.timer.we_are_refresher); - assert_eq!(uas.echo.refresher, Some(Refresher::Uas)); - assert!(!uas.require_timer); - } - - #[test] - fn uas_floors_interval_at_min_se_and_90s() { - // Requested 30 s with Min-SE 120 → 120 s. - let uas = negotiate_uas(&invite_headers("30", Some("120"), true)).unwrap(); - assert_eq!(uas.timer.interval_secs, 120); - assert_eq!(uas.echo.interval_secs, 120); - // Requested 30 s without Min-SE → RFC floor of 90 s. - let uas = negotiate_uas(&invite_headers("30", None, true)).unwrap(); - assert_eq!(uas.timer.interval_secs, 90); - } - - // ---- session_timer_loop against a mock dialog ---- - - #[derive(Debug, Clone, PartialEq, Eq)] - enum Event { - Refresh { session_expires: String }, - Bye, - } - - /// Scripted mock: pops one canned reply per refresh; records every - /// call with a timestamp readable under tokio's paused clock. - struct MockDialog { - events: Mutex>, - refresh_replies: Mutex, String>>>, - started: tokio::time::Instant, - } - - impl MockDialog { - fn new(refresh_replies: Vec, String>>) -> Self { - Self { - events: Mutex::new(Vec::new()), - refresh_replies: Mutex::new(refresh_replies), - started: tokio::time::Instant::now(), - } - } - - fn events(&self) -> Vec<(Duration, Event)> { - self.events.lock().unwrap().clone() - } - } - - fn response(code: u16, extra: Vec
) -> rsip::Response { - rsip::Response { - status_code: rsip::StatusCode::from(code), - version: rsip::Version::V2, - headers: headers(extra), - body: Vec::new(), - } - } - - impl SessionDialogOps for MockDialog { - async fn refresh( - &self, - hdrs: Vec
, - _body: Option>, - ) -> Result, BoxError> { - let se = hdrs - .iter() - .find_map(|h| match h { - Header::Other(name, value) if name == "Session-Expires" => Some(value.clone()), - _ => None, - }) - .unwrap_or_default(); - self.events.lock().unwrap().push(( - self.started.elapsed(), - Event::Refresh { - session_expires: se, - }, - )); - let reply = self.refresh_replies.lock().unwrap().remove(0); - reply.map_err(Into::into) - } - - async fn send_bye(&self) -> Result<(), BoxError> { - self.events - .lock() - .unwrap() - .push((self.started.elapsed(), Event::Bye)); - Ok(()) - } - } - - fn timer(interval_secs: u32, we_are_refresher: bool) -> SessionTimer { - SessionTimer { - interval_secs, - we_are_refresher, - } - } - - #[tokio::test(start_paused = true)] - async fn refresher_sends_refresh_every_half_interval() { - let dialog = Arc::new(MockDialog::new(vec![ - Ok(Some(response(200, vec![]))), - Ok(Some(response(200, vec![]))), - Ok(None), // third tick: dialog gone, loop exits - ])); - let cancel = CancellationToken::new(); - let outcome = session_timer_loop( - &*dialog, - timer(180, true), - None, - Arc::new(Notify::new()), - cancel, - ) - .await; - assert_eq!(outcome, SessionTimerOutcome::DialogGone); - - let events = dialog.events(); - assert_eq!(events.len(), 3); - assert_eq!(events[0].0, Duration::from_secs(90)); - assert_eq!(events[1].0, Duration::from_secs(180)); - assert_eq!(events[2].0, Duration::from_secs(270)); - for (_, e) in &events { - assert_eq!( - e, - &Event::Refresh { - session_expires: "180;refresher=uac".into() - } - ); - } - } - - #[tokio::test(start_paused = true)] - async fn refresher_adopts_interval_granted_in_refresh_response() { - // First 200 re-grants a longer interval; the second refresh must - // fire at the *new* half-interval. - let regrant = response( - 200, - vec![Header::Other( - "Session-Expires".into(), - "360;refresher=uac".into(), - )], - ); - let dialog = Arc::new(MockDialog::new(vec![Ok(Some(regrant)), Ok(None)])); - let cancel = CancellationToken::new(); - let outcome = session_timer_loop( - &*dialog, - timer(180, true), - None, - Arc::new(Notify::new()), - cancel, - ) - .await; - assert_eq!(outcome, SessionTimerOutcome::DialogGone); - - let events = dialog.events(); - assert_eq!(events[0].0, Duration::from_secs(90), "first at 180/2"); - assert_eq!( - events[1].0, - Duration::from_secs(90 + 180), - "second at 90 + 360/2 after the re-grant" - ); - } - - #[tokio::test(start_paused = true)] - async fn refresher_rejected_refresh_sends_bye() { - let dialog = Arc::new(MockDialog::new(vec![Ok(Some(response(481, vec![])))])); - let cancel = CancellationToken::new(); - let outcome = session_timer_loop( - &*dialog, - timer(180, true), - None, - Arc::new(Notify::new()), - cancel, - ) - .await; - assert_eq!(outcome, SessionTimerOutcome::RefreshFailed); - let events = dialog.events(); - assert!(matches!(events[0].1, Event::Refresh { .. })); - assert_eq!(events[1].1, Event::Bye); - } - - #[tokio::test(start_paused = true)] - async fn refresher_transport_error_sends_bye() { - let dialog = Arc::new(MockDialog::new(vec![Err("socket closed".into())])); - let cancel = CancellationToken::new(); - let outcome = session_timer_loop( - &*dialog, - timer(180, true), - None, - Arc::new(Notify::new()), - cancel, - ) - .await; - assert_eq!(outcome, SessionTimerOutcome::RefreshFailed); - assert_eq!(dialog.events().last().unwrap().1, Event::Bye); - } - - #[tokio::test(start_paused = true)] - async fn refresher_cancellation_wins_before_first_refresh() { - let dialog = Arc::new(MockDialog::new(vec![])); - let cancel = CancellationToken::new(); - cancel.cancel(); - let outcome = session_timer_loop( - &*dialog, - timer(180, true), - None, - Arc::new(Notify::new()), - cancel, - ) - .await; - assert_eq!(outcome, SessionTimerOutcome::Cancelled); - assert!(dialog.events().is_empty(), "no refresh, no BYE"); - } - - #[tokio::test(start_paused = true)] - async fn watchdog_sends_bye_when_session_lapses() { - let dialog = Arc::new(MockDialog::new(vec![])); - let cancel = CancellationToken::new(); - let outcome = session_timer_loop( - &*dialog, - timer(90, false), - None, - Arc::new(Notify::new()), - cancel, - ) - .await; - assert_eq!(outcome, SessionTimerOutcome::Expired); - let events = dialog.events(); - assert_eq!(events.len(), 1); - // 90 - min(32, 90/3) = 60 s. - assert_eq!(events[0], (Duration::from_secs(60), Event::Bye)); - } - - #[tokio::test(start_paused = true)] - async fn watchdog_resets_deadline_on_peer_refresh() { - let dialog = Arc::new(MockDialog::new(vec![])); - let cancel = CancellationToken::new(); - let refreshed = Arc::new(Notify::new()); - - let loop_task = tokio::spawn({ - let dialog = dialog.clone(); - let refreshed = refreshed.clone(); - let cancel = cancel.clone(); - async move { session_timer_loop(&*dialog, timer(90, false), None, refreshed, cancel).await } - }); - - // Just before the 60 s deadline, the peer refreshes. - tokio::time::sleep(Duration::from_secs(59)).await; - refreshed.notify_one(); - tokio::task::yield_now().await; - // Crossing the original deadline must NOT fire the BYE... - tokio::time::sleep(Duration::from_secs(30)).await; - assert!(dialog.events().is_empty(), "deadline should have reset"); - // ...but the rearmed deadline (59 + 60 = 119 s) must. - let outcome = loop_task.await.unwrap(); - assert_eq!(outcome, SessionTimerOutcome::Expired); - assert_eq!( - dialog.events(), - vec![(Duration::from_secs(119), Event::Bye)] - ); - } - - #[tokio::test(start_paused = true)] - async fn watchdog_cancellation_stands_down_without_bye() { - let dialog = Arc::new(MockDialog::new(vec![])); - let cancel = CancellationToken::new(); - let loop_task = tokio::spawn({ - let dialog = dialog.clone(); - let cancel = cancel.clone(); - async move { - session_timer_loop( - &*dialog, - timer(90, false), - None, - Arc::new(Notify::new()), - cancel, - ) - .await - } - }); - tokio::time::sleep(Duration::from_secs(10)).await; - cancel.cancel(); - assert_eq!(loop_task.await.unwrap(), SessionTimerOutcome::Cancelled); - assert!(dialog.events().is_empty()); - } -} 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..30ee459 --- /dev/null +++ b/crates/wavekat-sip/src/stack/call.rs @@ -0,0 +1,167 @@ +//! 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; + +/// 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, + 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(format!( + "SIP/2.0/UDP {local_addr};branch={}", + 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(), + )); + 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(), + } +} + +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(), + 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 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..2664ddd --- /dev/null +++ b/crates/wavekat-sip/src/stack/dialog.rs @@ -0,0 +1,433 @@ +//! 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; + +/// 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) + } + + /// 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) + } + + /// Shared request composer: Via (fresh branch) + target + tags + route set. + fn compose(&self, method: Method, seq: u32) -> 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(format!( + "SIP/2.0/UDP {host};branch={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())); + headers.push(Header::ContentLength( + rsip::headers::ContentLength::default(), + )); + + Request { + method, + uri: self.remote_target.clone(), + version: rsip::Version::V2, + headers, + body: Vec::new(), + } + } +} + +/// 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 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 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..da95331 --- /dev/null +++ b/crates/wavekat-sip/src/stack/registration.rs @@ -0,0 +1,169 @@ +//! 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; + +/// 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(format!( + "SIP/2.0/UDP {local_addr};branch={}", + 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); + } + + #[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..681dfbf --- /dev/null +++ b/crates/wavekat-sip/src/stack/response.rs @@ -0,0 +1,165 @@ +//! 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. + for header in request.headers.iter() { + match header { + Header::Via(_) | Header::From(_) | Header::CallId(_) | Header::CSeq(_) => { + 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 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..4daa311 --- /dev/null +++ b/crates/wavekat-sip/src/stack/transaction/mod.rs @@ -0,0 +1,505 @@ +//! 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, + }) + } +} + +/// 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}") +} + +/// 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 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 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..deede07 --- /dev/null +++ b/crates/wavekat-sip/src/stack/transport.rs @@ -0,0 +1,161 @@ +//! 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::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); + self.socket.send_to(&bytes, dst).await?; + Ok(()) + } + + /// Receive the next parseable SIP message and its source address. + /// + /// Malformed datagrams are dropped (logged at trace) 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?; + match parse(&buf[..n]) { + Some(msg) => return Ok((msg, src)), + None => trace!(%src, bytes = n, "dropping unparseable datagram"), + } + } + } +} + +#[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..7de14bb --- /dev/null +++ b/crates/wavekat-sip/src/stack/ua.rs @@ -0,0 +1,467 @@ +//! 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::{Method, Request, Response, SipMessage}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio_util::sync::CancellationToken; + +use super::auth; +use super::call::{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>, +} + +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_with_timers(local, Timers::default(), 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 { + 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), + }) + } + + 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 { + 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(&mut rx).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 + } + + /// 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()?; + if !self.engine.start_client(request.clone(), peer).await { + return None; + } + Some(rx) + } + + /// 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 + } +} + +/// 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(), + 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(); + } +} 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..ffd916f --- /dev/null +++ b/crates/wavekat-sip/tests/end_to_end_call.rs @@ -0,0 +1,89 @@ +//! 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::time::Duration; + +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use wavekat_sip::re_exports::Uri; +use wavekat_sip::{Caller, 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, + } +} + +#[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 + + // 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"); + + // 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(); +} 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/README.md b/docs/README.md index 5c612be..4963cf1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,3 +25,12 @@ 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 | diff --git a/docs/RFC-COVERAGE.md b/docs/RFC-COVERAGE.md index 4f4f7d9..0079ddc 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-27 (post rsipstack removal — see `docs/16`) 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,20 @@ 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 | `Caller::dial`, `Call` | +| §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` | +| §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 | Inbound in-dialog requests (BYE / OPTIONS / INFO / re-INVITE) are auto-answered `200 OK`; the 2xx ACK is absorbed | `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`) | | §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), multicast (§18.1.1), CANCEL of a pending outbound INVITE, +the `User-Agent` header (§20.41), and surfacing inbound in-dialog request +bodies to the consumer (in-dialog requests are auto-answered, not exposed). ### RFC 3264 — SDP offer/answer model (minimal subset) @@ -108,19 +110,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,36 +127,6 @@ 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). - ## Not implemented NAT traversal and media security, in particular, are fully delegated @@ -176,46 +135,43 @@ typically a PBX/SBC on the same network or a trunk that latches): | RFC | What it is | Status | |-----|------------|--------| +| 4028 | Session timers | Removed in `docs/16`; re-INVITEs are auto-answered but periodic refresh is not driven. Deferred to a follow-up on the engine. | +| 6086 (ex-2976) | SIP INFO (DTMF relay) | Removed in `docs/16`; inbound INFO is auto-answered `200 OK` but not surfaced, and sending INFO is not exposed. Deferred. | +| 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. **Session timers (RFC 4028)** — without driven refreshes, long calls can be + reaped by a server enforcing `Session-Expires`. Re-add on the engine. +2. **`rport` (RFC 3581)** — needed for responses to come back through NAT/PAT; + add `;rport` to outgoing Via and honor it on responses. +3. **TCP transport** — the `Transport::Tcp` variant is currently inert. +4. **RTCP receiver reports** — without them, neither side gets loss or jitter + feedback; fine on a LAN, blind over the open internet. +5. **TLS transport (SIPS)** — credentials currently ride plaintext except for + the digest exchange itself. +6. **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.