Skip to main content

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}