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;
44#[cfg(feature = "srt-encrypt")]
45mod crypto;
46mod egress;
47mod handshake;
48#[cfg(feature = "srt-encrypt")]
49mod keymaterial;
50mod packet;
51
52pub use egress::SrtCaller;
53pub use handshake::{HandshakeType, SrtHandshake};
54pub use packet::{ControlType, SrtPacket};
55// The MPEG-TS demuxer now lives in the shared `protocol::tsdemux` module (also
56// used by `udp` ingest); re-exported here so existing paths keep working.
57pub use crate::protocol::tsdemux::{TsDemuxer, TsPayload, TsTrackKind};
58
59use crate::inbound::{InboundProtocol, IngestContext, PublishSession};
60use crate::{CodecId, MediaFrame, Result, StreamKey};
61use async_trait::async_trait;
62use std::net::SocketAddr;
63use std::time::{Duration, Instant};
64use tokio_util::sync::CancellationToken;
65use tracing::{debug, info, warn};
66
67use arq::Receiver;
68
69/// Out-of-order reorder window before a stuck gap is force-skipped.
70const ARQ_WINDOW: usize = 1024;
71/// Cadence of ACK/NAK control feedback and buffer-relief checks.
72const CONTROL_INTERVAL: Duration = Duration::from_millis(10);
73
74/// SRT ingest worker — binds a UDP listener and demuxes one MPEG-TS feed.
75#[derive(Debug, Clone)]
76pub struct SrtHandler {
77    bind: SocketAddr,
78    key: StreamKey,
79    /// Pre-shared passphrase enabling AES-CTR decryption (feature `srt-encrypt`).
80    #[cfg(feature = "srt-encrypt")]
81    passphrase: Option<String>,
82}
83
84impl SrtHandler {
85    /// A listener bound to `bind` that publishes the received feed as `key`.
86    pub fn new(bind: SocketAddr, key: StreamKey) -> Self {
87        Self {
88            bind,
89            key,
90            #[cfg(feature = "srt-encrypt")]
91            passphrase: None,
92        }
93    }
94
95    /// Accept an **encrypted** feed, decrypting with `passphrase` (the SRT
96    /// `passphrase` the caller was configured with). Feature `srt-encrypt`.
97    #[cfg(feature = "srt-encrypt")]
98    pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> Self {
99        self.passphrase = Some(passphrase.into());
100        self
101    }
102
103    /// Answer a handshake datagram, recovering key material on an encrypted
104    /// conclusion (feature `srt-encrypt`).
105    #[cfg(feature = "srt-encrypt")]
106    fn answer(
107        &self,
108        datagram: &[u8],
109        km: &mut Option<keymaterial::KeyMaterial>,
110    ) -> Option<Vec<u8>> {
111        match &self.passphrase {
112            Some(pass) => {
113                let (reply, got) = handshake::respond_with_km(datagram, pass.as_bytes())?;
114                if got.is_some() {
115                    *km = got;
116                }
117                Some(reply)
118            }
119            None => handshake::respond(datagram),
120        }
121    }
122
123    /// Answer a handshake datagram (plaintext build).
124    #[cfg(not(feature = "srt-encrypt"))]
125    fn answer(&self, datagram: &[u8]) -> Option<Vec<u8>> {
126        handshake::respond(datagram)
127    }
128}
129
130/// Demux a (reassembled, decrypted) TS payload and publish any access units.
131fn publish_payload(demux: &mut TsDemuxer, sess: &PublishSession, payload: &[u8]) -> Result<()> {
132    for au in demux.push(payload) {
133        if au.codec == CodecId::Unknown {
134            continue;
135        }
136        let pts = au.pts_ms;
137        let mut frame = match au.kind {
138            TsTrackKind::Video => MediaFrame::new_video(pts, pts, au.data, au.codec, au.keyframe),
139            TsTrackKind::Audio => MediaFrame::new_audio(pts, au.data, au.codec),
140        };
141        if au.is_config {
142            frame.flags |= crate::FrameFlags::CONFIG;
143        }
144        sess.publish_frame(frame)?;
145    }
146    Ok(())
147}
148
149#[async_trait]
150impl InboundProtocol for SrtHandler {
151    fn name(&self) -> &'static str {
152        "srt"
153    }
154
155    async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()> {
156        use tokio::net::UdpSocket;
157
158        let socket = UdpSocket::bind(self.bind).await?;
159        info!(bind = %self.bind, "srt listener bound");
160        let mut buf = vec![0u8; 1500];
161        let mut peer: Option<SocketAddr> = None;
162        let mut caller_id: u32 = 0;
163        // A libsrt caller's Stream ID (`streamid`), if it sent one, selects which
164        // stream to publish; absent, the handler's configured key is used.
165        let mut requested_sid: Option<String> = None;
166        let mut session: Option<PublishSession> = None;
167        let mut demux = TsDemuxer::new();
168        let mut receiver = Receiver::new(ARQ_WINDOW);
169        let mut ack_no: u32 = 0;
170        let start = Instant::now();
171        let mut control = tokio::time::interval(CONTROL_INTERVAL);
172        control.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
173        #[cfg(feature = "srt-encrypt")]
174        let mut key_material: Option<keymaterial::KeyMaterial> = None;
175
176        loop {
177            tokio::select! {
178                _ = shutdown.cancelled() => break,
179
180                // Periodic ARQ feedback: ACK in-order progress, NAK any gaps, and
181                // relieve a stuck reorder buffer so permanent loss can't wedge it.
182                _ = control.tick() => {
183                    if let Some(p) = peer {
184                        let ts = start.elapsed().as_micros() as u32;
185                        if let Some(ack) = receiver.ack_seq() {
186                            let _ = socket.send_to(&arq::build_ack(ack_no, ack, ts, caller_id), p).await;
187                            ack_no = ack_no.wrapping_add(1);
188                        }
189                        let missing = receiver.missing();
190                        if !missing.is_empty() {
191                            let _ = socket.send_to(&arq::build_nak(&missing, ts, caller_id), p).await;
192                        }
193                        if let Some(sess) = session.as_ref() {
194                            for ready in receiver.relieve() {
195                                publish_payload(&mut demux, sess, &ready)?;
196                            }
197                        }
198                    }
199                }
200
201                r = socket.recv_from(&mut buf) => {
202                    let (n, from) = match r {
203                        Ok(v) => v,
204                        Err(e) => { warn!(error = %e, "srt recv failed"); continue; }
205                    };
206                    let datagram = &buf[..n];
207                    let Some(pkt) = SrtPacket::parse(datagram) else { continue; };
208
209                    match pkt {
210                        SrtPacket::Control { control_type, .. } => {
211                            // Bring the session up on the handshake; the caller's
212                            // ACKACK and keepalives need no action here.
213                            if control_type == ControlType::Handshake {
214                                #[cfg(feature = "srt-encrypt")]
215                                let reply = self.answer(datagram, &mut key_material);
216                                #[cfg(not(feature = "srt-encrypt"))]
217                                let reply = self.answer(datagram);
218                                if let Some(reply) = reply {
219                                    let _ = socket.send_to(&reply, from).await;
220                                    peer = Some(from);
221                                    if let Some(h) = SrtHandshake::parse(datagram) {
222                                        caller_id = h.socket_id;
223                                    }
224                                    // A Stream ID rides the CONCLUSION handshake; if
225                                    // present it overrides the configured stream id.
226                                    if let Some(sid) = handshake::stream_id(datagram) {
227                                        requested_sid = Some(sid);
228                                    }
229                                    debug!(%from, "srt handshake answered");
230                                }
231                            }
232                        }
233                        SrtPacket::Data { sequence, key_flag, payload_offset, .. } => {
234                            if peer != Some(from) {
235                                continue; // data before a handshake from this peer
236                            }
237                            if session.is_none() {
238                                // Honor a caller-supplied Stream ID by overriding
239                                // the stream id within the configured app namespace.
240                                let key = match &requested_sid {
241                                    Some(sid) => {
242                                        StreamKey::new(self.key.app.clone(), sid.as_str())
243                                    }
244                                    None => self.key.clone(),
245                                };
246                                session = Some(ctx.open_publish(key).await?);
247                            }
248                            let sess = session.as_ref().unwrap();
249                            let payload = datagram[payload_offset..].to_vec();
250                            // Decrypt in place (with the packet's own sequence)
251                            // before the reorder buffer, so reordering sees plaintext.
252                            #[cfg(feature = "srt-encrypt")]
253                            let payload = {
254                                let mut payload = payload;
255                                if key_flag != 0 {
256                                    match &key_material {
257                                        Some(km) => km.transform(sequence, &mut payload),
258                                        None => continue, // encrypted but no key yet
259                                    }
260                                }
261                                payload
262                            };
263                            #[cfg(not(feature = "srt-encrypt"))]
264                            if key_flag != 0 {
265                                continue; // encrypted feed, no crypto support built
266                            }
267                            // Reorder through the ARQ window, then demux in order.
268                            for ready in receiver.push(sequence, payload) {
269                                publish_payload(&mut demux, sess, &ready)?;
270                            }
271                        }
272                    }
273                }
274            }
275        }
276
277        if let Some(sess) = session {
278            sess.finish().await?;
279        }
280        Ok(())
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn handler_reports_name_and_key() {
290        let h = SrtHandler::new(
291            "127.0.0.1:9000".parse().unwrap(),
292            StreamKey::new("live", "feed"),
293        );
294        assert_eq!(h.name(), "srt");
295        assert_eq!(h.key.stream_id.as_str(), "feed");
296    }
297
298    use std::collections::HashSet;
299    use std::time::Duration;
300    use tokio::net::UdpSocket;
301    use tokio::sync::mpsc;
302    use tokio::time::timeout;
303
304    /// End-to-end lossy-link recovery: a caller streams packets through a test
305    /// listener that drops two of them on first sight and NAKs; the caller must
306    /// retransmit so the receiver delivers every payload, in order.
307    #[tokio::test]
308    async fn arq_recovers_dropped_packets() {
309        let listener = UdpSocket::bind("127.0.0.1:0").await.unwrap();
310        let addr = listener.local_addr().unwrap();
311        let (tx, rx) = mpsc::channel(32);
312        let shutdown = CancellationToken::new();
313        let caller_task = tokio::spawn(SrtCaller::new(addr).run(rx, shutdown.clone()));
314
315        // Handshake: induction then conclusion, both echoed by `respond`.
316        let mut buf = [0u8; 2048];
317        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
318        let dest = SrtHandshake::parse(&buf[..n]).unwrap().socket_id;
319        listener
320            .send_to(&handshake::respond(&buf[..n]).unwrap(), peer)
321            .await
322            .unwrap();
323        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
324        listener
325            .send_to(&handshake::respond(&buf[..n]).unwrap(), peer)
326            .await
327            .unwrap();
328
329        // Feed 12 uniquely-tagged payloads.
330        let count = 12usize;
331        for i in 0..count {
332            tx.send(bytes::Bytes::from(vec![i as u8; 100]))
333                .await
334                .unwrap();
335        }
336
337        let mut receiver = Receiver::new(64);
338        let mut delivered: Vec<Vec<u8>> = Vec::new();
339        let mut dropped_once: HashSet<u32> = HashSet::new();
340        while delivered.len() < count {
341            let (n, from) = timeout(Duration::from_secs(5), listener.recv_from(&mut buf))
342                .await
343                .expect("packet within timeout")
344                .unwrap();
345            let SrtPacket::Data {
346                sequence,
347                payload_offset,
348                ..
349            } = SrtPacket::parse(&buf[..n]).unwrap()
350            else {
351                continue;
352            };
353            // Drop seq 3 and 8 the first time they appear, and NAK them.
354            if (sequence == 3 || sequence == 8) && dropped_once.insert(sequence) {
355                let nak = arq::build_nak(&[(sequence, sequence)], 0, dest);
356                listener.send_to(&nak, from).await.unwrap();
357                continue;
358            }
359            let payload = buf[payload_offset..n].to_vec();
360            delivered.extend(receiver.push(sequence, payload));
361        }
362
363        shutdown.cancel();
364        let _ = caller_task.await;
365
366        let expected: Vec<Vec<u8>> = (0..count).map(|i| vec![i as u8; 100]).collect();
367        assert_eq!(delivered, expected, "every payload recovered, in order");
368        assert_eq!(dropped_once.len(), 2, "both losses were actually injected");
369    }
370
371    /// End-to-end encryption: an encrypted caller completes the KMREQ exchange,
372    /// and its on-wire payloads decrypt (only) with the recovered key material.
373    #[cfg(feature = "srt-encrypt")]
374    #[tokio::test]
375    async fn encrypted_caller_payload_decrypts_with_recovered_km() {
376        let pass = "swordfish-correct-horse";
377        let listener = UdpSocket::bind("127.0.0.1:0").await.unwrap();
378        let addr = listener.local_addr().unwrap();
379        let (tx, rx) = mpsc::channel(8);
380        let shutdown = CancellationToken::new();
381        let caller_task = tokio::spawn(
382            SrtCaller::new(addr)
383                .with_passphrase(pass)
384                .run(rx, shutdown.clone()),
385        );
386
387        let mut buf = [0u8; 2048];
388        // Induction (no KM yet).
389        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
390        let (reply, none) = handshake::respond_with_km(&buf[..n], pass.as_bytes()).unwrap();
391        assert!(none.is_none(), "induction carries no key material");
392        listener.send_to(&reply, peer).await.unwrap();
393        // Conclusion carries KMREQ; recover the SEK with the right passphrase.
394        let (n, peer) = listener.recv_from(&mut buf).await.unwrap();
395        let (reply, km) = handshake::respond_with_km(&buf[..n], pass.as_bytes()).unwrap();
396        let km = km.expect("conclusion KMREQ yields key material");
397        // A wrong passphrase must fail to unwrap the very same handshake.
398        assert!(handshake::respond_with_km(&buf[..n], b"wrong").is_none());
399        listener.send_to(&reply, peer).await.unwrap();
400
401        let plain = vec![0x47u8; TS_BYTES_PER_DATAGRAM_TEST];
402        tx.send(bytes::Bytes::from(plain.clone())).await.unwrap();
403
404        let (n, _) = timeout(Duration::from_secs(5), listener.recv_from(&mut buf))
405            .await
406            .expect("data packet")
407            .unwrap();
408        let SrtPacket::Data {
409            sequence,
410            key_flag,
411            payload_offset,
412            ..
413        } = SrtPacket::parse(&buf[..n]).unwrap()
414        else {
415            panic!("expected a data packet");
416        };
417        assert_eq!(key_flag, 1, "payload flagged as even-key encrypted");
418        let mut wire = buf[payload_offset..n].to_vec();
419        assert_ne!(wire, plain, "payload is ciphertext on the wire");
420        km.transform(sequence, &mut wire);
421        assert_eq!(wire, plain, "recovered key material decrypts the payload");
422
423        shutdown.cancel();
424        let _ = caller_task.await;
425    }
426
427    /// One full SRT datagram of TS payload (7 × 188).
428    #[cfg(feature = "srt-encrypt")]
429    const TS_BYTES_PER_DATAGRAM_TEST: usize = 7 * 188;
430}