Skip to main content

wavekat_sip/
endpoint.rs

1//! Shared SIP endpoint: UDP/TCP transport bound, dialog layer wired,
2//! incoming-transaction stream exposed.
3
4use std::net::{IpAddr, SocketAddr};
5use std::sync::Arc;
6
7use rsip::StatusCode;
8use rsipstack::{
9    dialog::{dialog::Dialog, dialog_layer::DialogLayer},
10    transaction::{
11        endpoint::{EndpointBuilder, EndpointInnerRef, EndpointOption},
12        transaction::Transaction,
13        TransactionReceiver,
14    },
15    transport::{udp::UdpConnection, SipAddr, SipConnection, TransportLayer},
16};
17use tokio_util::sync::CancellationToken;
18use tracing::{info, warn};
19
20use crate::account::{SipAccount, Transport};
21
22/// Host part appended to the random prefix in generated `Call-ID` headers.
23///
24/// rsipstack's default is `restsend.com` (its author's product domain), which
25/// would otherwise leak into every REGISTER and INVITE we send. We override it
26/// to our own product domain; the random prefix already provides global
27/// uniqueness per RFC 3261, so the suffix is purely cosmetic/branding.
28const CALLID_SUFFIX: &str = "wavekat.com";
29
30/// A bound SIP endpoint that owns its transport and dialog layer.
31pub struct SipEndpoint {
32    /// Endpoint inner ref — used to build requests, vias, etc.
33    pub inner: EndpointInnerRef,
34    /// Dialog layer for sending INVITEs and tracking dialogs.
35    pub dialog_layer: Arc<DialogLayer>,
36    /// First bound SIP address (host:port).
37    pub sip_addr: SipAddr,
38    /// Transport the endpoint was bound for. Mirrors `SipAccount::transport`
39    /// at the moment of `SipEndpoint::new`. Cached here so diagnostics can
40    /// report it without holding onto the account.
41    transport: Transport,
42    transport_cancel: CancellationToken,
43}
44
45impl SipEndpoint {
46    /// Bind transport and start the endpoint's serve loop.
47    ///
48    /// Returns the endpoint plus the stream of incoming transactions
49    /// (you'll typically forward INVITE transactions from this to a
50    /// callee handler).
51    pub async fn new(
52        account: &SipAccount,
53        _cancel: CancellationToken,
54    ) -> Result<(Self, TransactionReceiver), Box<dyn std::error::Error + Send + Sync>> {
55        let local_ip = detect_local_ip(account)?;
56        let bind_addr: SocketAddr = SocketAddr::new(local_ip, 0);
57        info!("Binding SIP transport to {bind_addr}");
58
59        let transport_cancel = CancellationToken::new();
60        let transport_layer = TransportLayer::new(transport_cancel.clone());
61
62        match account.transport {
63            Transport::Udp => {
64                let udp = UdpConnection::create_connection(
65                    bind_addr,
66                    None,
67                    Some(transport_cancel.clone()),
68                )
69                .await?;
70                transport_layer.add_transport(SipConnection::Udp(udp));
71            }
72            Transport::Tcp => {
73                // TCP uses outbound connections; transport_layer handles
74                // it via DNS/registry lookup.
75            }
76        }
77
78        let user_agent = build_user_agent(
79            env!("CARGO_PKG_VERSION"),
80            crate::GIT_HASH,
81            &os_version(),
82            std::env::consts::ARCH,
83            &hostname::get()
84                .map(|h| h.to_string_lossy().into_owned())
85                .unwrap_or_default(),
86        );
87
88        info!("User-Agent: {user_agent}");
89
90        let endpoint = EndpointBuilder::new()
91            .with_user_agent(&user_agent)
92            .with_transport_layer(transport_layer)
93            .with_cancel_token(transport_cancel.clone())
94            .with_option(EndpointOption {
95                callid_suffix: Some(CALLID_SUFFIX.to_string()),
96                ..EndpointOption::default()
97            })
98            .build();
99
100        let inner = endpoint.inner.clone();
101        tokio::spawn({
102            let inner = inner.clone();
103            async move {
104                if let Err(e) = inner.serve().await {
105                    warn!("endpoint serve error: {e}");
106                }
107            }
108        });
109
110        let sip_addr = endpoint
111            .get_addrs()
112            .into_iter()
113            .next()
114            .ok_or("No SIP address bound")?;
115
116        let dialog_layer = Arc::new(DialogLayer::new(inner.clone()));
117        let incoming = endpoint.incoming_transactions()?;
118
119        Ok((
120            Self {
121                inner,
122                dialog_layer,
123                sip_addr,
124                transport: account.transport,
125                transport_cancel,
126            },
127            incoming,
128        ))
129    }
130
131    /// Local IP address this endpoint is bound to.
132    pub fn local_ip(&self) -> IpAddr {
133        self.local_addr()
134            .map(|a| a.ip())
135            .unwrap_or(IpAddr::from([127, 0, 0, 1]))
136    }
137
138    /// Local socket address (IP + port) this endpoint is bound to.
139    ///
140    /// Returns `None` if the underlying rsipstack address can't be parsed
141    /// as a `SocketAddr` (in practice this only happens before transport
142    /// is fully up, which shouldn't be observable from a constructed
143    /// `SipEndpoint`).
144    pub fn local_addr(&self) -> Option<SocketAddr> {
145        self.sip_addr.addr.to_string().parse::<SocketAddr>().ok()
146    }
147
148    /// Transport this endpoint was bound for (UDP/TCP).
149    pub fn transport(&self) -> Transport {
150        self.transport
151    }
152
153    /// Cancel the transport — stops the serve loop and frees the socket.
154    pub fn shutdown(&self) {
155        self.transport_cancel.cancel();
156    }
157
158    /// Route an inbound in-dialog transaction (BYE, INFO, OPTIONS,
159    /// re-INVITE, …) to its matching dialog and drive the dialog's
160    /// `handle()` to completion.
161    ///
162    /// The incoming-transaction stream returned by [`SipEndpoint::new`]
163    /// yields *every* inbound transaction the transport receives — the
164    /// initial INVITE for a new call, but also subsequent BYE/INFO/etc.
165    /// for already-established dialogs. Initial INVITEs go to
166    /// [`crate::Callee`]; everything else should be handed to this
167    /// helper so the dialog state machine advances (e.g. moving to
168    /// `Terminated` on a remote BYE and sending the matching 200 OK).
169    ///
170    /// On a non-matching transaction this replies with `481 Call/
171    /// Transaction Does Not Exist` per RFC 3261; on a matching dialog
172    /// of an unsupported kind (subscriptions, publications) it replies
173    /// `501 Not Implemented`. The outcome is returned so callers can
174    /// emit diagnostics.
175    pub async fn dispatch_in_dialog(
176        &self,
177        mut tx: Transaction,
178    ) -> Result<DispatchOutcome, Box<dyn std::error::Error + Send + Sync>> {
179        let Some(dialog) = self.dialog_layer.match_dialog(&tx) else {
180            // No dialog matched. Best-effort 481; rsipstack's transport
181            // layer drops stateless replies that fail to send.
182            let _ = tx.reply(StatusCode::CallTransactionDoesNotExist).await;
183            return Ok(DispatchOutcome::NoDialog);
184        };
185
186        match dialog {
187            Dialog::ServerInvite(mut d) => {
188                d.handle(&mut tx).await?;
189                Ok(DispatchOutcome::Handled)
190            }
191            Dialog::ClientInvite(mut d) => {
192                d.handle(&mut tx).await?;
193                Ok(DispatchOutcome::Handled)
194            }
195            _ => {
196                let _ = tx.reply(StatusCode::NotImplemented).await;
197                Ok(DispatchOutcome::Unsupported)
198            }
199        }
200    }
201}
202
203/// Outcome of [`SipEndpoint::dispatch_in_dialog`].
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum DispatchOutcome {
206    /// A dialog matched and ran its handler. Subsequent state
207    /// transitions (e.g. `Terminated` on a BYE) will appear on the
208    /// dialog's `DialogStateReceiver`.
209    Handled,
210    /// No dialog matched the transaction's `Call-ID` + tag pair. The
211    /// helper replied `481 Call/Transaction Does Not Exist`.
212    NoDialog,
213    /// A dialog matched, but its kind isn't an INVITE dialog (e.g.
214    /// subscriptions). The helper replied `501 Not Implemented`.
215    Unsupported,
216}
217
218/// Build the User-Agent header string.
219fn build_user_agent(version: &str, git_hash: &str, os: &str, arch: &str, host: &str) -> String {
220    format!("wavekat-sip/{version} ({git_hash}) ({os}/{arch}) {host}")
221}
222
223/// Returns a human-friendly OS name with version, e.g. `"macOS 15.5"`.
224///
225/// Falls back to `std::env::consts::OS` if the version cannot be determined.
226fn os_version() -> String {
227    #[cfg(target_os = "macos")]
228    {
229        if let Ok(out) = std::process::Command::new("sw_vers")
230            .arg("-productVersion")
231            .output()
232        {
233            let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
234            if !ver.is_empty() {
235                return format!("macOS {ver}");
236            }
237        }
238    }
239    #[cfg(target_os = "linux")]
240    {
241        if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
242            for line in contents.lines() {
243                if let Some(name) = line.strip_prefix("PRETTY_NAME=") {
244                    return name.trim_matches('"').to_string();
245                }
246            }
247        }
248    }
249    #[cfg(target_os = "windows")]
250    {
251        if let Ok(out) = std::process::Command::new("cmd")
252            .args(["/C", "ver"])
253            .output()
254        {
255            let ver = String::from_utf8_lossy(&out.stdout).trim().to_string();
256            if !ver.is_empty() {
257                return ver;
258            }
259        }
260    }
261    std::env::consts::OS.to_string()
262}
263
264/// Detect the local IP that routes to the SIP server.
265///
266/// Opens a temporary UDP socket, connects to the server (no data sent),
267/// and reads back the OS-chosen source address.
268fn detect_local_ip(
269    account: &SipAccount,
270) -> Result<IpAddr, Box<dyn std::error::Error + Send + Sync>> {
271    let dest = format!("{}:{}", account.server(), account.port());
272    let sock = std::net::UdpSocket::bind("0.0.0.0:0")?;
273    sock.connect(&dest)?;
274    let local = sock.local_addr()?;
275    Ok(local.ip())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn make_account(server: Option<&str>, port: Option<u16>) -> SipAccount {
283        SipAccount {
284            display_name: "Test".to_string(),
285            username: "1001".to_string(),
286            password: "secret".to_string(),
287            domain: "localhost".to_string(),
288            auth_username: None,
289            server: server.map(|s| s.to_string()),
290            port,
291            transport: Transport::default(),
292        }
293    }
294
295    #[test]
296    fn build_user_agent_format() {
297        let ua = build_user_agent("0.0.1", "abc1234", "macOS 15.5", "aarch64", "myhost.local");
298        assert_eq!(
299            ua,
300            "wavekat-sip/0.0.1 (abc1234) (macOS 15.5/aarch64) myhost.local"
301        );
302    }
303
304    #[test]
305    fn build_user_agent_empty_host() {
306        let ua = build_user_agent("1.0.0", "def5678", "Linux", "x86_64", "");
307        assert_eq!(ua, "wavekat-sip/1.0.0 (def5678) (Linux/x86_64) ");
308    }
309
310    #[test]
311    fn os_version_returns_non_empty() {
312        let version = os_version();
313        assert!(!version.is_empty());
314        #[cfg(target_os = "macos")]
315        assert!(version.starts_with("macOS"), "got: {version}");
316    }
317
318    #[test]
319    fn detect_local_ip_returns_non_unspecified() {
320        let account = make_account(Some("127.0.0.1"), Some(5060));
321        let ip = detect_local_ip(&account).unwrap();
322        assert!(!ip.is_unspecified(), "detected IP should not be 0.0.0.0");
323        assert_eq!(ip, IpAddr::from([127, 0, 0, 1]));
324    }
325
326    #[test]
327    fn detect_local_ip_uses_server_field() {
328        let account = make_account(Some("127.0.0.1"), None);
329        let ip = detect_local_ip(&account).unwrap();
330        assert_eq!(ip, IpAddr::from([127, 0, 0, 1]));
331    }
332
333    #[test]
334    fn detect_local_ip_falls_back_to_domain() {
335        let account = make_account(None, None);
336        let ip = detect_local_ip(&account).unwrap();
337        assert_eq!(ip, IpAddr::from([127, 0, 0, 1]));
338    }
339
340    #[tokio::test]
341    async fn endpoint_exposes_local_addr_and_transport() {
342        let account = make_account(Some("127.0.0.1"), Some(5060));
343        let cancel = CancellationToken::new();
344        let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await.unwrap();
345
346        let local = endpoint.local_addr().expect("local_addr available");
347        assert_eq!(local.ip(), IpAddr::from([127, 0, 0, 1]));
348        assert_ne!(local.port(), 0, "bound port should be assigned");
349        assert_eq!(endpoint.local_ip(), local.ip());
350        assert_eq!(endpoint.transport(), Transport::Udp);
351
352        endpoint.shutdown();
353    }
354
355    #[tokio::test]
356    async fn endpoint_overrides_callid_suffix() {
357        let account = make_account(Some("127.0.0.1"), Some(5060));
358        let cancel = CancellationToken::new();
359        let (endpoint, _incoming) = SipEndpoint::new(&account, cancel.clone()).await.unwrap();
360
361        let suffix = endpoint
362            .inner
363            .option
364            .callid_suffix
365            .as_deref()
366            .expect("callid_suffix should be configured");
367        assert_eq!(suffix, CALLID_SUFFIX);
368        assert_ne!(
369            suffix, "restsend.com",
370            "should not fall back to rsipstack's default"
371        );
372
373        endpoint.shutdown();
374    }
375}