netget/server/sip/mod.rs
1//! SIP server implementation
2pub mod actions;
3
4use crate::server::connection::ConnectionId;
5use anyhow::Result;
6use std::net::SocketAddr;
7use std::sync::{Arc, LazyLock};
8use tokio::net::UdpSocket;
9use tokio::sync::mpsc;
10
11use crate::llm::action_helper::call_llm;
12use crate::llm::ollama_client::OllamaClient;
13use crate::logging::emit::Log;
14use crate::protocol::Event;
15use crate::server::SipProtocol;
16use crate::state::app_state::AppState;
17use actions::{
18 SIP_ACK_EVENT, SIP_BYE_EVENT, SIP_CANCEL_EVENT, SIP_INVITE_EVENT, SIP_OPTIONS_EVENT,
19 SIP_REGISTER_EVENT,
20};
21
22/// SIP server that handles VoIP signaling
23pub struct SipServer;
24
25impl SipServer {
26 /// Spawn SIP server with integrated LLM actions
27 pub async fn spawn_with_llm_actions(
28 listen_addr: SocketAddr,
29 llm_client: OllamaClient,
30 app_state: Arc<AppState>,
31 status_tx: mpsc::UnboundedSender<String>,
32 server_id: crate::state::ServerId,
33 ) -> Result<SocketAddr> {
34 let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
35 let local_addr = socket.local_addr()?;
36 Log::new(Some(&status_tx)).info(format!("SIP server listening on {}", local_addr));
37
38 let protocol = Arc::new(SipProtocol::new());
39
40 let task_registrar = app_state.clone();
41 let accept_handle = tokio::spawn(async move {
42 let mut buffer = vec![0u8; 65535]; // Max UDP packet size
43 let log = Log::new(Some(&status_tx));
44
45 loop {
46 match socket.recv_from(&mut buffer).await {
47 Ok((n, peer_addr)) => {
48 let data = buffer[..n].to_vec();
49 let connection_id =
50 ConnectionId::new(app_state.get_next_unified_id().await);
51
52 // Add connection to ServerInstance
53 use crate::state::server::{
54 ConnectionState as ServerConnectionState, ConnectionStatus,
55 ProtocolConnectionInfo,
56 };
57 let now = crate::utils::clock::Instant::now();
58 let conn_state = ServerConnectionState {
59 id: connection_id,
60 remote_addr: peer_addr,
61 local_addr,
62 bytes_sent: 0,
63 bytes_received: n as u64,
64 packets_sent: 0,
65 packets_received: 1,
66 last_activity: now,
67 status: ConnectionStatus::Active,
68 status_changed_at: now,
69 protocol_info: ProtocolConnectionInfo::empty(),
70 };
71 app_state
72 .add_connection_to_server(server_id, conn_state)
73 .await;
74 let _ = status_tx.send("__UPDATE_UI__".to_string());
75
76 // Summary + full payload FileOnly: the sip_* event templates
77 // render the equivalent line to the TUI.
78 log.debug(format!("SIP received {} bytes from {}", n, peer_addr));
79 if let Ok(text) = String::from_utf8(data.clone()) {
80 let preview = crate::utils::truncate_for_log(&text, 200);
81 log.trace(format!("SIP message: {}", preview));
82 }
83
84 let llm_clone = llm_client.clone();
85 let state_clone = app_state.clone();
86 let status_clone = status_tx.clone();
87 let socket_clone = socket.clone();
88 let protocol_clone = protocol.clone();
89
90 // Tracked, not detached: stop_server must abort this task too.
91 let task_owner = app_state.clone();
92 task_owner
93 .spawn_server_task(server_id, async move {
94 let log = Log::new(Some(&status_clone));
95 // Parse SIP message
96 let sip_message = match Self::parse_sip_message(&data) {
97 Ok(msg) => msg,
98 Err(e) => {
99 log.warn(format!(
100 "SIP failed to parse message from {}: {}",
101 peer_addr, e
102 ));
103 return;
104 }
105 };
106
107 // Request summary FileOnly: the sip_* event template renders
108 // the equivalent line to the TUI.
109 log.debug(format!(
110 "SIP {} request from {} (Call-ID: {})",
111 sip_message.method, peer_addr, sip_message.call_id
112 ));
113
114 // Create event based on SIP method
115 let event = Self::create_event(
116 &sip_message,
117 peer_addr,
118 local_addr,
119 connection_id,
120 );
121
122 // Call LLM with SIP event
123 match call_llm(
124 &llm_clone,
125 &state_clone,
126 server_id,
127 Some(connection_id),
128 &event,
129 protocol_clone.as_ref(),
130 )
131 .await
132 {
133 Ok(execution_result) => {
134 // ACK takes no response at all (RFC 3261 §17), so the
135 // whole reply path is skipped for it - including the
136 // no-answer refusal below. Answering an ACK is a protocol
137 // violation whatever the model said.
138 if sip_message.method == "ACK" {
139 log.debug(format!(
140 "SIP ACK from {} (Call-ID {}) decision=model_answer; \
141 ACK takes no response",
142 peer_addr, sip_message.call_id
143 ));
144 return;
145 }
146
147 // Pick the action that is actually a SIP response. The model
148 // may legitimately return a common action (set_memory,
149 // schedule_task, ...) alongside - or ahead of - its reply, and
150 // taking `raw_actions.first()` blindly turned that into a
151 // fabricated status line while the real answer was ignored.
152 let response_action = execution_result
153 .raw_actions
154 .iter()
155 .find(|a| is_sip_response_action(a));
156
157 let Some(action) = response_action else {
158 // The model was reachable and produced no SIP response.
159 // That is not an accept: on REGISTER or INVITE, staying
160 // silent leaves the UAC retransmitting for 32 seconds and
161 // tells the operator nothing, so refuse explicitly and
162 // keep the decision distinguishable from a backend
163 // failure (radius' decision= shape).
164 log.warn(format!(
165 "SIP {} from {} (Call-ID {}) decision=model_silent; \
166 refusing with 500 (the model returned no SIP \
167 response action)",
168 sip_message.method, peer_addr, sip_message.call_id
169 ));
170 let refusal = Self::build_sip_response(
171 &sip_message,
172 &serde_json::json!({
173 "status_code": 500,
174 "reason_phrase": "Server Internal Error"
175 }),
176 );
177 if let Err(e) =
178 socket_clone.send_to(&refusal, peer_addr).await
179 {
180 log.error(format!(
181 "SIP failed to send 500 to {}: {}",
182 peer_addr, e
183 ));
184 }
185 return;
186 };
187
188 // An explicit non-2xx is the model refusing, which must stay
189 // separable in the log from the silence above and from a
190 // backend outage below.
191 let decision = match action
192 .get("status_code")
193 .and_then(|v| v.as_u64())
194 {
195 Some(code) if !(200..300).contains(&code) => {
196 "model_reject"
197 }
198 Some(_) => "model_answer",
199 // build_sip_response answers 500 for this; naming it
200 // here keeps "the model forgot a field" apart from
201 // "the model chose 500".
202 None => "fail_closed_malformed_action",
203 };
204 log.debug(format!(
205 "SIP {} from {} (Call-ID {}) decision={}",
206 sip_message.method,
207 peer_addr,
208 sip_message.call_id,
209 decision
210 ));
211
212 // Build SIP response from action JSON
213 let response =
214 Self::build_sip_response(&sip_message, action);
215
216 // Send SIP response
217 match socket_clone.send_to(&response, peer_addr).await {
218 Ok(sent) => {
219 // Summary FileOnly: the send action template
220 // already reports the send to the TUI.
221 log.debug(format!(
222 "SIP sent {} byte response to {}",
223 sent, peer_addr
224 ));
225 }
226 Err(e) => {
227 log.error(format!(
228 "SIP failed to send response: {}",
229 e
230 ));
231 }
232 }
233
234 // Media flow: SIP is signaling only, but when built with
235 // the `rtp` feature and the accept action carries an
236 // `rtp_audio` description, we honour the SDP we just
237 // negotiated by streaming real RTP to the caller's
238 // advertised media address. This is what makes a SIP INVITE
239 // result in RTP actually arriving, rather than a 200 OK with
240 // an SDP that points at nothing.
241 {
242 // No `unwrap_or(200)`: a missing status_code was answered
243 // 500 above, and treating it as 200 here would stream
244 // media for a call that was never accepted.
245 let status_code = action
246 .get("status_code")
247 .and_then(|v| v.as_u64())
248 .unwrap_or(0);
249 if sip_message.method == "INVITE" && status_code == 200
250 {
251 if let Some(rtp_audio) = action.get("rtp_audio") {
252 Self::stream_invite_media(
253 sip_message.body.as_deref(),
254 peer_addr,
255 rtp_audio.clone(),
256 status_clone.clone(),
257 state_clone.clone(),
258 server_id,
259 )
260 .await;
261 }
262 }
263 }
264 }
265 Err(e) => {
266 // Non-fatal: a 503 (wire fallback) is built and sent
267 // below for everything except ACK, so this is WARN.
268 log.warn(format!(
269 "SIP LLM error for {} from {} (Call-ID {}): {}",
270 sip_message.method, peer_addr, sip_message.call_id, e
271 ));
272
273 // ACK is the one method that must never be answered
274 // (RFC 3261 §17: ACK is not a transaction that takes a
275 // response), so silence is correct there and only there.
276 if sip_message.method == "ACK" {
277 log.debug("SIP ACK needs no response; nothing sent");
278 return;
279 }
280
281 // Everything else gets a 503 Service Unavailable built
282 // from the request, so the Via/From/To/Call-ID/CSeq match
283 // and the client's transaction actually completes. Left
284 // silent, the UAC retransmits on timers A/E and only gives
285 // up after Timer B/F (32s).
286 if crate::llm::is_overload_error(&e) {
287 log.warn(format!(
288 "SIP 503 to {} (Call-ID {}): LLM capacity exhausted",
289 peer_addr, sip_message.call_id
290 ));
291 }
292 let response = Self::build_sip_response(
293 &sip_message,
294 &serde_json::json!({
295 "status_code": 503,
296 "reason_phrase": "Service Unavailable",
297 "retry_after": 5
298 }),
299 );
300 match socket_clone.send_to(&response, peer_addr).await {
301 Ok(sent) => {
302 log.debug(format!(
303 "SIP 503 Service Unavailable to {} ({} bytes)",
304 peer_addr, sent
305 ));
306 }
307 Err(send_err) => {
308 log.error(format!(
309 "SIP failed to send 503 to {}: {}",
310 peer_addr, send_err
311 ));
312 }
313 }
314 }
315 }
316 })
317 .await;
318 }
319 Err(e) => {
320 log.error(format!("SIP recv error: {}", e));
321 }
322 }
323 }
324 });
325
326 // Register the accept loop so stop_server can abort it and release the port.
327 task_registrar
328 .register_server_task(server_id, accept_handle)
329 .await;
330
331 Ok(local_addr)
332 }
333
334 /// Parse SIP message from bytes
335 fn parse_sip_message(data: &[u8]) -> Result<SipMessage> {
336 let text = String::from_utf8(data.to_vec())?;
337 let lines: Vec<&str> = text.lines().collect();
338
339 if lines.is_empty() {
340 anyhow::bail!("Empty SIP message");
341 }
342
343 // Parse request line (e.g., "REGISTER sip:example.com SIP/2.0")
344 let request_line: Vec<&str> = lines[0].split_whitespace().collect();
345 if request_line.len() < 3 {
346 anyhow::bail!("Invalid SIP request line");
347 }
348
349 let method = request_line[0].to_string();
350 let request_uri = request_line[1].to_string();
351
352 // Parse headers
353 let mut call_id = String::new();
354 let mut from = String::new();
355 let mut to = String::new();
356 let mut via = Vec::new();
357 let mut cseq = String::new();
358 let mut contact = None;
359 let mut expires = None;
360 let mut content_type = None;
361 let mut content_length = 0;
362
363 let mut i = 1;
364 while i < lines.len() {
365 let line = lines[i];
366 if line.is_empty() {
367 // End of headers
368 i += 1;
369 break;
370 }
371
372 if let Some(colon_pos) = line.find(':') {
373 let (header_name, header_value) = line.split_at(colon_pos);
374 let header_value = header_value[1..].trim();
375
376 match header_name.to_lowercase().as_str() {
377 "call-id" => call_id = header_value.to_string(),
378 "from" | "f" => from = header_value.to_string(),
379 "to" | "t" => to = header_value.to_string(),
380 "via" | "v" => via.push(header_value.to_string()),
381 "cseq" => cseq = header_value.to_string(),
382 "contact" | "m" => contact = Some(header_value.to_string()),
383 "expires" => expires = header_value.parse().ok(),
384 "content-type" | "c" => content_type = Some(header_value.to_string()),
385 "content-length" | "l" => content_length = header_value.parse().unwrap_or(0),
386 _ => {}
387 }
388 }
389
390 i += 1;
391 }
392
393 // Parse body (SDP)
394 let mut body = String::new();
395 if content_length > 0 && i < lines.len() {
396 body = lines[i..].join("\r\n");
397 }
398
399 Ok(SipMessage {
400 method,
401 request_uri,
402 call_id,
403 from,
404 to,
405 via,
406 cseq,
407 contact,
408 expires,
409 content_type,
410 body: if body.is_empty() { None } else { Some(body) },
411 })
412 }
413
414 /// Create event from SIP message
415 fn create_event(
416 sip_message: &SipMessage,
417 peer_addr: SocketAddr,
418 local_addr: SocketAddr,
419 connection_id: ConnectionId,
420 ) -> Event {
421 let base_data = serde_json::json!({
422 "peer_addr": peer_addr.to_string(),
423 "local_addr": local_addr.to_string(),
424 "connection_id": connection_id.to_string(),
425 "call_id": sip_message.call_id,
426 "from": sip_message.from,
427 "to": sip_message.to,
428 "cseq": sip_message.cseq,
429 });
430
431 match sip_message.method.as_str() {
432 "REGISTER" => {
433 let mut data = base_data;
434 if let Some(contact) = &sip_message.contact {
435 data["contact"] = serde_json::json!(contact);
436 }
437 if let Some(expires) = sip_message.expires {
438 data["expires"] = serde_json::json!(expires);
439 }
440 Event {
441 event_type: &SIP_REGISTER_EVENT,
442 data,
443 }
444 }
445 "INVITE" => {
446 let mut data = base_data;
447 if let Some(body) = &sip_message.body {
448 data["sdp"] = serde_json::json!(body);
449 }
450 Event {
451 event_type: &SIP_INVITE_EVENT,
452 data,
453 }
454 }
455 "BYE" => Event {
456 event_type: &SIP_BYE_EVENT,
457 data: base_data,
458 },
459 "ACK" => Event {
460 event_type: &SIP_ACK_EVENT,
461 data: base_data,
462 },
463 "OPTIONS" => {
464 let mut data = base_data;
465 data["answer_with"] = serde_json::json!(actions::OPTIONS_ANSWER_WITH);
466 Event {
467 event_type: &SIP_OPTIONS_EVENT,
468 data,
469 }
470 }
471 "CANCEL" => Event {
472 event_type: &SIP_CANCEL_EVENT,
473 data: base_data,
474 },
475 _ => {
476 // Unknown method, treat as OPTIONS event
477 Event {
478 event_type: &SIP_OPTIONS_EVENT,
479 data: base_data,
480 }
481 }
482 }
483 }
484
485 /// Build SIP response from action JSON
486 fn build_sip_response(request: &SipMessage, response_action: &serde_json::Value) -> Vec<u8> {
487 // A non-object action used to `.expect(...)` here. That is a panic reachable from the
488 // wire - the model's own output, inside a `tokio::spawn` that swallows the panic - so
489 // the peer got silence, the log got nothing, and the server went on reporting Running.
490 // An empty object routes into the missing-status_code branch below, which is the same
491 // fail-closed 500 a malformed action deserves.
492 static EMPTY: LazyLock<serde_json::Map<String, serde_json::Value>> =
493 LazyLock::new(serde_json::Map::new);
494 let response_data = match response_action.as_object() {
495 Some(obj) => obj,
496 None => {
497 tracing::warn!(
498 "SIP action was not a JSON object ({}); answering 500",
499 crate::utils::truncate_for_log(&response_action.to_string(), 120)
500 );
501 &EMPTY
502 }
503 };
504 // A missing `status_code` is a malformed action, not a decision, so it must not
505 // become 200. On REGISTER a defaulted 200 is an accepted registration and on INVITE
506 // an accepted call - granted because a field was forgotten. SIP has no default
507 // status, so the honest answer is the server's own 500; the model's actual choices
508 // (401, 403, 486) are unaffected because they name the field.
509 let (status_code, default_reason) =
510 match response_data.get("status_code").and_then(|v| v.as_u64()) {
511 Some(code) => (code as u16, actions::default_reason_phrase(code as u16)),
512 None => {
513 tracing::warn!(
514 "SIP action carried no status_code; answering 500 rather than defaulting \
515 to 200, which would accept the request"
516 );
517 (500u16, "Server Internal Error")
518 }
519 };
520
521 let reason_phrase = response_data
522 .get("reason_phrase")
523 .and_then(|v| v.as_str())
524 .unwrap_or(default_reason);
525
526 // Build status line
527 let mut response = format!("SIP/2.0 {} {}\r\n", status_code, reason_phrase);
528
529 // Add Via headers (copy from request)
530 for via in &request.via {
531 response.push_str(&format!("Via: {}\r\n", via));
532 }
533
534 // Add From header (copy from request)
535 response.push_str(&format!("From: {}\r\n", request.from));
536
537 // Add To header (copy from request, add tag if not present)
538 let to_header = if request.to.contains(";tag=") {
539 request.to.clone()
540 } else {
541 // Generate a tag for the response
542 format!("{};tag={}", request.to, Self::generate_tag())
543 };
544 response.push_str(&format!("To: {}\r\n", to_header));
545
546 // Add Call-ID header (copy from request)
547 response.push_str(&format!("Call-ID: {}\r\n", request.call_id));
548
549 // Add CSeq header (copy from request)
550 response.push_str(&format!("CSeq: {}\r\n", request.cseq));
551
552 // Add Contact header for successful REGISTER
553 if request.method == "REGISTER" && status_code == 200 {
554 if let Some(contact) = &request.contact {
555 response.push_str(&format!("Contact: {}\r\n", contact));
556 }
557 }
558
559 // Add Expires header for REGISTER responses
560 if request.method == "REGISTER" {
561 let expires = response_data
562 .get("expires")
563 .and_then(|v| v.as_u64())
564 .unwrap_or(3600);
565 response.push_str(&format!("Expires: {}\r\n", expires));
566 }
567
568 // Add Retry-After when the action asks for one. RFC 3261 §20.33: a 503 SHOULD
569 // carry it, so the caller retries after a bounded wait instead of failing over
570 // to another proxy or giving up on the URI entirely.
571 if let Some(retry_after) = response_data.get("retry_after").and_then(|v| v.as_u64()) {
572 response.push_str(&format!("Retry-After: {}\r\n", retry_after));
573 }
574
575 // Add Allow header for OPTIONS responses
576 if request.method == "OPTIONS" {
577 if let Some(allow_methods) = response_data.get("allow_methods") {
578 if let Some(methods) = allow_methods.as_array() {
579 let methods_str: Vec<String> = methods
580 .iter()
581 .filter_map(|v| v.as_str())
582 .map(String::from)
583 .collect();
584 response.push_str(&format!("Allow: {}\r\n", methods_str.join(", ")));
585 }
586 }
587 }
588
589 // Add body (SDP) for successful INVITE responses
590 let body = if request.method == "INVITE" && status_code == 200 {
591 response_data
592 .get("sdp")
593 .and_then(|v| v.as_str())
594 .map(String::from)
595 } else {
596 None
597 };
598
599 // Add Content-Length and body
600 if let Some(body_text) = &body {
601 response.push_str("Content-Type: application/sdp\r\n");
602 response.push_str(&format!("Content-Length: {}\r\n", body_text.len()));
603 response.push_str("\r\n");
604 response.push_str(body_text);
605 } else {
606 response.push_str("Content-Length: 0\r\n");
607 response.push_str("\r\n");
608 }
609
610 response.into_bytes()
611 }
612
613 /// Generate a random tag for SIP responses
614 fn generate_tag() -> String {
615 use rand::Rng;
616 let tag: u32 = rand::thread_rng().gen();
617 format!("{:x}", tag)
618 }
619
620 /// Stream RTP audio to the media address the caller advertised in its INVITE SDP.
621 ///
622 /// Only compiled with the `rtp` feature. The `rtp_audio` value is the same structured media
623 /// description the RTP protocol understands (`content`, `tone_hz`, `payload_type`,
624 /// `duration_ms`) — the model describes what the call carries, and the shared media engine
625 /// (`crate::server::rtp::media`) owns the samples and RTP framing. A fresh ephemeral UDP
626 /// socket is used because RTP must not share the SIP signaling port.
627 async fn stream_invite_media(
628 caller_sdp: Option<&str>,
629 caller_addr: SocketAddr,
630 rtp_audio: serde_json::Value,
631 status_tx: mpsc::UnboundedSender<String>,
632 app_state: Arc<AppState>,
633 server_id: crate::state::ServerId,
634 ) {
635 use crate::server::rtp::media::{self, AudioCodec, RtpPacketizer};
636
637 let Some((ip, port)) = caller_sdp.and_then(parse_sdp_audio_target) else {
638 Log::new(Some(&status_tx)).warn(
639 "SIP INVITE had rtp_audio but no parseable m=audio target in the caller's SDP",
640 );
641 return;
642 };
643
644 // Refuse to stream anywhere but the source of the INVITE. SIP runs on UDP, so the
645 // source address is trivially spoofable and the SDP body names its own destination: an
646 // attacker who sends one 400-byte INVITE claiming `c=IN IP4 <victim>` would otherwise
647 // have NetGet blast seconds of G.711 at a third party it never heard from. Sending only
648 // to the address that actually spoke to us makes the media path unamplifiable, and the
649 // legitimate case is unaffected because a real UAC advertises its own address.
650 if ip != caller_addr.ip() {
651 Log::new(Some(&status_tx)).warn(format!(
652 "SIP INVITE decision=refused_media_redirect: SDP asks for media at {} but the \
653 INVITE came from {}; refusing to stream RTP to a third party",
654 ip,
655 caller_addr.ip()
656 ));
657 return;
658 }
659 let target = std::net::SocketAddr::new(ip, port);
660
661 let codec = rtp_audio
662 .get("payload_type")
663 .and_then(|v| v.as_str())
664 .map(AudioCodec::parse)
665 .unwrap_or(Ok(AudioCodec::Pcmu))
666 .unwrap_or(AudioCodec::Pcmu);
667 let content = media::parse_audio_content(&rtp_audio)
668 .unwrap_or(media::AudioContent::Tone { hz: 440.0 });
669 let duration_ms = rtp_audio
670 .get("duration_ms")
671 .and_then(|v| v.as_u64())
672 .unwrap_or(2000);
673
674 // Tracked, not detached: stop_server must abort this task too.
675 let task_owner = app_state.clone();
676 task_owner
677 .spawn_server_task(server_id, async move {
678 let log = Log::new(Some(&status_tx));
679 let socket = match tokio::net::UdpSocket::bind(std::net::SocketAddr::from((
680 [0u8, 0, 0, 0],
681 0,
682 )))
683 .await
684 {
685 Ok(s) => s,
686 Err(e) => {
687 log.error(format!("SIP RTP socket bind failed: {}", e));
688 return;
689 }
690 };
691 let payload = match media::synthesize(codec, &content, duration_ms) {
692 Ok(p) => p,
693 Err(e) => {
694 log.warn(format!("SIP RTP synthesis: {}", e));
695 return;
696 }
697 };
698 let mut packetizer =
699 RtpPacketizer::new(rand::random(), codec.payload_type(), None, None);
700 let packets = packetizer.packetize(&payload, media::G711_SAMPLES_PER_FRAME);
701 let mut sent = 0u64;
702 for pkt in &packets {
703 if socket.send_to(pkt, target).await.is_err() {
704 break;
705 }
706 sent += 1;
707 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
708 }
709 log.info(format!(
710 "SIP call media: {} RTP {} packet(s) to {}",
711 sent,
712 codec.rtpmap_name(),
713 target
714 ));
715 })
716 .await;
717 }
718}
719
720/// True if this action is one of the six SIP response verbs, i.e. something
721/// [`SipServer::build_sip_response`] can turn into a status line.
722///
723/// The reply path selects on this rather than taking the first action the model returned: a
724/// common action (`set_memory`, `schedule_task`, …) returned alongside the reply is executed by
725/// the shared executor and must not be mistaken for one.
726fn is_sip_response_action(action: &serde_json::Value) -> bool {
727 matches!(
728 action.get("type").and_then(|v| v.as_str()),
729 Some("sip_register" | "sip_invite" | "sip_bye" | "sip_ack" | "sip_options" | "sip_cancel")
730 )
731}
732
733/// Extract the media target (connection IP + audio port) from an SDP offer.
734///
735/// Reads the `c=IN IP4 <ip>` connection line and the first `m=audio <port> ...` line. Returns
736/// None if either is missing or unparseable.
737fn parse_sdp_audio_target(sdp: &str) -> Option<(std::net::IpAddr, u16)> {
738 let mut ip: Option<std::net::IpAddr> = None;
739 let mut port: Option<u16> = None;
740 for line in sdp.lines() {
741 let line = line.trim();
742 if let Some(rest) = line.strip_prefix("c=IN IP4 ") {
743 ip = rest
744 .trim()
745 .split('/')
746 .next()
747 .and_then(|s| s.trim().parse().ok());
748 } else if let Some(rest) = line.strip_prefix("m=audio ") {
749 port = rest.split_whitespace().next().and_then(|s| s.parse().ok());
750 }
751 }
752 Some((ip?, port?))
753}
754
755/// Parsed SIP message
756#[derive(Debug, Clone)]
757struct SipMessage {
758 method: String,
759 #[allow(dead_code)]
760 request_uri: String,
761 call_id: String,
762 from: String,
763 to: String,
764 via: Vec<String>,
765 cseq: String,
766 contact: Option<String>,
767 expires: Option<u32>,
768 #[allow(dead_code)]
769 content_type: Option<String>,
770 body: Option<String>,
771}