sapflow-srt 0.1.0

SRT (Secure Reliable Transport) for live streams in pure Rust, sans-IO: listener/receiver and caller/sender, optional AES-CTR encryption, no libsrt.
Documentation

sapflow-srt

sapflow's own SRT: live-mode SRT as a listener and receiver, and as a bounded, paced caller and sender, with optional AES-128/256-CTR encryption. MIT, like the rest of sapflow.

Written from the SRT specification, draft-sharabayko-srt-01, and from watching real sessions on the wire: libsrt 1.5.4's srt-live-transmit and ffmpeg 8.1.2 (libsrt 1.5.3), recorded through a UDP relay and read as black boxes. No code was taken from srt-rs (Apache-2.0) or libsrt (MPL-2.0). Where the specification and real encoders differ, the encoders win; each difference is recorded in docs/reference.md.

Shape

Sans-IO, no async runtime, unsafe forbidden, no dependencies beyond pinned RustCrypto primitives for encryption (aes, ctr, aes-kw, pbkdf2, sha1) with zeroize and getrandom, and no panicking constructs outside tests (unwrap, expect, panic! and indexing are denied by lint). A Listener takes datagrams with their source address and the time and gives back datagrams to send, events and the next time it needs; the server owns the socket and the timer.

Not on crates.io yet; depend on it by git:

[dependencies]
sapflow-srt = { git = "https://github.com/forgesworn/sapflow" }

A complete listener that accepts every caller and prints each payload is examples/listen.rs (cargo run -p sapflow-srt --example listen). Its core:

use sapflow_srt::{Config, Event, Listener, RejectCode};

let mut listener = Listener::new(Config::default(), Instant::now());
// for each datagram from the socket:
listener.handle_datagram(Instant::now(), peer, &datagram); // SocketAddr, &[u8]
// and whenever poll_timeout() comes due:
listener.handle_timeout(Instant::now());

while let Some(event) = listener.poll_event() {
    match event {
        // stream_id is the caller's raw bytes (Vec<u8>) and untrusted.
        Event::Connect { id, peer, stream_id } => {
            if stream_id == b"demo" {
                listener.accept(id, Instant::now());
            } else {
                listener.reject(id, RejectCode::PEER);
            }
        }
        Event::Data { id, payload } => { /* Vec<u8> of MPEG-TS, in order, on time */ }
        Event::Closed { id, reason, stats } => { /* reason: Option<CloseReason> */ }
    }
}
while let Some((to, bytes)) = listener.poll_transmit() { // (SocketAddr, Vec<u8>)
    socket.send_to(&bytes, to)?;
}
let next: Option<Instant> = listener.poll_timeout(); // None when idle

Config sets the latency (default 120 ms; a caller asking for more gets more), connection and handshake limits, buffer size, idle and accept timeouts, and encryption (crypto, require_encryption). To refuse a caller, listener.reject(id, code), where RejectCode has the SRT rejection codes as constants (PEER, BADSECRET, RESOURCE, ...) and RejectCode::http(status) for the application range; to choose a per-caller secret, accept_with_crypto. Sending is Caller (examples/send.rs).

Module What
seq 31-bit sequence numbers and 32-bit timestamps, wrapping
packet every packet type, parsed and encoded, bounded by the datagram
loss the loss list and when to ask again
rtt RTT from ACK/ACKACK
receiver one connection: buffer, TSBPD delivery, too-late drop, drift, ACK, NAK, keepalive, idle timeout
listener version 5 handshake, SYN cookies keyed per process, stream id, latency negotiation, limits
caller, sender live caller: handshake, paced bounded sending, retransmission from NAKs
crypto AES-128/256-CTR key material: PBKDF2 key derivation, key wrap, refresh

Scope

Live mode only: listener/receiver and caller/sender. Not file mode, rendezvous, groups or packet filters. Encryption is AES-128 or AES-256 in CTR mode with strict key negotiation and refresh; AES-192 and GCM are refused. CTR does not authenticate media, and control packets, including the stream id, stay in clear. The supported profile is set out in src/crypto.rs. Seamless reconnection, late-drop negotiation and adaptive bitrate on the caller side are not yet qualified.

How it compares with libsrt and gosrt under loss, with measurements, is in docs/benchmarks.md and docs/comparison.md at the repository root.

Tests

  • Unit tests per module (cargo test -p sapflow-srt).
  • tests/differential.rs: recorded publishes replayed through the listener must deliver what srt-protocol delivered for the same trace (its output recorded before it was removed), and under loss exactly what libsrt's receiver delivered in the recorded session.
  • tests/caller.rs: the caller and sender against our listener.
  • tests/black_box_sender.py, tests/black_box_crypto.py: our sender and encryption against libsrt's tools, as black boxes.
  • Fuzz targets own_srt_packet, own_srt_listener and own_srt_caller (fuzz/README.md).