Skip to main content

flowcat_core/sip/
agent.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! `SipAgent` — a SIP user agent on one trunk (REGISTER + inbound/outbound calls).
4//!
5//! `SipAgent` wraps `rsipstack`'s endpoint/dialog machinery into the small,
6//! Flowcat-shaped surface the voice host needs (see SIP-DESIGN.md §2):
7//!
8//! - [`SipAgent::start`] binds the SIP UDP transport, starts the endpoint serve
9//!   loop, the incoming-INVITE pump, and (if credentials are given) a REGISTER +
10//!   periodic-refresh loop — all as background tokio tasks under one cancel token.
11//! - [`SipAgent::next_inbound`] yields the next inbound INVITE as an
12//!   [`InboundInvite`] (`call_id` / `from` / `to_did`); the host calls
13//!   [`InboundInvite::answer`] to 200-OK it with our SDP answer and get a
14//!   [`SipTransport`].
15//! - [`SipAgent::originate`] places an outbound INVITE to an E.164 with a caller
16//!   id, awaits the 200 OK, and returns a [`SipTransport`] over the negotiated
17//!   media (rsipstack sends the ACK as part of `do_invite`).
18//!
19//! ## Signaling vs. media
20//!
21//! rsipstack owns *signaling only*. For each established call we hand-roll the
22//! media: bind a fresh RTP `UdpSocket`, put its address + our G.711 offer/answer
23//! in the SDP, parse the peer's SDP for their RTP address + chosen codec, and
24//! build a [`SipTransport`] (RTP ↔ `MediaIn`). The negotiated dialog's state
25//! channel is watched so a BYE / `Terminated` fires the transport's hangup token,
26//! which surfaces as [`MediaIn::Stop`](crate::transport::MediaIn::Stop).
27//!
28//! ## What is gated on a live trunk
29//!
30//! `start` / `register` / `next_inbound` / `originate` open real UDP sockets and
31//! speak SIP to a registrar/peer; they are exercised against a live trunk, not in
32//! unit tests (there is no registrar here). The deterministic media plumbing they
33//! sit on top of — RTP, SDP, the jitter buffer, and the `SipTransport` ↔ `MediaIn`
34//! mapping — is unit-tested in `rtp.rs`, `sdp.rs`, and `transport.rs`.
35
36use std::net::{IpAddr, Ipv4Addr, SocketAddr};
37use std::sync::Arc;
38use std::time::Duration;
39
40use rsipstack::dialog::authenticate::Credential;
41use rsipstack::dialog::dialog::{DialogState, DialogStateReceiver, DialogStateSender};
42use rsipstack::dialog::dialog_layer::DialogLayer;
43use rsipstack::dialog::invitation::InviteOption;
44use rsipstack::dialog::server_dialog::ServerInviteDialog;
45use rsipstack::sip as rsip;
46use rsipstack::sip::prelude::HeadersExt;
47use rsipstack::transaction::endpoint::EndpointInnerRef;
48use rsipstack::transaction::TransactionReceiver;
49use rsipstack::transport::{udp::UdpConnection, TransportLayer};
50use rsipstack::EndpointBuilder;
51use tokio::net::UdpSocket;
52use tokio::sync::{mpsc, Mutex};
53use tokio_util::sync::CancellationToken;
54
55use crate::error::FlowcatError;
56use crate::sip::sdp;
57use crate::sip::transport::SipTransport;
58
59/// Default SIP signaling port if the caller doesn't pick one.
60const DEFAULT_SIP_PORT: u16 = 5060;
61/// Default first RTP port we try when binding a media socket (even ports per RFC
62/// 3550; we scan upward in steps of 2). A wide range so many concurrent calls fit.
63/// Overridable per trunk via [`SipConfig::rtp_port_base`].
64pub const DEFAULT_RTP_PORT_BASE: u16 = 16000;
65/// Default number of even ports to probe before giving up binding an RTP socket.
66/// Overridable per trunk via [`SipConfig::rtp_port_tries`]: a deployment whose
67/// public UDP port budget is constrained (e.g. behind a GKE LoadBalancer
68/// forwarding rule, which is capped at 5 ports) sets this small (e.g. 4) so the
69/// bound RTP range fits the exposed ports — at the cost of capping the number of
70/// concurrent call media legs to that many.
71pub const DEFAULT_RTP_PORT_TRIES: u16 = 200;
72/// Floor on the re-REGISTER interval (seconds) regardless of the server's expiry.
73const MIN_REREGISTER_SECS: u64 = 30;
74
75/// Configuration for a [`SipAgent`] (one trunk).
76#[derive(Debug, Clone)]
77pub struct SipConfig {
78    /// Registrar / proxy SIP URI, e.g. `sip:sip.example.com`.
79    pub server: String,
80    /// SIP auth username (the trunk login).
81    pub login: String,
82    /// SIP auth password.
83    pub password: String,
84    /// Caller-ID (E.164 or trunk number) used as the From user on outbound calls.
85    pub caller_id: String,
86    /// Public IP to advertise in Via/Contact/SDP for NAT (`None` → use the bound
87    /// local interface address). Telephony trunks behind NAT need this set.
88    pub public_ip: Option<Ipv4Addr>,
89    /// Local SIP signaling port to bind (`None` → [`DEFAULT_SIP_PORT`]).
90    pub sip_port: Option<u16>,
91    /// First RTP port to probe when binding call media (even, RFC 3550; the scan
92    /// steps up by 2). Use [`DEFAULT_RTP_PORT_BASE`] unless the deployment pins it.
93    pub rtp_port_base: u16,
94    /// Number of even ports to probe from `rtp_port_base` before failing to bind.
95    /// Use [`DEFAULT_RTP_PORT_TRIES`] unless a constrained public UDP port budget
96    /// needs a small range (this caps concurrent call media to `rtp_port_tries`).
97    pub rtp_port_tries: u16,
98}
99
100/// An inbound INVITE surfaced to the host by [`SipAgent::next_inbound`].
101///
102/// Carries just what the control plane needs to resolve the call (Call-ID, the
103/// caller, the dialed DID), plus the machinery to answer it. The DID/caller are
104/// taken from the SIP To/From user parts — the control plane maps the DID to an
105/// org/agent; the INVITE body is never trusted for identity (SIP-DESIGN.md §"Security").
106pub struct InboundInvite {
107    /// SIP Call-ID of the inbound dialog.
108    pub call_id: String,
109    /// Caller number (From URI user part), best-effort.
110    pub from: String,
111    /// Dialed DID (To/Request-URI user part) — the number that was called.
112    pub to_did: String,
113    /// The peer's SDP offer (the INVITE body), already parsed for media params.
114    offer: sdp::SdpMedia,
115    /// The rsipstack server dialog to accept/reject.
116    dialog: ServerInviteDialog,
117    /// IP we advertise in the SDP answer (public IP or local).
118    advertise_ip: Ipv4Addr,
119    /// RTP bind range (base, count) for the answer's media socket — carried from
120    /// the agent's [`SipConfig`] so inbound media honors the same port budget.
121    rtp_port_base: u16,
122    rtp_port_tries: u16,
123    /// State channel for this dialog (watched to drive the hangup token).
124    state_rx: DialogStateReceiver,
125}
126
127impl InboundInvite {
128    /// 200-OK this INVITE with our G.711 SDP answer and return the media transport.
129    ///
130    /// Binds a fresh RTP socket, builds the answer committing to the codec the
131    /// peer offered (PCMU preferred), accepts the dialog, and spins up a
132    /// [`SipTransport`] whose hangup token is wired to this dialog's `Terminated`
133    /// state. Consumes `self`.
134    pub async fn answer(self) -> Result<SipTransport, FlowcatError> {
135        let codec = self.offer.codec;
136        let (rtp_sock, rtp_port) = bind_rtp_socket(self.rtp_port_base, self.rtp_port_tries).await?;
137        let answer_sdp = sdp::build_answer(self.advertise_ip, rtp_port, codec);
138
139        // Peer RTP address from their offer.
140        let peer = SocketAddr::new(IpAddr::V4(self.offer.ip), self.offer.port);
141
142        // Accept (sends 200 OK with our SDP answer in the dialog's transaction).
143        let headers = vec![rsip::Header::ContentType("application/sdp".into())];
144        self.dialog
145            .accept(Some(headers), Some(answer_sdp.into_bytes()))
146            .map_err(|e| FlowcatError::Transport(format!("SIP accept failed: {e}")))?;
147
148        let hangup = CancellationToken::new();
149        spawn_dialog_watch(self.state_rx, hangup.clone());
150
151        Ok(SipTransport::start(
152            rtp_sock,
153            peer,
154            codec,
155            self.call_id,
156            hangup,
157        ))
158    }
159
160    /// Reject this INVITE (default 486 Busy Here unless a code is given).
161    pub fn reject(self, code: Option<rsip::StatusCode>) {
162        let _ = self
163            .dialog
164            .reject(Some(code.unwrap_or(rsip::StatusCode::BusyHere)), None);
165    }
166}
167
168/// A SIP user agent for one trunk. See the module docs.
169pub struct SipAgent {
170    cfg: SipConfig,
171    endpoint_inner: EndpointInnerRef,
172    dialog_layer: Arc<DialogLayer>,
173    /// Inbound INVITEs from the incoming pump.
174    inbound_rx: Mutex<mpsc::Receiver<InboundInvite>>,
175    /// IP advertised in SDP (public IP if configured, else the bound local addr).
176    advertise_ip: Ipv4Addr,
177    /// Root cancel token; dropping the agent (or calling [`SipAgent::shutdown`])
178    /// tears down the endpoint + all background tasks.
179    cancel: CancellationToken,
180}
181
182impl SipAgent {
183    /// Start the agent: bind the SIP UDP transport, launch the endpoint serve
184    /// loop + incoming-INVITE pump (+ registration loop if a password is set).
185    ///
186    /// Does not block on registration; call [`SipAgent::register`] to await the
187    /// first REGISTER result, or just let the background loop keep the binding
188    /// fresh. Returns once the transport is bound and tasks are spawned.
189    pub async fn start(cfg: SipConfig) -> Result<Self, FlowcatError> {
190        let cancel = CancellationToken::new();
191        let sip_port = cfg.sip_port.unwrap_or(DEFAULT_SIP_PORT);
192
193        // Bind the SIP signaling UDP socket. Bind to 0.0.0.0 so we receive on all
194        // interfaces; advertise the public IP (if given) in Via/Contact via the
195        // connection's `external` address.
196        let local: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), sip_port);
197        let external = cfg
198            .public_ip
199            .map(|ip| SocketAddr::new(IpAddr::V4(ip), sip_port));
200
201        let transport_layer = TransportLayer::new(cancel.clone());
202        let conn = UdpConnection::create_connection(local, external, Some(cancel.child_token()))
203            .await
204            .map_err(|e| FlowcatError::Transport(format!("bind SIP UDP {local}: {e}")))?;
205        transport_layer.add_transport(conn.into());
206
207        let endpoint = EndpointBuilder::new()
208            .with_user_agent("flowcat")
209            .with_cancel_token(cancel.clone())
210            .with_transport_layer(transport_layer)
211            .build();
212
213        let endpoint_inner = endpoint.inner.clone();
214        let dialog_layer = Arc::new(DialogLayer::new(endpoint.inner.clone()));
215
216        // The IP we put in SDP: the configured public IP, or the bound local addr.
217        let advertise_ip = match cfg.public_ip {
218            Some(ip) => ip,
219            None => local_advertise_ip(&endpoint),
220        };
221
222        let incoming = endpoint
223            .incoming_transactions()
224            .map_err(|e| FlowcatError::Transport(format!("incoming_transactions: {e}")))?;
225
226        // The endpoint serve loop must run for the whole life of the agent.
227        let serve_cancel = cancel.clone();
228        tokio::spawn(async move {
229            tokio::select! {
230                _ = endpoint.serve() => {}
231                _ = serve_cancel.cancelled() => {}
232            }
233        });
234
235        // Inbound-INVITE pump.
236        let (inbound_tx, inbound_rx) = mpsc::channel::<InboundInvite>(16);
237        tokio::spawn(incoming_pump(
238            dialog_layer.clone(),
239            incoming,
240            inbound_tx,
241            advertise_ip,
242            cfg.rtp_port_base,
243            cfg.rtp_port_tries,
244            cancel.clone(),
245        ));
246
247        // Registration loop (only if a password is configured).
248        if !cfg.password.is_empty() {
249            tokio::spawn(register_loop(
250                endpoint_inner.clone(),
251                cfg.clone(),
252                cancel.clone(),
253            ));
254        }
255
256        Ok(Self {
257            cfg,
258            endpoint_inner,
259            dialog_layer,
260            inbound_rx: Mutex::new(inbound_rx),
261            advertise_ip,
262            cancel,
263        })
264    }
265
266    /// Send one REGISTER now and await its result (does not start the refresh
267    /// loop — that runs from [`SipAgent::start`]). Useful at bring-up to fail fast
268    /// if the trunk credentials are wrong. Returns the registration expiry secs.
269    pub async fn register(&self) -> Result<u32, FlowcatError> {
270        let credential = Credential {
271            username: self.cfg.login.clone(),
272            password: self.cfg.password.clone(),
273            realm: None,
274        };
275        let server = parse_server_uri(&self.cfg.server)?;
276        let mut reg = rsipstack::dialog::registration::Registration::new(
277            self.endpoint_inner.clone(),
278            Some(credential),
279        );
280        let resp = reg
281            .register(server, None)
282            .await
283            .map_err(|e| FlowcatError::Transport(format!("REGISTER failed: {e}")))?;
284        if resp.status_code != rsip::StatusCode::OK {
285            return Err(FlowcatError::Transport(format!(
286                "REGISTER rejected: {}",
287                resp.status_code
288            )));
289        }
290        Ok(reg.expires())
291    }
292
293    /// Yield the next inbound INVITE, or `None` once the agent is shut down.
294    pub async fn next_inbound(&self) -> Option<InboundInvite> {
295        self.inbound_rx.lock().await.recv().await
296    }
297
298    /// Originate an outbound call to `to_e164` from `caller_id` (overrides the
299    /// configured caller-id when given). Awaits the 200 OK and returns the media
300    /// transport over the negotiated G.711.
301    pub async fn originate(
302        &self,
303        to_e164: &str,
304        caller_id: Option<&str>,
305    ) -> Result<SipTransport, FlowcatError> {
306        let server = parse_server_uri(&self.cfg.server)?;
307        let host = server.host_with_port.clone();
308        let caller_user = caller_id.unwrap_or(&self.cfg.caller_id);
309
310        // Build caller / callee / contact URIs against the trunk host.
311        let caller = make_uri(caller_user, host.clone());
312        let callee = make_uri(to_e164, host.clone());
313        let contact = make_uri(caller_user, host.clone());
314
315        // Bind RTP + build our G.711 offer (both PCMU & PCMA).
316        let (rtp_sock, rtp_port) =
317            bind_rtp_socket(self.cfg.rtp_port_base, self.cfg.rtp_port_tries).await?;
318        let offer = sdp::build_offer(self.advertise_ip, rtp_port);
319
320        let credential = Credential {
321            username: self.cfg.login.clone(),
322            password: self.cfg.password.clone(),
323            realm: None,
324        };
325        let invite = InviteOption {
326            caller,
327            callee,
328            contact,
329            content_type: Some("application/sdp".to_string()),
330            offer: Some(offer.into_bytes()),
331            credential: Some(credential),
332            ..Default::default()
333        };
334
335        let (state_tx, state_rx) = self.dialog_layer.new_dialog_state_channel();
336        let (dialog, resp) = self
337            .dialog_layer
338            .do_invite(invite, state_tx)
339            .await
340            .map_err(|e| FlowcatError::Transport(format!("INVITE failed: {e}")))?;
341        let resp =
342            resp.ok_or_else(|| FlowcatError::Transport("INVITE got no final response".into()))?;
343        if resp.status_code != rsip::StatusCode::OK {
344            return Err(FlowcatError::Transport(format!(
345                "outbound call not answered: {}",
346                resp.status_code
347            )));
348        }
349
350        // Parse the answer SDP for the peer's RTP address + chosen codec.
351        let answer_body = String::from_utf8_lossy(resp.body());
352        let media = sdp::parse(&answer_body)
353            .map_err(|e| FlowcatError::Transport(format!("bad answer SDP: {e}")))?;
354        let peer = SocketAddr::new(IpAddr::V4(media.ip), media.port);
355
356        let call_id = dialog.id().call_id.to_string();
357        let hangup = CancellationToken::new();
358        spawn_dialog_watch(state_rx, hangup.clone());
359
360        Ok(SipTransport::start(
361            rtp_sock,
362            peer,
363            media.codec,
364            call_id,
365            hangup,
366        ))
367    }
368
369    /// Tear down the agent: cancels the endpoint serve loop + all tasks.
370    pub fn shutdown(&self) {
371        self.cancel.cancel();
372    }
373}
374
375impl Drop for SipAgent {
376    fn drop(&mut self) {
377        self.cancel.cancel();
378    }
379}
380
381/// Watch a dialog's state channel; on `Terminated` (BYE / timeout / decline)
382/// fire `hangup` so the [`SipTransport`] surfaces `MediaIn::Stop`.
383fn spawn_dialog_watch(mut state_rx: DialogStateReceiver, hangup: CancellationToken) {
384    tokio::spawn(async move {
385        while let Some(state) = state_rx.recv().await {
386            if let DialogState::Terminated(id, reason) = state {
387                tracing::debug!(%id, ?reason, "SIP dialog terminated");
388                hangup.cancel();
389                break;
390            }
391        }
392        // Channel closed without an explicit Terminated → also treat as hangup.
393        hangup.cancel();
394    });
395}
396
397/// The incoming-INVITE pump: matches in-dialog requests to their dialogs, and
398/// turns out-of-dialog INVITEs into [`InboundInvite`]s on the channel.
399///
400/// Modeled on rsipstack's `client` example `process_incoming_request`.
401async fn incoming_pump(
402    dialog_layer: Arc<DialogLayer>,
403    mut incoming: TransactionReceiver,
404    inbound_tx: mpsc::Sender<InboundInvite>,
405    advertise_ip: Ipv4Addr,
406    rtp_port_base: u16,
407    rtp_port_tries: u16,
408    cancel: CancellationToken,
409) {
410    loop {
411        let mut tx = tokio::select! {
412            _ = cancel.cancelled() => break,
413            t = incoming.recv() => match t {
414                Some(t) => t,
415                None => break,
416            },
417        };
418
419        // In-dialog request (has a To-tag): route to the existing dialog.
420        let has_to_tag = tx
421            .original
422            .to_header()
423            .ok()
424            .and_then(|to| to.tag().ok().flatten())
425            .is_some();
426        if has_to_tag {
427            if let Some(mut d) = dialog_layer.match_dialog(&tx) {
428                tokio::spawn(async move {
429                    let _ = d.handle(&mut tx).await;
430                });
431            } else {
432                let _ = tx
433                    .reply(rsip::StatusCode::CallTransactionDoesNotExist)
434                    .await;
435            }
436            continue;
437        }
438
439        // Out-of-dialog: we only set up new calls on INVITE. ACK for a 2xx is
440        // delivered into the dialog's own handler; everything else gets a 200.
441        match tx.original.method {
442            rsip::Method::Invite => {
443                if let Err(e) = handle_new_invite(
444                    &dialog_layer,
445                    tx,
446                    &inbound_tx,
447                    advertise_ip,
448                    rtp_port_base,
449                    rtp_port_tries,
450                )
451                .await
452                {
453                    tracing::debug!(error = %e, "failed to set up inbound INVITE");
454                }
455            }
456            rsip::Method::Ack => { /* handled within the dialog */ }
457            _ => {
458                let _ = tx.reply(rsip::StatusCode::OK).await;
459            }
460        }
461    }
462}
463
464/// Build an [`InboundInvite`] from a fresh INVITE transaction and push it to the
465/// host. Parses the offer SDP up front so a bad/again-unsupported offer is
466/// rejected here (488) rather than after the host commits.
467async fn handle_new_invite(
468    dialog_layer: &Arc<DialogLayer>,
469    tx: rsipstack::transaction::transaction::Transaction,
470    inbound_tx: &mpsc::Sender<InboundInvite>,
471    advertise_ip: Ipv4Addr,
472    rtp_port_base: u16,
473    rtp_port_tries: u16,
474) -> Result<(), FlowcatError> {
475    let mut tx = tx;
476    // Identity from the SIP headers (never the body): From user = caller, To user
477    // = dialed DID.
478    let from = uri_user(tx.original.from_header().ok().and_then(|h| h.uri().ok()));
479    let to_did = uri_user(tx.original.to_header().ok().and_then(|h| h.uri().ok()));
480    let call_id = tx
481        .original
482        .call_id_header()
483        .map(|c| c.to_string())
484        .unwrap_or_default();
485
486    // Parse the SDP offer.
487    let offer_body = String::from_utf8_lossy(tx.original.body());
488    let offer = match sdp::parse(&offer_body) {
489        Ok(m) => m,
490        Err(e) => {
491            tracing::debug!(error = %e, "rejecting INVITE with unusable SDP offer");
492            let _ = tx.reply(rsip::StatusCode::NotAcceptableHere).await;
493            return Err(FlowcatError::Transport(format!("bad offer SDP: {e}")));
494        }
495    };
496
497    // Create the server dialog (allocates the To-tag) and its state channel.
498    let (state_tx, state_rx): (DialogStateSender, DialogStateReceiver) =
499        dialog_layer.new_dialog_state_channel();
500    let dialog = dialog_layer
501        .get_or_create_server_invite(&tx, state_tx, None, None)
502        .map_err(|e| FlowcatError::Transport(format!("get_or_create_server_invite: {e}")))?;
503
504    // Drive the dialog's INVITE handler (sends 100 Trying, processes ACK/CANCEL)
505    // in the background; `answer`/`reject` send the final response through it.
506    let mut dialog_for_handle = dialog.clone();
507    tokio::spawn(async move {
508        let _ = dialog_for_handle.handle(&mut tx).await;
509    });
510
511    let invite = InboundInvite {
512        call_id,
513        from,
514        to_did,
515        offer,
516        dialog,
517        advertise_ip,
518        rtp_port_base,
519        rtp_port_tries,
520        state_rx,
521    };
522    inbound_tx
523        .send(invite)
524        .await
525        .map_err(|_| FlowcatError::Transport("inbound channel closed".into()))
526}
527
528/// Background REGISTER + refresh loop (mirrors the rsipstack example).
529async fn register_loop(
530    endpoint_inner: EndpointInnerRef,
531    cfg: SipConfig,
532    cancel: CancellationToken,
533) {
534    let credential = Credential {
535        username: cfg.login.clone(),
536        password: cfg.password.clone(),
537        realm: None,
538    };
539    let server = match parse_server_uri(&cfg.server) {
540        Ok(u) => u,
541        Err(e) => {
542            tracing::error!(error = %e, "SIP register loop: bad server URI; not registering");
543            return;
544        }
545    };
546    let mut reg =
547        rsipstack::dialog::registration::Registration::new(endpoint_inner, Some(credential));
548    loop {
549        match reg.register(server.clone(), None).await {
550            Ok(resp) if resp.status_code == rsip::StatusCode::OK => {
551                let expires = reg.expires();
552                tracing::info!(
553                    expires = (expires as u64).max(MIN_REREGISTER_SECS),
554                    "SIP registered"
555                );
556                tokio::select! {
557                    _ = cancel.cancelled() => return,
558                    _ = tokio::time::sleep(Duration::from_secs(reregister_delay_secs(expires))) => {}
559                }
560            }
561            Ok(resp) => {
562                tracing::warn!(status = %resp.status_code, "SIP register rejected; retrying");
563                tokio::select! {
564                    _ = cancel.cancelled() => return,
565                    _ = tokio::time::sleep(Duration::from_secs(MIN_REREGISTER_SECS)) => {}
566                }
567            }
568            Err(e) => {
569                tracing::warn!(error = %e, "SIP register error; retrying");
570                tokio::select! {
571                    _ = cancel.cancelled() => return,
572                    _ = tokio::time::sleep(Duration::from_secs(MIN_REREGISTER_SECS)) => {}
573                }
574            }
575        }
576    }
577}
578
579// ---------------------------------------------------------------------------
580// Helpers
581// ---------------------------------------------------------------------------
582
583/// Parse a SIP server string into a `rsip::Uri`, prefixing `sip:` if missing.
584fn parse_server_uri(server: &str) -> Result<rsip::Uri, FlowcatError> {
585    let s = if server.starts_with("sip:") || server.starts_with("sips:") {
586        server.to_string()
587    } else {
588        format!("sip:{server}")
589    };
590    rsip::Uri::try_from(s.as_str())
591        .map_err(|e| FlowcatError::Transport(format!("bad SIP server URI {server:?}: {e}")))
592}
593
594/// Build a `sip:<user>@<host>` URI for caller/callee/contact.
595fn make_uri(user: &str, host_with_port: rsip::HostWithPort) -> rsip::Uri {
596    rsip::Uri {
597        scheme: Some(rsip::Scheme::Sip),
598        auth: Some(rsip::Auth {
599            user: user.to_string(),
600            password: None,
601        }),
602        host_with_port,
603        params: vec![],
604        headers: vec![],
605    }
606}
607
608/// The user part of a URI (the phone number), or empty string.
609fn uri_user(uri: Option<rsip::Uri>) -> String {
610    uri.and_then(|u| u.auth.map(|a| a.user)).unwrap_or_default()
611}
612
613/// Seconds to wait before re-REGISTER, given the server's granted expiry.
614/// Floors the expiry at [`MIN_REREGISTER_SECS`] then refreshes at 75 % of it, so a
615/// `0`/short expiry (odd server response) can't melt into a hot re-REGISTER loop.
616fn reregister_delay_secs(expires: u32) -> u64 {
617    (expires as u64).max(MIN_REREGISTER_SECS) * 3 / 4
618}
619
620/// Resolve the local IPv4 we advertise in SDP when no public IP is configured,
621/// from the endpoint's first bound address. Falls back to loopback.
622fn local_advertise_ip(endpoint: &rsipstack::transaction::endpoint::Endpoint) -> Ipv4Addr {
623    endpoint
624        .get_addrs()
625        .first()
626        .and_then(|a| SocketAddr::try_from(a.addr.clone()).ok())
627        .and_then(|sa| match sa.ip() {
628            IpAddr::V4(v4) => Some(v4),
629            IpAddr::V6(_) => None,
630        })
631        .unwrap_or(Ipv4Addr::LOCALHOST)
632}
633
634/// Bind a fresh RTP `UdpSocket` on an even port in the configured range.
635/// Returns the socket and the port it bound (which goes in our SDP).
636async fn bind_rtp_socket(base: u16, tries: u16) -> Result<(UdpSocket, u16), FlowcatError> {
637    // RFC 3550: RTP uses an EVEN port (RTCP is the odd port above it). Force the
638    // base even so a misconfigured odd `rtp_port_base` can't yield odd RTP ports
639    // (some carriers reject them or assume RTCP = RTP+1 and collide).
640    let base = base & !1;
641    for i in 0..tries {
642        // Checked arithmetic so a misconfigured base/tries can't overflow u16 and
643        // wrap to a low port; an out-of-range slot just ends the scan early.
644        let Some(port) = i.checked_mul(2).and_then(|off| base.checked_add(off)) else {
645            break;
646        };
647        let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port);
648        if let Ok(sock) = UdpSocket::bind(addr).await {
649            return Ok((sock, port));
650        }
651    }
652    Err(FlowcatError::Transport(format!(
653        "no free RTP port in {}..{}",
654        base,
655        base.saturating_add(tries.saturating_mul(2))
656    )))
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn parse_server_uri_adds_scheme() {
665        let u = parse_server_uri("sip.zadarma.com").unwrap();
666        assert_eq!(u.scheme, Some(rsip::Scheme::Sip));
667        // Host preserved.
668        assert!(u.to_string().contains("sip.zadarma.com"));
669    }
670
671    #[test]
672    fn parse_server_uri_keeps_explicit_scheme() {
673        let u = parse_server_uri("sip:1.2.3.4:5070").unwrap();
674        assert!(u.to_string().contains("1.2.3.4"));
675    }
676
677    #[test]
678    fn make_uri_sets_user_and_scheme() {
679        let host = rsip::HostWithPort::try_from("sip.example.com:5060").unwrap();
680        let u = make_uri("+15551234567", host);
681        assert_eq!(u.auth.as_ref().unwrap().user, "+15551234567");
682        assert_eq!(u.scheme, Some(rsip::Scheme::Sip));
683    }
684
685    #[test]
686    fn uri_user_extracts_number_or_empty() {
687        let host = rsip::HostWithPort::try_from("h:5060").unwrap();
688        let u = make_uri("18005551212", host);
689        assert_eq!(uri_user(Some(u)), "18005551212");
690        assert_eq!(uri_user(None), "");
691    }
692
693    // ── DID extraction variants (the inbound-resolve identity anchor) ───────────
694    fn z_host() -> rsip::HostWithPort {
695        rsip::HostWithPort::try_from("pbx.zadarma.com:5060").unwrap()
696    }
697
698    #[test]
699    fn uri_user_preserves_zadarma_did_verbatim() {
700        // A bare DID (no leading '+') is returned verbatim for the control plane
701        // to route. (Reserved fictional 555-0100 test number — not a real line.)
702        assert_eq!(
703            uri_user(Some(make_uri("12025550100", z_host()))),
704            "12025550100"
705        );
706    }
707
708    #[test]
709    fn uri_user_keeps_leading_plus() {
710        // The control plane won't re-prefix a value already starting with '+',
711        // so a +-form DID still routes (see internal.rs sip_inbound_resolve).
712        assert_eq!(
713            uri_user(Some(make_uri("+12025550100", z_host()))),
714            "+12025550100"
715        );
716    }
717
718    #[test]
719    fn uri_user_extension_and_junk_pass_through_for_fail_closed_404() {
720        // A PBX extension or a scanner's junk user-part is returned verbatim;
721        // the control plane 404s it → INVITE rejected (fail-closed). No panic.
722        assert_eq!(uri_user(Some(make_uri("100", z_host()))), "100");
723        assert_eq!(
724            uri_user(Some(make_uri("nmap-probe", z_host()))),
725            "nmap-probe"
726        );
727    }
728
729    #[test]
730    fn uri_user_bare_host_uri_yields_empty() {
731        // Scanner INVITE to a bare host (no user-part) → "" (not a panic).
732        let uri = rsip::Uri::try_from("sip:pbx.zadarma.com").unwrap();
733        assert_eq!(uri_user(Some(uri)), "");
734    }
735
736    // ── re-REGISTER cadence (Bug-5: short/zero expiry must not hot-loop) ────────
737    #[test]
738    fn reregister_delay_uses_three_quarters_of_expiry() {
739        // Default trunk cadence: expires=50 → max(50,30)=50 → 50*3/4 = 37s.
740        assert_eq!(reregister_delay_secs(50), 37);
741    }
742
743    #[test]
744    fn reregister_delay_floors_short_expiry() {
745        assert_eq!(reregister_delay_secs(0), 30 * 3 / 4);
746        assert_eq!(reregister_delay_secs(10), 30 * 3 / 4);
747    }
748
749    #[test]
750    fn reregister_delay_scales_long_expiry_without_overflow() {
751        assert_eq!(reregister_delay_secs(3600), 2700);
752        assert_eq!(reregister_delay_secs(u32::MAX), (u32::MAX as u64) * 3 / 4);
753    }
754
755    /// Two RTP binds must get distinct ports (the scan steps past a taken port).
756    #[tokio::test]
757    async fn bind_rtp_socket_returns_distinct_ports() {
758        let (s1, p1) = bind_rtp_socket(DEFAULT_RTP_PORT_BASE, DEFAULT_RTP_PORT_TRIES)
759            .await
760            .unwrap();
761        let (s2, p2) = bind_rtp_socket(DEFAULT_RTP_PORT_BASE, DEFAULT_RTP_PORT_TRIES)
762            .await
763            .unwrap();
764        assert_ne!(p1, p2);
765        // Ports are even (RFC 3550 convention for RTP).
766        assert_eq!(p1 % 2, 0);
767        assert_eq!(p2 % 2, 0);
768        drop((s1, s2));
769    }
770
771    /// A constrained range is honored: the bound port stays within
772    /// `[base, base + 2*tries)` and is even. Guards the GKE small-port-budget path.
773    #[tokio::test]
774    async fn bind_rtp_socket_honors_custom_range() {
775        let base = 31000u16;
776        let tries = 4u16;
777        let (s, p) = bind_rtp_socket(base, tries).await.unwrap();
778        assert!(p >= base && p < base + tries * 2, "port {p} out of range");
779        assert_eq!(p % 2, 0);
780        drop(s);
781    }
782
783    /// An ODD base must still yield an EVEN RTP port (RFC 3550) — guards the
784    /// `base & !1` fix against a misconfigured odd `rtp_port_base`.
785    #[tokio::test]
786    async fn bind_rtp_socket_yields_even_port_even_for_odd_base() {
787        let (s, p) = bind_rtp_socket(31001, 8).await.unwrap(); // odd base
788        assert_eq!(
789            p % 2,
790            0,
791            "RTP port {p} is odd despite odd base (RFC 3550 wants even)"
792        );
793        drop(s);
794    }
795}