Skip to main content

arcly_stream/protocol/srt/
mod.rs

1//! Native SRT ingest handler (feature `srt`).
2//!
3//! Accepts SRT (Secure Reliable Transport) connections in **listener** mode,
4//! parses the control/data packet stream, and extracts the encapsulated
5//! MPEG-TS payload into elementary media frames that flow onto the engine bus
6//! through the standard [`InboundProtocol`] seam.
7//!
8//! [`InboundProtocol`]: crate::inbound::InboundProtocol
9//!
10//! # Pipeline
11//!
12//! ```text
13//!  UDP datagram ─▶ SrtPacket::parse ─▶ data payload ─▶ TsDemuxer ─▶ AccessUnit
14//!                       │                                              │
15//!                       └─ handshake (induction/conclusion)           ▼
16//!                                                              PublishSession
17//! ```
18//!
19//! The SRT [packet header][SrtPacket] (SRT RFC, draft-sharabayko-srt) and the
20//! [`SrtHandshake`] induction/conclusion exchange are parsed here; the MPEG-TS
21//! bytes ride in SRT *data* packets and are demuxed by [`TsDemuxer`].
22//!
23//! # Scope
24//!
25//! Loss recovery is modeled with a full **ARQ window**: the listener reorders by
26//! sequence number, NAKs gaps, and periodically ACKs; the caller keeps a
27//! retransmission buffer and replays NAK'd packets. With the `srt-encrypt`
28//! feature, **AES-CTR** media encryption and the `KMREQ`/`KMRSP` key-material
29//! exchange are supported via a pure-Rust crypto core — no dependency, still
30//! `#![forbid(unsafe_code)]`. Without that feature an encrypted feed is rejected
31//! at handshake.
32//!
33//! A caller's **Stream ID** (`streamid`) is honored: the HSv5 `SRT_CMD_SID`
34//! extension a libsrt caller sends to select its stream is parsed (by the
35//! internal `handshake::stream_id`) and used as the published stream id within
36//! the configured app namespace (falling back to the handler's key when absent).
37//!
38//! Interop note: the handshake/ARQ/KM/`streamid` wire formats follow
39//! draft-sharabayko-srt and interoperate with this crate's own caller/listener
40//! and with libsrt callers that send a Stream ID; exhaustive option negotiation
41//! with third-party SRT stacks is a follow-up.
42
43mod arq;
44mod conn;
45#[cfg(feature = "srt-encrypt")]
46mod crypto;
47mod egress;
48mod handshake;
49#[cfg(feature = "srt-encrypt")]
50mod keymaterial;
51mod packet;
52
53pub use egress::SrtCaller;
54pub use handshake::{HandshakeType, SrtHandshake};
55pub use packet::{ControlType, SrtPacket};
56// The MPEG-TS demuxer now lives in the shared `protocol::tsdemux` module (also
57// used by `udp` ingest); re-exported here so existing paths keep working.
58pub use crate::protocol::tsdemux::{TsDemuxer, TsPayload, TsTrackKind};
59
60use crate::bus::PlaybackRegistry;
61use crate::inbound::{InboundProtocol, IngestContext};
62use crate::{Result, StreamKey};
63use async_trait::async_trait;
64use std::collections::HashMap;
65use std::net::SocketAddr;
66use std::sync::Arc;
67use tokio_util::sync::CancellationToken;
68use tracing::{info, warn};
69
70/// Out-of-order reorder window before a stuck gap is force-skipped.
71const ARQ_WINDOW: usize = 1024;
72/// Cadence of ACK/NAK control feedback and buffer-relief checks.
73const CONTROL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
74/// Inbound-datagram queue depth per connection task.
75const CONN_QUEUE: usize = 512;
76
77/// SRT **server**: one UDP listener that demultiplexes many concurrent SRT
78/// connections by peer address and routes each by its `streamid` mode —
79/// `m=publish` callers are *ingested* (published onto the bus), `m=request`
80/// callers are *served* (a stream muxed back to them as MPEG-TS). Each
81/// connection runs as its own task with independent ARQ/encryption state.
82#[derive(Clone)]
83pub struct SrtHandler {
84    bind: SocketAddr,
85    /// Default stream key when a caller omits `streamid` (publish only).
86    key: StreamKey,
87    /// Playback side, required to serve `m=request` (egress) callers. `None`
88    /// disables egress (publish-only listener).
89    playback: Option<Arc<dyn PlaybackRegistry>>,
90    /// Egress gate (per-app toggle + play token) for `m=request`. `None` = open.
91    gate: Option<crate::auth::EgressGate>,
92    /// Pre-shared passphrase enabling AES-CTR (feature `srt-encrypt`).
93    #[cfg(feature = "srt-encrypt")]
94    passphrase: Option<String>,
95}
96
97/// Resolve a caller's `streamid` to a [`StreamKey`]. libsrt convention prefixes
98/// the resource with `#!::r=`; an embedded `/` selects `app/stream`, otherwise
99/// the value is a stream within the listener's default app (`default.app`). This
100/// lets a single listener route to any `app/stream` (e.g.
101/// `streamid=#!::r=live/test` or `streamid=live/test`).
102fn resolve_streamid(default: &StreamKey, sid: &str) -> StreamKey {
103    // Strip a libsrt `#!::{...}` wrapper, taking the `r=` (resource) field.
104    let resource = sid
105        .strip_prefix("#!::")
106        .and_then(|rest| {
107            rest.split(',')
108                .find_map(|kv| kv.strip_prefix("r=").or_else(|| kv.strip_prefix("s=")))
109        })
110        .unwrap_or(sid)
111        .trim_matches('/');
112    match resource.split_once('/') {
113        Some((app, stream)) if !app.is_empty() && !stream.is_empty() => StreamKey::new(app, stream),
114        _ => StreamKey::new(default.app.clone(), resource),
115    }
116}
117
118impl SrtHandler {
119    /// A listener bound to `bind`. Publish-only until [`with_playback`](Self::with_playback) is set.
120    /// `key` is the fallback stream key when a `m=publish` caller omits `streamid`.
121    pub fn new(bind: SocketAddr, key: StreamKey) -> Self {
122        Self {
123            bind,
124            key,
125            playback: None,
126            gate: None,
127            #[cfg(feature = "srt-encrypt")]
128            passphrase: None,
129        }
130    }
131
132    /// Enable serving `m=request` (egress) callers from `playback`.
133    pub fn with_playback(mut self, playback: Arc<dyn PlaybackRegistry>) -> Self {
134        self.playback = Some(playback);
135        self
136    }
137
138    /// Gate `m=request` (egress) callers through `gate` (per-app toggle + token).
139    pub fn with_gate(mut self, gate: crate::auth::EgressGate) -> Self {
140        self.gate = Some(gate);
141        self
142    }
143
144    /// Pre-shared passphrase for AES-CTR (both encrypted ingest and egress).
145    /// Feature `srt-encrypt`.
146    #[cfg(feature = "srt-encrypt")]
147    pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> Self {
148        self.passphrase = Some(passphrase.into());
149        self
150    }
151}
152
153#[async_trait]
154impl InboundProtocol for SrtHandler {
155    fn name(&self) -> &'static str {
156        "srt"
157    }
158
159    async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()> {
160        use tokio::net::UdpSocket;
161        use tokio::sync::mpsc;
162
163        let socket = Arc::new(UdpSocket::bind(self.bind).await?);
164        info!(bind = %self.bind, "srt server listening");
165
166        // One inbound queue per peer; a connection task drains it and owns all
167        // per-connection state (handshake, ARQ, encryption, publish/play).
168        let mut conns: HashMap<SocketAddr, mpsc::Sender<Vec<u8>>> = HashMap::new();
169        let mut buf = vec![0u8; 1500];
170
171        loop {
172            tokio::select! {
173                _ = shutdown.cancelled() => break,
174                r = socket.recv_from(&mut buf) => {
175                    let (n, from) = match r {
176                        Ok(v) => v,
177                        Err(e) => { warn!(error = %e, "srt recv failed"); continue; }
178                    };
179                    let datagram = buf[..n].to_vec();
180
181                    // Route to the peer's connection task if it exists.
182                    if let Some(tx) = conns.get(&from) {
183                        if tx.try_send(datagram).is_err() {
184                            // Task gone or backlogged past the queue: drop the peer;
185                            // a fresh handshake will re-establish it.
186                            conns.remove(&from);
187                        }
188                        continue;
189                    }
190
191                    // New peer: only a handshake (induction) starts a connection.
192                    if !conn::is_handshake(&datagram) {
193                        continue;
194                    }
195                    let (tx, rx) = mpsc::channel(CONN_QUEUE);
196                    let cfg = conn::ConnConfig {
197                        socket: socket.clone(),
198                        peer: from,
199                        ctx: ctx.clone(),
200                        playback: self.playback.clone(),
201                        gate: self.gate.clone(),
202                        default_key: self.key.clone(),
203                        #[cfg(feature = "srt-encrypt")]
204                        passphrase: self.passphrase.clone(),
205                        shutdown: shutdown.clone(),
206                    };
207                    tokio::spawn(conn::run(cfg, rx));
208                    let _ = tx.try_send(datagram);
209                    conns.insert(from, tx);
210                }
211            }
212        }
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn resolve_streamid_parses_app_and_stream() {
223        let def = StreamKey::new("live", "srt");
224        // bare app/stream
225        let k = resolve_streamid(&def, "show/cam1");
226        assert_eq!(k.app.as_str(), "show");
227        assert_eq!(k.stream_id.as_str(), "cam1");
228        // libsrt resource wrapper
229        let k = resolve_streamid(&def, "#!::r=show/cam1,m=publish");
230        assert_eq!(k.app.as_str(), "show");
231        assert_eq!(k.stream_id.as_str(), "cam1");
232        // stream-only falls back to the listener's default app
233        let k = resolve_streamid(&def, "cam9");
234        assert_eq!(k.app.as_str(), "live");
235        assert_eq!(k.stream_id.as_str(), "cam9");
236    }
237
238    #[test]
239    fn handler_reports_name_and_key() {
240        let h = SrtHandler::new(
241            "127.0.0.1:9000".parse().unwrap(),
242            StreamKey::new("live", "feed"),
243        );
244        assert_eq!(h.name(), "srt");
245        assert_eq!(h.key.stream_id.as_str(), "feed");
246    }
247
248    use super::arq::Receiver;
249    use super::packet::SrtPacket;
250    use std::collections::HashSet;
251    use std::time::Duration;
252    use tokio::net::UdpSocket;
253    use tokio::sync::mpsc;
254    use tokio::time::timeout;
255
256    /// End-to-end lossy-link recovery: a caller streams packets through a test
257    /// listener that drops two of them on first sight and NAKs; the caller must
258    /// retransmit so the receiver delivers every payload, in order.
259    #[tokio::test]
260    async fn arq_recovers_dropped_packets() {
261        let listener = UdpSocket::bind("127.0.0.1:0").await.unwrap();
262        let addr = listener.local_addr().unwrap();
263        let (tx, rx) = mpsc::channel(32);
264        let shutdown = CancellationToken::new();
265        let caller_task = tokio::spawn(SrtCaller::new(addr).run(rx, shutdown.clone()));
266
267        // Handshake: induction then conclusion, both echoed by `respond`.
268        let mut buf = [0u8; 2048];
269        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
270        let dest = SrtHandshake::parse(&buf[..n]).unwrap().socket_id;
271        listener
272            .send_to(&handshake::respond(&buf[..n]).unwrap(), peer)
273            .await
274            .unwrap();
275        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
276        listener
277            .send_to(&handshake::respond(&buf[..n]).unwrap(), peer)
278            .await
279            .unwrap();
280
281        // Feed 12 uniquely-tagged payloads.
282        let count = 12usize;
283        for i in 0..count {
284            tx.send(bytes::Bytes::from(vec![i as u8; 100]))
285                .await
286                .unwrap();
287        }
288
289        let mut receiver = Receiver::new(64);
290        let mut delivered: Vec<Vec<u8>> = Vec::new();
291        let mut dropped_once: HashSet<u32> = HashSet::new();
292        while delivered.len() < count {
293            let (n, from) = timeout(Duration::from_secs(5), listener.recv_from(&mut buf))
294                .await
295                .expect("packet within timeout")
296                .unwrap();
297            let SrtPacket::Data {
298                sequence,
299                payload_offset,
300                ..
301            } = SrtPacket::parse(&buf[..n]).unwrap()
302            else {
303                continue;
304            };
305            // Drop seq 3 and 8 the first time they appear, and NAK them.
306            if (sequence == 3 || sequence == 8) && dropped_once.insert(sequence) {
307                let nak = arq::build_nak(&[(sequence, sequence)], 0, dest);
308                listener.send_to(&nak, from).await.unwrap();
309                continue;
310            }
311            let payload = buf[payload_offset..n].to_vec();
312            delivered.extend(receiver.push(sequence, payload));
313        }
314
315        shutdown.cancel();
316        let _ = caller_task.await;
317
318        let expected: Vec<Vec<u8>> = (0..count).map(|i| vec![i as u8; 100]).collect();
319        assert_eq!(delivered, expected, "every payload recovered, in order");
320        assert_eq!(dropped_once.len(), 2, "both losses were actually injected");
321    }
322
323    /// End-to-end encryption: an encrypted caller completes the KMREQ exchange,
324    /// and its on-wire payloads decrypt (only) with the recovered key material.
325    #[cfg(feature = "srt-encrypt")]
326    #[tokio::test]
327    async fn encrypted_caller_payload_decrypts_with_recovered_km() {
328        let pass = "swordfish-correct-horse";
329        let listener = UdpSocket::bind("127.0.0.1:0").await.unwrap();
330        let addr = listener.local_addr().unwrap();
331        let (tx, rx) = mpsc::channel(8);
332        let shutdown = CancellationToken::new();
333        let caller_task = tokio::spawn(
334            SrtCaller::new(addr)
335                .with_passphrase(pass)
336                .run(rx, shutdown.clone()),
337        );
338
339        let mut buf = [0u8; 2048];
340        // Induction (no KM yet).
341        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
342        let (reply, none) = handshake::respond_with_km(&buf[..n], pass.as_bytes()).unwrap();
343        assert!(none.is_none(), "induction carries no key material");
344        listener.send_to(&reply, peer).await.unwrap();
345        // Conclusion carries KMREQ; recover the SEK with the right passphrase.
346        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
347        let (reply, km) = handshake::respond_with_km(&buf[..n], pass.as_bytes()).unwrap();
348        let km = km.expect("conclusion KMREQ yields key material");
349        // A wrong passphrase must fail to unwrap the very same handshake.
350        assert!(handshake::respond_with_km(&buf[..n], b"wrong").is_none());
351        listener.send_to(&reply, peer).await.unwrap();
352
353        let plain = vec![0x47u8; TS_BYTES_PER_DATAGRAM_TEST];
354        tx.send(bytes::Bytes::from(plain.clone())).await.unwrap();
355
356        let (n, _) = timeout(Duration::from_secs(5), listener.recv_from(&mut buf))
357            .await
358            .expect("data packet")
359            .unwrap();
360        let SrtPacket::Data {
361            sequence,
362            key_flag,
363            payload_offset,
364            ..
365        } = SrtPacket::parse(&buf[..n]).unwrap()
366        else {
367            panic!("expected a data packet");
368        };
369        assert_eq!(key_flag, 1, "payload flagged as even-key encrypted");
370        let mut wire = buf[payload_offset..n].to_vec();
371        assert_ne!(wire, plain, "payload is ciphertext on the wire");
372        km.transform(sequence, &mut wire);
373        assert_eq!(wire, plain, "recovered key material decrypts the payload");
374
375        shutdown.cancel();
376        let _ = caller_task.await;
377    }
378
379    /// One full SRT datagram of TS payload (7 × 188).
380    #[cfg(feature = "srt-encrypt")]
381    const TS_BYTES_PER_DATAGRAM_TEST: usize = 7 * 188;
382}