netget/server/rtsp/mod.rs
1//! RTSP server (RFC 2326).
2//!
3//! The control front door to NetGet's RTP media. A client (ffprobe, ffplay, VLC) runs
4//! OPTIONS → DESCRIBE → SETUP → PLAY → TEARDOWN; NetGet owns the RTSP framing (CSeq, Transport,
5//! Session, RTP-Info) deterministically, while the model shapes the DESCRIBE SDP, decides status
6//! codes, and — via PLAY — decides what the RTP stream carries. The media itself is synthesized
7//! by the shared `crate::server::rtp::media` engine, so an RTSP PLAY results in real RTP arriving.
8
9pub mod actions;
10
11use anyhow::Result;
12use std::collections::HashMap;
13use std::net::SocketAddr;
14use std::sync::Arc;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::net::{TcpStream, UdpSocket};
17use tokio::sync::mpsc;
18use tracing::debug;
19
20use crate::llm::action_helper::call_llm;
21use crate::llm::ollama_client::OllamaClient;
22use crate::logging::emit::Log;
23use crate::protocol::Event;
24use crate::server::connection::ConnectionId;
25use crate::server::rtp::media::{self, AudioCodec, RtpPacketizer};
26use crate::server::RtspProtocol;
27use crate::state::app_state::AppState;
28use actions::{
29 RTSP_DESCRIBE_EVENT, RTSP_OPTIONS_EVENT, RTSP_OTHER_EVENT, RTSP_PLAY_EVENT, RTSP_SETUP_EVENT,
30 RTSP_TEARDOWN_EVENT,
31};
32
33/// Parsed RTSP request.
34#[derive(Debug, Clone)]
35struct RtspRequest {
36 method: String,
37 uri: String,
38 cseq: String,
39 headers: HashMap<String, String>,
40}
41
42/// Per-connection RTSP session state.
43#[derive(Default)]
44struct Session {
45 id: Option<String>,
46 rtp_socket: Option<Arc<UdpSocket>>,
47 client_rtp_addr: Option<SocketAddr>,
48 server_rtp_port: Option<u16>,
49 /// Abort handle, not a `JoinHandle`: dropping a `JoinHandle` only detaches the task,
50 /// so a PLAY stream survived the session it belonged to. Registered with the server as
51 /// well, so `stop_server` ends it — RTP leaves over a separate UDP socket, and nothing
52 /// about closing the RTSP connection would otherwise stop the packets.
53 play_task: Option<tokio::task::AbortHandle>,
54}
55
56/// Largest single RTSP request the server will accumulate before refusing the peer.
57///
58/// RTSP requests are control messages — `DESCRIBE`, `SETUP`, `PLAY` — and the largest thing
59/// one legitimately carries is an SDP body on `ANNOUNCE`, which is kilobytes. 1 MiB is far
60/// above that and far below anything worth buffering from an unauthenticated peer: the parser
61/// needs a complete request before it can act, so until a `\r\n\r\n` arrives every byte is held.
62pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
63
64/// How long a peer that has connected and sent nothing at all may hold this connection.
65///
66/// **No NetGet client can be that peer**, which is what settles the number. There is no
67/// `src/client/rtsp/`, so this server's card shows `[ + client ]` disabled with "no client
68/// implementation for this protocol is compiled in" (`src/tui/cards.rs`) and the
69/// connected-and-silent peer that made `tcp`, `telnet`, `ldap`, `whois` and `redis` settle on
70/// 300 seconds cannot be created here. Nor is a peer ever waiting to be spoken to: RTSP's
71/// meaningful injection from the dashboard is `close_connection` — every `rtsp_*_response`
72/// returns `NoAction` because the framing is built here rather than by the action — so nothing
73/// useful happens on a connection whose client has said nothing.
74///
75/// Every peer that *can* be here speaks first and at once. RTSP is request/response with the
76/// client always initiating (RFC 2326 §10), and ffprobe, ffplay and VLC all send `OPTIONS` or
77/// `DESCRIBE` inside their open path. Thirty seconds is the same number `s3` and `etcd` argue
78/// from the same client-speaks-first property, and it sits between nginx's
79/// `client_header_timeout` of 60s and Apache's `RequestReadTimeout header=20`, which bound the
80/// identical thing for RTSP's parent protocol.
81const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
82
83/// How long an established RTSP session may go without a further request.
84///
85/// This bound is not free the way an ordinary idle bound is: closing the control connection
86/// aborts `Session::play_task`, so a value that is too tight stops **live media** for a client
87/// that is happily receiving it. RTP leaves over its own UDP socket and a playing client can be
88/// silent on the control channel the whole time.
89///
90/// What makes 300 seconds safe is RFC 2326 §12.37: a session's timeout defaults to 60 seconds
91/// when the `Session` header carries no `timeout=` parameter — as this server's does not — so
92/// every client that intends to keep a session alive must already send something (`OPTIONS` or
93/// `GET_PARAMETER`) well inside a minute, and ffmpeg does exactly that at `timeout / 2`.
94/// Five minutes is five of those windows: a client that has sent nothing for it has stopped
95/// keeping its own session alive by its own rules, and tearing down media it is no longer
96/// maintaining is correct rather than a surprise.
97///
98/// As everywhere, the deadline wraps the read alone: the model round-trip that shapes a
99/// DESCRIBE's SDP, and a `manual` rule parking that event for a human, both happen after the
100/// read has returned.
101const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
102
103/// Concurrent control connections this server admits.
104///
105/// [`accept_bounded::DEFAULT_MAX_CONNECTIONS`](crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS).
106/// Each admitted connection may buffer [`MAX_REQUEST_BYTES`] and may own a PLAY task with its
107/// own UDP socket, so the cap is what turns those per-connection bounds into total ones —
108/// 256 MiB of buffer and 256 sockets at the very worst.
109const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
110
111/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
112///
113/// RTSP inherits HTTP's status vocabulary (RFC 2326 §11) and `503 Service Unavailable` is the
114/// answer a busy server owes. It carries no `CSeq`, for the same reason the oversized-request
115/// `413` below carries none: the refused peer has sent no request, so there is no sequence
116/// number to echo, and RFC 2326 permits a response the server generates without one. ffmpeg
117/// and VLC both surface a 503 as a server-side refusal rather than as a transport fault, which
118/// is the whole point of saying it out loud.
119const CONNECTION_CAP_REFUSAL: &[u8] =
120 b"RTSP/1.0 503 Service Unavailable\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
121
122pub struct RtspServer;
123
124impl RtspServer {
125 /// Spawn the RTSP server. Awaits the TCP bind so failure is reported as `Err`, and registers
126 /// the accept loop so `stop_server` can abort it.
127 pub async fn spawn_with_llm_actions(
128 listen_addr: SocketAddr,
129 llm_client: OllamaClient,
130 app_state: Arc<AppState>,
131 status_tx: mpsc::UnboundedSender<String>,
132 server_id: crate::state::ServerId,
133 first_byte_timeout_secs: Option<u64>,
134 idle_timeout_secs: Option<u64>,
135 ) -> Result<SocketAddr> {
136 // Both bounds are tunable because their right value is a property of who is on the
137 // other end, which only the operator knows. The defaults are argued beside the
138 // constants.
139 let first_byte_timeout = first_byte_timeout_secs
140 .map(std::time::Duration::from_secs)
141 .unwrap_or(FIRST_BYTE_READ_TIMEOUT);
142 let idle_timeout = idle_timeout_secs
143 .map(std::time::Duration::from_secs)
144 .unwrap_or(IDLE_BETWEEN_REQUESTS_TIMEOUT);
145 let listener =
146 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
147 let local_addr = listener.local_addr()?;
148 Log::new(Some(&status_tx)).info(format!("RTSP server listening on {}", local_addr));
149
150 let protocol = Arc::new(RtspProtocol::new());
151 let task_registrar = app_state.clone();
152
153 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
154 let accept_handle = tokio::spawn(async move {
155 loop {
156 match crate::server::accept_bounded::accept_bounded(
157 &listener,
158 &limiter,
159 CONNECTION_CAP_REFUSAL,
160 "RTSP",
161 Some(&status_tx),
162 )
163 .await
164 {
165 Ok((stream, remote_addr, permit)) => {
166 let connection_id =
167 ConnectionId::new(app_state.get_next_unified_id().await);
168 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
169
170 use crate::state::server::{
171 ConnectionState as ServerConnectionState, ConnectionStatus,
172 ProtocolConnectionInfo,
173 };
174 let now = crate::utils::clock::Instant::now();
175 let conn_state = ServerConnectionState {
176 id: connection_id,
177 remote_addr,
178 local_addr: local_addr_conn,
179 bytes_sent: 0,
180 bytes_received: 0,
181 packets_sent: 0,
182 packets_received: 0,
183 last_activity: now,
184 status: ConnectionStatus::Active,
185 status_changed_at: now,
186 protocol_info: ProtocolConnectionInfo::empty(),
187 };
188 app_state
189 .add_connection_to_server(server_id, conn_state)
190 .await;
191 let _ = status_tx.send("__UPDATE_UI__".to_string());
192
193 let llm = llm_client.clone();
194 let state = app_state.clone();
195 let stx = status_tx.clone();
196 let proto = protocol.clone();
197 // Tracked, not detached: stop_server must abort this task too.
198 let task_owner = app_state.clone();
199 task_owner
200 .spawn_server_task(server_id, async move {
201 // The permit is released when this task ends, which is what
202 // makes MAX_CONNECTIONS a cap on live connections rather than
203 // on the accept rate.
204 let _permit = permit;
205 if let Err(e) = Self::handle_connection(
206 stream,
207 remote_addr,
208 connection_id,
209 server_id,
210 llm,
211 state,
212 stx.clone(),
213 proto,
214 first_byte_timeout,
215 idle_timeout,
216 )
217 .await
218 {
219 Log::new(Some(&stx))
220 .debug(format!("RTSP connection closed: {}", e));
221 }
222 })
223 .await;
224 }
225 Err(e) => {
226 Log::new(Some(&status_tx)).error(format!("RTSP accept error: {}", e));
227 }
228 }
229 }
230 });
231
232 task_registrar
233 .register_server_task(server_id, accept_handle)
234 .await;
235 Ok(local_addr)
236 }
237
238 #[allow(clippy::too_many_arguments)]
239 async fn handle_connection(
240 stream: TcpStream,
241 remote_addr: SocketAddr,
242 connection_id: ConnectionId,
243 server_id: crate::state::ServerId,
244 llm: OllamaClient,
245 state: Arc<AppState>,
246 status_tx: mpsc::UnboundedSender<String>,
247 protocol: Arc<RtspProtocol>,
248 first_byte_timeout: std::time::Duration,
249 idle_timeout: std::time::Duration,
250 ) -> Result<()> {
251 // Share the write half between the reader loop and the peer-injection task through
252 // one Arc<Mutex<_>>, so an injected write cannot interleave with a response.
253 let (read_half, write_half) = tokio::io::split(stream);
254 let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
255 let mut session = Session::default();
256
257 // Peer messaging: register a handle so the dashboard's "message this peer" /
258 // "disconnect this peer" can inject an action into THIS connection through the same
259 // executor the LLM path uses. RTSP's response verbs return `NoAction` (framing — CSeq,
260 // Transport, Session — is built here in mod.rs, not by the action), so an injected
261 // `rtsp_*_response` writes no bytes; the meaningful injection is `close_connection`,
262 // which the generic peer task half-closes (the reader then sees EOF below).
263 let peer_rx = crate::server::peer_support::register_peer_channel(
264 &state,
265 server_id,
266 connection_id.as_u32(),
267 )
268 .await;
269 crate::server::peer_support::spawn_peer_command_task(
270 peer_rx,
271 protocol.clone(),
272 state.clone(),
273 server_id,
274 connection_id.as_u32(),
275 write_half.clone(),
276 status_tx.clone(),
277 );
278
279 let result = Self::run_connection(
280 read_half,
281 &write_half,
282 remote_addr,
283 connection_id,
284 server_id,
285 &llm,
286 &state,
287 &status_tx,
288 &protocol,
289 &mut session,
290 first_byte_timeout,
291 idle_timeout,
292 )
293 .await;
294
295 // Every exit path — EOF, read error, 1 MiB overflow, injected close — lands here.
296 if let Some(task) = session.play_task.take() {
297 task.abort();
298 }
299 state
300 .remove_peer_handle(server_id, connection_id.as_u32())
301 .await;
302 state
303 .close_connection_on_server(server_id, connection_id)
304 .await;
305 let _ = status_tx.send("__UPDATE_UI__".to_string());
306 result
307 }
308
309 #[allow(clippy::too_many_arguments)]
310 async fn run_connection(
311 mut read_half: tokio::io::ReadHalf<TcpStream>,
312 write_half: &Arc<tokio::sync::Mutex<tokio::io::WriteHalf<TcpStream>>>,
313 remote_addr: SocketAddr,
314 connection_id: ConnectionId,
315 server_id: crate::state::ServerId,
316 llm: &OllamaClient,
317 state: &Arc<AppState>,
318 status_tx: &mpsc::UnboundedSender<String>,
319 protocol: &Arc<RtspProtocol>,
320 session: &mut Session,
321 first_byte_timeout: std::time::Duration,
322 idle_timeout: std::time::Duration,
323 ) -> Result<()> {
324 let mut buffer: Vec<u8> = Vec::new();
325 let mut chunk = [0u8; 8192];
326 // Has this peer had a request answered yet? Until it has, the shorter bound applies.
327 let mut answered_one = false;
328
329 loop {
330 // Extract as many complete requests as the buffer holds before reading more.
331 while let Some((req, consumed)) = parse_rtsp_request(&buffer) {
332 buffer.drain(..consumed);
333 Log::new(Some(status_tx)).trace(format!("RTSP {} {}", req.method, req.uri));
334 let response = Self::process_request(
335 &req,
336 remote_addr,
337 connection_id,
338 server_id,
339 llm,
340 state,
341 status_tx,
342 session,
343 protocol.as_ref(),
344 )
345 .await;
346 let bytes = response.into_bytes();
347 {
348 let mut w = write_half.lock().await;
349 w.write_all(&bytes).await?;
350 w.flush().await?;
351 }
352 state
353 .update_connection_stats(
354 server_id,
355 connection_id,
356 None,
357 Some(bytes.len() as u64),
358 None,
359 Some(1),
360 )
361 .await;
362 // From here the peer is an established session, so every later read gets the
363 // longer idle bound.
364 answered_one = true;
365 }
366
367 // The deadline wraps this read and nothing else. The model round-trip that shapes
368 // a DESCRIBE's SDP or decides a PLAY, and a `manual` rule parking that event for a
369 // human (300s by default), both happen above — after a read has already returned —
370 // so neither can be timed out from under itself. What is bounded is only the time
371 // a peer may hold this connection while sending nothing.
372 let read_timeout = if answered_one {
373 idle_timeout
374 } else {
375 first_byte_timeout
376 };
377 let n = match tokio::time::timeout(read_timeout, read_half.read(&mut chunk)).await {
378 Ok(result) => result?,
379 Err(_) => {
380 Log::new(Some(status_tx)).info(format!(
381 "RTSP connection {} sent nothing for {}s; closing idle connection \
382 decision=fail_closed_read_timeout",
383 connection_id,
384 read_timeout.as_secs()
385 ));
386 return Ok(());
387 }
388 };
389 if n == 0 {
390 return Ok(());
391 }
392 state
393 .update_connection_stats(
394 server_id,
395 connection_id,
396 Some(n as u64),
397 None,
398 Some(1),
399 None,
400 )
401 .await;
402 buffer.extend_from_slice(&chunk[..n]);
403 if buffer.len() > MAX_REQUEST_BYTES {
404 // RTSP inherits HTTP's status vocabulary (RFC 2326 §11), and 413 is the
405 // answer. Closing without one made an oversized request indistinguishable
406 // from a dropped connection, so a client reported a transport fault instead
407 // of the refusal it was given. `CSeq` cannot be echoed — the request head is
408 // exactly what did not arrive — so the reply carries none, which RFC 2326
409 // permits for a response the server generates without a parsed request.
410 tracing::error!(
411 "RTSP connection {} sent a request over {} bytes; \
412 decision=fail_closed_oversized_request",
413 connection_id,
414 MAX_REQUEST_BYTES
415 );
416 Log::new(Some(status_tx)).error(format!(
417 "RTSP connection {} refused: request exceeds {} bytes",
418 connection_id, MAX_REQUEST_BYTES
419 ));
420 let refusal = b"RTSP/1.0 413 Request Entity Too Large\r\n\r\n";
421 {
422 let mut w = write_half.lock().await;
423 let _ = w.write_all(refusal).await;
424 let _ = w.flush().await;
425 }
426 state
427 .update_connection_stats(
428 server_id,
429 connection_id,
430 None,
431 Some(refusal.len() as u64),
432 None,
433 Some(1),
434 )
435 .await;
436 return Ok(());
437 }
438 }
439 }
440
441 #[allow(clippy::too_many_arguments)]
442 async fn process_request(
443 req: &RtspRequest,
444 remote_addr: SocketAddr,
445 connection_id: ConnectionId,
446 server_id: crate::state::ServerId,
447 llm: &OllamaClient,
448 state: &AppState,
449 status_tx: &mpsc::UnboundedSender<String>,
450 session: &mut Session,
451 protocol: &RtspProtocol,
452 ) -> String {
453 let base = serde_json::json!({
454 "peer_addr": remote_addr.to_string(),
455 "connection_id": connection_id.to_string(),
456 "uri": req.uri,
457 "cseq": req.cseq,
458 "method": req.method,
459 });
460
461 let event = match req.method.as_str() {
462 "OPTIONS" => Event {
463 event_type: &RTSP_OPTIONS_EVENT,
464 data: base,
465 },
466 "DESCRIBE" => Event {
467 event_type: &RTSP_DESCRIBE_EVENT,
468 data: base,
469 },
470 "SETUP" => {
471 let mut d = base;
472 d["transport"] =
473 serde_json::json!(req.headers.get("transport").cloned().unwrap_or_default());
474 Event {
475 event_type: &RTSP_SETUP_EVENT,
476 data: d,
477 }
478 }
479 "PLAY" => Event {
480 event_type: &RTSP_PLAY_EVENT,
481 data: base,
482 },
483 "TEARDOWN" => Event {
484 event_type: &RTSP_TEARDOWN_EVENT,
485 data: base,
486 },
487 _ => Event {
488 event_type: &RTSP_OTHER_EVENT,
489 data: base,
490 },
491 };
492
493 let action =
494 match call_llm(llm, state, server_id, Some(connection_id), &event, protocol).await {
495 Ok(result) => {
496 let action = result.raw_actions.into_iter().next();
497 if action.is_none() {
498 // The model was reachable and said nothing. Distinct in the log from a
499 // backend failure and from an explicit refusal; the RTSP framing
500 // defaults below then apply.
501 Log::new(Some(status_tx)).warn(format!(
502 "RTSP {} {} decision=model_no_answer (no action returned)",
503 req.method, req.uri
504 ));
505 }
506 action
507 }
508 Err(e) => {
509 // Fail closed: answer so the client's request completes deterministically
510 // rather than hanging, and never fabricate a stream. The peer gets a
511 // category only — the error itself goes to the log and the status stream.
512 let failure = crate::utils::WireFailure::classify(&e);
513 let (code, reason, decision, extra) = match failure {
514 // Transient saturation: 503 + Retry-After tells the client to back off
515 // rather than record a permanent fault.
516 crate::utils::WireFailure::Overloaded => (
517 503u16,
518 "Service Unavailable",
519 "fail_closed_llm_overloaded",
520 vec![("Retry-After".to_string(), "5".to_string())],
521 ),
522 crate::utils::WireFailure::Unavailable => (
523 500u16,
524 "Internal Server Error",
525 "fail_closed_llm_error",
526 Vec::new(),
527 ),
528 };
529 Log::new(Some(status_tx)).error(format!(
530 "RTSP {} {} decision={} -> {} {}: {}",
531 req.method, req.uri, decision, code, reason, e
532 ));
533 return build_response(code, reason, &req.cseq, &extra, None, None);
534 }
535 };
536
537 // An explicit non-2xx from the model is a refusal, not an absence of one. Keep it
538 // distinguishable in the log from both branches above (radius' decision= shape).
539 if let Some(code) = action
540 .as_ref()
541 .and_then(|a| a.get("status_code"))
542 .and_then(|v| v.as_u64())
543 {
544 if !(200..300).contains(&code) {
545 Log::new(Some(status_tx)).warn(format!(
546 "RTSP {} {} decision=model_reject -> {}",
547 req.method, req.uri, code
548 ));
549 }
550 }
551
552 match req.method.as_str() {
553 "OPTIONS" => {
554 let methods = action
555 .as_ref()
556 .and_then(|a| a.get("public_methods"))
557 .and_then(|v| v.as_array())
558 .map(|arr| {
559 arr.iter()
560 .filter_map(|v| v.as_str())
561 .collect::<Vec<_>>()
562 .join(", ")
563 })
564 .unwrap_or_else(|| "OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN".to_string());
565 build_response(
566 status_of(&action, 200),
567 "OK",
568 &req.cseq,
569 &[("Public".into(), methods)],
570 None,
571 None,
572 )
573 }
574 "DESCRIBE" => {
575 let sdp = action
576 .as_ref()
577 .and_then(|a| a.get("sdp"))
578 .and_then(|v| v.as_str())
579 .map(String::from)
580 .unwrap_or_else(|| default_sdp(&req.uri));
581 build_response(
582 status_of(&action, 200),
583 "OK",
584 &req.cseq,
585 &[("Content-Type".into(), "application/sdp".into())],
586 Some(sdp),
587 None,
588 )
589 }
590 "SETUP" => Self::handle_setup(req, remote_addr, &action, status_tx, session).await,
591 "PLAY" => {
592 Self::handle_play(
593 req,
594 &action,
595 connection_id,
596 server_id,
597 state,
598 status_tx,
599 session,
600 )
601 .await
602 }
603 "TEARDOWN" => {
604 if let Some(task) = session.play_task.take() {
605 task.abort();
606 }
607 session.rtp_socket = None;
608 session.client_rtp_addr = None;
609 let extra = session
610 .id
611 .as_ref()
612 .map(|id| vec![("Session".to_string(), id.clone())])
613 .unwrap_or_default();
614 build_response(status_of(&action, 200), "OK", &req.cseq, &extra, None, None)
615 }
616 _ => build_response(
617 status_of(&action, 501),
618 "Not Implemented",
619 &req.cseq,
620 &[],
621 None,
622 None,
623 ),
624 }
625 }
626
627 /// SETUP: allocate a server-side RTP UDP socket, remember the client's RTP port, and return a
628 /// well-formed Transport + Session. The framing is owned by Rust; the model only gates the
629 /// status code.
630 async fn handle_setup(
631 req: &RtspRequest,
632 remote_addr: SocketAddr,
633 action: &Option<serde_json::Value>,
634 status_tx: &mpsc::UnboundedSender<String>,
635 session: &mut Session,
636 ) -> String {
637 let transport = req.headers.get("transport").cloned().unwrap_or_default();
638 let client_rtp_port = parse_client_rtp_port(&transport);
639
640 // Bind a UDP socket for outbound RTP on the address family the client reached us on.
641 //
642 // This used to hardcode 127.0.0.1, which works for a loopback test and for nothing else:
643 // a client on another host got a SETUP that succeeded, a Transport header naming a real
644 // server_port, a 200 OK to its PLAY — and then every `send_to` failed, because a
645 // loopback-bound socket cannot reach an off-host address. The session looked established
646 // and no media ever arrived, which is the worst shape a failure can take.
647 let bind_addr = match remote_addr.ip() {
648 std::net::IpAddr::V4(_) => SocketAddr::from(([0u8, 0, 0, 0], 0)),
649 std::net::IpAddr::V6(_) => SocketAddr::from((std::net::Ipv6Addr::UNSPECIFIED, 0)),
650 };
651 let rtp_socket = match UdpSocket::bind(bind_addr).await {
652 Ok(s) => Arc::new(s),
653 Err(e) => {
654 Log::new(Some(status_tx))
655 .error(format!("RTSP SETUP could not bind RTP socket: {}", e));
656 return build_response(500, "Internal Server Error", &req.cseq, &[], None, None);
657 }
658 };
659 let server_rtp_port = rtp_socket.local_addr().map(|a| a.port()).unwrap_or(0);
660
661 let session_id = session
662 .id
663 .clone()
664 .unwrap_or_else(|| format!("{:08X}", rand::random::<u32>()));
665
666 if let Some(port) = client_rtp_port {
667 session.client_rtp_addr = Some(SocketAddr::new(remote_addr.ip(), port));
668 }
669 session.rtp_socket = Some(rtp_socket);
670 session.server_rtp_port = Some(server_rtp_port);
671 session.id = Some(session_id.clone());
672
673 let transport_resp = format!(
674 "RTP/AVP;unicast;client_port={}-{};server_port={}-{}",
675 client_rtp_port.unwrap_or(0),
676 client_rtp_port.map(|p| p + 1).unwrap_or(0),
677 server_rtp_port,
678 server_rtp_port + 1
679 );
680 // FileOnly: the SETUP response action's own log_template already reports
681 // "-> RTSP {status_code} SETUP" to the TUI at INFO.
682 Log::new(Some(status_tx)).debug(format!(
683 "RTSP SETUP session={} client_rtp={:?} server_rtp={}",
684 session_id, client_rtp_port, server_rtp_port
685 ));
686 build_response(
687 status_of(action, 200),
688 "OK",
689 &req.cseq,
690 &[
691 ("Transport".into(), transport_resp),
692 ("Session".into(), session_id),
693 ],
694 None,
695 None,
696 )
697 }
698
699 /// PLAY: start streaming synthesized RTP to the client's RTP port. Content is decided by the
700 /// model's play action (tone/dtmf/silence); framing by the shared media engine.
701 #[allow(clippy::too_many_arguments)]
702 async fn handle_play(
703 req: &RtspRequest,
704 action: &Option<serde_json::Value>,
705 connection_id: ConnectionId,
706 server_id: crate::state::ServerId,
707 state: &AppState,
708 status_tx: &mpsc::UnboundedSender<String>,
709 session: &mut Session,
710 ) -> String {
711 let (socket, client_addr, session_id) = match (
712 session.rtp_socket.clone(),
713 session.client_rtp_addr,
714 session.id.clone(),
715 ) {
716 (Some(s), Some(c), Some(id)) => (s, c, id),
717 _ => {
718 Log::new(Some(status_tx)).warn("RTSP PLAY before a completed SETUP; refusing");
719 return build_response(
720 455,
721 "Method Not Valid In This State",
722 &req.cseq,
723 &[],
724 None,
725 None,
726 );
727 }
728 };
729
730 // Media description from the play action (VNC-style: structured, not bytes).
731 //
732 // A *malformed* description is refused rather than substituted. Both of these used to
733 // swallow the parse error and fall back to PCMU/440 Hz, so a model that asked for
734 // `content:"dtmf"` and forgot `digits`, or named a codec this engine cannot synthesize,
735 // got a confident 200 OK and a tone it never asked for — with the reason discarded. An
736 // absent field still takes the documented default; only a value that fails to parse is
737 // an error, and RTSP has a status code for it.
738 let codec = match action
739 .as_ref()
740 .and_then(|a| a.get("payload_type"))
741 .and_then(|v| v.as_str())
742 {
743 Some(pt) => match AudioCodec::parse(pt) {
744 Ok(c) => c,
745 Err(e) => {
746 Log::new(Some(status_tx)).warn(format!(
747 "RTSP PLAY decision=fail_closed_bad_action; refusing to stream: {}",
748 e
749 ));
750 return build_response(400, "Bad Request", &req.cseq, &[], None, None);
751 }
752 },
753 None => AudioCodec::Pcmu,
754 };
755 let content = match action.as_ref().map(media::parse_audio_content) {
756 Some(Ok(c)) => c,
757 Some(Err(e)) => {
758 Log::new(Some(status_tx)).warn(format!(
759 "RTSP PLAY decision=fail_closed_bad_action; refusing to stream: {}",
760 e
761 ));
762 return build_response(400, "Bad Request", &req.cseq, &[], None, None);
763 }
764 None => media::AudioContent::Tone { hz: 440.0 },
765 };
766 let duration_ms = action
767 .as_ref()
768 .and_then(|a| a.get("duration_ms"))
769 .and_then(|v| v.as_u64())
770 .unwrap_or(5000);
771
772 let ssrc: u32 = action
773 .as_ref()
774 .and_then(|a| a.get("ssrc"))
775 .and_then(|v| v.as_u64())
776 .map(|v| v as u32)
777 .unwrap_or_else(rand::random);
778
779 // Abort any previous stream on this session.
780 if let Some(task) = session.play_task.take() {
781 task.abort();
782 }
783
784 let stx = status_tx.clone();
785 let state_clone = state.clone();
786 let task_owner = state.clone();
787 let task = task_owner
788 .spawn_server_task(server_id, async move {
789 let payload = match media::synthesize(codec, &content, duration_ms) {
790 Ok(p) => p,
791 Err(e) => {
792 Log::new(Some(&stx)).warn(format!("RTSP PLAY synthesis: {}", e));
793 return;
794 }
795 };
796 let mut packetizer = RtpPacketizer::new(ssrc, codec.payload_type(), None, None);
797 let packets = packetizer.packetize(&payload, media::G711_SAMPLES_PER_FRAME);
798 let mut sent = 0u64;
799 let mut bytes = 0u64;
800 for pkt in &packets {
801 if socket.send_to(pkt, client_addr).await.is_err() {
802 break;
803 }
804 sent += 1;
805 bytes += pkt.len() as u64;
806 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
807 }
808 // Count the streamed RTP against THIS connection (the media leaves over a
809 // separate UDP socket, but it belongs to this RTSP session).
810 state_clone
811 .update_connection_stats(
812 server_id,
813 connection_id,
814 None,
815 Some(bytes),
816 None,
817 Some(sent),
818 )
819 .await;
820 // FileOnly: analogous to rtp's send_rtp_audio summary, kept off the TUI to avoid
821 // adding a second line alongside the PLAY response's own INFO log_template.
822 Log::new(Some(&stx)).debug(format!(
823 "RTSP PLAY streamed {} RTP packet(s) to {} ({} bytes)",
824 sent, client_addr, bytes
825 ));
826 })
827 .await;
828 session.play_task = Some(task);
829
830 debug!("RTSP PLAY session={} → {}", session_id, client_addr);
831 let rtp_info = format!("url={};seq={};rtptime={}", req.uri, 0, 0);
832 build_response(
833 status_of(action, 200),
834 "OK",
835 &req.cseq,
836 &[
837 ("Session".into(), session_id),
838 ("RTP-Info".into(), rtp_info),
839 ],
840 None,
841 None,
842 )
843 }
844}
845
846/// Status code override: the model may set `status_code`; otherwise use the method default.
847fn status_of(action: &Option<serde_json::Value>, default: u16) -> u16 {
848 action
849 .as_ref()
850 .and_then(|a| a.get("status_code"))
851 .and_then(|v| v.as_u64())
852 .map(|v| v as u16)
853 .unwrap_or(default)
854}
855
856/// Build an RTSP/1.0 response with CSeq echoed and an optional SDP/body.
857fn build_response(
858 status_code: u16,
859 default_reason: &str,
860 cseq: &str,
861 extra_headers: &[(String, String)],
862 body: Option<String>,
863 reason_override: Option<&str>,
864) -> String {
865 let reason = reason_override.unwrap_or_else(|| reason_phrase(status_code, default_reason));
866 let mut resp = format!("RTSP/1.0 {} {}\r\n", status_code, reason);
867 resp.push_str(&format!("CSeq: {}\r\n", cseq));
868 resp.push_str("Server: NetGet-RTSP\r\n");
869 for (k, v) in extra_headers {
870 resp.push_str(&format!("{}: {}\r\n", k, v));
871 }
872 if let Some(b) = &body {
873 resp.push_str(&format!("Content-Length: {}\r\n", b.len()));
874 resp.push_str("\r\n");
875 resp.push_str(b);
876 } else {
877 resp.push_str("Content-Length: 0\r\n\r\n");
878 }
879 resp
880}
881
882fn reason_phrase(code: u16, default: &str) -> &'static str {
883 match code {
884 200 => "OK",
885 400 => "Bad Request",
886 404 => "Not Found",
887 455 => "Method Not Valid In This State",
888 457 => "Invalid Range",
889 461 => "Unsupported Transport",
890 500 => "Internal Server Error",
891 501 => "Not Implemented",
892 503 => "Service Unavailable",
893 _ => {
894 // `default` is a caller-supplied &str; the reason must stay `&'static` so nothing
895 // derived from an error can reach the status line. Fall back to a category phrase
896 // rather than "OK", which would make a refusal read as `RTSP/1.0 403 OK`.
897 let _ = default;
898 match code {
899 100..=199 => "Continue",
900 200..=299 => "OK",
901 300..=399 => "Redirection",
902 400..=499 => "Client Error",
903 _ => "Server Error",
904 }
905 }
906 }
907}
908
909/// Default SDP when the model does not shape one: a single PCMU audio stream.
910fn default_sdp(uri: &str) -> String {
911 format!(
912 "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=NetGet Stream\r\nc=IN IP4 127.0.0.1\r\nt=0 0\r\n\
913 m=audio 0 RTP/AVP 0\r\na=rtpmap:0 PCMU/8000\r\na=control:{}\r\n",
914 uri
915 )
916}
917
918/// Extract the first client RTP port from a Transport header (`client_port=A-B`).
919fn parse_client_rtp_port(transport: &str) -> Option<u16> {
920 for part in transport.split(';') {
921 let part = part.trim();
922 if let Some(rest) = part.strip_prefix("client_port=") {
923 let first = rest.split('-').next()?;
924 return first.trim().parse().ok();
925 }
926 }
927 None
928}
929
930/// Parse one complete RTSP request from `buf`. Returns the request and how many bytes it
931/// consumed, or None if the buffer does not yet hold a full message.
932fn parse_rtsp_request(buf: &[u8]) -> Option<(RtspRequest, usize)> {
933 // Locate the header terminator on the BYTES, and decode only the header block.
934 //
935 // This used to run `std::str::from_utf8` over the whole buffer first, which made the parser
936 // fail on anything the buffer merely happened to end in the middle of: a multi-byte
937 // character split across two TCP segments, or a binary body, invalidated the entire buffer
938 // — including a complete request sitting in front of it. The connection then stalled with a
939 // fully-formed request unanswered until the 1 MiB overflow closed it.
940 let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n")?;
941 let header_block = std::str::from_utf8(&buf[..header_end]).ok()?;
942 let mut lines = header_block.split("\r\n");
943
944 let request_line = lines.next()?;
945 let mut parts = request_line.split_whitespace();
946 let method = parts.next()?.to_string();
947 let uri = parts.next()?.to_string();
948 let _version = parts.next()?; // RTSP/1.0
949
950 let mut headers = HashMap::new();
951 for line in lines {
952 if let Some(idx) = line.find(':') {
953 let name = line[..idx].trim().to_ascii_lowercase();
954 let value = line[idx + 1..].trim().to_string();
955 headers.insert(name, value);
956 }
957 }
958
959 let content_length: usize = headers
960 .get("content-length")
961 .and_then(|v| v.parse().ok())
962 .unwrap_or(0);
963 let total = header_end + 4 + content_length;
964 if buf.len() < total {
965 return None; // body not fully arrived yet
966 }
967
968 let cseq = headers.get("cseq").cloned().unwrap_or_default();
969 Some((
970 RtspRequest {
971 method,
972 uri,
973 cseq,
974 headers,
975 },
976 total,
977 ))
978}