1use 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
22const CALLID_SUFFIX: &str = "wavekat.com";
29
30pub struct SipEndpoint {
32 pub inner: EndpointInnerRef,
34 pub dialog_layer: Arc<DialogLayer>,
36 pub sip_addr: SipAddr,
38 transport: Transport,
42 transport_cancel: CancellationToken,
43}
44
45impl SipEndpoint {
46 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 }
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 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 pub fn local_addr(&self) -> Option<SocketAddr> {
145 self.sip_addr.addr.to_string().parse::<SocketAddr>().ok()
146 }
147
148 pub fn transport(&self) -> Transport {
150 self.transport
151 }
152
153 pub fn shutdown(&self) {
155 self.transport_cancel.cancel();
156 }
157
158 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum DispatchOutcome {
206 Handled,
210 NoDialog,
213 Unsupported,
216}
217
218fn 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
223fn 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
264fn 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}