Files
open-bamboo-networking/stubs/rtsp_client.cpp
T

1217 lines
47 KiB
C++

// RTSP/RTSPS client implementation.
//
// The control plane (OPTIONS / DESCRIBE / SETUP / PLAY) is plain text
// over TLS, line-oriented per RFC 2326. After PLAY the same TCP
// connection carries interleaved RTP/RTCP frames in the wire format
//
// '$' <channel:1> <length:2> <rtp-or-rtcp-packet>
//
// from RFC 2326 section 10.12. The interleaved channels are picked at
// SETUP time -- we always ask for 0 = RTP, 1 = RTCP.
//
// The control plane keeps running after PLAY: GET_PARAMETER keepalive
// requests fire every 15 seconds on a worker thread to avoid Bambu's
// 30 s idle teardown. Their responses come back through the same TCP
// connection; we let them flow into the demux loop, which recognises
// them by the leading "RTSP/" string instead of the '$' interleave
// marker, drains the response, and continues.
#include "rtsp_client.hpp"
#include "source_log.hpp"
#include "tls_socket.hpp"
#include "obn/os_compat.hpp"
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/ssl.h>
#if defined(_WIN32)
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# ifndef NOMINMAX
# define NOMINMAX
# endif
# include <winsock2.h>
# include <windows.h>
#else
# include <sys/socket.h>
# include <unistd.h>
#endif
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <deque>
#include <mutex>
#include <random>
#include <string>
#include <thread>
#include <vector>
namespace obn::rtsp {
namespace {
using obn::source::log_at;
using obn::source::log_fmt;
using obn::source::Logger;
using obn::source::LL_DEBUG;
using obn::source::LL_TRACE;
using obn::source::LL_WARN;
using obn::source::set_last_error;
// Hard caps to keep a misbehaving server from making us allocate
// arbitrarily large buffers. Every Bambu RTSP message we have seen is
// well under 4 KB; any single RTP packet is under 64 KB by definition
// (the interleave length field is 16 bits).
constexpr std::size_t kMaxResponseBody = 64u << 10;
constexpr std::size_t kMaxRtpPacket = 64u << 10;
// ---------- base64 (small, inline) ----------
constexpr char kB64Alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string base64_encode(const std::uint8_t* data, std::size_t n)
{
std::string out;
out.reserve(((n + 2) / 3) * 4);
std::size_t i = 0;
while (i + 3 <= n) {
std::uint32_t v = (std::uint32_t(data[i]) << 16)
| (std::uint32_t(data[i + 1]) << 8)
| std::uint32_t(data[i + 2]);
out.push_back(kB64Alphabet[(v >> 18) & 0x3f]);
out.push_back(kB64Alphabet[(v >> 12) & 0x3f]);
out.push_back(kB64Alphabet[(v >> 6) & 0x3f]);
out.push_back(kB64Alphabet[ v & 0x3f]);
i += 3;
}
if (i + 1 == n) {
std::uint32_t v = std::uint32_t(data[i]) << 16;
out.push_back(kB64Alphabet[(v >> 18) & 0x3f]);
out.push_back(kB64Alphabet[(v >> 12) & 0x3f]);
out.push_back('=');
out.push_back('=');
} else if (i + 2 == n) {
std::uint32_t v = (std::uint32_t(data[i]) << 16)
| (std::uint32_t(data[i + 1]) << 8);
out.push_back(kB64Alphabet[(v >> 18) & 0x3f]);
out.push_back(kB64Alphabet[(v >> 12) & 0x3f]);
out.push_back(kB64Alphabet[(v >> 6) & 0x3f]);
out.push_back('=');
}
return out;
}
bool base64_decode(const std::string& in, std::vector<std::uint8_t>* out)
{
out->clear();
out->reserve((in.size() / 4) * 3);
std::uint32_t buf = 0;
int bits = 0;
for (char c : in) {
if (c == '=' || c == '\r' || c == '\n' || c == ' ') {
if (c == '=') break;
continue;
}
int v;
if (c >= 'A' && c <= 'Z') v = c - 'A';
else if (c >= 'a' && c <= 'z') v = c - 'a' + 26;
else if (c >= '0' && c <= '9') v = c - '0' + 52;
else if (c == '+') v = 62;
else if (c == '/') v = 63;
else return false;
buf = (buf << 6) | static_cast<std::uint32_t>(v);
bits += 6;
if (bits >= 8) {
bits -= 8;
out->push_back(static_cast<std::uint8_t>((buf >> bits) & 0xff));
}
}
return true;
}
// ---------- MD5 (for Digest auth) ----------
// Bambu printers run live555 on port 322 and reply to every DESCRIBE
// with `WWW-Authenticate: Digest realm="LIVE555 Streaming Media",
// nonce="..."`. Implementing the RFC 2617 Digest algorithm ourselves
// is half a screen of code and saves us from adding a curl / glib-net /
// gstrtsp runtime dependency. EVP_Digest is in libcrypto, which we
// already link for TLS, so no new soname enters DT_NEEDED.
std::string md5_hex(const std::string& in)
{
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int dlen = 0;
EVP_Digest(in.data(), in.size(), digest, &dlen, EVP_md5(), nullptr);
static const char* kHex = "0123456789abcdef";
std::string out;
out.resize(dlen * 2);
for (unsigned int i = 0; i < dlen; ++i) {
out[2 * i] = kHex[digest[i] >> 4];
out[2 * i + 1] = kHex[digest[i] & 0x0f];
}
return out;
}
// 16 random hex chars for the cnonce field. std::random_device is fine
// here -- the cnonce only needs to be non-repeating per request.
std::string random_cnonce()
{
std::random_device rd;
std::uniform_int_distribution<std::uint64_t> dist;
char buf[17];
std::snprintf(buf, sizeof(buf), "%016lx",
static_cast<unsigned long>(dist(rd)));
return std::string(buf, 16);
}
// Parse a comma-separated `key=value` list as it appears in an
// RFC 2617 WWW-Authenticate header (after the `Digest ` prefix).
// Values may be quoted ("...") with embedded commas; unquoted values
// run until the next comma or end-of-string. Keys are lower-cased.
void parse_auth_params(const std::string& s,
std::vector<std::pair<std::string, std::string>>* out)
{
out->clear();
std::size_t i = 0;
while (i < s.size()) {
while (i < s.size() && (s[i] == ' ' || s[i] == ',' || s[i] == '\t')) ++i;
if (i >= s.size()) break;
std::size_t key_begin = i;
while (i < s.size() && s[i] != '=' && s[i] != ',') ++i;
std::string k(s.data() + key_begin, i - key_begin);
std::string v;
if (i < s.size() && s[i] == '=') {
++i;
if (i < s.size() && s[i] == '"') {
++i;
std::size_t v_begin = i;
while (i < s.size() && s[i] != '"') {
if (s[i] == '\\' && i + 1 < s.size()) ++i;
++i;
}
v.assign(s.data() + v_begin, i - v_begin);
if (i < s.size() && s[i] == '"') ++i;
} else {
std::size_t v_begin = i;
while (i < s.size() && s[i] != ',') ++i;
v.assign(s.data() + v_begin, i - v_begin);
}
}
// Trim & lowercase the key; trim the value.
while (!k.empty() && std::isspace(static_cast<unsigned char>(k.back())))
k.pop_back();
std::size_t kb = 0;
while (kb < k.size() && std::isspace(static_cast<unsigned char>(k[kb]))) ++kb;
k.erase(0, kb);
for (auto& c : k) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
while (!v.empty() && std::isspace(static_cast<unsigned char>(v.back())))
v.pop_back();
std::size_t vb = 0;
while (vb < v.size() && std::isspace(static_cast<unsigned char>(v[vb]))) ++vb;
v.erase(0, vb);
out->emplace_back(std::move(k), std::move(v));
}
}
// ---------- string helpers ----------
std::string lc(std::string s)
{
for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::string trim(const std::string& s)
{
std::size_t a = 0;
while (a < s.size() && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
std::size_t b = s.size();
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
bool starts_with(const std::string& s, const char* prefix)
{
std::size_t n = std::strlen(prefix);
return s.size() >= n && std::memcmp(s.data(), prefix, n) == 0;
}
// Resolve a SETUP target. RFC 2326 lets `a=control:` be either an
// absolute URL or a relative one; in the absolute case we use it as
// is, in the relative case we append it to the original DESCRIBE URL.
std::string resolve_control(const std::string& base, const std::string& control)
{
if (control.empty() || control == "*") return base;
if (starts_with(control, "rtsp://") || starts_with(control, "rtsps://"))
return control;
if (control.front() == '/') {
// Authority-relative: keep base scheme://host:port and replace path.
auto p = base.find("://");
if (p == std::string::npos) return base + control;
auto slash = base.find('/', p + 3);
std::string root = (slash == std::string::npos) ? base : base.substr(0, slash);
return root + control;
}
if (!base.empty() && base.back() == '/') return base + control;
return base + "/" + control;
}
// Build a request URL from the parsed Url for the control channel.
// We always reuse the same URL for OPTIONS / DESCRIBE / PLAY /
// GET_PARAMETER -- only SETUP gets the per-track variant.
std::string base_url(const Url& url)
{
std::string s = url.tls ? "rtsps://" : "rtsp://";
s += url.host;
s += ":";
s += std::to_string(url.port);
if (!url.path.empty() && url.path.front() != '/') s += "/";
s += url.path;
return s;
}
// ---------- RTSP response parsing ----------
struct Response {
int status = 0;
std::string reason;
// Header keys lower-cased; values trimmed.
std::vector<std::pair<std::string, std::string>> headers;
std::string body;
std::string header(const char* key) const
{
std::string k = lc(key);
for (auto const& h : headers)
if (h.first == k) return h.second;
return {};
}
};
// Read one RTSP response off the SSL stream. `prefetch_byte` is the
// already-consumed first byte of the status line, or -1 if the caller
// has not peeked. The prefetch path is required after PLAY: live555
// pipelines the first interleaved RTP frame ahead of the PLAY 200 OK,
// so the demux loop in Impl::await_response peeks one byte to tell
// `$` frames apart from `R` (RTSP/1.0 ...) responses, then hands the
// `R` here to be re-stitched into the status line.
//
// Returns 0 / 1 / -1 like ssl_read_full.
int read_response(SSL* ssl, Response* out, int prefetch_byte = -1)
{
out->headers.clear();
out->body.clear();
out->status = 0;
out->reason.clear();
std::string line;
if (prefetch_byte >= 0) {
// Read the rest of the status line, byte by byte, with the
// supplied byte as the seed. Mirrors ssl_read_line semantics.
line.push_back(static_cast<char>(prefetch_byte));
char prev = static_cast<char>(prefetch_byte);
while (line.size() < 8192) {
char c = '\0';
int n = SSL_read(ssl, &c, 1);
if (n <= 0) {
int err = SSL_get_error(ssl, n);
if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE)
continue;
if (err == SSL_ERROR_ZERO_RETURN) return 1;
return -1;
}
if (prev == '\r' && c == '\n') {
line.pop_back(); // strip the CR we just pushed
break;
}
line.push_back(c);
prev = c;
}
} else {
int rc = obn::tls::ssl_read_line(ssl, &line);
if (rc != 0) return rc;
}
// "RTSP/1.0 200 OK"
if (line.size() < 12 || std::memcmp(line.data(), "RTSP/1.", 7) != 0) {
set_last_error(("rtsp: malformed status line: " + line).c_str());
return -1;
}
auto sp1 = line.find(' ', 9);
if (sp1 == std::string::npos) {
set_last_error("rtsp: status line missing reason");
return -1;
}
out->status = std::atoi(line.substr(9, sp1 - 9).c_str());
out->reason = trim(line.substr(sp1 + 1));
// Headers.
std::size_t content_length = 0;
for (;;) {
int hrc = obn::tls::ssl_read_line(ssl, &line);
if (hrc != 0) return hrc;
if (line.empty()) break;
auto colon = line.find(':');
if (colon == std::string::npos) continue;
std::string k = lc(trim(line.substr(0, colon)));
std::string v = trim(line.substr(colon + 1));
if (k == "content-length") content_length = static_cast<std::size_t>(std::atoi(v.c_str()));
out->headers.emplace_back(std::move(k), std::move(v));
}
if (content_length > kMaxResponseBody) {
set_last_error("rtsp: response body exceeds cap");
return -1;
}
if (content_length > 0) {
out->body.resize(content_length);
int brc = obn::tls::ssl_read_full(ssl, out->body.data(), content_length);
if (brc != 0) return brc;
}
return 0;
}
// ---------- SDP parsing ----------
// Find the first H.264 video track, fill *track and *control_attr.
// Returns true if we have at least the rtpmap; sprop-parameter-sets
// are optional (decoder will catch up with in-band SPS/PPS).
bool parse_sdp(const std::string& sdp, H264Track* track, std::string* control)
{
track->sps.clear();
track->pps.clear();
track->clock_rate = 90000;
track->rtp_pt = -1;
control->clear();
int current_pt = -1;
bool in_video = false;
bool seen_rtpmap = false;
int video_pt = -1;
std::size_t i = 0;
while (i < sdp.size()) {
std::size_t end = sdp.find('\n', i);
std::string line = sdp.substr(i, (end == std::string::npos ? sdp.size() : end) - i);
i = (end == std::string::npos) ? sdp.size() : end + 1;
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() < 2 || line[1] != '=') continue;
char tag = line[0];
std::string val = line.substr(2);
if (tag == 'm') {
// m=<media> <port> <proto> <fmt-list>
in_video = starts_with(val, "video ");
if (in_video) {
// Pick the first payload type listed; multiple PT lines are
// legal but Bambu uses one.
auto last_sp = val.rfind(' ');
if (last_sp != std::string::npos)
video_pt = std::atoi(val.substr(last_sp + 1).c_str());
track->rtp_pt = video_pt;
}
} else if (in_video && tag == 'a') {
if (starts_with(val, "control:")) {
if (control->empty()) *control = trim(val.substr(8));
} else if (starts_with(val, "rtpmap:")) {
// a=rtpmap:<pt> <encoding>/<clock>[/...]
std::string rest = val.substr(7);
auto sp = rest.find(' ');
if (sp == std::string::npos) continue;
int pt = std::atoi(rest.substr(0, sp).c_str());
std::string enc = rest.substr(sp + 1);
if (pt != video_pt) continue;
auto slash = enc.find('/');
if (slash == std::string::npos) continue;
std::string codec = lc(enc.substr(0, slash));
if (codec != "h264") {
set_last_error(("rtsp: unsupported codec " + codec).c_str());
return false;
}
seen_rtpmap = true;
track->clock_rate = static_cast<std::uint32_t>(
std::atoi(enc.substr(slash + 1).c_str()));
if (track->clock_rate == 0) track->clock_rate = 90000;
} else if (starts_with(val, "fmtp:")) {
// a=fmtp:<pt> <param>=<value>;<param>=<value>;...
std::string rest = val.substr(5);
auto sp = rest.find(' ');
if (sp == std::string::npos) continue;
int pt = std::atoi(rest.substr(0, sp).c_str());
if (pt != video_pt) continue;
std::string params = rest.substr(sp + 1);
// Split on ';' and look for sprop-parameter-sets.
std::size_t p = 0;
while (p < params.size()) {
std::size_t q = params.find(';', p);
std::string kv = params.substr(p, (q == std::string::npos ? params.size() : q) - p);
p = (q == std::string::npos) ? params.size() : q + 1;
auto eq = kv.find('=');
if (eq == std::string::npos) continue;
std::string k = lc(trim(kv.substr(0, eq)));
std::string v = trim(kv.substr(eq + 1));
if (k == "sprop-parameter-sets") {
// Comma-separated base64 NAL units; first SPS,
// then PPS, sometimes SEI follows.
std::size_t r = 0;
int idx = 0;
while (r < v.size()) {
std::size_t s = v.find(',', r);
std::string b64 = v.substr(r, (s == std::string::npos ? v.size() : s) - r);
r = (s == std::string::npos) ? v.size() : s + 1;
std::vector<std::uint8_t> nal;
if (!base64_decode(b64, &nal) || nal.empty()) continue;
int nal_type = nal[0] & 0x1f;
if (nal_type == 7 && track->sps.empty()) track->sps = std::move(nal);
else if (nal_type == 8 && track->pps.empty()) track->pps = std::move(nal);
(void)idx;
++idx;
}
}
}
}
} else if (tag == 'm') {
(void)current_pt;
}
}
if (!seen_rtpmap) {
set_last_error("rtsp: SDP had no usable H.264 video track");
return false;
}
return true;
}
} // namespace
// ============================================================
// Client::Impl
// ============================================================
struct Client::Impl {
Logger logger;
void* log_ctx;
obn::os::socket_t fd = obn::os::kInvalidSocket;
SSL* ssl = nullptr;
// Mutex protects ssl/fd against the keepalive thread writing
// GET_PARAMETER requests while the data thread is reading
// interleaved frames. Reads on the data thread don't take it
// (single reader); only the writer-side does.
std::mutex io_mu;
// Demux state.
std::deque<Nalu> nalu_queue;
std::vector<std::uint8_t> fu_buf; // FU-A reassembly
std::uint32_t fu_ts = 0;
bool fu_au_end = false;
// Sequencing.
int cseq = 1;
std::string session; // RTSP Session header value (with timeout=...)
std::string session_id; // session value with timeout suffix stripped
std::string url_full; // base URL for OPTIONS/DESCRIBE/PLAY/GET_PARAMETER
std::string url_setup; // resolved control URL for SETUP / TEARDOWN
// Auth state. user / passwd are stashed at start() time so the
// 401 -> retry path and the keepalive thread can rebuild a Digest
// header without reaching back into the original Url.
std::string user;
std::string passwd;
// Auth scheme picked from the most recent WWW-Authenticate. Empty
// until the first 401 Unauthorized response carries a challenge.
// When set, every subsequent request (including keepalive and
// teardown) reuses these fields with the existing nonce until the
// server invalidates it -- at which point we re-handshake the
// challenge transparently inside request_with_auth.
enum class AuthScheme { None, Basic, Digest };
AuthScheme auth_scheme = AuthScheme::None;
// Digest state (RFC 2617). qop / opaque / algorithm are optional
// in the challenge; live555 sends realm + nonce only. We keep the
// others around in case a future printer firmware switches to a
// qop=auth challenge with an opaque echoback.
std::string digest_realm;
std::string digest_nonce;
std::string digest_qop;
std::string digest_opaque;
std::string digest_algorithm;
std::uint32_t digest_nc = 0; // nonce-count, used only with qop
// Keepalive.
std::thread keepalive;
std::atomic<bool> stop_flag{false};
std::condition_variable keepalive_cv;
std::mutex keepalive_mu;
H264Track track;
Impl(Logger l, void* c) : logger(l ? l : obn::source::noop_logger), log_ctx(c) {}
~Impl() = default;
// ----- request helpers -----
// Build a fresh `Authorization: ...\r\n` header for the current
// (method, uri) pair, or return an empty string if we have no
// credentials yet. Called for every outbound request once the
// 401-handshake has populated digest_*.
std::string compute_authorization(const char* method, const std::string& uri)
{
if (auth_scheme == AuthScheme::None) return std::string();
if (auth_scheme == AuthScheme::Basic) {
std::string up = user + ":" + passwd;
return "Authorization: Basic " +
base64_encode(reinterpret_cast<const std::uint8_t*>(up.data()),
up.size()) +
"\r\n";
}
// Digest. Live555 omits qop; RFC 2069 simple-mode formula:
// HA1 = MD5(user:realm:passwd)
// HA2 = MD5(method:uri)
// response = MD5(HA1:nonce:HA2)
// RFC 2617 with qop=auth adds nc + cnonce into the response
// hash, which we honour for forward-compat even though no
// Bambu firmware we have seen uses it.
std::string ha1 = md5_hex(user + ":" + digest_realm + ":" + passwd);
std::string ha2 = md5_hex(std::string(method) + ":" + uri);
std::string response;
std::string extra;
if (!digest_qop.empty()) {
// qop in the challenge can be "auth" or a list ("auth,auth-int").
// We always negotiate auth.
std::string cnonce = random_cnonce();
char nc_buf[9];
std::snprintf(nc_buf, sizeof(nc_buf), "%08x", ++digest_nc);
response = md5_hex(ha1 + ":" + digest_nonce + ":" + nc_buf + ":" +
cnonce + ":auth:" + ha2);
extra = std::string(", qop=auth, nc=") + nc_buf +
", cnonce=\"" + cnonce + "\"";
} else {
response = md5_hex(ha1 + ":" + digest_nonce + ":" + ha2);
}
std::string hdr = "Authorization: Digest username=\"" + user + "\", realm=\"" +
digest_realm + "\", nonce=\"" + digest_nonce + "\", uri=\"" +
uri + "\", response=\"" + response + "\"";
if (!digest_opaque.empty())
hdr += ", opaque=\"" + digest_opaque + "\"";
if (!digest_algorithm.empty())
hdr += ", algorithm=" + digest_algorithm;
hdr += extra;
hdr += "\r\n";
return hdr;
}
// Adopts a new challenge from a 401 response. Returns true if we
// could understand the challenge (Basic or Digest), false if the
// server demanded something exotic we cannot satisfy (mutual TLS
// auth, NTLM, etc.) -- in which case the caller should fail with
// a clear error rather than spin forever on retries.
bool adopt_challenge(const Response& resp)
{
std::string www = resp.header("www-authenticate");
if (www.empty()) return false;
if (lc(www.substr(0, 7)) == "digest ") {
std::vector<std::pair<std::string, std::string>> p;
parse_auth_params(www.substr(7), &p);
digest_realm.clear();
digest_nonce.clear();
digest_qop.clear();
digest_opaque.clear();
digest_algorithm.clear();
for (auto const& kv : p) {
if (kv.first == "realm") digest_realm = kv.second;
else if (kv.first == "nonce") digest_nonce = kv.second;
else if (kv.first == "qop") digest_qop = kv.second;
else if (kv.first == "opaque") digest_opaque = kv.second;
else if (kv.first == "algorithm") digest_algorithm = kv.second;
}
if (digest_realm.empty() || digest_nonce.empty()) return false;
digest_nc = 0;
auth_scheme = AuthScheme::Digest;
log_fmt(logger, log_ctx,
"rtsp: adopted Digest challenge (realm=\"%s\", qop=\"%s\")",
digest_realm.c_str(), digest_qop.c_str());
return true;
}
if (lc(www.substr(0, 6)) == "basic ") {
auth_scheme = AuthScheme::Basic;
log_fmt(logger, log_ctx,
"rtsp: adopted Basic challenge (realm in challenge ignored)");
return true;
}
return false;
}
int send_request(const char* method, const std::string& url,
const std::string& extra_headers,
const std::string& body)
{
char buf[4096];
int n = std::snprintf(buf, sizeof(buf),
"%s %s RTSP/1.0\r\n"
"CSeq: %d\r\n"
"User-Agent: open-bamboo-networking/0.1\r\n"
"%s%s%s",
method, url.c_str(), cseq++,
session_id.empty() ? "" : "Session: ",
session_id.empty() ? "" : session_id.c_str(),
session_id.empty() ? "" : "\r\n");
if (n < 0 || n >= static_cast<int>(sizeof(buf))) {
set_last_error("rtsp: request too long");
return -1;
}
std::string req(buf, static_cast<std::size_t>(n));
req += compute_authorization(method, url);
req += extra_headers;
if (!body.empty()) {
char clbuf[64];
std::snprintf(clbuf, sizeof(clbuf), "Content-Length: %zu\r\n", body.size());
req += clbuf;
}
req += "\r\n";
if (!body.empty()) req += body;
log_at(LL_TRACE, logger, log_ctx, "rtsp -> %s %s", method, url.c_str());
std::lock_guard<std::mutex> lk(io_mu);
if (!ssl) {
set_last_error("rtsp: ssl is null");
return -1;
}
if (obn::tls::ssl_write_all(ssl, req.data(), req.size()) != 0) {
set_last_error("rtsp: write failed");
return -1;
}
return 0;
}
// High-level wrapper: send a request and read the response, with
// one transparent retry on `401 Unauthorized + WWW-Authenticate`.
// Returns 0/1/-1 like await_response (1 == clean EOS before any
// bytes were read).
int request_with_auth(const char* method, const std::string& url,
const std::string& extra_headers,
const std::string& body,
Response* out)
{
if (send_request(method, url, extra_headers, body) != 0) return -1;
int rc = await_response(out);
if (rc != 0) return rc;
if (out->status != 401) return 0;
// First 401, or a follow-up 401 telling us the nonce went stale.
// Either way, adopt the (possibly new) challenge and retry once.
if (!adopt_challenge(*out)) {
set_last_error("rtsp: 401 Unauthorized with no usable "
"WWW-Authenticate (Bambu access code wrong, "
"or unsupported auth scheme)");
return -1;
}
if (send_request(method, url, extra_headers, body) != 0) return -1;
rc = await_response(out);
if (rc != 0) return rc;
if (out->status == 401) {
set_last_error("rtsp: 401 Unauthorized after Digest retry "
"-- access code rejected by printer");
return -1;
}
return 0;
}
int await_response(Response* out)
{
// No mutex: only the start() / read_nalu() / keepalive thread
// ever calls this, and they coordinate higher up. Specifically,
// the keepalive thread does not read the response itself; it
// lets the demux loop in read_nalu() consume it (or, before
// PLAY, start() reads each response inline).
//
// After PLAY, live555 streams interleaved RTP frames on the
// same TCP connection; in TCP-interleaved transport it can
// pipeline the first $-frame ahead of the PLAY 200 OK
// response (which is allowed by the RFC -- the response only
// has to arrive on the same connection, not first). Peek one
// byte to disambiguate: '$' = interleaved frame, anything
// else = start of an RTSP status line. Buffer any pre-PLAY
// frames into nalu_queue for the data path to drain later.
for (;;) {
std::uint8_t prefix;
int rc = obn::tls::ssl_read_full(ssl, &prefix, 1);
if (rc != 0) return rc;
if (prefix == '$') {
std::uint8_t hdr[3];
rc = obn::tls::ssl_read_full(ssl, hdr, 3);
if (rc != 0) return rc;
int channel = hdr[0];
std::uint16_t plen = static_cast<std::uint16_t>(
(std::uint16_t(hdr[1]) << 8) | hdr[2]);
if (plen > kMaxRtpPacket) {
set_last_error("rtsp: implausible interleaved frame length");
return -1;
}
std::vector<std::uint8_t> rtp(plen);
if (plen > 0) {
rc = obn::tls::ssl_read_full(ssl, rtp.data(), plen);
if (rc != 0) return rc;
}
// Channel 0 = RTP, anything else (RTCP, etc.) we drop.
// The early frames are real video; keeping them avoids
// a brief blank at PLAY time once the data path picks
// up read_nalu().
if (channel == 0) decode_rtp_h264(rtp);
log_at(LL_TRACE, logger, log_ctx,
"rtsp: buffered $-frame ahead of response "
"(ch=%d len=%u)", channel, static_cast<unsigned>(plen));
continue;
}
int read_rc = read_response(ssl, out, static_cast<int>(prefix));
if (read_rc != 0) return read_rc;
log_at(LL_TRACE, logger, log_ctx,
"rtsp <- %d %s (body=%zu)",
out->status, out->reason.c_str(), out->body.size());
return 0;
}
}
// ----- handshake -----
int do_options(const Url&)
{
Response resp;
// OPTIONS routinely succeeds without auth on Bambu firmware
// (live555 only authenticates DESCRIBE+SETUP+PLAY), but we
// still go through request_with_auth so a future revision
// that does demand auth here gets the same retry path.
if (request_with_auth("OPTIONS", url_full, "", "", &resp) != 0) return -1;
if (resp.status != 200) {
char e[128];
std::snprintf(e, sizeof(e), "rtsp: OPTIONS returned %d %s",
resp.status, resp.reason.c_str());
set_last_error(e);
return -1;
}
return 0;
}
int do_describe(const Url&, std::string* sdp_out)
{
Response resp;
if (request_with_auth("DESCRIBE", url_full,
"Accept: application/sdp\r\n", "", &resp) != 0)
return -1;
if (resp.status != 200) {
char e[128];
std::snprintf(e, sizeof(e), "rtsp: DESCRIBE returned %d %s",
resp.status, resp.reason.c_str());
set_last_error(e);
return -1;
}
*sdp_out = std::move(resp.body);
return 0;
}
int do_setup(const Url&)
{
Response resp;
if (request_with_auth("SETUP", url_setup,
"Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n",
"", &resp) != 0)
return -1;
if (resp.status != 200) {
char e[128];
std::snprintf(e, sizeof(e), "rtsp: SETUP returned %d %s",
resp.status, resp.reason.c_str());
set_last_error(e);
return -1;
}
session = resp.header("session");
if (session.empty()) {
set_last_error("rtsp: SETUP did not return a Session header");
return -1;
}
// Strip the optional "; timeout=NN" suffix; stash the bare id
// for subsequent requests.
auto semi = session.find(';');
session_id = (semi == std::string::npos) ? session : session.substr(0, semi);
session_id = trim(session_id);
return 0;
}
int do_play(const Url&)
{
Response resp;
if (request_with_auth("PLAY", url_full,
"Range: npt=0.000-\r\n", "", &resp) != 0)
return -1;
if (resp.status != 200) {
char e[128];
std::snprintf(e, sizeof(e), "rtsp: PLAY returned %d %s",
resp.status, resp.reason.c_str());
set_last_error(e);
return -1;
}
return 0;
}
void do_teardown_best_effort()
{
if (session_id.empty() || !ssl) return;
// Best-effort fire-and-forget; the response (if any) is
// discarded. We ignore errors here because by the time we
// call this the user is already exiting.
std::string auth = compute_authorization("TEARDOWN", url_full);
std::string req = std::string("TEARDOWN ") + url_full + " RTSP/1.0\r\n" +
"CSeq: " + std::to_string(cseq++) + "\r\n" +
"Session: " + session_id + "\r\n" +
auth +
"\r\n";
std::lock_guard<std::mutex> lk(io_mu);
if (ssl)
(void)obn::tls::ssl_write_all(ssl, req.data(), req.size());
}
// ----- keepalive -----
void keepalive_main()
{
while (!stop_flag.load(std::memory_order_acquire)) {
std::unique_lock<std::mutex> lk(keepalive_mu);
keepalive_cv.wait_for(lk, std::chrono::seconds(15), [&] {
return stop_flag.load(std::memory_order_acquire);
});
if (stop_flag.load(std::memory_order_acquire)) break;
// GET_PARAMETER with no body is the standard idle ping
// live555 accepts. Build the request inline (instead of
// through send_request) so we keep the Session: header
// even if the data path also wants the io_mu mutex.
std::string auth = compute_authorization("GET_PARAMETER", url_full);
std::string req = std::string("GET_PARAMETER ") + url_full +
" RTSP/1.0\r\n" +
"CSeq: " + std::to_string(cseq++) + "\r\n" +
"Session: " + session_id + "\r\n" +
auth +
"\r\n";
std::lock_guard<std::mutex> io_lk(io_mu);
if (!ssl) break;
if (obn::tls::ssl_write_all(ssl, req.data(), req.size()) != 0) {
log_at(LL_DEBUG, logger, log_ctx, "rtsp: keepalive write failed");
break;
}
log_at(LL_TRACE, logger, log_ctx, "rtsp: keepalive sent");
}
}
// ----- demux -----
// Read the next chunk on the wire, classify it, and either:
// * push one or more decoded NALUs into nalu_queue (data plane),
// * silently drain a stray RTSP keepalive response (control plane),
// * return 1 on clean EOS, -1 on hard error, 0 on "made progress
// but no NAL ready yet" -- caller loops.
int pull_one_message()
{
std::uint8_t prefix;
int rc = obn::tls::ssl_read_full(ssl, &prefix, 1);
if (rc != 0) return rc;
if (prefix == '$') {
// Interleaved RTP/RTCP frame.
std::uint8_t hdr[3];
rc = obn::tls::ssl_read_full(ssl, hdr, 3);
if (rc != 0) return rc;
int channel = hdr[0];
std::uint16_t plen = static_cast<std::uint16_t>(
(std::uint16_t(hdr[1]) << 8) | hdr[2]);
if (plen == 0 || plen > kMaxRtpPacket) {
set_last_error("rtsp: implausible interleaved frame length");
return -1;
}
std::vector<std::uint8_t> rtp(plen);
rc = obn::tls::ssl_read_full(ssl, rtp.data(), plen);
if (rc != 0) return rc;
// Channel 0 = RTP, channel 1 = RTCP. Bambu only sends 0
// for video, but we filter defensively.
if (channel != 0) return 0;
decode_rtp_h264(rtp);
return 0;
}
if (prefix == 'R') {
// Stray RTSP response (e.g. to our keepalive GET_PARAMETER).
// Re-prepend the 'R' we already consumed and parse normally.
std::string status = "R";
std::string rest;
rc = obn::tls::ssl_read_line(ssl, &rest);
if (rc != 0) return rc;
status += rest;
(void)status;
// Drain headers + (optional) body.
std::size_t content_length = 0;
for (;;) {
std::string line;
rc = obn::tls::ssl_read_line(ssl, &line);
if (rc != 0) return rc;
if (line.empty()) break;
auto colon = line.find(':');
if (colon != std::string::npos) {
std::string k = lc(trim(line.substr(0, colon)));
std::string v = trim(line.substr(colon + 1));
if (k == "content-length")
content_length = static_cast<std::size_t>(std::atoi(v.c_str()));
}
}
if (content_length > kMaxResponseBody) {
set_last_error("rtsp: stray response body exceeds cap");
return -1;
}
if (content_length > 0) {
std::vector<std::uint8_t> body(content_length);
rc = obn::tls::ssl_read_full(ssl, body.data(), content_length);
if (rc != 0) return rc;
}
log_at(LL_TRACE, logger, log_ctx, "rtsp: drained stray response");
return 0;
}
// Unknown leading byte: protocol desync. There is no robust
// re-sync strategy on a TCP-interleaved stream; fail and let
// the upper layer reconnect.
char e[128];
std::snprintf(e, sizeof(e),
"rtsp: unexpected wire byte 0x%02x (expected '$' or 'R')",
prefix);
set_last_error(e);
return -1;
}
void decode_rtp_h264(const std::vector<std::uint8_t>& rtp)
{
if (rtp.size() < 12) return;
std::uint8_t b0 = rtp[0];
std::uint8_t b1 = rtp[1];
if ((b0 >> 6) != 2) return; // V must be 2
std::uint8_t cc = b0 & 0x0f;
bool x = (b0 & 0x10) != 0;
bool m = (b1 & 0x80) != 0;
int pt = b1 & 0x7f;
if (pt != track.rtp_pt) return;
std::uint32_t ts = (std::uint32_t(rtp[4]) << 24)
| (std::uint32_t(rtp[5]) << 16)
| (std::uint32_t(rtp[6]) << 8)
| std::uint32_t(rtp[7]);
std::size_t off = 12 + 4 * cc;
if (x) {
if (rtp.size() < off + 4) return;
std::uint16_t ext_len_words =
static_cast<std::uint16_t>(
(std::uint16_t(rtp[off + 2]) << 8) | rtp[off + 3]);
off += 4 + std::size_t(ext_len_words) * 4;
}
if (off >= rtp.size()) return;
const std::uint8_t* p = rtp.data() + off;
std::size_t n = rtp.size() - off;
if (n == 0) return;
std::uint8_t nal0 = p[0];
int nal_type = nal0 & 0x1f;
if (nal_type >= 1 && nal_type <= 23) {
// Single NAL unit.
Nalu nu;
nu.data.assign(p, p + n);
nu.rtp_ts_90khz = ts;
nu.au_end = m;
nalu_queue.emplace_back(std::move(nu));
return;
}
if (nal_type == 24) {
// STAP-A: skip the STAP header byte, then iterate
// [size16 | NALU]* sub-units.
std::size_t i = 1;
while (i + 2 <= n) {
std::uint16_t sub_size =
static_cast<std::uint16_t>(
(std::uint16_t(p[i]) << 8) | p[i + 1]);
i += 2;
if (sub_size == 0 || i + sub_size > n) break;
Nalu nu;
nu.data.assign(p + i, p + i + sub_size);
nu.rtp_ts_90khz = ts;
nu.au_end = m && (i + sub_size == n);
nalu_queue.emplace_back(std::move(nu));
i += sub_size;
}
return;
}
if (nal_type == 28) {
// FU-A. First byte we already saw is the FU indicator; the
// next is the FU header. Reassemble into fu_buf, emit on E.
if (n < 2) return;
std::uint8_t fu_ind = p[0];
std::uint8_t fu_hdr = p[1];
bool start = (fu_hdr & 0x80) != 0;
bool end = (fu_hdr & 0x40) != 0;
int orig_t = fu_hdr & 0x1f;
if (start) {
fu_buf.clear();
fu_buf.reserve(n);
fu_buf.push_back(static_cast<std::uint8_t>(
(fu_ind & 0xe0) | (orig_t & 0x1f)));
fu_buf.insert(fu_buf.end(), p + 2, p + n);
fu_ts = ts;
fu_au_end = m;
} else if (!fu_buf.empty()) {
fu_buf.insert(fu_buf.end(), p + 2, p + n);
if (m) fu_au_end = true;
} else {
// Mid/End packet without a Start: out-of-order or
// recovery; safest to drop and wait for the next
// Start.
return;
}
if (end) {
Nalu nu;
nu.data = std::move(fu_buf);
nu.rtp_ts_90khz = fu_ts;
nu.au_end = fu_au_end;
fu_buf.clear();
fu_au_end = false;
nalu_queue.emplace_back(std::move(nu));
}
return;
}
// 25..27 (MTAP) and 29..31 (FU-B / reserved) are not used by
// Bambu firmware; log once at TRACE if we ever see them.
log_at(LL_TRACE, logger, log_ctx,
"rtsp: unhandled H.264 NAL type %d", nal_type);
}
};
// ============================================================
// Client public surface
// ============================================================
Client::Client(obn::source::Logger logger, void* log_ctx)
: impl_(std::make_unique<Impl>(logger, log_ctx)) {}
Client::~Client() { stop(); }
int Client::start(const Url& url, int connect_timeout_ms)
{
auto& I = *impl_;
log_fmt(I.logger, I.log_ctx,
"rtsp: dialing %s://%s:%d",
url.tls ? "rtsps" : "rtsp", url.host.c_str(), url.port);
if (url.tls) {
if (obn::tls::dial_tls(url.host, url.port, connect_timeout_ms,
&I.fd, &I.ssl,
url.device.empty() ? nullptr : url.device.c_str()) != 0) {
log_fmt(I.logger, I.log_ctx, "rtsp: TLS dial failed: %s",
obn::source::get_last_error());
return -1;
}
log_fmt(I.logger, I.log_ctx, "rtsp: TLS established (cipher=%s)",
SSL_get_cipher(I.ssl));
} else {
I.fd = obn::tls::dial(url.host, url.port, connect_timeout_ms);
if (!obn::os::socket_valid(I.fd)) {
log_fmt(I.logger, I.log_ctx, "rtsp: dial failed: %s",
obn::source::get_last_error());
return -1;
}
// No TLS path: we'd need a second helper to read/write plain
// TCP. Bambu never uses it, so for now refuse cleanly.
set_last_error("rtsp: plain rtsp:// is not supported");
obn::os::close_socket(I.fd);
I.fd = obn::os::kInvalidSocket;
return -1;
}
I.url_full = base_url(url);
I.user = url.user;
I.passwd = url.passwd;
// Auth scheme stays AuthScheme::None until the first 401 response
// tells us what the server wants. request_with_auth then adopts
// the challenge and retries.
if (I.do_options(url) != 0) goto fail;
{
std::string sdp;
if (I.do_describe(url, &sdp) != 0) goto fail;
log_at(LL_TRACE, I.logger, I.log_ctx,
"rtsp: SDP (%zu bytes) follows\n%s", sdp.size(), sdp.c_str());
std::string control;
if (!parse_sdp(sdp, &impl_->track, &control)) goto fail;
I.url_setup = resolve_control(I.url_full, control);
log_fmt(I.logger, I.log_ctx,
"rtsp: track pt=%d clock=%u sps=%zuB pps=%zuB control=%s",
impl_->track.rtp_pt, impl_->track.clock_rate,
impl_->track.sps.size(), impl_->track.pps.size(),
I.url_setup.c_str());
}
if (I.do_setup(url) != 0) goto fail;
if (I.do_play(url) != 0) goto fail;
log_fmt(I.logger, I.log_ctx, "rtsp: PLAY ok, session=%s", I.session_id.c_str());
I.keepalive = std::thread(&Impl::keepalive_main, impl_.get());
return 0;
fail:
log_fmt(I.logger, I.log_ctx, "rtsp: handshake failed: %s",
obn::source::get_last_error());
obn::tls::close_tls(&I.fd, &I.ssl);
return -1;
}
const H264Track& Client::track() const noexcept { return impl_->track; }
int Client::read_nalu(Nalu* out)
{
auto& I = *impl_;
while (I.nalu_queue.empty()) {
if (I.stop_flag.load(std::memory_order_acquire) || !I.ssl) return -1;
int rc = I.pull_one_message();
if (rc != 0) return rc;
}
*out = std::move(I.nalu_queue.front());
I.nalu_queue.pop_front();
return 0;
}
void Client::stop()
{
if (!impl_) return;
auto& I = *impl_;
if (I.stop_flag.exchange(true)) return;
I.keepalive_cv.notify_all();
if (obn::os::socket_valid(I.fd)) obn::os::shutdown_both(I.fd);
if (I.keepalive.joinable()) I.keepalive.join();
I.do_teardown_best_effort();
std::lock_guard<std::mutex> lk(I.io_mu);
obn::tls::close_tls(&I.fd, &I.ssl);
}
void Client::cancel()
{
if (!impl_) return;
auto& I = *impl_;
if (obn::os::socket_valid(I.fd)) obn::os::shutdown_both(I.fd);
I.keepalive_cv.notify_all();
}
} // namespace obn::rtsp