From 6a05a88053cfeac46f3a999dc4d874872362938e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Tue, 4 Aug 2026 17:29:11 +1000 Subject: [PATCH 1/6] websocket: reframe for streaming, not one MAVLink packet at a time The implementation was written for MAVLink, where a message is at most 280 bytes and the peer always speaks first. Video moves megabytes a second through the same code and broke every one of those assumptions. Fixed: the handshake matched the literal "GET / HTTP/1.1", so any other path was refused; the pending buffer was 1024 bytes, which dropped any frame larger than ~1017; the payload was assembled in a variable-length array sized from the wire, so the peer chose a stack allocation; a short write returned 0, which the caller read as failure and tore the connection down; and recv() silently discarded the remainder when the caller's buffer was smaller than the frame. Framing now happens once into a queue with a sent-offset, so a partial write resumes instead of re-sending a header, and fragmentation, ping and close are handled rather than assumed absent. --- tests/test_websocket_framing.py | 206 +++++++++++++ websocket.cpp | 508 ++++++++++++++++++++++---------- websocket.h | 46 ++- 3 files changed, 592 insertions(+), 168 deletions(-) create mode 100644 tests/test_websocket_framing.py diff --git a/tests/test_websocket_framing.py b/tests/test_websocket_framing.py new file mode 100644 index 0000000..cdcca6f --- /dev/null +++ b/tests/test_websocket_framing.py @@ -0,0 +1,206 @@ +"""WebSocket framing tests for the cases the MAVLink path never exercised. + +MAVLink frames are under 300 bytes and arrive one per segment, so the +original implementation could get away with a fixed 1 KiB buffer, no +fragmentation handling and no control-frame handling. Video traffic hits +all three. These tests pin the corrected behaviour. + +Each test uses a ping/pong round trip as the liveness probe. That is a +deliberately strong assertion: a pong only comes back if the proxy +consumed *exactly* the right number of bytes for everything sent before +it, so it detects framing desync as well as connection loss. +""" +import base64 +import os +import socket +import struct +import time + +import pytest + +from test_config import TEST_PORT_ENGINEER +from test_connections import BaseConnectionTest + +# Server->client frames are never masked, so a mask of zero is only used +# on the client->server side here, where the RFC requires one. +_ZERO_MASK = b"\x00\x00\x00\x00" + + +def _ws_handshake(s, target="/"): + key = base64.b64encode(b"x" * 16).decode() + req = ( + f"GET {target} HTTP/1.1\r\n" + "Host: localhost\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode() + s.sendall(req) + s.settimeout(5.0) + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = s.recv(4096) + if not chunk: + break + buf += chunk + assert b"101" in buf, f"no 101 Switching Protocols: {buf!r}" + + +def _frame(opcode, payload=b"", fin=True, masked=True): + """Build a client->server frame with a zero mask (payload unchanged).""" + b0 = (0x80 if fin else 0x00) | opcode + n = len(payload) + mask_bit = 0x80 if masked else 0x00 + if n <= 125: + hdr = bytes([b0, mask_bit | n]) + elif n <= 0xFFFF: + hdr = bytes([b0, mask_bit | 126]) + struct.pack(">H", n) + else: + hdr = bytes([b0, mask_bit | 127]) + struct.pack(">Q", n) + if masked: + hdr += _ZERO_MASK + return hdr + payload + + +def _read_frame(s, timeout=5.0): + """Read one server->client frame. Returns (opcode, payload) or None.""" + s.settimeout(timeout) + buf = b"" + + def _need(k): + nonlocal buf + while len(buf) < k: + chunk = s.recv(4096) + if not chunk: + return False + buf += chunk + return True + + if not _need(2): + return None + opcode = buf[0] & 0x0F + ln = buf[1] & 0x7F + pos = 2 + if ln == 126: + if not _need(4): + return None + ln = struct.unpack(">H", buf[2:4])[0] + pos = 4 + elif ln == 127: + if not _need(10): + return None + ln = struct.unpack(">Q", buf[2:10])[0] + pos = 10 + # server->client frames must not be masked + assert (buf[1] & 0x80) == 0, "server masked a frame" + if not _need(pos + ln): + return None + return opcode, buf[pos:pos + ln] + + +def _connect(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(5.0) + s.connect(("127.0.0.1", TEST_PORT_ENGINEER)) + return s + + +def _assert_alive(s, token): + """Ping/pong round trip: proves the link is up AND framing is in sync.""" + s.sendall(_frame(0x9, token)) + got = _read_frame(s) + assert got is not None, "no pong: connection closed" + assert got[0] == 0xA, f"expected pong (0xA), got opcode 0x{got[0]:x}" + assert got[1] == token, f"pong payload mismatch: {got[1]!r} != {token!r}" + + +class TestWebSocketFraming(BaseConnectionTest): + + def test_ping_gets_pong(self, test_server): + """Control frames are answered rather than passed to MAVLink.""" + s = _connect() + try: + _ws_handshake(s) + _assert_alive(s, b"probe-1") + finally: + s.close() + + @pytest.mark.parametrize("size", [2000, 16384, 65536], + ids=["2k", "16k", "64k"]) + def test_large_frame_does_not_kill_connection(self, test_server, size): + """Frames beyond the old 1 KiB pending[] used to fail the link. + + The payload is not valid MAVLink -- it gets parsed and discarded. + What matters is that the connection survives and stays in sync. + """ + s = _connect() + try: + _ws_handshake(s) + s.sendall(_frame(0x2, b"\x00" * size)) + _assert_alive(s, b"after-large") + finally: + s.close() + + def test_fragmented_message_reassembled(self, test_server): + """A message split across continuation frames must be consumed whole.""" + s = _connect() + try: + _ws_handshake(s) + part = b"\x11" * 4096 + s.sendall(_frame(0x2, part, fin=False)) # first fragment + s.sendall(_frame(0x0, part, fin=False)) # continuation + s.sendall(_frame(0x0, part, fin=True)) # final + _assert_alive(s, b"after-frag") + finally: + s.close() + + def test_interleaved_ping_during_fragments(self, test_server): + """A control frame may arrive between fragments (RFC 6455 s5.4).""" + s = _connect() + try: + _ws_handshake(s) + s.sendall(_frame(0x2, b"\x22" * 1000, fin=False)) + _assert_alive(s, b"mid-frag") + s.sendall(_frame(0x0, b"\x22" * 1000, fin=True)) + _assert_alive(s, b"post-frag") + finally: + s.close() + + def test_unmasked_client_frame_rejected(self, test_server): + """RFC 6455 s5.1 requires client->server frames to be masked.""" + s = _connect() + try: + _ws_handshake(s) + s.sendall(_frame(0x2, b"unmasked payload", masked=False)) + time.sleep(0.3) + # The proxy must drop us; a pong would mean it accepted it. + s.settimeout(3.0) + try: + data = s.recv(4096) + except socket.timeout: + pytest.fail("proxy neither closed nor responded to an " + "unmasked frame") + assert data == b"", \ + f"expected connection close, got {data!r}" + finally: + s.close() + + self.assert_with_proxy_log( + test_server, test_server.proc.poll() is None, + "supportproxy died on an unmasked frame (should just drop the " + "connection)") + + def test_handshake_accepts_path_and_query(self, test_server): + """detect()/handshake must not require the literal 'GET / HTTP/1.1'. + + Video viewers connect to targets like /v1?token=... -- the old + exact-prefix match rejected those outright. + """ + s = _connect() + try: + _ws_handshake(s, target="/v1?token=abc123") + _assert_alive(s, b"pathy") + finally: + s.close() diff --git a/websocket.cpp b/websocket.cpp index 3fc7c94..6e9fc48 100644 --- a/websocket.cpp +++ b/websocket.cpp @@ -19,18 +19,34 @@ #define SSL_CERT_DIR "./" #endif -static const char *ws_prefix = "GET / HTTP/1.1"; +// Only the method token is matched, not a whole request line: the target +// may be any path/query ("/", "/v1?token=..."), and matching a literal +// "GET / HTTP/1.1" would reject those. "GET " is still unambiguous against +// the alternatives on this port -- a raw MAVLink2 frame starts 0xFD (v1 +// 0xFE) and a TLS ClientHello 0x16. +static const char *ws_prefix = "GET "; static uint8_t wss_prefix[] { 0x16, 0x03, 0x01 }; +// WebSocket opcodes (RFC 6455 s5.2) +#define WS_OP_CONT 0x0 +#define WS_OP_TEXT 0x1 +#define WS_OP_BIN 0x2 +#define WS_OP_CLOSE 0x8 +#define WS_OP_PING 0x9 +#define WS_OP_PONG 0xA + +// Read chunk when pulling from the socket. +#define WS_READ_CHUNK 16384 + /* see if this could be a WebSocket connection by looking at the first packet */ ws_detect_t WebSocket::detect(int fd) { - const size_t ws_len = strlen(ws_prefix); // 14 ("GET / HTTP/1.1") + const size_t ws_len = strlen(ws_prefix); // 4 ("GET ") const size_t wss_len = sizeof(wss_prefix); // 3 (TLS ClientHello) - uint8_t peekbuf[14] {}; + uint8_t peekbuf[8] {}; ssize_t peekn = ::recv(fd, peekbuf, sizeof(peekbuf), MSG_PEEK); if (peekn <= 0) { @@ -47,12 +63,12 @@ ws_detect_t WebSocket::detect(int fd) return WS_MORE; // matches so far, need more to be sure } - // HTTP upgrade (ws). NOTE: ws_prefix is a char*, so its length is - // strlen(), not sizeof() — the old sizeof() admitted an 8-byte - // prefix and then strncmp'd 14 bytes against a half-filled buffer, - // misclassifying a fragmented "GET / HTTP/1.1" as raw MAVLink. + // HTTP upgrade (ws). ws_prefix is a char*, so its length is strlen(), + // not sizeof() -- the old sizeof() admitted an 8-byte prefix and then + // compared against a half-filled buffer, misclassifying a fragmented + // request as raw MAVLink. const size_t ws_cmp = n < ws_len ? n : ws_len; - if (strncmp(ws_prefix, (const char *)peekbuf, ws_cmp) == 0) { + if (memcmp(ws_prefix, peekbuf, ws_cmp) == 0) { if (n >= ws_len) { return WS_YES; } @@ -68,10 +84,11 @@ ws_detect_t WebSocket::detect(int fd) /* constructor */ -WebSocket::WebSocket(int _fd) +WebSocket::WebSocket(int _fd, size_t _max_message) : + max_message(_max_message) { fd = _fd; - uint8_t peekbuf[14] {}; + uint8_t peekbuf[8] {}; const ssize_t peekn = ::recv(fd, peekbuf, sizeof(peekbuf), MSG_PEEK); if (peekn >= ssize_t(sizeof(wss_prefix)) && memcmp(wss_prefix, peekbuf, sizeof(wss_prefix)) == 0) { @@ -127,20 +144,48 @@ WebSocket::~WebSocket() void WebSocket::check_headers(void) { - auto len = strnlen((const char *)pending, npending); + // Headers end at the first blank line. Without waiting for the full + // terminator a fragmented request could be parsed with a truncated + // (or absent) key. + static const char terminator[] = "\r\n\r\n"; + if (rx.size() < 4) { + return; + } + const uint8_t *end = nullptr; + for (size_t i = 0; i + 4 <= rx.size(); i++) { + if (memcmp(&rx[i], terminator, 4) == 0) { + end = &rx[i] + 4; + break; + } + } + if (end == nullptr) { + return; + } + const size_t header_bytes = size_t(end - rx.data()); + std::string headers(reinterpret_cast(rx.data()), header_bytes); + + // Request target from "GET HTTP/1.1", for path routing. + const size_t sp1 = headers.find(' '); + if (sp1 != std::string::npos) { + const size_t sp2 = headers.find(' ', sp1 + 1); + if (sp2 != std::string::npos) { + req_target = headers.substr(sp1 + 1, sp2 - sp1 - 1); + } + } - // parse Sec-WebSocket-Key from HTTP headers - std::string headers(reinterpret_cast(pending), len); std::string key_marker = "Sec-WebSocket-Key: "; size_t key_pos = headers.find(key_marker); if (key_pos != std::string::npos) { key_pos += key_marker.length(); - size_t end = headers.find("\r\n", key_pos); - if (end != std::string::npos) { - std::string sec_key = headers.substr(key_pos, end - key_pos); + size_t hend = headers.find("\r\n", key_pos); + if (hend != std::string::npos) { + std::string sec_key = headers.substr(key_pos, hend - key_pos); if (send_handshake(sec_key)) { done_headers = true; - npending = 0; + // Drop only the header bytes: a client may pipeline its + // first frame into the same segment, and discarding the + // whole buffer would lose it. + rx.erase(rx.begin(), rx.begin() + header_bytes); printf("WebSocket: done headers\n"); } } @@ -152,117 +197,198 @@ void WebSocket::check_headers(void) */ void WebSocket::fill_pending(void) { - // ensure always null terminated - auto space = (sizeof(pending)-1) - npending; - if (fd >= 0 && space > 0) { - ssize_t n = 0; - if (ssl) { - if (!SSL_handshake_complete) { - auto res = SSL_accept(ssl); - if (res <= 0) { - int err = SSL_get_error(ssl, res); - if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) { - // still pending - return; - } - ERR_print_errors_fp(stdout); - fd = -1; // owner closes the socket - return; - } - printf("SSL handshake completed\n"); - SSL_handshake_complete = true; - } - n = SSL_read(ssl, &pending[npending], space); - if (n <= 0) { - int err = SSL_get_error(ssl, n); + if (fd < 0) { + return; + } + // Bound the raw buffer too: a peer that never completes a frame must + // not be able to make us grow without limit. + if (rx.size() > max_message + 64) { + printf("WebSocket: receive buffer overflow\n"); + fd = -1; + return; + } + const size_t off = rx.size(); + rx.resize(off + WS_READ_CHUNK); + ssize_t n = 0; + if (ssl) { + if (!SSL_handshake_complete) { + auto res = SSL_accept(ssl); + if (res <= 0) { + int err = SSL_get_error(ssl, res); + rx.resize(off); if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) { - return; - } - if (err == SSL_ERROR_ZERO_RETURN) { - // orderly shutdown - fd = -1; // owner closes the socket + // still pending return; } ERR_print_errors_fp(stdout); fd = -1; // owner closes the socket return; } - } else { - n = ::recv(fd, &pending[npending], space, 0); - if (n < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return; - } - fd = -1; // owner closes the socket + printf("SSL handshake completed\n"); + SSL_handshake_complete = true; + } + n = SSL_read(ssl, &rx[off], WS_READ_CHUNK); + if (n <= 0) { + int err = SSL_get_error(ssl, n); + rx.resize(off); + if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) { return; } - if (n == 0) { - // EOF + if (err == SSL_ERROR_ZERO_RETURN) { + // orderly shutdown fd = -1; // owner closes the socket return; } + ERR_print_errors_fp(stdout); + fd = -1; // owner closes the socket + return; + } + } else { + n = ::recv(fd, &rx[off], WS_READ_CHUNK, 0); + if (n < 0) { + rx.resize(off); + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return; + } + fd = -1; // owner closes the socket + return; + } + if (n == 0) { + // EOF + rx.resize(off); + fd = -1; // owner closes the socket + return; } - npending += n; } + rx.resize(off + size_t(n)); } /* - decode an incoming WebSocket packet and overwrite buf with the decoded data - return the number of decoded payload bytes, -1 if the frame is - incomplete (wait for more data), or -2 if the frame can never be - decoded (it doesn't fit in pending[]; the stream is unrecoverable) + Decode complete frames out of rx, appending payload to msg. + + Handles fragmentation (continuation frames until FIN) and control + frames (ping is answered, close ends the stream, pong ignored) rather + than passing them to the caller as if they were data. + + Returns false if the stream is unrecoverable and the connection must + be failed. */ -ssize_t WebSocket::decode(uint8_t *buf, size_t n, size_t &used) +bool WebSocket::decode_frames(void) { - if (n < 2) return -1; - - // NOTE: opcode currently unused, reserved for future handling of ping/close/etc. - [[maybe_unused]] uint8_t opcode = buf[0] & 0x0F; - bool masked = buf[1] & 0x80; - uint64_t payload_len = buf[1] & 0x7F; - size_t pos = 2; - - if (payload_len == 126) { - if (n < 4) return -1; - payload_len = ntohs(*(uint16_t *)(buf + pos)); - pos += 2; - } else if (payload_len == 127) { - if (n < 10) return -1; - payload_len = be64toh(*(uint64_t *)(buf + pos)); - pos += 8; - } - - // bound payload_len before the completeness checks below: pending[] is - // fixed-size, and an attacker-supplied payload_len near UINT64_MAX would - // wrap "pos + 4 + payload_len" to a small number, letting the check pass. - // fill_pending() keeps one byte for a NUL, so a frame needing more than - // sizeof(pending)-1 bytes can never complete: waiting for more data - // would wedge the connection forever, so fail it instead. - const size_t mask_bytes = masked ? 4 : 0; - if (payload_len > (sizeof(pending)-1) - pos - mask_bytes) { - return -2; - } - - if (masked) { - if (n < pos + 4 + payload_len) { - return -1; - } - uint8_t mask[4]; - memcpy(mask, buf + pos, 4); - pos += 4; - for (size_t i = 0; i < payload_len; i++) { - buf[i] = buf[pos + i] ^ mask[i % 4]; - } - } else { - if (n < pos + payload_len) { - return -1; - } - memmove(buf, buf + pos, payload_len); - } + size_t pos = 0; + for (;;) { + if (rx.size() - pos < 2) { + break; + } + const uint8_t *f = &rx[pos]; + const bool fin = (f[0] & 0x80) != 0; + const uint8_t opcode = f[0] & 0x0F; + const bool masked = (f[1] & 0x80) != 0; + uint64_t payload_len = f[1] & 0x7F; + size_t hdr = 2; + + if (payload_len == 126) { + if (rx.size() - pos < 4) break; + // assemble by hand: casting the buffer to uint16_t* is + // undefined for an unaligned address and trips -Wcast-align + payload_len = (uint64_t(f[2]) << 8) | uint64_t(f[3]); + hdr = 4; + } else if (payload_len == 127) { + if (rx.size() - pos < 10) break; + payload_len = 0; + for (int i = 0; i < 8; i++) { + payload_len = (payload_len << 8) | uint64_t(f[2 + i]); + } + hdr = 10; + } + + // Bound before any addition: an attacker-supplied length near + // UINT64_MAX would otherwise wrap the completeness check below. + if (payload_len > max_message) { + printf("WebSocket: frame of %llu bytes exceeds limit\n", + (unsigned long long)payload_len); + return false; + } + // RFC 6455 s5.1: client-to-server frames MUST be masked. + if (!masked) { + printf("WebSocket: unmasked client frame rejected\n"); + return false; + } + // Control frames must be short and unfragmented (s5.5). + const bool is_control = (opcode & 0x8) != 0; + if (is_control && (payload_len > 125 || !fin)) { + printf("WebSocket: malformed control frame\n"); + return false; + } + + const size_t need = hdr + 4 + size_t(payload_len); + if (rx.size() - pos < need) { + break; // incomplete; wait for more + } + + uint8_t mask[4]; + memcpy(mask, f + hdr, 4); + uint8_t *payload = &rx[pos + hdr + 4]; + for (size_t i = 0; i < payload_len; i++) { + payload[i] ^= mask[i % 4]; + } + + switch (opcode) { + case WS_OP_CLOSE: + // Echo the close and stop; the owner closes the socket. + if (!sent_close) { + queue_frame(WS_OP_CLOSE, payload, size_t(payload_len)); + sent_close = true; + flush(); + } + return false; + case WS_OP_PING: + queue_frame(WS_OP_PONG, payload, size_t(payload_len)); + flush(); + break; + case WS_OP_PONG: + break; // unsolicited pongs are legal and ignored + case WS_OP_CONT: + if (!in_fragment) { + printf("WebSocket: continuation with no message open\n"); + return false; + } + if (msg.size() + payload_len > max_message) { + printf("WebSocket: fragmented message exceeds limit\n"); + return false; + } + msg.insert(msg.end(), payload, payload + payload_len); + if (fin) { + in_fragment = false; + } + break; + case WS_OP_TEXT: + case WS_OP_BIN: + if (in_fragment) { + printf("WebSocket: new message while one is open\n"); + return false; + } + if (msg.size() + payload_len > max_message) { + printf("WebSocket: message exceeds limit\n"); + return false; + } + msg.insert(msg.end(), payload, payload + payload_len); + if (!fin) { + in_fragment = true; + } + break; + default: + printf("WebSocket: unknown opcode 0x%x\n", unsigned(opcode)); + return false; + } - used = pos + payload_len; + pos += need; + } - return payload_len; + if (pos > 0) { + rx.erase(rx.begin(), rx.begin() + pos); + } + return true; } /* @@ -344,67 +470,110 @@ bool WebSocket::send_handshake(const std::string &key) } /* - encode a packet onto a connected WebSocket + append a framed message to the output queue */ -ssize_t WebSocket::send(const void *buf, size_t n) +void WebSocket::queue_frame(uint8_t opcode, const void *data, size_t n) { - if (!done_headers) { - // The HTTP upgrade response hasn't been sent yet. Writing a - // MAVLink frame onto the socket now would land *before* the - // "HTTP/1.1 101" line and corrupt the handshake (the peer's WS - // parser sees binary garbage as the status line). Drop the - // frame but report it as sent so the caller doesn't treat it as - // a dead link and tear the session down; the handshake - // completes on the next read and forwarding resumes. - return n; - } uint8_t header[10]; - size_t header_len = 0; - header[0] = 0x82; // FIN + binary opcode + size_t header_len; + header[0] = uint8_t(0x80 | opcode); // FIN + opcode if (n <= 125) { - header[1] = n; + header[1] = uint8_t(n); header_len = 2; } else if (n <= 65535) { header[1] = 126; - *(uint16_t *)(header + 2) = htons(n); + header[2] = uint8_t((n >> 8) & 0xFF); + header[3] = uint8_t(n & 0xFF); header_len = 4; } else { header[1] = 127; - *(uint64_t *)(header + 2) = htobe64(n); + for (int i = 0; i < 8; i++) { + header[2 + i] = uint8_t((uint64_t(n) >> (56 - 8*i)) & 0xFF); + } header_len = 10; } - uint8_t pkt[header_len + n]; - memcpy(pkt, header, header_len); - memcpy(&pkt[header_len], buf, n); + // Drop the fully-flushed prefix first so tx doesn't grow forever on + // a long-lived link. + if (tx_sent > 0 && tx_sent == tx.size()) { + tx.clear(); + tx_sent = 0; + } + tx.insert(tx.end(), header, header + header_len); + const uint8_t *p = static_cast(data); + tx.insert(tx.end(), p, p + n); +} - ssize_t sent; - if (_is_SSL && ssl) { - sent = SSL_write(ssl, pkt, sizeof(pkt)); - if (sent <= 0) { - int err = SSL_get_error(ssl, sent); - if (err == SSL_ERROR_WANT_WRITE || err == SSL_ERROR_WANT_READ) { - return 0; // try again later +/* + push queued output to the socket + */ +bool WebSocket::flush(void) +{ + while (tx_sent < tx.size()) { + const size_t remain = tx.size() - tx_sent; + ssize_t wret; + if (_is_SSL && ssl) { + wret = SSL_write(ssl, &tx[tx_sent], int(remain)); + if (wret <= 0) { + int err = SSL_get_error(ssl, wret); + if (err == SSL_ERROR_WANT_WRITE || err == SSL_ERROR_WANT_READ) { + return true; // stays queued + } + ERR_print_errors_fp(stdout); + fd = -1; + return false; } - ERR_print_errors_fp(stdout); - fd = -1; // owner closes the socket - return -1; - } - } else { - sent = ::send(fd, pkt, sizeof(pkt), 0); - if (sent < 0) { - if (errno == EAGAIN || errno == EWOULDBLOCK) { - return 0; + } else { + wret = ::send(fd, &tx[tx_sent], remain, 0); + if (wret < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return true; // stays queued + } + fd = -1; + return false; } - fd = -1; // owner closes the socket - return -1; } + tx_sent += size_t(wret); + } + tx.clear(); + tx_sent = 0; + return true; +} + +/* + encode a packet onto a connected WebSocket + */ +ssize_t WebSocket::send(const void *buf, size_t n) +{ + if (!done_headers) { + // The HTTP upgrade response hasn't been sent yet. Writing a + // MAVLink frame onto the socket now would land *before* the + // "HTTP/1.1 101" line and corrupt the handshake (the peer's WS + // parser sees binary garbage as the status line). Drop the + // frame but report it as sent so the caller doesn't treat it as + // a dead link and tear the session down; the handshake + // completes on the next read and forwarding resumes. + return n; + } + if (fd < 0) { + return -1; } - if (sent < ssize_t(sizeof(pkt))) { - return 0; // partial; retry later + // Queue-then-flush rather than write-and-hope. Previously a short + // write returned 0, which send_message() reports as failure and the + // caller turns into a connection teardown -- so a momentarily full + // socket killed the session. Framing once into tx and tracking + // tx_sent means a partial write simply resumes where it left off. + if (tx.size() - tx_sent > WS_MAX_TX_QUEUE) { + printf("WebSocket: output queue full, dropping connection\n"); + fd = -1; + return -1; } - return sizeof(pkt) - header_len; + queue_frame(WS_OP_BIN, buf, n); + if (!flush()) { + return -1; + } + return n; } /* @@ -412,6 +581,21 @@ ssize_t WebSocket::send(const void *buf, size_t n) */ ssize_t WebSocket::recv(void *buf, size_t n) { + // Hand back anything already decoded before reading more, so a + // caller with a small buffer drains a large message across calls + // instead of losing the remainder. + if (msg_taken < msg.size()) { + const size_t avail = msg.size() - msg_taken; + const size_t take = n < avail ? n : avail; + memcpy(buf, &msg[msg_taken], take); + msg_taken += take; + if (msg_taken == msg.size()) { + msg.clear(); + msg_taken = 0; + } + return ssize_t(take); + } + fill_pending(); if (fd < 0) { return -1; @@ -425,22 +609,20 @@ ssize_t WebSocket::recv(void *buf, size_t n) return 0; } } - size_t used; - auto decode_len = decode(pending, npending, used); - if (decode_len == -2) { - // unrecoverable frame; fail the connection so the owner closes it - fd = -1; // owner closes the socket - return -1; + if (!decode_frames()) { + fd = -1; // owner closes the socket + return -1; } - if (decode_len == -1) { - return 0; + // A message still being assembled from fragments isn't deliverable yet. + if (in_fragment || msg.empty()) { + return 0; } - if (ssize_t(n) > decode_len) { - n = decode_len; + const size_t take = n < msg.size() ? n : msg.size(); + memcpy(buf, msg.data(), take); + msg_taken = take; + if (msg_taken == msg.size()) { + msg.clear(); + msg_taken = 0; } - - memcpy(buf, pending, n); - memmove(pending, &pending[used], npending-used); - npending -= used; - return n; + return ssize_t(take); } diff --git a/websocket.h b/websocket.h index f4b7e93..c8dd70f 100644 --- a/websocket.h +++ b/websocket.h @@ -7,6 +7,7 @@ #include #include #include +#include #include // Result of peeking at a new TCP stream's first bytes. @@ -18,9 +19,18 @@ // misclassifies a fragmented handshake as raw MAVLink) enum ws_detect_t { WS_NO, WS_YES, WS_MORE }; +// Largest single WebSocket message we will reassemble. MAVLink frames are +// <300 bytes; video sends 8-64 KiB. A message larger than this fails the +// connection rather than being buffered without bound. +#define WS_DEFAULT_MAX_MESSAGE (256*1024) + +// Cap on framed-but-unwritten output. A peer that stops reading must not +// make us buffer without bound; past this we fail the connection. +#define WS_MAX_TX_QUEUE (1024*1024) + class WebSocket { public: - WebSocket(int fd); + explicit WebSocket(int fd, size_t max_message = WS_DEFAULT_MAX_MESSAGE); ~WebSocket(); static ws_detect_t detect(int fd); @@ -30,23 +40,49 @@ class WebSocket { return _is_SSL; } + // Request target from the HTTP upgrade line ("/", "/v1?token=..."), + // empty until the handshake completes. Lets a caller route on path. + const std::string &request_target(void) const { + return req_target; + } + + // Push queued output. Callers driving a write-ready event loop use + // this; send() also flushes opportunistically. False = link is dead. + bool flush(void); + bool has_pending_output(void) const { + return tx_sent < tx.size(); + } + private: int fd = -1; bool _is_SSL = false; bool SSL_handshake_complete = false; - uint8_t pending[1024] {}; - uint32_t npending = 0; SSL *ssl = nullptr; SSL_CTX *ctx = nullptr; bool done_headers = false; + bool sent_close = false; + + const size_t max_message; + + std::vector rx; // raw bytes read off the socket + std::vector msg; // decoded payload not yet handed to caller + size_t msg_taken = 0; // how much of msg the caller has consumed + bool in_fragment = false; // mid multi-frame message + + std::vector tx; // framed output + size_t tx_sent = 0; // how much of tx has reached the socket + + std::string req_target; char handshake_buf[512] {}; size_t handshake_len = 0; size_t handshake_sent = 0; - void fill_pending(void); bool send_handshake(const std::string &key); void check_headers(void); - ssize_t decode(uint8_t *buf, size_t n, size_t &used); + // Pull complete frames out of rx into msg. Returns false if the + // stream is unrecoverable and the connection must be failed. + bool decode_frames(void); + void queue_frame(uint8_t opcode, const void *data, size_t n); }; From 8c89eb108b9b81c05a575464f815846ed8a7fc68 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Tue, 4 Aug 2026 17:29:11 +1000 Subject: [PATCH 2/6] keydb/conntdb: video record fields, layout asserts and CLI Grows KeyEntry 168 -> 344 bytes for the video settings: up to three ports, per-slot options, viewer and publish keys, a disk budget, the MAVLink grace window and a per-slot RTMP path. The append-only contract holds in both directions -- readers zero-extend a short record, writers preserve a tail they do not understand -- so old and new binaries interoperate. The layout was an unchecked ABI shared with keydb_lib.py's PACK_FORMAT. It is now asserted: sizeof, every offset, and that int and float are four bytes. Writing that revealed the same hazard in ConnEntry, where a naively appended field lands at offset 60 inside padding that older Python writers zero, with sizeof unchanged and no size check to catch it -- so the padding is named and the video fields start at 64. Cleanup gains a second budget rather than one shared pool. Video and telemetry are deleted under the same retention but from separate quotas, so a busy camera can never evict a user's tlogs. --- cleanup.cpp | 157 +++++++++++-- cleanup.h | 33 +++ conntdb.cpp | 50 ++++ conntdb.h | 101 ++++++++ conntdb_lib.py | 67 +++++- keydb.h | 111 ++++++++- keydb.py | 140 +++++++++++ keydb_lib.py | 464 +++++++++++++++++++++++++++++++++++-- session.cpp | 24 +- tests/test_keydb_log.py | 32 ++- tests/test_video_ports.py | 300 ++++++++++++++++++++++++ tests/test_video_schema.py | 202 ++++++++++++++++ 12 files changed, 1617 insertions(+), 64 deletions(-) create mode 100644 tests/test_video_ports.py create mode 100644 tests/test_video_schema.py diff --git a/cleanup.cpp b/cleanup.cpp index 9ad73a5..0230ca0 100644 --- a/cleanup.cpp +++ b/cleanup.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,50 @@ off_t port2_quota_bytes(void) return cached; } +static off_t parse_quota_env(const char *name, off_t dflt) +{ + const char *env = getenv(name); + if (env == nullptr || *env == '\0') { + return dflt; + } + // strict: plain positive bytes only. A prefix parse would turn a + // well-meant "1GB" into a 1-byte quota and let the cleanup pass + // delete nearly the whole log tree. + char *endp = nullptr; + errno = 0; + long long v = strtoll(env, &endp, 10); + if (errno == 0 && endp != env && *endp == '\0' && v > 0) { + return off_t(v); + } + ::printf("ignoring invalid %s '%s' (want plain bytes); using %lld\n", + name, env, (long long)dflt); + return dflt; +} + +off_t port2_video_quota_bytes(void) +{ + static off_t cached = -1; + if (cached < 0) { + cached = parse_quota_env("SUPPORTPROXY_PORT2_VIDEO_QUOTA_BYTES", + off_t(4) * 1024 * 1024 * 1024); + } + return cached; +} + +bool video_have_free_space(const char *base_dir) +{ + struct statvfs vfs; + if (statvfs(base_dir, &vfs) != 0) { + return true; // can't tell; don't block recording on it + } + const uint64_t free_bytes = uint64_t(vfs.f_bavail) * vfs.f_frsize; + const uint64_t total = uint64_t(vfs.f_blocks) * vfs.f_frsize; + const uint64_t floor_abs = uint64_t(2) * 1024 * 1024 * 1024; + const uint64_t floor_pct = total / 20; // 5% + const uint64_t want = floor_abs > floor_pct ? floor_abs : floor_pct; + return free_bytes > want; +} + namespace { struct PassCtx { @@ -54,16 +99,41 @@ struct PassCtx { }; /* - Predicate for "this is a session file we should age out under - log_retention_days". Covers both .tlog (raw MAVLink frames) and - .bin (ArduPilot dataflash logs) so the retention rule is uniform — - per spec, both file types share the entry's retention setting. + What kind of session file this is. + + Retention treats both kinds identically -- one per-entry setting + covers everything -- but the quota does not: video and telemetry get + independent budgets, because a shared pool sorted by mtime would let + a few minutes of video evict a whole flight's telemetry. */ +enum session_kind { + SESSION_NONE = 0, + SESSION_TELEM, // .tlog, .bin + SESSION_VIDEO, // .vN.ts +}; + +static session_kind session_file_kind(const char *name) +{ + const size_t n = strlen(name); + if (n > 5 && strcmp(name + n - 5, ".tlog") == 0) { + return SESSION_TELEM; + } + if (n > 4 && strcmp(name + n - 4, ".bin") == 0) { + return SESSION_TELEM; + } + // ".v.ts" -- the slot is part of the name so the + // three slots of one entry never collide. + if (n > 6 && strcmp(name + n - 3, ".ts") == 0 + && name[n - 6] == '.' && name[n - 5] == 'v' + && name[n - 4] >= '1' && name[n - 4] <= '9') { + return SESSION_VIDEO; + } + return SESSION_NONE; +} + static bool is_session_file(const char *name) { - size_t n = strlen(name); - return (n > 5 && strcmp(name + n - 5, ".tlog") == 0) || - (n > 4 && strcmp(name + n - 4, ".bin") == 0); + return session_file_kind(name) != SESSION_NONE; } /* @@ -86,10 +156,31 @@ static bool is_session_file(const char *name) // still be unlinked, and a just-closed session is protected slightly // longer than needed. Both are acceptable: a healthy binlog/tlog // writes many times per second. -static constexpr time_t ACTIVE_FILE_GRACE_S = 60; +// Overridable for tests: with the default 60s, every segment a short +// test writes is still "live" and none is evictable, so the quota pass +// correctly frees nothing and the behaviour cannot be observed at all. +static time_t active_file_grace_s(void) +{ + static time_t cached = -1; + if (cached >= 0) { + return cached; + } + cached = 60; + const char *env = getenv("SUPPORTPROXY_ACTIVE_FILE_GRACE"); + if (env != nullptr && *env != '\0') { + char *endp = nullptr; + errno = 0; + long v = strtol(env, &endp, 10); + if (errno == 0 && endp != env && *endp == '\0' && v >= 0) { + cached = time_t(v); + } + } + return cached; +} -static void enforce_port2_quota(uint32_t port2, const char *base_dir, - off_t needed = 0) +static void enforce_quota(uint32_t port2, const char *base_dir, + session_kind kind, off_t quota, + off_t needed) { char port_dir[768]; snprintf(port_dir, sizeof(port_dir), "%s/%u", base_dir, port2); @@ -124,7 +215,8 @@ static void enforce_port2_quota(uint32_t port2, const char *base_dir, } struct dirent *fent; while ((fent = readdir(dd)) != nullptr) { - if (fent->d_name[0] == '.' || !is_session_file(fent->d_name)) { + if (fent->d_name[0] == '.' + || session_file_kind(fent->d_name) != kind) { continue; } char fpath[1280]; @@ -137,7 +229,7 @@ static void enforce_port2_quota(uint32_t port2, const char *base_dir, // st_size wildly overstates what they cost on disk const off_t alloc = off_t(fst.st_blocks) * 512; total += alloc; - if (time(nullptr) - fst.st_mtime < ACTIVE_FILE_GRACE_S) { + if (time(nullptr) - fst.st_mtime < active_file_grace_s()) { // live session file: count it, never delete it continue; } @@ -151,7 +243,6 @@ static void enforce_port2_quota(uint32_t port2, const char *base_dir, // can happen with total still at or just under the quota, and // without accounting for it here the pass would free nothing and // the caller's write would be dropped forever. - const off_t quota = port2_quota_bytes(); if (total + needed <= quota) { return; } @@ -168,9 +259,11 @@ static void enforce_port2_quota(uint32_t port2, const char *base_dir, break; } if (unlink(it.path.c_str()) == 0) { - ::printf("log cleanup: removed %s for quota " + ::printf("log cleanup: removed %s for %s quota " "(port2=%u total %lld > %lld)\n", - it.path.c_str(), unsigned(port2), + it.path.c_str(), + kind == SESSION_VIDEO ? "video" : "telemetry", + unsigned(port2), (long long)total, (long long)quota); total -= it.size; // Try rmdir on the date dir in case this was its last file; @@ -247,17 +340,23 @@ static void retention_pass(uint32_t port2, double retention_days, } static void cleanup_for_port2(uint32_t port2, double retention_days, + uint32_t video_quota_mb, const char *base_dir, time_t now) { - // Two passes per port2: + // Passes per port2: // 1. retention_pass: per-entry "delete files older than the - // configured retention". Skipped when retention=0 (keep - // forever). - // 2. enforce_port2_quota: hard 1 GiB cap. Runs even if - // retention=0, so even a "keep forever" entry can't fill - // the disk. + // configured retention", covering both kinds. Skipped when + // retention=0 (keep forever). + // 2. one quota pass per kind, with independent budgets. Both run + // even if retention=0, so even a "keep forever" entry cannot + // fill the disk -- and video can never evict telemetry, + // because it is never a candidate in the telemetry pass. retention_pass(port2, retention_days, base_dir, now); - enforce_port2_quota(port2, base_dir); + enforce_quota(port2, base_dir, SESSION_TELEM, port2_quota_bytes(), 0); + const off_t vquota = video_quota_mb != 0 + ? off_t(video_quota_mb) * 1024 * 1024 + : port2_video_quota_bytes(); + enforce_quota(port2, base_dir, SESSION_VIDEO, vquota, 0); } static int traverse_cb(struct tdb_context *db, TDB_DATA key, TDB_DATA data, void *ptr) @@ -279,7 +378,7 @@ static int traverse_cb(struct tdb_context *db, TDB_DATA key, TDB_DATA data, void return 0; } cleanup_for_port2(uint32_t(port2), double(k.log_retention_days), - ctx->base_dir, ctx->now); + k.video_quota_mb, ctx->base_dir, ctx->now); return 0; } @@ -312,7 +411,17 @@ static void sleep_seconds(double s) void log_cleanup_port2_quota(unsigned port2, const char *base_dir, off_t needed) { - enforce_port2_quota(port2, base_dir, needed); + // binlog's write-time gate: telemetry budget only. Freeing video + // here would let a .bin write delete a recording, which is exactly + // the cross-eviction the split budgets exist to prevent. + enforce_quota(port2, base_dir, SESSION_TELEM, port2_quota_bytes(), needed); +} + +void log_cleanup_port2_video_quota(unsigned port2, const char *base_dir, + off_t quota, off_t needed) +{ + enforce_quota(port2, base_dir, SESSION_VIDEO, + quota > 0 ? quota : port2_video_quota_bytes(), needed); } void log_cleanup_once(const char *base_dir) diff --git a/cleanup.h b/cleanup.h index f37b662..b95cd0d 100644 --- a/cleanup.h +++ b/cleanup.h @@ -13,6 +13,39 @@ */ off_t port2_quota_bytes(void); +/* + Per-port-pair on-disk quota (bytes) for video segments. Separate from + the telemetry budget on purpose: video is orders of magnitude larger + per second than a tlog, and a single shared pool sorted by mtime would + let a few minutes of video evict a flight's telemetry. The two are + enforced independently so that cannot happen. + + Default 4 GiB; override with SUPPORTPROXY_PORT2_VIDEO_QUOTA_BYTES. + A non-zero KeyEntry.video_quota_mb overrides both, per entry. + + Sizing rule: the quota pass cannot delete the segment currently being + written (see ACTIVE_FILE_GRACE_S), so with segment duration S, grace G + and aggregate bitrate B the un-evictable working set is (S+G)*B, and + the budget needs to clear that with room to spare: + quota >= (S + G) * B / 0.8 + */ +off_t port2_video_quota_bytes(void); + +/* + Refuse to start a new segment when the filesystem holding base_dir has + less than max(2 GiB, 5%) free. Per-entry quotas bound one entry; they + do nothing about N entries x 3 slots filling a disk between them. + */ +bool video_have_free_space(const char *base_dir); + +/* + Video equivalent of log_cleanup_port2_quota: free video segments for + this entry ahead of a write of `needed` bytes. `quota` of 0 means the + server default. + */ +void log_cleanup_port2_video_quota(unsigned port2, const char *base_dir, + off_t quota, off_t needed); + /* Run forever: every SUPPORTPROXY_CLEANUP_INTERVAL seconds (default 3600, env var override accepts a float for tests), traverse keys.tdb and diff --git a/conntdb.cpp b/conntdb.cpp index 0d3a39f..0c1970b 100644 --- a/conntdb.cpp +++ b/conntdb.cpp @@ -196,6 +196,46 @@ int conn_delete_for_port2(TDB_CONTEXT *db, int port2) return n; } +int conn_delete_index_range(TDB_CONTEXT *db, int port2, int lo, int hi) +{ + struct port2_filter f { port2, {} }; + tdb_traverse(db, collect_port2, &f); + int n = 0; + for (auto &k : f.matches) { + if (k.conn_index < lo || k.conn_index > hi) { + continue; + } + TDB_DATA kd; + kd.dptr = (uint8_t *)&k; + kd.dsize = sizeof(k); + if (tdb_delete(db, kd) == 0) { + n++; + } + } + return n; +} + +bool conn_get_user(TDB_CONTEXT *db, int port2, struct ConnEntry &out) +{ + struct ConnKey k; + auto kd = make_key(k, port2, 0); + auto d = tdb_fetch(db, kd); + if (d.dptr == nullptr) { + return false; + } + bool ok = false; + if (d.dsize >= CONNENTRY_MIN_SIZE) { + // zero-extend a record written by older code: `authenticated` + // then reads 0, which fails closed for bidi entries + memset(&out, 0, sizeof(out)); + size_t copy = d.dsize < sizeof(out) ? d.dsize : sizeof(out); + memcpy(&out, d.dptr, copy); + ok = (out.magic == CONN_MAGIC && out.is_user != 0); + } + free(d.dptr); + return ok; +} + void conn_recreate_empty(void) { // Easiest way to nuke all records is to remove the file. tdb_open @@ -218,3 +258,13 @@ void conn_remove_port2(int port2) conn_delete_for_port2(db, port2); conn_db_close_commit(db); } + +void conn_remove_video(int port2) +{ + auto *db = conn_db_open_transaction(); + if (db == nullptr) { + return; + } + conn_delete_index_range(db, port2, VIDEO_CONN_INDEX_BASE, INT32_MAX); + conn_db_close_commit(db); +} diff --git a/conntdb.h b/conntdb.h index b973121..15308ea 100644 --- a/conntdb.h +++ b/conntdb.h @@ -46,6 +46,30 @@ // matching slot, and deletes the record. #define CONN_FLAG_DROP_REQUESTED (1u << 0) +// ConnEntry.role +#define CONN_ROLE_MAVLINK 0 +#define CONN_ROLE_VIDEO_PUB 1 +#define CONN_ROLE_VIDEO_SUB 2 + +// ConnEntry.app_proto — the application protocol on top of .transport +#define CONN_APP_MAVLINK 0 +#define CONN_APP_MPEGTS 1 +#define CONN_APP_RTSP 2 +#define CONN_APP_HTTP 3 +#define CONN_APP_SRT 4 +#define CONN_APP_RTMP 5 + +/* + Video rows live in a conn_index range disjoint from the MAVLink ones + (0 = user, 1..MAX_COMM2_LINKS = engineer slots), because the two + writers snapshot independently: each deletes and rewrites only its own + range, so neither erases the other's rows. + */ +#define VIDEO_CONN_INDEX_BASE 1000 +#define VIDEO_CONN_STRIDE 256 +#define VIDEO_PUB_INDEX(slot) (VIDEO_CONN_INDEX_BASE + (slot)*VIDEO_CONN_STRIDE) +#define VIDEO_SUB_INDEX(slot, i) (VIDEO_PUB_INDEX(slot) + 1 + (i)) + struct ConnEntry { uint64_t magic; // CONN_MAGIC uint64_t connected_at; // unix seconds @@ -61,13 +85,77 @@ struct ConnEntry { uint8_t is_user; // 1 if this is mav1, 0 if engineer-side uint32_t flags; // reserved (forward-compat) uint32_t _pad; // keep total a multiple of 8 + uint32_t _pad2; // was implicit tail padding; see below + // Fields below are the video extension. They start at offset 64, + // after _pad2, for the reason spelled out in the comment below. + uint8_t role; // CONN_ROLE_* + uint8_t stream_idx; // video slot 0..KEY_MAX_VIDEO_PORTS-1 + uint8_t app_proto; // CONN_APP_* + uint8_t authenticated; // 1 = MAVLink signature validated. Only the + // session child ever sets this; the video + // child requires it on bidi entries. + uint32_t _pad3; }; +/* + ABI shared with conntdb_lib.py's PACK_FORMAT ("= VIDEO_CONN_INDEX_BASE). +void conn_remove_video(int port2); diff --git a/conntdb_lib.py b/conntdb_lib.py index 2fe9e67..d526dbc 100644 --- a/conntdb_lib.py +++ b/conntdb_lib.py @@ -44,12 +44,49 @@ # HBB peer_port_be, transport, is_user ( 4) # I flags ( 4) # I _pad ( 4) -# Raw: 60 bytes. C++ rounds sizeof() up to 64 to align the next -# instance at an 8-byte boundary (alignof(uint64_t)). Add 4 explicit -# pad bytes here so the on-disk size matches. -PACK_FORMAT = " 64 +# BBBB role, stream_idx, app_proto, authenticated ( 4) +# 4x _pad3 ( 4) -> 72 +# +# The video fields start at 64, not 60. Bytes 60..63 were implicit tail +# padding in C++ (the struct is 8-aligned) which this format spells out +# as "4x" -- so a field placed there would be zeroed by any writer using +# the older format, while sizeof() stayed 64 and no size check caught it. +PACK_FORMAT = "= KEY_MAX_VIDEO_PORTS) { + return 0; + } + return (video_flags >> VIDEO_SLOT_SHIFT(slot)) & 0xFFu; +} + +static inline uint32_t video_entry_opts(uint32_t video_flags) +{ + return (video_flags >> VIDEO_OPT_SHIFT) & 0xFFu; +} + +// A publisher with no credential is accepted when a MAVLink session for +// the same entry was seen from the same address within this window, so +// video rides through a MAVLink dropout instead of being revoked. +#define VIDEO_MAV_GRACE_DEFAULT_S 60u struct KeyEntry { uint64_t magic; @@ -48,9 +95,71 @@ struct KeyEntry { float log_retention_days; // tlog + bin; 0.0 = forever; fractional values allowed for tests uint32_t fc_sysid; // 0 = match any; otherwise only monitor packets from this MAVLink sysid (binlog reboot detection) float tz_offset_hours; // log naming: GMT offset in hours (fractional allowed), used only when KEY_FLAG_USE_TZ is set - uint32_t reserved[14]; + uint32_t video_ports[KEY_MAX_VIDEO_PORTS]; // 0 = slot unused + uint32_t video_flags; // VIDEO_SLOT_* / VIDEO_OPT_*, see above + uint8_t video_viewer_key[32]; // sha256(viewer password); all-zero = open + uint8_t video_publish_key[32]; // sha256(publish password); all-zero = the + // MAVLink-session check is the only gate + uint32_t video_quota_mb; // per-entry video disk budget; 0 = default + uint32_t video_mav_grace_s; // publisher grace after MAVLink drops; + // 0 = VIDEO_MAV_GRACE_DEFAULT_S + /* + RTMP publish path for each slot, "app/stream" as configured on the + camera, e.g. "PhoenixFPV/FPV". Empty = accept whatever is + published. + + Optional, and an access control rather than a requirement: RTMP is + parsed here now (videortmp.cpp), so the app and stream are read off + the wire. Set, only that path is admitted on the slot. + */ + char video_rtmp_path[KEY_MAX_VIDEO_PORTS][32]; + uint32_t reserved[12]; }; +/* + The on-disk layout is an ABI shared with keydb_lib.py's PACK_FORMAT + (" 248 -> 344 as video fields were added. That is + allowed by the append-only contract at the top of this file: readers + zero-extend a short record and writers preserve any tail they don't + understand, so old and new binaries interoperate in both directions. + */ +static_assert(sizeof(int) == 4, "KeyEntry ABI assumes 32-bit int"); +static_assert(sizeof(float) == 4, "KeyEntry ABI assumes 32-bit float"); +static_assert(sizeof(struct KeyEntry) == 344, "KeyEntry size changed"); +static_assert(offsetof(struct KeyEntry, magic) == 0, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, timestamp) == 8, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, secret_key) == 16, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, port1) == 48, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, connections) == 52, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, count1) == 56, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, count2) == 60, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, name) == 64, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, flags) == 96, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, log_retention_days) == 100, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, fc_sysid) == 104, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, tz_offset_hours) == 108, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_ports) == 112, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_flags) == 124, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_viewer_key) == 128, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_publish_key) == 160, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_quota_mb) == 192, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_mav_grace_s) == 196, "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, video_rtmp_path) == 200, + "KeyEntry layout"); +static_assert(sizeof(((struct KeyEntry *)nullptr)->video_rtmp_path) == 96, + "KeyEntry layout"); +static_assert(offsetof(struct KeyEntry, reserved) == 296, "KeyEntry layout"); +// No implicit tail padding, so appending a field trips the size assert. +static_assert(offsetof(struct KeyEntry, reserved) + 12*sizeof(uint32_t) + == sizeof(struct KeyEntry), "KeyEntry must have no tail padding"); +// KEYENTRY_MIN_SIZE is the pre-flags layout: everything through name[]. +static_assert(KEYENTRY_MIN_SIZE == offsetof(struct KeyEntry, flags), + "KEYENTRY_MIN_SIZE must be the offset of the first post-legacy field"); + /* open DB with or without a transaction */ diff --git a/keydb.py b/keydb.py index bb2c7f7..11800f3 100755 --- a/keydb.py +++ b/keydb.py @@ -28,6 +28,10 @@ def main(): 'setretention', 'setsysid', 'settz', + 'setvideo', 'videoflag', 'videoopt', 'setrtmp', + 'setviewerpass', 'setpublishpass', + 'setvideoquota', 'setvideograce', + 'video', 'stats'], help="action to perform") parser.add_argument("args", default=[], nargs=argparse.REMAINDER) @@ -121,6 +125,142 @@ def main(): else: print("Set log retention=%.4g days for %s" % (days, ke)) + elif args.action == "setvideo": + if not args.args: + raise CLIError( + "Usage: keydb.py setvideo PORT2 [VPORT ...] " + "(up to %d ports; none clears them all)" + % keydb_lib.MAX_VIDEO_PORTS) + port2 = int(args.args[0]) + try: + vports = [int(a) for a in args.args[1:]] + except ValueError: + raise CLIError("video ports must be integers, got %r" + % (args.args[1:],)) + ke = keydb_lib.set_video_ports(db, port2, vports) + if any(ke.video_ports): + print("Set video ports %s for %s" + % (','.join(str(p) for p in ke.video_ports if p), ke)) + else: + print("Cleared video ports for %s" % ke) + + elif args.action == "setrtmp": + # setrtmp PORT2 SLOT [app/stream] -- omit to clear + if len(args.args) not in (2, 3): + raise CLIError( + "Usage: keydb.py setrtmp PORT2 SLOT [app/stream] " + "(omit the path to clear)") + port2 = int(args.args[0]) + slot = int(args.args[1]) + path = args.args[2] if len(args.args) == 3 else '' + ke = keydb_lib.set_video_rtmp_path(db, port2, slot, path) + got = ke.rtmp_path(slot) + print("Set slot %d RTMP path to %s for %s" + % (slot, repr(got) if got else "(cleared)", ke)) + + elif args.action in ("videoflag", "videoopt"): + # videoflag PORT2 SLOT NAME [on|off] -- per-slot option + # videoopt PORT2 NAME [on|off] -- entry-wide option + per_slot = args.action == "videoflag" + usage = ("keydb.py videoflag PORT2 SLOT NAME [on|off] (NAME: %s)" + % ', '.join(sorted(keydb_lib.VIDEO_SLOT_FLAG_NAMES)) + if per_slot else + "keydb.py videoopt PORT2 NAME [on|off] (NAME: %s)" + % ', '.join(sorted(keydb_lib.VIDEO_OPT_FLAG_NAMES))) + nargs = 3 if per_slot else 2 + if len(args.args) not in (nargs, nargs + 1): + raise CLIError("Usage: %s" % usage) + state = args.args[nargs].lower() if len(args.args) > nargs else "on" + if state not in ("on", "off"): + raise CLIError("state must be 'on' or 'off', got %r" % state) + on = state == "on" + port2 = int(args.args[0]) + if per_slot: + slot = int(args.args[1]) + ke = keydb_lib.set_video_slot_flag(db, port2, slot, + args.args[2], on) + print("Set slot %d %s=%s for %s" + % (slot, args.args[2], state, ke)) + else: + ke = keydb_lib.set_video_entry_flag(db, port2, + args.args[1], on) + print("Set video %s=%s for %s" % (args.args[1], state, ke)) + + elif args.action in ("setviewerpass", "setpublishpass"): + which = ("viewer" if args.action == "setviewerpass" else "publish") + if len(args.args) not in (1, 2): + raise CLIError( + "Usage: keydb.py %s PORT2 [PASSPHRASE] " + "(omit PASSPHRASE to clear)" % args.action) + port2 = int(args.args[0]) + phrase = args.args[1] if len(args.args) == 2 else '' + fn = (keydb_lib.set_video_viewer_pass + if which == "viewer" else keydb_lib.set_video_publish_pass) + ke = fn(db, port2, phrase) + if phrase: + print("Set video %s password for %s" % (which, ke)) + else: + print("Cleared video %s password for %s" % (which, ke)) + + elif args.action == "setvideoquota": + _expect(args.args, 2, + "keydb.py setvideoquota PORT2 MB (0 = server default)") + try: + mb = int(args.args[1]) + except ValueError: + raise CLIError("MB must be an integer, got %r" % args.args[1]) + ke = keydb_lib.set_video_quota(db, int(args.args[0]), mb) + if mb == 0: + print("Cleared video quota (server default) for %s" % ke) + else: + print("Set video quota=%d MB for %s" % (mb, ke)) + + elif args.action == "setvideograce": + _expect(args.args, 2, + "keydb.py setvideograce PORT2 SECONDS (0 = default %d)" + % keydb_lib.VIDEO_MAV_GRACE_DEFAULT_S) + try: + secs = int(args.args[1]) + except ValueError: + raise CLIError("SECONDS must be an integer, got %r" + % args.args[1]) + ke = keydb_lib.set_video_grace(db, int(args.args[0]), secs) + print("Set video MAVLink grace=%ds for %s" + % (ke.mav_grace_seconds(), ke)) + + elif args.action == "video": + _expect(args.args, 1, "keydb.py video PORT2") + port2 = int(args.args[0]) + ke = keydb_lib.KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + print("video: %s" % ("enabled" if ke.video_enabled() + else "disabled (set the 'video' flag)")) + active = ke.active_video_ports() + if not active: + print(" no video ports configured") + for slot, port in active: + opts = ke.slot_opt_names(slot) + print(" slot %d: port %d %s%s" + % (slot, port, + "srt" if 'srt' in opts else "mpegts", + ''.join(' +' + o for o in sorted(opts) + if o != 'srt'))) + rp = ke.rtmp_path(slot) + print(" RTMP path: %s" + % (rp if rp else "(any)")) + eopts = ke.entry_opt_names() + print(" options: %s" % (','.join(sorted(eopts)) if eopts + else '(none)')) + print(" viewer password: %s" + % ("set" if ke.video_viewer_pass_set() else "not set (open)")) + print(" publish password: %s" + % ("set" if ke.video_publish_pass_set() + else "not set (MAVLink session required)")) + print(" mavlink grace: %ds" % ke.mav_grace_seconds()) + print(" quota: %s" % ("%d MB" % ke.video_quota_mb + if ke.video_quota_mb else "server default")) + elif args.action == "setsysid": _expect(args.args, 2, "keydb.py setsysid PORT2 SYSID " diff --git a/keydb_lib.py b/keydb_lib.py index e43ad82..b09c150 100644 --- a/keydb_lib.py +++ b/keydb_lib.py @@ -24,10 +24,11 @@ # Pre-flags layout was 96 bytes. Anything smaller is invalid; anything bigger # is acceptable (extra trailing bytes belong to a newer schema we ignore). # -# The current C++ struct ends with `uint32_t flags`, `float log_retention_days`, -# `uint32_t fc_sysid`, `float tz_offset_hours`, and `uint32_t reserved[14]`. All -# are 4-byte aligned and slot in cleanly after the existing fields, so the -# struct is 168 bytes with no trailing pad. When a future field is added, claim +# The current C++ struct ends with the video fields -- ports, flags, the +# viewer and publish keys, the quota, the MAVLink grace window, the per-slot +# RTMP paths -- and `uint32_t reserved[12]`. All are 4-byte aligned and slot in +# cleanly after the existing fields, so the struct is 344 bytes with no +# trailing pad. When a future field is added, claim # another `reserved[]` slot (renumber: shrink reserved by 1, add a named field) # so the on-disk byte layout stays compatible — the zero-init paths in # db_load_key (C++) and unpack() (Python) handle older records transparently. @@ -36,8 +37,12 @@ # server-local naming (the flag, not the value, decides whether the offset # is used), needing no conversion. KEYENTRY_MIN_SIZE = 96 -PACK_FORMAT = "> (slot * VIDEO_SLOT_BITS)) & 0xFF + + +def video_set_slot_opts(video_flags, slot, opts): + """Return video_flags with slot's option byte replaced.""" + if not 0 <= slot < MAX_VIDEO_PORTS: + raise ValueError("slot out of range: %r" % (slot,)) + shift = slot * VIDEO_SLOT_BITS + return (video_flags & ~(0xFF << shift)) | ((opts & 0xFF) << shift) + + +def video_entry_opts(video_flags): + """The entry-wide option byte.""" + return (video_flags >> VIDEO_OPT_SHIFT) & 0xFF + + +def video_set_entry_opts(video_flags, opts): + return ((video_flags & ~(0xFF << VIDEO_OPT_SHIFT)) + | ((opts & 0xFF) << VIDEO_OPT_SHIFT)) # Timezone offset is a plain GMT offset in hours (fractional allowed, e.g. @@ -88,6 +159,31 @@ class CLIError(Exception): """Raised by helpers below when input is invalid or the entry is missing.""" +def _video_key(passphrase): + """sha256 of a video password; all-zero when unset. + + All-zero is the 'no password' sentinel, so an empty passphrase must + hash to zeros rather than to sha256(b'') -- otherwise clearing a + password would set one that the empty string matches. + """ + if not passphrase: + return bytearray(32) + if isinstance(passphrase, str): + passphrase = passphrase.encode('utf-8') + return bytearray(hashlib.sha256(passphrase).digest()) + + +def _video_key_matches(stored, passphrase): + if not any(stored): + return False # no password set: callers decide what that means + if not passphrase: + return False + if isinstance(passphrase, str): + passphrase = passphrase.encode('utf-8') + return hmac.compare_digest(bytes(stored), + hashlib.sha256(passphrase).digest()) + + class KeyEntry: def __init__(self, port2): self.magic = KEY_MAGIC @@ -102,6 +198,13 @@ def __init__(self, port2): self.log_retention_days = 0.0 self.fc_sysid = 0 self.tz_offset_hours = 0.0 + self.video_ports = [0] * MAX_VIDEO_PORTS + self.video_flags = 0 + self.video_viewer_key = bytearray(32) + self.video_publish_key = bytearray(32) + self.video_quota_mb = 0 + self.video_mav_grace_s = 0 + self.video_rtmp_path = [''] * MAX_VIDEO_PORTS self.reserved = [0] * RESERVED_WORDS self.port2 = port2 # opaque trailing bytes from a record written by a future schema @@ -110,6 +213,7 @@ def __init__(self, port2): def pack(self): name = self.name.encode('UTF-8').ljust(32, b'\x00')[:32] reserved = list(self.reserved) + [0] * (RESERVED_WORDS - len(self.reserved)) + vports = list(self.video_ports) + [0] * (MAX_VIDEO_PORTS - len(self.video_ports)) body = struct.pack(PACK_FORMAT, self.magic, self.timestamp, bytes(self.secret_key), self.port1, self.connections, self.count1, @@ -117,6 +221,14 @@ def pack(self): self.log_retention_days, self.fc_sysid, self.tz_offset_hours, + *vports[:MAX_VIDEO_PORTS], + self.video_flags, + bytes(self.video_viewer_key), + bytes(self.video_publish_key), + self.video_quota_mb, + self.video_mav_grace_s, + *[self._rtmp_bytes(i) + for i in range(MAX_VIDEO_PORTS)], *reserved[:RESERVED_WORDS]) return body + self._tail @@ -135,7 +247,19 @@ def unpack(self, data): self.connections, self.count1, self.count2, name, self.flags, self.log_retention_days, self.fc_sysid, self.tz_offset_hours) = unpacked[:12] - self.reserved = list(unpacked[12:12 + RESERVED_WORDS]) + n = 12 + self.video_ports = list(unpacked[n:n + MAX_VIDEO_PORTS]) + n += MAX_VIDEO_PORTS + (self.video_flags, viewer_key, publish_key, + self.video_quota_mb, self.video_mav_grace_s) = unpacked[n:n + 5] + n += 5 + self.video_rtmp_path = [ + b.decode('utf-8', errors='ignore').rstrip('\0') + for b in unpacked[n:n + MAX_VIDEO_PORTS]] + n += MAX_VIDEO_PORTS + self.reserved = list(unpacked[n:n + RESERVED_WORDS]) + self.video_viewer_key = bytearray(viewer_key) + self.video_publish_key = bytearray(publish_key) self.secret_key = bytearray(secret_key) self.name = name.decode('utf-8', errors='ignore').rstrip('\0') @@ -171,6 +295,101 @@ def passphrase_matches(self, passphrase): def is_admin(self): return bool(self.flags & FLAG_ADMIN) + # --- video --------------------------------------------------------- + + def video_enabled(self): + return bool(self.flags & FLAG_VIDEO) + + def active_video_ports(self): + """(slot, port) for each configured slot, in slot order.""" + return [(i, p) for i, p in enumerate(self.video_ports[:MAX_VIDEO_PORTS]) + if p] + + def video_port_count(self): + """How many video slots this entry uses. + + Derived from the ports rather than stored, so there is no second + source of truth to disagree with them. It is the highest + allocated slot, not the number allocated, so an entry with a gap + still accounts for every port it owns. Never 0: an entry with no + ports yet is presented as wanting one. + """ + highest = 0 + for slot in range(MAX_VIDEO_PORTS): + if self.video_ports[slot]: + highest = slot + 1 + return highest or 1 + + def _rtmp_bytes(self, slot): + """One slot's path as a fixed 32-byte field.""" + paths = list(self.video_rtmp_path) + [''] * MAX_VIDEO_PORTS + return paths[slot].encode('utf-8')[:31] + + def rtmp_path(self, slot): + paths = list(self.video_rtmp_path) + [''] * MAX_VIDEO_PORTS + return paths[slot] if 0 <= slot < MAX_VIDEO_PORTS else '' + + def set_rtmp_path(self, slot, path): + """Set (or clear) the RTMP app/stream for one slot. + + Stored as the camera spells it -- "PhoenixFPV/FPV" -- because + that is the form it is compared against when a publisher names + its app and stream. + """ + if not 0 <= slot < MAX_VIDEO_PORTS: + raise CLIError("slot must be 0..%d" % (MAX_VIDEO_PORTS - 1)) + path = (path or '').strip().strip('/') + if len(path.encode('utf-8')) > 31: + raise CLIError("RTMP path too long (max 31 bytes): %r" % path) + # A path is pasted from a camera's config page, so reject the + # characters that would change the URL's meaning rather than + # silently building a different one. + bad = set(path) & set(' ?#@\\"\'<>') + if bad: + raise CLIError("RTMP path may not contain %s" + % ' '.join(sorted(bad))) + paths = list(self.video_rtmp_path) + [''] * MAX_VIDEO_PORTS + paths[slot] = path + self.video_rtmp_path = paths[:MAX_VIDEO_PORTS] + + def slot_opts(self, slot): + return video_slot_opts(self.video_flags, slot) + + def set_slot_opts(self, slot, opts): + self.video_flags = video_set_slot_opts(self.video_flags, slot, opts) + + def slot_opt_names(self, slot): + opts = self.slot_opts(slot) + return [n for n, b in VIDEO_SLOT_FLAG_NAMES.items() if opts & b] + + def entry_opt_names(self): + opts = video_entry_opts(self.video_flags) + return [n for n, b in VIDEO_OPT_FLAG_NAMES.items() if opts & b] + + def set_video_viewer_pass(self, passphrase): + """Empty/None clears the password (open viewing).""" + self.video_viewer_key = _video_key(passphrase) + + def set_video_publish_pass(self, passphrase): + """Empty/None clears it, leaving the MAVLink check as the only gate.""" + self.video_publish_key = _video_key(passphrase) + + def video_viewer_pass_set(self): + return any(self.video_viewer_key) + + def video_publish_pass_set(self): + return any(self.video_publish_key) + + def video_viewer_pass_matches(self, passphrase): + return _video_key_matches(self.video_viewer_key, passphrase) + + def video_publish_pass_matches(self, passphrase): + return _video_key_matches(self.video_publish_key, passphrase) + + def mav_grace_seconds(self): + """Effective grace window; 0 in the record means the default.""" + return self.video_mav_grace_s or VIDEO_MAV_GRACE_DEFAULT_S + def flag_names(self): on = [n for n, b in FLAG_NAMES.items() if self.flags & b] unknown = self.flags & ~sum(FLAG_NAMES.values()) @@ -256,12 +475,77 @@ def list_entries(db): def get_port_sets(db): + """(port1s, port2s, video_ports) across every entry. + + Video ports share the same listening-port namespace as port1/port2, + so every uniqueness check has to consider all three sets. Prefer + ports_in_use() for new code; this stays for callers that need the + split. + """ ports1 = set() ports2 = set() + portsv = set() for e in list_entries(db): ports1.add(e.port1) ports2.add(e.port2) - return ports1, ports2 + portsv.update(p for p in e.video_ports[:MAX_VIDEO_PORTS] if p) + return ports1, ports2, portsv + + +def ports_in_use(db, exclude_port2=None): + """Every port bound by any entry, as one set. + + exclude_port2 drops that entry's own ports, so an edit doesn't + collide with itself. + """ + used = set() + for e in list_entries(db): + if exclude_port2 is not None and e.port2 == exclude_port2: + continue + used.add(e.port1) + used.add(e.port2) + used.update(p for p in e.video_ports[:MAX_VIDEO_PORTS] if p) + used.discard(0) + return used + + +def suggest_video_ports(db, ke, count, keep=None): + """Pick `count` free video ports for `ke`, counting up from + VIDEO_PORT_BASE. + + `keep` is the entry's current ports; an already-allocated slot keeps + its port rather than being renumbered, so opening the edit page and + saving it does not silently move a running stream to a new port. + Returns a MAX_VIDEO_PORTS-long list, 0 for slots beyond `count`. + """ + keep = list(keep or []) + keep += [0] * (MAX_VIDEO_PORTS - len(keep)) + + used = ports_in_use(db, exclude_port2=ke.port2) + used.update(p for p in (ke.port1, ke.port2) if p) + # A kept port must not be handed to another slot as well. + used.update(p for p in keep[:count] if p) + + out = [] + nxt = VIDEO_PORT_BASE + for slot in range(MAX_VIDEO_PORTS): + if slot >= count: + out.append(0) + continue + if keep[slot]: + out.append(keep[slot]) + continue + while nxt in used and nxt <= VIDEO_PORT_MAX: + nxt += 1 + if nxt > VIDEO_PORT_MAX: + # Nothing free above the base. Leave it for the operator to + # fill in rather than suggesting a port that cannot be used. + out.append(0) + continue + out.append(nxt) + used.add(nxt) + nxt += 1 + return out def find_by_port(db, port): @@ -287,11 +571,13 @@ def count_admins(db): # caller's responsibility so multiple mutations can share one transaction. def add_entry(db, port1, port2, name, passphrase): - ports1, ports2 = get_port_sets(db) - if port1 in ports1 or port1 in ports2: - raise CLIError("Entry already exists for port1 %d" % port1) - if port2 in ports2 or port2 in ports1: - raise CLIError("Entry already exists for port2 %d" % port2) + used = ports_in_use(db) + if port1 in used: + raise CLIError("Port %d is already in use" % port1) + if port2 in used: + raise CLIError("Port %d is already in use" % port2) + if port1 == port2: + raise CLIError("port1 and port2 must differ") ke = KeyEntry(port2) ke.port1 = port1 ke.name = name @@ -424,6 +710,156 @@ def set_fc_sysid(db, port2, sysid): return ke +def validate_video_ports(db, ke, ports): + """Normalise `ports` to a MAX_VIDEO_PORTS-long list, or raise CLIError. + + Video ports share the listening-port namespace with port1/port2, so + each is checked against every port any *other* entry binds, against + this entry's own port1/port2, and against the others in this list. + + Split out from set_video_ports() so the web UI can validate against + an entry it has already fetched and is about to store itself, rather + than going through a second fetch/store. + """ + if len(ports) > MAX_VIDEO_PORTS: + raise CLIError("at most %d video ports (got %d)" + % (MAX_VIDEO_PORTS, len(ports))) + + vports = [int(p or 0) for p in ports] + vports += [0] * (MAX_VIDEO_PORTS - len(vports)) + used = ports_in_use(db, exclude_port2=ke.port2) + seen = set() + for p in vports: + if p == 0: + continue + if p < VIDEO_PORT_MIN or p > VIDEO_PORT_MAX: + raise CLIError("video port %d out of range %d..%d" + % (p, VIDEO_PORT_MIN, VIDEO_PORT_MAX)) + if p in (ke.port1, ke.port2): + raise CLIError("video port %d collides with this entry's " + "own port1/port2" % p) + if p in seen: + raise CLIError("video port %d is listed twice" % p) + if p in used: + raise CLIError("Port %d is already in use" % p) + seen.add(p) + return vports + + +def set_video_ports(db, port2, ports): + """Set this entry's video ports. `ports` is a list of up to 3 ints; + 0 (or a short list) leaves the remaining slots unused.""" + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + ke.video_ports = validate_video_ports(db, ke, ports) + ke.store(db) + return ke + + +def set_video_rtmp_path(db, port2, slot, path): + """Set the RTMP app/stream a slot accepts, e.g. 'PhoenixFPV/FPV'. + + Optional, and an access control rather than a requirement: the + publisher's app and stream are read off the wire, so a blank path + accepts whatever the camera publishes. + """ + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + if not 0 <= slot < MAX_VIDEO_PORTS: + raise CLIError("video slot must be 0..%d (got %r)" + % (MAX_VIDEO_PORTS - 1, slot)) + ke.set_rtmp_path(slot, path) + ke.store(db) + return ke + + +def set_video_slot_flag(db, port2, slot, flag_name, on=True): + """Set or clear one per-slot video option (srt / record / raw_tcp).""" + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + if not 0 <= slot < MAX_VIDEO_PORTS: + raise CLIError("video slot must be 0..%d (got %r)" + % (MAX_VIDEO_PORTS - 1, slot)) + bit = VIDEO_SLOT_FLAG_NAMES.get(flag_name) + if bit is None: + raise CLIError("unknown video slot flag '%s' (known: %s)" + % (flag_name, ', '.join(sorted(VIDEO_SLOT_FLAG_NAMES)))) + opts = ke.slot_opts(slot) + ke.set_slot_opts(slot, (opts | bit) if on else (opts & ~bit)) + ke.store(db) + return ke + + +def set_video_entry_flag(db, port2, flag_name, on=True): + """Set or clear one entry-wide video option (audio).""" + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + bit = VIDEO_OPT_FLAG_NAMES.get(flag_name) + if bit is None: + raise CLIError("unknown video option '%s' (known: %s)" + % (flag_name, ', '.join(sorted(VIDEO_OPT_FLAG_NAMES)))) + opts = video_entry_opts(ke.video_flags) + ke.video_flags = video_set_entry_opts( + ke.video_flags, (opts | bit) if on else (opts & ~bit)) + ke.store(db) + return ke + + +def set_video_viewer_pass(db, port2, passphrase): + """Set (or clear, with an empty passphrase) the video viewer password.""" + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + ke.set_video_viewer_pass(passphrase) + ke.store(db) + return ke + + +def set_video_publish_pass(db, port2, passphrase): + """Set (or clear) the video publish password. + + Cleared is the normal case: publish is then gated only by a MAVLink + session from the same address within the grace window. + """ + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + ke.set_video_publish_pass(passphrase) + ke.store(db) + return ke + + +def set_video_quota(db, port2, quota_mb): + """Per-entry video disk budget in MB. 0 = use the server default.""" + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + q = int(quota_mb) + if q < 0: + raise CLIError("video quota must be >= 0 (got %r)" % quota_mb) + ke.video_quota_mb = q + ke.store(db) + return ke + + +def set_video_grace(db, port2, seconds): + """Publisher grace after the MAVLink session drops. 0 = default.""" + ke = KeyEntry(port2) + if not ke.fetch(db): + raise CLIError("No entry for port2 %d" % port2) + s = int(seconds) + if s < 0 or s > VIDEO_MAV_GRACE_MAX_S: + raise CLIError("video grace must be 0..%d seconds (got %r)" + % (VIDEO_MAV_GRACE_MAX_S, seconds)) + ke.video_mav_grace_s = s + ke.store(db) + return ke + + def convert_db(db): """Convert legacy 48-byte records to the current layout.""" count = 0 diff --git a/session.cpp b/session.cpp index 3bfaa90..aa05ac6 100644 --- a/session.cpp +++ b/session.cpp @@ -68,14 +68,26 @@ void session_time_strings(time_t utc, bool use_offset, double tz_offset_hours, tm.tm_hour, tm.tm_min, tm.tm_sec); } -// True if neither /.tlog nor /.bin exists. +// Every extension a session can produce. A basename is only free if +// none of them is taken: the files of one session share a name, so +// handing back a name that any of them already occupies would append +// into (or truncate) another session's log. +static const char *SESSION_EXTS[] = { + ".tlog", ".bin", ".v1.ts", ".v2.ts", ".v3.ts", +}; + +// True if no session file of any kind exists under this basename. static bool basename_free(const char *dir, const char *candidate) { - char p_tlog[2048], p_bin[2048]; - snprintf(p_tlog, sizeof(p_tlog), "%s/%s.tlog", dir, candidate); - snprintf(p_bin, sizeof(p_bin), "%s/%s.bin", dir, candidate); - struct stat st; - return stat(p_tlog, &st) != 0 && stat(p_bin, &st) != 0; + for (const char *ext : SESSION_EXTS) { + char p[2048]; + snprintf(p, sizeof(p), "%s/%s%s", dir, candidate, ext); + struct stat st; + if (stat(p, &st) == 0) { + return false; + } + } + return true; } void session_unique_basename(const char *base_dir, uint32_t port2, diff --git a/tests/test_keydb_log.py b/tests/test_keydb_log.py index 894db89..76eceab 100644 --- a/tests/test_keydb_log.py +++ b/tests/test_keydb_log.py @@ -20,11 +20,14 @@ KEYDB_PY = os.path.join(_REPO_ROOT, 'keydb.py') -def test_pack_format_size_is_168(): - """The on-disk record is 168 bytes after appending log_retention_days - + reserved[16].""" - assert struct.calcsize(keydb_lib.PACK_FORMAT) == 168 - assert keydb_lib.KEYENTRY_CURRENT_SIZE == 168 +def test_pack_format_size_is_248(): + """The on-disk record is 248 bytes after appending the video fields. + + keydb.h carries a matching static_assert, so this catches either side + drifting from the other. + """ + assert struct.calcsize(keydb_lib.PACK_FORMAT) == 344 + assert keydb_lib.KEYENTRY_CURRENT_SIZE == 344 def test_pack_unpack_roundtrip(): @@ -35,7 +38,7 @@ def test_pack_unpack_roundtrip(): e.flags = keydb_lib.FLAG_TLOG | keydb_lib.FLAG_ADMIN e.log_retention_days = 0.0001 data = e.pack() - assert len(data) == 168 + assert len(data) == 344 e2 = keydb_lib.KeyEntry(0) e2.unpack(data) @@ -45,7 +48,7 @@ def test_pack_unpack_roundtrip(): # float32 quantisation: tolerate ~1e-7 relative error assert abs(e2.log_retention_days - 0.0001) < 1e-7 assert e2.tz_offset_hours == 0.0 - assert e2.reserved == [0] * 14 + assert e2.reserved == [0] * keydb_lib.RESERVED_WORDS def test_legacy_104byte_record_zero_extends(): @@ -71,11 +74,16 @@ def test_legacy_104byte_record_zero_extends(): assert decoded.log_retention_days == 0.0 assert decoded.fc_sysid == 0 assert decoded.tz_offset_hours == 0.0 - assert decoded.reserved == [0] * 14 + assert decoded.reserved == [0] * keydb_lib.RESERVED_WORDS + + # video fields default to unset + assert decoded.video_ports == [0] * keydb_lib.MAX_VIDEO_PORTS + assert decoded.video_flags == 0 + assert not decoded.video_viewer_pass_set() - # Re-pack: should emit the full 168-byte modern layout. + # Re-pack: should emit the full 248-byte modern layout. re = decoded.pack() - assert len(re) == 168 + assert len(re) == 344 def test_forward_compat_tail_is_preserved(): @@ -94,7 +102,7 @@ def test_forward_compat_tail_is_preserved(): assert decoded._tail == extra re = decoded.pack() assert re.endswith(extra) - assert len(re) == 168 + len(extra) + assert len(re) == 344 + len(extra) def test_flag_names_includes_tlog(): @@ -318,7 +326,7 @@ def test_tz_offset_round_trip(): e2 = keydb_lib.KeyEntry(0) e2.unpack(e.pack()) assert abs(e2.tz_offset_hours - 5.5) < 1e-6 - assert e2.reserved == [0] * 14 + assert e2.reserved == [0] * keydb_lib.RESERVED_WORDS def test_format_tz_offset(): diff --git a/tests/test_video_ports.py b/tests/test_video_ports.py new file mode 100644 index 0000000..1ce4c91 --- /dev/null +++ b/tests/test_video_ports.py @@ -0,0 +1,300 @@ +"""Video port allocation, option setters, and the keydb.py CLI actions. + +Video ports share the listening-port namespace with port1/port2, so the +uniqueness rule has to be bidirectional: a video port must not take a +port some other entry already binds, *and* a new entry must not take a +port already used for video. Both directions are tested here. +""" +import os +import subprocess +import sys + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +import keydb_lib # noqa: E402 +from keydb_lib import CLIError # noqa: E402 + +KEYDB_PY = os.path.join(_REPO_ROOT, 'keydb.py') + +PORT1, PORT2 = 20001, 20002 +OTHER1, OTHER2 = 30001, 30002 + + +@pytest.fixture +def db(tmp_path): + """A keys.tdb with two entries, inside an open transaction.""" + d = keydb_lib.init_db(str(tmp_path / 'keys.tdb')) + d.transaction_start() + keydb_lib.add_entry(d, PORT1, PORT2, 'vid', 'pw') + keydb_lib.add_entry(d, OTHER1, OTHER2, 'other', 'pw2') + yield d + try: + d.transaction_cancel() + except Exception: + pass + d.close() + + +def _ports(d, port2=PORT2): + ke = keydb_lib.KeyEntry(port2) + assert ke.fetch(d) + return ke.video_ports + + +def test_set_and_clear_video_ports(db): + keydb_lib.set_video_ports(db, PORT2, [21001, 21002]) + assert _ports(db) == [21001, 21002, 0] + keydb_lib.set_video_ports(db, PORT2, []) + assert _ports(db) == [0, 0, 0] + + +@pytest.mark.parametrize('bad,msg', [ + ([OTHER1], 'already in use'), # another entry's port1 + ([OTHER2], 'already in use'), # another entry's port2 + ([PORT1], 'own port1/port2'), # our own port1 + ([PORT2], 'own port1/port2'), # our own port2 + ([22000, 22000], 'listed twice'), # duplicate in one call + ([80000], 'out of range'), # above the port range + ([80], 'out of range'), # below VIDEO_PORT_MIN + ([1, 2, 3, 4], 'at most 3'), # too many +]) +def test_video_port_collisions_rejected(db, bad, msg): + with pytest.raises(CLIError) as ei: + keydb_lib.set_video_ports(db, PORT2, bad) + assert msg in str(ei.value) + # a rejected call must not have partially applied + assert _ports(db) == [0, 0, 0] + + +def test_video_port_blocks_a_later_add(db): + """The check is bidirectional: a new entry can't take a video port.""" + keydb_lib.set_video_ports(db, PORT2, [21001]) + with pytest.raises(CLIError) as ei: + keydb_lib.add_entry(db, 21001, 40002, 'clash', 'pw') + assert 'already in use' in str(ei.value) + + +def test_video_port_can_be_reassigned_to_itself(db): + """Re-setting the same ports must not collide with the entry's own.""" + keydb_lib.set_video_ports(db, PORT2, [21001, 21002]) + keydb_lib.set_video_ports(db, PORT2, [21001, 21002]) + assert _ports(db) == [21001, 21002, 0] + # and reordering is fine + keydb_lib.set_video_ports(db, PORT2, [21002, 21001]) + assert _ports(db) == [21002, 21001, 0] + + +def test_ports_in_use_excludes_named_entry(db): + keydb_lib.set_video_ports(db, PORT2, [21001]) + all_used = keydb_lib.ports_in_use(db) + assert {PORT1, PORT2, OTHER1, OTHER2, 21001} <= all_used + mine_excluded = keydb_lib.ports_in_use(db, exclude_port2=PORT2) + assert {OTHER1, OTHER2} <= mine_excluded + assert not ({PORT1, PORT2, 21001} & mine_excluded) + + +def test_get_port_sets_returns_three_sets(db): + keydb_lib.set_video_ports(db, PORT2, [21001]) + p1, p2, pv = keydb_lib.get_port_sets(db) + assert PORT1 in p1 and OTHER1 in p1 + assert PORT2 in p2 and OTHER2 in p2 + assert pv == {21001} + + +def test_slot_and_entry_flags(db): + keydb_lib.set_video_slot_flag(db, PORT2, 0, 'record') + keydb_lib.set_video_slot_flag(db, PORT2, 1, 'srt') + keydb_lib.set_video_entry_flag(db, PORT2, 'audio') + ke = keydb_lib.KeyEntry(PORT2) + assert ke.fetch(db) + assert ke.slot_opt_names(0) == ['record'] + assert ke.slot_opt_names(1) == ['srt'] + assert ke.entry_opt_names() == ['audio'] + + keydb_lib.set_video_slot_flag(db, PORT2, 0, 'record', on=False) + ke.fetch(db) + assert ke.slot_opt_names(0) == [] + assert ke.slot_opt_names(1) == ['srt'] # untouched + + +def test_unknown_flag_names_rejected(db): + with pytest.raises(CLIError): + keydb_lib.set_video_slot_flag(db, PORT2, 0, 'nosuchflag') + with pytest.raises(CLIError): + keydb_lib.set_video_entry_flag(db, PORT2, 'nosuchopt') + with pytest.raises(CLIError): + keydb_lib.set_video_slot_flag(db, PORT2, 9, 'record') + + +def test_quota_and_grace_bounds(db): + keydb_lib.set_video_quota(db, PORT2, 4096) + keydb_lib.set_video_grace(db, PORT2, 90) + ke = keydb_lib.KeyEntry(PORT2) + assert ke.fetch(db) + assert ke.video_quota_mb == 4096 and ke.mav_grace_seconds() == 90 + + with pytest.raises(CLIError): + keydb_lib.set_video_quota(db, PORT2, -1) + with pytest.raises(CLIError): + keydb_lib.set_video_grace(db, PORT2, -1) + with pytest.raises(CLIError): + keydb_lib.set_video_grace(db, PORT2, + keydb_lib.VIDEO_MAV_GRACE_MAX_S + 1) + + # 0 means "use the default", not "no grace" + keydb_lib.set_video_grace(db, PORT2, 0) + ke.fetch(db) + assert ke.mav_grace_seconds() == keydb_lib.VIDEO_MAV_GRACE_DEFAULT_S + + +def test_video_passwords_via_setters(db): + keydb_lib.set_video_viewer_pass(db, PORT2, 'viewpw') + keydb_lib.set_video_publish_pass(db, PORT2, 'pubpw') + ke = keydb_lib.KeyEntry(PORT2) + assert ke.fetch(db) + assert ke.video_viewer_pass_matches('viewpw') + assert ke.video_publish_pass_matches('pubpw') + assert ke.passphrase_matches('pw') # MAVLink passphrase untouched + + keydb_lib.set_video_viewer_pass(db, PORT2, '') + ke.fetch(db) + assert not ke.video_viewer_pass_set() + assert ke.video_publish_pass_set() # independent + + +# --- CLI ----------------------------------------------------------------- + +def _cli(workdir, *argv): + return subprocess.run( + [sys.executable, KEYDB_PY] + list(argv), + cwd=str(workdir), capture_output=True, text=True) + + +@pytest.fixture +def cli_db(tmp_path): + _cli(tmp_path, 'initialise') + _cli(tmp_path, 'add', str(PORT1), str(PORT2), 'vid', 'pw') + return tmp_path + + +def test_cli_setvideo_and_video_summary(cli_db): + assert _cli(cli_db, 'setflag', str(PORT2), 'video').returncode == 0 + r = _cli(cli_db, 'setvideo', str(PORT2), '21001', '21002') + assert r.returncode == 0, r.stderr + assert '21001,21002' in r.stdout + + assert _cli(cli_db, 'videoflag', str(PORT2), '0', 'record').returncode == 0 + assert _cli(cli_db, 'videoflag', str(PORT2), '1', 'srt').returncode == 0 + assert _cli(cli_db, 'videoopt', str(PORT2), 'audio').returncode == 0 + assert _cli(cli_db, 'setviewerpass', str(PORT2), 'vp').returncode == 0 + + out = _cli(cli_db, 'video', str(PORT2)).stdout + assert 'video: enabled' in out + assert 'slot 0: port 21001 mpegts +record' in out + assert 'slot 1: port 21002 srt' in out + assert 'options: audio' in out + assert 'viewer password: set' in out + assert 'publish password: not set' in out + + +def test_cli_rejects_colliding_video_port(cli_db): + _cli(cli_db, 'add', str(OTHER1), str(OTHER2), 'other', 'pw2') + r = _cli(cli_db, 'setvideo', str(PORT2), str(OTHER1)) + assert r.returncode == 1 + assert 'already in use' in r.stdout + r.stderr + + +def test_cli_clears_ports_and_passwords(cli_db): + _cli(cli_db, 'setvideo', str(PORT2), '21001') + _cli(cli_db, 'setviewerpass', str(PORT2), 'vp') + + assert _cli(cli_db, 'setvideo', str(PORT2)).returncode == 0 + assert _cli(cli_db, 'setviewerpass', str(PORT2)).returncode == 0 + + out = _cli(cli_db, 'video', str(PORT2)).stdout + assert 'no video ports configured' in out + assert 'viewer password: not set' in out + + +def test_cli_videoflag_off(cli_db): + _cli(cli_db, 'setvideo', str(PORT2), '21001') + _cli(cli_db, 'videoflag', str(PORT2), '0', 'record') + assert 'record' in _cli(cli_db, 'video', str(PORT2)).stdout + r = _cli(cli_db, 'videoflag', str(PORT2), '0', 'record', 'off') + assert r.returncode == 0 + assert '+record' not in _cli(cli_db, 'video', str(PORT2)).stdout + + +class TestSuggestVideoPorts: + """Automatic allocation counts up from VIDEO_PORT_BASE.""" + + def test_starts_at_the_base(self, db): + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + got = keydb_lib.suggest_video_ports(db, ke, 1) + assert got[0] == keydb_lib.VIDEO_PORT_BASE + assert got[1:] == [0, 0] + + def test_consecutive_within_one_entry(self, db): + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + assert keydb_lib.suggest_video_ports(db, ke, 3) == [ + keydb_lib.VIDEO_PORT_BASE, + keydb_lib.VIDEO_PORT_BASE + 1, + keydb_lib.VIDEO_PORT_BASE + 2] + + def test_skips_ports_another_entry_holds(self, db): + base = keydb_lib.VIDEO_PORT_BASE + other = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + other.video_ports = [base, base + 2, 0] + other.store(db) + ke = keydb_lib.add_entry(db, 10003, 10004, 'b', 'p') + assert keydb_lib.suggest_video_ports(db, ke, 2) == [base + 1, + base + 3, 0] + + def test_skips_port1_and_port2(self, db): + base = keydb_lib.VIDEO_PORT_BASE + ke = keydb_lib.add_entry(db, base, base + 1, 'a', 'p') + assert keydb_lib.suggest_video_ports(db, ke, 1) == [base + 2, 0, 0] + + def test_keeps_an_already_allocated_port(self, db): + """An entry that is already streaming on a port must not be + renumbered just because its edit page was opened.""" + base = keydb_lib.VIDEO_PORT_BASE + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + ke.video_ports = [50000, 0, 0] + got = keydb_lib.suggest_video_ports(db, ke, 2, keep=ke.video_ports) + assert got == [50000, base, 0] + + def test_kept_port_is_not_reused_for_another_slot(self, db): + base = keydb_lib.VIDEO_PORT_BASE + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + ke.video_ports = [0, base, 0] + got = keydb_lib.suggest_video_ports(db, ke, 2, keep=ke.video_ports) + assert got == [base + 1, base, 0] + assert len(set(p for p in got if p)) == 2 + + def test_suggestions_validate(self, db): + """What the page offers must be storable, or the admin gets an + error on a form they did not edit.""" + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + got = keydb_lib.suggest_video_ports(db, ke, 3) + assert keydb_lib.validate_video_ports(db, ke, got) == got + + +class TestVideoPortCount: + def test_no_ports_reads_as_one(self, db): + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + assert ke.video_port_count() == 1 + + def test_counts_the_highest_slot_not_the_total(self, db): + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + ke.video_ports = [40001, 0, 40003] + assert ke.video_port_count() == 3 + + def test_two(self, db): + ke = keydb_lib.add_entry(db, 10001, 10002, 'a', 'p') + ke.video_ports = [40001, 40002, 0] + assert ke.video_port_count() == 2 diff --git a/tests/test_video_schema.py b/tests/test_video_schema.py new file mode 100644 index 0000000..c4ae28e --- /dev/null +++ b/tests/test_video_schema.py @@ -0,0 +1,202 @@ +"""Schema tests for the video fields in keys.tdb and connections.tdb. + +Both records are an ABI shared with C++ (keydb.h / conntdb.h carry +matching static_asserts). These tests cover the Python half and, more +importantly, the forward/backward-compatibility contract: a record +written by an older build must read back with the video fields unset, +and a record written by a newer build must survive a read-modify-write +here without losing its tail. +""" +import struct +import sys +import os + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +import conntdb_lib # noqa: E402 +import keydb_lib # noqa: E402 + +# The record layout as the pre-video schema wrote it. +PREVIDEO_KEY_FMT = " 100 + 1 + for slot in range(keydb_lib.MAX_VIDEO_PORTS): + pub = (conntdb_lib.VIDEO_CONN_INDEX_BASE + + slot * conntdb_lib.VIDEO_CONN_STRIDE) + assert pub > 100 + # viewers for one slot must not run into the next slot's publisher + last_sub = pub + conntdb_lib.VIDEO_CONN_STRIDE - 1 + next_pub = pub + conntdb_lib.VIDEO_CONN_STRIDE + assert last_sub < next_pub From cace83bac6a22db5b21dad69ac3ec795bf235c6e Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Tue, 4 Aug 2026 17:29:39 +1000 Subject: [PATCH 3/6] video: per-entry video child with UDP, RTSP and native RTMP ingest Brings video into the machinery that already solves NAT traversal, per-entry credentials and log retention for MAVLink, so recordings sit beside the tlogs under the same rules and mediamtx -- which ran with no authentication, no supervision and no retention -- can be switched off. Video runs in a long-lived child of the parent, not of the MAVLink session child. The session child idles out after 10 s, which would take video with it, and a password holder must be able to publish with no MAVLink at all. Admission has two independent paths: a publish password standalone, or a MAVLink session from the same address within a grace window, which is what lets video ride through a telemetry dropout. Ingest is MPEG-TS over UDP, RTSP, or RTMP. RTSP is spliced to a loopback ffmpeg untouched from its first byte: the protocol is request/response, so classifying before ANNOUNCE deadlocks, and answering OPTIONS makes ffmpeg's listener reject the following request for being CSeq 2. RTMP could not use that splice. ffmpeg's listener answers FCPublish with a bare "onFCPublish" -- the command name alone, no transaction id, no null, no status object. Measured against the Phoenix camera: the exchange completes as far as createStream, ffmpeg grants stream id 1, and the camera then waits 5 s and hangs up without ever sending publish or a frame. Replaying its bytes at a local ffmpeg produced byte-identical responses, and the same camera published 74 MB to a server that answers properly. So RTMP is spoken here -- handshake, chunk demux, and the commands a publisher uses -- and the media converted to FLV, which ffmpeg is happy to demux from a pipe. That also drops the loopback port, and gives RTMP somewhere to carry a publish password. Fan-out is a memcpy from a per-slot ring: the publisher never inspects viewer state, never blocks and never allocates, so one slow viewer cannot stall the stream or the others. Viewers join at a validated PAT/PMT/random-access point so the stream is decodable from the first byte they see. Two things a camera needed that a synthetic publisher never showed. ffmpeg's AVCC to Annex-B conversion emits a zero-length NAL unit ahead of every access unit for this camera -- 8362 of 25254 on a clean capture -- which is invalid H.264 that Chrome refuses outright and Firefox plays regardless, so -bsf:v h264_metadata rewrites them out. And the muxer buffered a 32 KiB AVIO block before writing, most of a second at this bitrate, so the live flags the design called for are now actually passed. An unauthenticated peer drives the RTMP parser, since admission needs the credential out of publish, so the exposure is bounded throughout. A handshake owns nothing: several negotiate side by side in a pending pool and the slot is awarded on publish, after admission -- if a pending handshake held the slot, one byte from anywhere would deny publishing, and letting a newcomer evict the incumbent only makes that last-arrival-wins. The pre-publish phase is capped in bytes and seconds, every length from the wire is checked against what is buffered, chunk state is committed only once a whole chunk is present (otherwise ordinary TCP segmentation makes a header parse twice and apply its timestamp delta again), only fmt 3 may continue a message, and assembly is bounded across the session rather than per chunk stream. Acknowledgements and ping replies are sent: a publisher that sets a window and never sees a type-3 back is entitled to stop, which presents as a camera that streams for a while and then stalls. Credentials are redacted before anything is logged. A viewer may authenticate with ?pw=, which unlike the 60-second view token is long-lived, and the log is kept on disk and rendered into the admin UI. --- Makefile | 13 +- httpreq.cpp | 243 +++++ httpreq.h | 76 ++ supportproxy.cpp | 236 ++++- tests/rtmp_client.py | 173 ++++ tests/test_bidi_video_preauth.py | 173 ++++ tests/test_video_child.py | 490 +++++++++ tests/test_video_ingest.py | 254 +++++ tests/test_video_record.py | 275 ++++++ tests/test_video_rtsp.py | 1008 +++++++++++++++++++ tests/test_video_view.py | 820 ++++++++++++++++ tests/tsgen.py | 176 ++++ video.cpp | 1583 ++++++++++++++++++++++++++++++ video.h | 39 + videoauth.cpp | 200 ++++ videoauth.h | 119 +++ videorec.cpp | 262 +++++ videorec.h | 97 ++ videortmp.cpp | 1008 +++++++++++++++++++ videortmp.h | 239 +++++ videortsp.cpp | 372 +++++++ videortsp.h | 108 ++ videostream.cpp | 178 ++++ videostream.h | 66 ++ videots.cpp | 842 ++++++++++++++++ videots.h | 165 ++++ videoview.cpp | 494 ++++++++++ videoview.h | 179 ++++ 28 files changed, 9876 insertions(+), 12 deletions(-) create mode 100644 httpreq.cpp create mode 100644 httpreq.h create mode 100644 tests/rtmp_client.py create mode 100644 tests/test_bidi_video_preauth.py create mode 100644 tests/test_video_child.py create mode 100644 tests/test_video_ingest.py create mode 100644 tests/test_video_record.py create mode 100644 tests/test_video_rtsp.py create mode 100644 tests/test_video_view.py create mode 100644 tests/tsgen.py create mode 100644 video.cpp create mode 100644 video.h create mode 100644 videoauth.cpp create mode 100644 videoauth.h create mode 100644 videorec.cpp create mode 100644 videorec.h create mode 100644 videortmp.cpp create mode 100644 videortmp.h create mode 100644 videortsp.cpp create mode 100644 videortsp.h create mode 100644 videostream.cpp create mode 100644 videostream.h create mode 100644 videots.cpp create mode 100644 videots.h create mode 100644 videoview.cpp create mode 100644 videoview.h diff --git a/Makefile b/Makefile index 36e1624..5b324a8 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ CXXFLAGS := $(CXXFLAGS) -DMAVLINK_SIGNING_TIMESTAMP_LIMIT=600 LIBS := -ltdb -lssl -lcrypto # Source files -SOURCES := supportproxy.cpp mavlink.cpp util.cpp keydb.cpp conntdb.cpp tlog.cpp session.cpp binlog.cpp cleanup.cpp websocket.cpp +SOURCES := supportproxy.cpp mavlink.cpp util.cpp keydb.cpp conntdb.cpp tlog.cpp session.cpp binlog.cpp cleanup.cpp websocket.cpp video.cpp videoauth.cpp videots.cpp videostream.cpp videorec.cpp videoview.cpp httpreq.cpp videortsp.cpp videortmp.cpp OBJECTS := $(SOURCES:.cpp=.o) TARGET := supportproxy @@ -73,7 +73,7 @@ mavlink.o: mavlink.cpp mavlink.h $(MAVLINK_DIR)/protocol.h # Dependencies. mavlink.h includes keydb.h, so any object that pulls in # mavlink.h transitively depends on keydb.h too. -supportproxy.o: supportproxy.cpp mavlink.h util.h keydb.h conntdb.h tlog.h binlog.h session.h cleanup.h websocket.h +supportproxy.o: supportproxy.cpp mavlink.h util.h keydb.h conntdb.h tlog.h binlog.h session.h cleanup.h websocket.h video.h videots.h mavlink.o: mavlink.cpp mavlink.h keydb.h $(MAVLINK_DIR)/protocol.h util.o: util.cpp util.h keydb.o: keydb.cpp keydb.h @@ -83,6 +83,15 @@ session.o: session.cpp session.h binlog.o: binlog.cpp binlog.h session.h mavlink.h util.h cleanup.h $(MAVLINK_DIR)/protocol.h cleanup.o: cleanup.cpp cleanup.h keydb.h websocket.o: websocket.cpp websocket.h util.h +video.o: video.cpp video.h videoauth.h videots.h videostream.h videorec.h videoview.h httpreq.h videortsp.h videortmp.h conntdb.h keydb.h util.h +videoauth.o: videoauth.cpp videoauth.h conntdb.h keydb.h +videots.o: videots.cpp videots.h +videostream.o: videostream.cpp videostream.h +videorec.o: videorec.cpp videorec.h session.h cleanup.h +videoview.o: videoview.cpp videoview.h httpreq.h videostream.h videots.h videoauth.h keydb.h +httpreq.o: httpreq.cpp httpreq.h +videortsp.o: videortsp.cpp videortsp.h +videortmp.o: videortmp.cpp videortmp.h httpreq.h # Testing test: $(TARGET) diff --git a/httpreq.cpp b/httpreq.cpp new file mode 100644 index 0000000..e844208 --- /dev/null +++ b/httpreq.cpp @@ -0,0 +1,243 @@ +/* + Minimal HTTP request parsing for the video port. See httpreq.h. + */ +#include "httpreq.h" + +#include +#include +#include + +#include +#include +#include + +int HttpRequest::feed(const uint8_t *buf, size_t n) +{ + if (buf_.size() + n > HTTP_MAX_REQUEST) { + return -1; + } + buf_.append(reinterpret_cast(buf), n); + const size_t end = buf_.find("\r\n\r\n"); + if (end == std::string::npos) { + // Tolerate bare-LF headers from hand-rolled clients. + const size_t end2 = buf_.find("\n\n"); + if (end2 == std::string::npos) { + return 0; + } + } + return parse() ? 1 : -1; +} + +bool HttpRequest::parse(void) +{ + size_t hdr_end = buf_.find("\r\n\r\n"); + size_t sep = 4; + if (hdr_end == std::string::npos) { + hdr_end = buf_.find("\n\n"); + sep = 2; + if (hdr_end == std::string::npos) { + return false; + } + } + leftover_ = buf_.substr(hdr_end + sep); + const std::string head = buf_.substr(0, hdr_end); + + size_t line_end = head.find('\n'); + if (line_end == std::string::npos) { + return false; + } + std::string line = head.substr(0, line_end); + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + headers_ = head.substr(line_end + 1); + + const size_t sp1 = line.find(' '); + if (sp1 == std::string::npos) { + return false; + } + const size_t sp2 = line.find(' ', sp1 + 1); + if (sp2 == std::string::npos) { + return false; + } + method_ = line.substr(0, sp1); + target_ = line.substr(sp1 + 1, sp2 - sp1 - 1); + + const size_t q = target_.find('?'); + if (q == std::string::npos) { + path_ = target_; + query_.clear(); + } else { + path_ = target_.substr(0, q); + query_ = target_.substr(q + 1); + } + return !method_.empty() && !path_.empty(); +} + +static std::string lower(const std::string &s) +{ + std::string o = s; + for (auto &c : o) { + c = char(tolower(static_cast(c))); + } + return o; +} + +std::string HttpRequest::header(const char *name) const +{ + const std::string want = lower(name) + ":"; + size_t pos = 0; + while (pos < headers_.size()) { + size_t eol = headers_.find('\n', pos); + if (eol == std::string::npos) { + eol = headers_.size(); + } + std::string line = headers_.substr(pos, eol - pos); + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + if (lower(line).compare(0, want.size(), want) == 0) { + std::string v = line.substr(want.size()); + size_t b = v.find_first_not_of(" \t"); + if (b == std::string::npos) { + return ""; + } + size_t e = v.find_last_not_of(" \t"); + return v.substr(b, e - b + 1); + } + pos = eol + 1; + } + return ""; +} + +std::string HttpRequest::query(const char *name) const +{ + const std::string want = name; + size_t pos = 0; + while (pos <= query_.size()) { + size_t amp = query_.find('&', pos); + if (amp == std::string::npos) { + amp = query_.size(); + } + const std::string kv = query_.substr(pos, amp - pos); + const size_t eq = kv.find('='); + if (eq != std::string::npos && kv.compare(0, eq, want) == 0) { + return http_url_decode(kv.substr(eq + 1)); + } + if (amp == query_.size()) { + break; + } + pos = amp + 1; + } + return ""; +} + +std::string http_url_decode(const std::string &s) +{ + std::string o; + o.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + if (s[i] == '+') { + o += ' '; + } else if (s[i] == '%' && i + 2 < s.size() + && isxdigit(static_cast(s[i + 1])) + && isxdigit(static_cast(s[i + 2]))) { + const std::string hex = s.substr(i + 1, 2); + o += char(strtol(hex.c_str(), nullptr, 16)); + i += 2; + } else { + o += s[i]; + } + } + return o; +} + +std::string http_basic_password(const std::string &authorization) +{ + const std::string prefix = "Basic "; + if (authorization.size() <= prefix.size() + || lower(authorization).compare(0, prefix.size(), + lower(prefix)) != 0) { + return ""; + } + const std::string b64 = authorization.substr(prefix.size()); + + // base64-decode; the result is "user:password" and we want the pass + std::string out(b64.size(), '\0'); + BIO *b = BIO_new_mem_buf(b64.data(), int(b64.size())); + BIO *d = BIO_new(BIO_f_base64()); + BIO_set_flags(d, BIO_FLAGS_BASE64_NO_NL); + b = BIO_push(d, b); + const int n = BIO_read(b, &out[0], int(out.size())); + BIO_free_all(b); + if (n <= 0) { + return ""; + } + out.resize(size_t(n)); + const size_t colon = out.find(':'); + if (colon == std::string::npos) { + return ""; + } + return out.substr(colon + 1); +} + +std::string http_simple_response(int code, const char *reason, + const char *content_type, + const std::string &text) +{ + char head[512]; + snprintf(head, sizeof(head), + "HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %zu\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n" + "\r\n", + code, reason, content_type, text.size()); + return std::string(head) + text; +} + + +std::string http_redact_target(const std::string &target) +{ + static const char *secret_keys[] = { "pw", "password", "t", "key" }; + const size_t q = target.find('?'); + if (q == std::string::npos) { + return target; + } + std::string out = target.substr(0, q + 1); + size_t at = q + 1; + bool first = true; + while (at <= target.size()) { + size_t end = target.find('&', at); + if (end == std::string::npos) { + end = target.size(); + } + const std::string kv = target.substr(at, end - at); + const size_t eq = kv.find('='); + if (!first) { + out += '&'; + } + first = false; + if (eq == std::string::npos) { + out += kv; + } else { + const std::string k = kv.substr(0, eq); + bool secret = false; + for (const char *s : secret_keys) { + if (k == s) { + secret = true; + break; + } + } + out += k; + out += '='; + out += secret ? "" : kv.substr(eq + 1); + } + if (end == target.size()) { + break; + } + at = end + 1; + } + return out; +} diff --git a/httpreq.h b/httpreq.h new file mode 100644 index 0000000..712b506 --- /dev/null +++ b/httpreq.h @@ -0,0 +1,76 @@ +/* + Minimal HTTP request parsing for the video port. + + Only what a viewer connection needs: the request line, a handful of + headers, and a query string. Deliberately not a general HTTP server -- + the video port serves one thing. + */ +#pragma once + +#include +#include + +#include + +// Longest request we will buffer before giving up on a peer. A viewer +// request is a few hundred bytes; anything much larger is a client +// doing something we do not serve. +#define HTTP_MAX_REQUEST 8192 + +class HttpRequest { +public: + // Feed bytes as they arrive. Returns: + // 1 complete request parsed + // 0 incomplete, feed more + // -1 malformed or too large + int feed(const uint8_t *buf, size_t n); + + const std::string &method(void) const { return method_; } + const std::string &target(void) const { return target_; } + const std::string &path(void) const { return path_; } + + // Header lookup, case-insensitive. Empty string when absent. + std::string header(const char *name) const; + + // Query parameter from the request target. Empty when absent. + std::string query(const char *name) const; + + // Bytes left over after the request (a pipelined body, normally + // none). The caller owns what it does with them. + const std::string &leftover(void) const { return leftover_; } + +private: + std::string buf_; + std::string method_; + std::string target_; + std::string path_; + std::string query_; + std::string headers_; // raw block, searched case-insensitively + std::string leftover_; + + bool parse(void); +}; + +// Percent-decode, in place semantics (returns a new string). Invalid +// escapes are left as-is rather than silently dropped. +std::string http_url_decode(const std::string &s); + +/* + A request target with credential query values replaced. + + Anything logged has to go through this. A viewer may authenticate with + ?pw=, and unlike the 60-second view token that is a long-lived + credential -- writing it to proxy.log puts it on disk for the life of + the file and into every operator's browser through the server page. + Redacting at the point of display is too late for the copy on disk. + */ +std::string http_redact_target(const std::string &target); + +// Decode a "Basic base64(user:pass)" credential. Returns the password +// part, or an empty string if the header is not Basic or is malformed. +std::string http_basic_password(const std::string &authorization); + +// Build a simple response with no body beyond `text`. +std::string http_simple_response(int code, const char *reason, + const char *content_type, + const std::string &text); diff --git a/supportproxy.cpp b/supportproxy.cpp index c0eecab..3613bdc 100644 --- a/supportproxy.cpp +++ b/supportproxy.cpp @@ -43,6 +43,9 @@ #include "session.h" #include "cleanup.h" #include "websocket.h" +#include "video.h" +#include "videots.h" +#include "videostream.h" #include @@ -79,6 +82,14 @@ struct listen_port { int sock1_udp, sock2_udp; int sock1_tcp, sock2_listen; pid_t pid; + // Long-lived video child. Independent of `pid`: video must survive + // a MAVLink session ending, and must run with no session at all + // when the entry has a publish password. + pid_t video_pid; + time_t video_respawn_after; // backoff so a child that dies at once + // can't be re-forked in a tight loop + uint32_t video_ports[KEY_MAX_VIDEO_PORTS]; + uint32_t video_flags; uint32_t flags; uint8_t fc_sysid; // 0 = match any; otherwise the FC's MAVLink // sysid for binlog reboot detection @@ -135,12 +146,56 @@ static void close_sockets(struct listen_port *p); Used both at startup and on each reload; reload_ports() handles the flip side (entries that were in keys.tdb last time and aren't now). */ +/* + Video config that requires a rebind: the enable bit, the ports, and + the per-slot options (SRT vs plain MPEG-TS changes how the UDP socket + is used). A change here re-forks the video child. Policy that does not + need a rebind -- credentials, grace, quota -- is re-read by the child + itself on its tick, so those take effect without dropping a publisher. + */ +static bool video_cfg_differs(const struct listen_port *p, uint32_t flags, + const uint32_t *video_ports, + uint32_t video_flags) +{ + if ((p->flags & KEY_FLAG_VIDEO) != (flags & KEY_FLAG_VIDEO)) { + return true; + } + if (p->video_flags != video_flags) { + return true; + } + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + if (p->video_ports[i] != video_ports[i]) { + return true; + } + } + return false; +} + +static void video_stop_child(struct listen_port *p, const char *why) +{ + if (p->video_pid == 0) { + return; + } + printf("[%d] video child %d stopping (%s)\n", + p->port2, int(p->video_pid), why); + kill(p->video_pid, SIGTERM); +} + static void upsert_port(int port1, int port2, uint32_t flags, uint8_t fc_sysid, - float tz_offset_hours) + float tz_offset_hours, const uint32_t *video_ports, + uint32_t video_flags) { for (auto *p = ports; p; p=p->next) { if (p->port2 == port2) { p->seen = true; + if (video_cfg_differs(p, flags, video_ports, video_flags)) { + // Ports/enable/slot options changed: the running child + // still binds the old set, so stop it and let + // check_children() re-fork with the new config. + video_stop_child(p, "video config changed"); + } + memcpy(p->video_ports, video_ports, sizeof(p->video_ports)); + p->video_flags = video_flags; if (p->removed) { // came back: re-add as a fresh listener printf("[%d] re-added (port1=%d)\n", port2, port1); @@ -185,6 +240,10 @@ static void upsert_port(int port1, int port2, uint32_t flags, uint8_t fc_sysid, p->sock1_tcp = -1; p->sock2_listen = -1; p->pid = 0; + p->video_pid = 0; + p->video_respawn_after = 0; + memcpy(p->video_ports, video_ports, sizeof(p->video_ports)); + p->video_flags = video_flags; p->flags = flags; p->fc_sysid = fc_sysid; p->tz_offset_hours = tz_offset_hours; @@ -211,7 +270,7 @@ static int handle_record(struct tdb_context *db, TDB_DATA key, TDB_DATA data, vo // a MAVLink sysid (0..255), so truncate to uint8 once it crosses the // C++/binlog boundary. The CLI / web UI already cap at 255. upsert_port(k.port1, port2, k.flags, uint8_t(k.fc_sysid), - k.tz_offset_hours); + k.tz_offset_hours, k.video_ports, k.video_flags); return 0; } @@ -331,6 +390,12 @@ static void main_loop(struct listen_port *p) // polluted by log traffic. Engineer→user direction is unchanged. BinlogWriter binlog; const bool binlog_enabled = (p->flags & KEY_FLAG_BINLOG) != 0; + + // Video counts as a downstream consumer of user-side packets even + // though nothing here writes video: with bidi signing, the video + // side needs this session to reach an authenticated state, and on + // the TCP path that only happens inside the parse block below. + const bool video_enabled = (p->flags & KEY_FLAG_VIDEO) != 0; if (binlog_enabled) { // Per-entry sysid filter for SYSTEM_TIME-based reboot // detection. 0 (default) accepts any sysid. @@ -613,10 +678,11 @@ static void main_loop(struct listen_port *p) } mavlink_message_t msg {}; // Parse user-side bytes whenever there's anywhere for them to - // go: a connected engineer (forward), tlog recording, or - // binlog recording. Without one of those, the bytes are read - // off the socket but discarded. - if (conn2_count > 0 || binlog_enabled || tlog_enabled) { + // go: a connected engineer (forward), tlog recording, binlog + // recording, or video (which needs the session to authenticate). + // Without one of those, the bytes are read off the socket but + // discarded. + if (conn2_count > 0 || binlog_enabled || tlog_enabled || video_enabled) { uint8_t *buf0 = buf; while (n > 0 && mav1.receive_message(buf0, n, msg)) { mav1_rx_msgs++; @@ -807,8 +873,18 @@ static void main_loop(struct listen_port *p) count1++; mavlink_message_t msg {}; // Parse whenever a downstream consumer needs it (engineer - // forward, tlog, or binlog). Otherwise just discard. - if (conn2_count > 0 || binlog_enabled || tlog_enabled) { + // forward, tlog, binlog, or video). Otherwise just discard. + // + // video_enabled is load-bearing here, not just symmetry. On + // this TCP path conn1 latches at accept(), before any + // signature check, and receive_message() below is the only + // thing that ever sets is_authenticated(). A bidi entry with + // video but no engineer/tlog/binlog would therefore never + // authenticate, and the CONN1_BIDI_PREAUTH_SECONDS check + // would kill the session. (The UDP path differs: it + // validates inside its latch block, so it authenticates + // regardless of this gate.) + if (conn2_count > 0 || binlog_enabled || tlog_enabled || video_enabled) { uint8_t *buf0 = buf; while (n > 0 && mav1.receive_message(buf0, n, msg)) { mav1_rx_msgs++; @@ -1126,6 +1202,101 @@ static void open_sockets(struct listen_port *p) } } +/* + Fork the long-lived video child for one entry. + + Unlike handle_connection()'s per-pair child this is forked from + reload_ports() rather than on traffic, and it outlives any MAVLink + session. The parent owns it directly, which is what makes shutdown + ordering knowable: check_children() reaps it and clears video_pid. + */ +static void fork_video_child(struct listen_port *p) +{ + int ready[2] = { -1, -1 }; + if (pipe(ready) != 0) { + printf("[%d] video: pipe failed - %s\n", p->port2, strerror(errno)); + return; + } + + pid_t pid = fork(); + if (pid < 0) { + printf("[%d] video: fork failed - %s\n", p->port2, strerror(errno)); + close(ready[0]); + close(ready[1]); + return; + } + if (pid == 0) { + close(ready[0]); + // Die with the parent. PDEATHSIG only fires for a parent that + // was alive when it was armed, hence the getppid() recheck. + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() == 1) { + _exit(0); + } + // The session children set SIGCHLD to SIG_IGN, which makes + // waitpid() fail with ECHILD. We are not descended from them, + // but be explicit: this child supervises its own subprocesses + // in later phases and needs real exit statuses. + signal(SIGCHLD, SIG_DFL); + signal(SIGUSR1, SIG_DFL); + + // fd sanitation. Being a child of the *parent* rather than of a + // session child, we never inherit conn1, the accepted engineer + // sockets, SSL state or the open tlog/binlog fds -- only the + // listeners and the epoll instance, which all go here. + if (g_epfd != -1) { + close(g_epfd); + g_epfd = -1; + } + for (auto *p2 = ports; p2; p2 = p2->next) { + close_sockets(p2); + } + video_child_main(p->port2, ready[1]); + // video_child_main is noreturn and _exit()s: never fall back + // into the parent's code with copied destructors that would + // close fd numbers we have since reused. + } + + close(ready[1]); + p->video_pid = pid; + + // Read the readiness byte. The child writes it right after binding, + // and closes the fd on any exit path, so this cannot hang. + uint8_t st = 0; + ssize_t n = read(ready[0], &st, 1); + close(ready[0]); + if (n == 1 && st != 0) { + printf("[%d] video child %d started but a port failed to bind - %s\n", + p->port2, int(pid), strerror(int(st))); + } else if (n == 1) { + printf("[%d] video child %d ready\n", p->port2, int(pid)); + } else { + printf("[%d] video child %d exited before signalling ready\n", + p->port2, int(pid)); + } +} + +/* + Start or stop video children so the running set matches keys.tdb. + + Called from main() as well as reload_ports(): without the startup + call, an entry with video enabled would sit with its ports unbound + until the first 5 s reload, which looks like the feature is broken. + */ +static void reconcile_video_children(void) +{ + const time_t now = time(nullptr); + for (auto *p = ports; p; p=p->next) { + const bool want = !p->removed + && video_entry_wants_child(p->flags, p->video_ports); + if (want && p->video_pid == 0 && now >= p->video_respawn_after) { + fork_video_child(p); + } else if (!want && p->video_pid != 0) { + video_stop_child(p, "video disabled"); + } + } +} + /* check for child exit. Returns true if a per-port-pair child was reaped (the caller should refresh the epoll set so the reopened @@ -1148,11 +1319,34 @@ static bool check_children(void) } bool found_child = false; for (auto *p = ports; p; p=p->next) { + if (p->video_pid == pid) { + // Video children are long-lived, so an exit is either a + // config change we asked for or a crash. Either way the + // backoff keeps a child that dies immediately from being + // re-forked in a tight loop; reload_ports() re-forks it. + printf("[%d] video child %d exited (status %d)\n", + p->port2, int(pid), + WIFEXITED(wstatus) ? WEXITSTATUS(wstatus) : -1); + p->video_pid = 0; + p->video_respawn_after = time(nullptr) + 2; + conn_remove_video(p->port2); + found_child = true; + break; + } if (p->pid == pid) { printf("[%d] Child %d exited\n", p->port2, int(pid)); p->pid = 0; - // drop any live-connection records the child wrote - conn_remove_port2(p->port2); + // Drop the records this child wrote -- but only its own + // index range. A video child for the same entry may still + // be running, and whole-port2 delete would erase its rows. + { + auto *cdb = conn_db_open_transaction(); + if (cdb != nullptr) { + conn_delete_index_range(cdb, p->port2, 0, + VIDEO_CONN_INDEX_BASE - 1); + conn_db_close_commit(cdb); + } + } found_child = true; reaped = true; // Don't reopen listening sockets for an entry that was @@ -1293,6 +1487,7 @@ static void reload_ports(void) if (p->pid != 0) { kill(p->pid, SIGTERM); } + video_stop_child(p, "entry removed"); conn_remove_port2(p->port2); } } @@ -1303,6 +1498,8 @@ static void reload_ports(void) open_sockets(p); } } + + reconcile_video_children(); } /* @@ -1402,6 +1599,24 @@ static void wait_connection(void) int main(int argc, char *argv[]) { setvbuf(stdout, nullptr, _IOLBF, 4096); + // Unit checks for the TS scanner's bit twiddling. End-to-end tests + // find that class of bug only intermittently, so it gets a direct + // entry point that the suite invokes. + if (argc > 1 && strcmp(argv[1], "--selftest-video") == 0) { + int rc = videots_selftest(); + if (rc == 0) { + rc = videostream_selftest(); + } + if (rc == 0) { + // A short deterministic fuzz run on every invocation, so a + // regression in the PSI parsing shows up in the normal + // suite rather than only in a dedicated campaign. + const unsigned iters = argc > 2 ? unsigned(atoi(argv[2])) : 2000; + const uint32_t seed = argc > 3 ? uint32_t(atoi(argv[3])) : 1; + rc = videots_fuzz(iters, seed); + } + return rc; + } // a peer-closed TCP/WS/SSL connection must fail the write with // EPIPE, not kill the child (and its whole session) with SIGPIPE signal(SIGPIPE, SIG_IGN); @@ -1420,6 +1635,7 @@ int main(int argc, char *argv[]) printf("Added %u ports\n", unsigned(count_ports())); db_close_cancel(db); + reconcile_video_children(); fork_cleanup_child(); wait_connection(); diff --git a/tests/rtmp_client.py b/tests/rtmp_client.py new file mode 100644 index 0000000..cc1f928 --- /dev/null +++ b/tests/rtmp_client.py @@ -0,0 +1,173 @@ +"""A minimal RTMP publisher, for the cases ffmpeg's client never sends. + +ffmpeg publishes politely: it waits for onStatus before streaming, and +its writes happen to align with chunk boundaries. Two defects in our +server were invisible to it -- media pipelined into the same segment as +publish, and a chunk header split from its payload -- so the tests need +a client that can be told to do both. + +It replays the tags of a real FLV file rather than synthesising H.264, +so the bitstream, the avcC and the frame types are genuine. +""" +import os +import socket +import struct + + +def _amf_str(s): + b = s.encode() + return b'\x02' + struct.pack('>H', len(b)) + b + + +def _amf_num(v): + return b'\x00' + struct.pack('>d', v) + + +def _amf_null(): + return b'\x05' + + +def _amf_obj(d): + out = b'\x03' + for k, v in d.items(): + kb = k.encode() + out += struct.pack('>H', len(kb)) + kb + out += _amf_str(v) if isinstance(v, str) else _amf_num(v) + return out + b'\x00\x00\x09' + + +def read_flv_tags(path): + """[(tag_type, timestamp, payload)] for audio/video/script tags.""" + d = open(path, 'rb').read() + i = 9 + 4 # header + PreviousTagSize0 + tags = [] + while i + 11 <= len(d): + ttype = d[i] & 0x1f + sz = int.from_bytes(d[i + 1:i + 4], 'big') + ts = int.from_bytes(d[i + 4:i + 7], 'big') | (d[i + 7] << 24) + body = d[i + 11:i + 11 + sz] + if len(body) < sz: + break + if ttype in (8, 9, 18): + tags.append((ttype, ts, body)) + i += 11 + sz + 4 + return tags + + +class RtmpPublisher: + """Publishes to a SupportProxy video port, byte layout under test control.""" + + def __init__(self, host, port, app='PhoenixFPV', stream='FPV', + timeout=10): + self.s = socket.create_connection((host, port), timeout) + self.s.settimeout(timeout) + self.app = app + self.stream = stream + self.out_chunk = 4096 + + # -- framing --------------------------------------------------- + + def _chunk(self, csid, mtype, sid, ts, payload, fmt=0): + if fmt == 0: + hdr = (bytes([csid]) + ts.to_bytes(3, 'big') + + len(payload).to_bytes(3, 'big') + bytes([mtype]) + + struct.pack(' 0: + return pid + return None + + def stop(self): + self.proc.terminate() + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + self.proc.wait(timeout=5) + self._t.join(timeout=2) + + +@pytest.fixture +def proxy(tmp_path): + made = {} + + def _make(**kw): + wd = _make_workdir(tmp_path, **kw) + made['p'] = Proxy(wd) + made['wd'] = wd + return made['p'] + + yield _make + if 'p' in made: + made['p'].stop() + + +def _port_bound(port, proto='udp'): + """True if anything is listening on `port`, read from /proc/net.""" + path = '/proc/net/' + ('udp' if proto == 'udp' else 'tcp') + want = '%04X' % port + with open(path) as f: + next(f) + for line in f: + local = line.split()[1] + if local.split(':')[1].upper() == want: + return True + return False + + +class Publisher: + """A UDP publisher on ONE socket. + + Reusing the socket matters: the proxy latches a publisher by + (address, port), so a fresh socket per burst would present a new + source port each time and never take the established-publisher fast + path -- which is not how a real publisher behaves. + """ + + def __init__(self, port): + self.port = port + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + def send(self, n=3): + for _ in range(n): + self.sock.sendto(b'\x47' + b'\x00' * 187, ('127.0.0.1', self.port)) + time.sleep(0.05) + + def close(self): + self.sock.close() + + +def _send_ts(port, n=3): + pub = Publisher(port) + try: + pub.send(n) + finally: + pub.close() + + +class _Mav: + """A MAVLink user-side session on port1, driven in a thread.""" + + def __init__(self, signed=False): + from pymavlink import mavutil + self.mavutil = mavutil + self.conn = mavutil.mavlink_connection( + 'udpout:127.0.0.1:%d' % PORT_USER, source_system=1, + source_component=1, use_native=False) + if signed: + import hashlib + self.conn.setup_signing( + hashlib.sha256(PASSPHRASE.encode()).digest(), + sign_outgoing=True) + self._stop = threading.Event() + self._t = threading.Thread(target=self._run, daemon=True) + self._t.start() + + def _run(self): + m = self.mavutil.mavlink + while not self._stop.is_set(): + try: + self.conn.mav.heartbeat_send( + m.MAV_TYPE_QUADROTOR, m.MAV_AUTOPILOT_ARDUPILOTMEGA, + 0, 0, m.MAV_STATE_ACTIVE) + except Exception: + pass + time.sleep(0.3) + + def stop(self): + self._stop.set() + self._t.join(timeout=2) + try: + self.conn.close() + except Exception: + pass + + +def _video_rows(workdir): + path = conntdb_lib.conn_path_for(str(workdir / 'keys.tdb')) + return [c for c in conntdb_lib.list_active(path, max_age_s=3600) + if c.is_video] + + +def _mav_rows(workdir): + path = conntdb_lib.conn_path_for(str(workdir / 'keys.tdb')) + return [c for c in conntdb_lib.list_active(path, max_age_s=3600) + if not c.is_video] + + +@pytest.mark.integration +class TestVideoChildLifecycle: + def test_port_bound_with_no_mavlink_session(self, proxy): + """The headline property: video does not wait on MAVLink.""" + p = proxy() + assert p.wait_for(r'video slot 0 listening'), p.log + assert _port_bound(VPORT, 'udp'), 'video UDP port not bound' + assert _port_bound(VPORT, 'tcp'), 'video TCP port not bound' + + def test_video_child_holds_no_mavlink_fds(self, proxy): + """Being a child of the parent, it never inherits session fds.""" + p = proxy(vports=(VPORT,)) + assert p.wait_for(r'video child \d+ ready'), p.log + vpid = p.video_pid() + assert vpid is not None, p.log + socks = [os.readlink('/proc/%d/fd/%s' % (vpid, fd)) + for fd in os.listdir('/proc/%d/fd' % vpid)] + n_socks = len([s for s in socks if 'socket' in s]) + # exactly one UDP + one TCP listener for the single slot + assert n_socks == 2, 'expected 2 sockets, got %d: %r' % (n_socks, socks) + + def test_disable_video_stops_child_and_frees_port(self, proxy, tmp_path): + p = proxy() + assert p.wait_for(r'video child \d+ ready'), p.log + assert _port_bound(VPORT, 'udp') + + db = keydb_lib.open_db(str(tmp_path / 'work' / 'keys.tdb')) + db.transaction_start() + keydb_lib.clear_flag(db, PORT_ENG, 'video') + db.transaction_prepare_commit(); db.transaction_commit(); db.close() + + assert p.wait_for(r'video child \d+ stopping \(video disabled\)'), p.log + deadline = time.time() + 10 + while time.time() < deadline and _port_bound(VPORT, 'udp'): + time.sleep(0.2) + assert not _port_bound(VPORT, 'udp'), 'port still bound after disable' + + def test_killed_video_child_is_respawned(self, proxy): + p = proxy() + assert p.wait_for(r'video child \d+ ready'), p.log + first = p.video_pid() + assert first is not None + os.kill(first, 9) + assert p.wait_for(r'video child %d exited' % first), p.log + deadline = time.time() + 20 + second = None + while time.time() < deadline: + second = p.video_pid() + if second is not None and second != first: + break + time.sleep(0.3) + assert second is not None and second != first, \ + 'video child not respawned:\n%s' % p.log + + def test_parent_exit_leaves_no_orphan(self, proxy): + p = proxy() + assert p.wait_for(r'video child \d+ ready'), p.log + vpid = p.video_pid() + p.stop() + deadline = time.time() + 10 + while time.time() < deadline and os.path.exists('/proc/%d' % vpid): + time.sleep(0.2) + assert not os.path.exists('/proc/%d' % vpid), \ + 'video child %d outlived the parent' % vpid + + def test_port_change_rebinds(self, proxy, tmp_path): + p = proxy(vports=(VPORT,)) + assert p.wait_for(r'video child \d+ ready'), p.log + + db = keydb_lib.open_db(str(tmp_path / 'work' / 'keys.tdb')) + db.transaction_start() + keydb_lib.set_video_ports(db, PORT_ENG, [VPORT2]) + db.transaction_prepare_commit(); db.transaction_commit(); db.close() + + assert p.wait_for(r'video config changed'), p.log + deadline = time.time() + 20 + while time.time() < deadline: + if _port_bound(VPORT2, 'udp') and not _port_bound(VPORT, 'udp'): + break + time.sleep(0.3) + assert _port_bound(VPORT2, 'udp'), 'new port not bound:\n%s' % p.log + assert not _port_bound(VPORT, 'udp'), 'old port still bound' + + +@pytest.mark.integration +class TestVideoAdmission: + def test_publish_rejected_without_mavlink(self, proxy): + p = proxy() + assert p.wait_for(r'video slot 0 listening'), p.log + _send_ts(VPORT) + assert p.wait_for(r'rejected .*no MAVLink session'), p.log + + def test_publish_accepted_with_mavlink_from_same_ip(self, proxy): + p = proxy() + assert p.wait_for(r'video slot 0 listening'), p.log + mav = _Mav() + try: + assert p.wait_for(r'have UDP conn1'), p.log + time.sleep(1.0) # let the session's ConnEntry land + for _ in range(10): + _send_ts(VPORT, n=2) + if re.search(r'video slot 0 publisher', p.log): + break + time.sleep(0.5) + assert re.search(r'video slot 0 publisher', p.log), p.log + finally: + mav.stop() + + def test_video_survives_mavlink_going_away(self, proxy): + """The whole point of decoupling: a telemetry dropout must not + revoke a publisher that is already streaming.""" + p = proxy(grace=60) + assert p.wait_for(r'video slot 0 listening'), p.log + pub = Publisher(VPORT) + mav = _Mav() + try: + assert p.wait_for(r'have UDP conn1'), p.log + time.sleep(1.0) + for _ in range(10): + pub.send(2) + if re.search(r'video slot 0 publisher', p.log): + break + time.sleep(0.5) + assert re.search(r'video slot 0 publisher', p.log), p.log + finally: + mav.stop() + + # MAVLink is gone. Keep publishing across the session child's + # 10 s idle-out -- a real publisher does not stop just because + # telemetry dropped, and going silent here would trip the + # separate publisher-idle release and test the wrong thing. + try: + marker = len(p.lines) + deadline = time.time() + 16 + while time.time() < deadline: + pub.send(2) + time.sleep(0.4) + + assert p.video_pid() is not None, \ + 'video child died with the MAVLink session:\n%s' % p.log + later = ''.join(p.lines[marker:]) + assert 'rejected' not in later, \ + 'publisher revoked after MAVLink went away:\n%s' % later + finally: + pub.close() + + def test_publisher_can_start_during_a_mavlink_outage(self, proxy): + """Within the grace window, a publisher may start with the + MAVLink session already gone. + + This is what the grace window is for, and it only works because + the last-known-good session outlives its connections.tdb row -- + the row is deleted as soon as the session child exits. + """ + p = proxy(grace=120) + assert p.wait_for(r'video slot 0 listening'), p.log + mav = _Mav() + try: + assert p.wait_for(r'have UDP conn1'), p.log + time.sleep(1.5) # let the session's ConnEntry be written + finally: + mav.stop() + + # Wait out the session child so its row is gone entirely. + assert p.wait_for(r'Child \d+ exited', timeout=25), p.log + time.sleep(1.0) + + marker = len(p.lines) + for _ in range(10): + _send_ts(VPORT, n=2) + if re.search(r'video slot 0 publisher', ''.join(p.lines[marker:])): + break + time.sleep(0.5) + later = ''.join(p.lines[marker:]) + assert 'video slot 0 publisher' in later, \ + 'publisher refused inside the grace window:\n%s' % later + + def test_publish_password_cannot_be_met_over_plain_udp(self, proxy): + """Path A takes precedence, and plain MPEG-TS/UDP carries no + credential -- so a passworded entry refuses UDP publish even + with a live MAVLink session from the same address.""" + p = proxy(publish_pass='pubpw') + assert p.wait_for(r'video slot 0 listening'), p.log + mav = _Mav() + try: + assert p.wait_for(r'have UDP conn1'), p.log + time.sleep(1.0) + _send_ts(VPORT, n=4) + # The reason is specifically "this transport cannot carry a + # password", not "wrong password" -- a udpsink never sent + # one, and saying "wrong" would send an operator looking for + # a typo that isn't there. + assert p.wait_for(r'rejected .*cannot carry one'), p.log + assert 'video slot 0 publisher' not in p.log + finally: + mav.stop() + + def test_bidi_entry_requires_authenticated_session(self, proxy): + """An unsigned session on a bidi entry must not authorise video.""" + p = proxy(flags=('video', 'bidi_sign')) + assert p.wait_for(r'video slot 0 listening'), p.log + mav = _Mav(signed=False) # unsigned: never authenticates + try: + time.sleep(2.0) + _send_ts(VPORT, n=4) + assert p.wait_for(r'rejected'), p.log + assert 'video slot 0 publisher' not in p.log, \ + 'unsigned session authorised video on a bidi entry:\n%s' % p.log + finally: + mav.stop() + + +@pytest.mark.integration +class TestVideoConnRows: + def test_publisher_row_written_in_video_index_range(self, proxy, tmp_path): + p = proxy() + assert p.wait_for(r'video slot 0 listening'), p.log + mav = _Mav() + try: + assert p.wait_for(r'have UDP conn1'), p.log + time.sleep(1.0) + for _ in range(10): + _send_ts(VPORT, n=2) + if re.search(r'video slot 0 publisher', p.log): + break + time.sleep(0.5) + assert re.search(r'video slot 0 publisher', p.log), p.log + + wd = tmp_path / 'work' + deadline = time.time() + 15 + rows = [] + while time.time() < deadline: + rows = _video_rows(wd) + if rows: + break + time.sleep(0.5) + assert rows, 'no video ConnEntry written:\n%s' % p.log + r = rows[0] + assert r.conn_index >= conntdb_lib.VIDEO_CONN_INDEX_BASE + assert r.role == conntdb_lib.CONN_ROLE_VIDEO_PUB + assert r.stream_idx == 0 + assert r.port2 == PORT_ENG + + # and the MAVLink row must still be there: the two writers + # each clear only their own index range + assert _mav_rows(wd), \ + 'video snapshot erased the MAVLink rows:\n%s' % p.log + finally: + mav.stop() diff --git a/tests/test_video_ingest.py b/tests/test_video_ingest.py new file mode 100644 index 0000000..57b04db --- /dev/null +++ b/tests/test_video_ingest.py @@ -0,0 +1,254 @@ +"""MPEG-TS/UDP ingest and the join-point scanner, end to end. + +Phase 2 has no viewers yet, so the scanner's conclusions are observed +through the per-tick stats line. What matters here is that a real +datagram stream is accepted, parsed into a program, and reaches the +point where a viewer *could* join -- and that malformed input is +counted and dropped rather than half-parsed. +""" +import os +import re +import subprocess +import sys +import time + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import tsgen # noqa: E402 +from test_video_child import (Proxy, Publisher, _Mav, _make_workdir, # noqa: E402 + _port_bound, VPORT) + +SUPPORTPROXY_BIN = os.path.join(_REPO_ROOT, 'supportproxy') + +STATS_RE = re.compile( + r'video slot (\d+) stats: (\d+) KiB, (\d+) pkts, pat=(\d+) pmt=(\d+) ' + r'rai=(\d+) cc_err=(\d+) crc_err=(\d+) bad_dgram=(\d+) ' + r'vpid=0x([0-9a-f]+) stype=0x([0-9a-f]+) join=(\w+)') + + +def last_stats(proxy): + """Parse the most recent stats line, or None.""" + m = None + for line in proxy.lines: + found = STATS_RE.search(line) + if found: + m = found + if m is None: + return None + return { + 'slot': int(m.group(1)), 'kib': int(m.group(2)), + 'packets': int(m.group(3)), 'pat': int(m.group(4)), + 'pmt': int(m.group(5)), 'rai': int(m.group(6)), + 'cc_err': int(m.group(7)), 'crc_err': int(m.group(8)), + 'bad_dgram': int(m.group(9)), 'vpid': int(m.group(10), 16), + 'stype': int(m.group(11), 16), 'join': m.group(12), + } + + +def wait_stats(proxy, predicate, timeout=20): + deadline = time.time() + timeout + while time.time() < deadline: + st = last_stats(proxy) + if st is not None and predicate(st): + return st + time.sleep(0.5) + return last_stats(proxy) + + +@pytest.fixture +def running(tmp_path): + """A proxy with video enabled and an authorised publisher.""" + made = {} + + def _start(**kw): + wd = _make_workdir(tmp_path, **kw) + p = Proxy(wd) + made['p'] = p + assert p.wait_for(r'video slot 0 listening'), p.log + mav = _Mav() + made['mav'] = mav + assert p.wait_for(r'have UDP conn1'), p.log + time.sleep(1.2) # let the session's ConnEntry land + return p + + yield _start + if 'mav' in made: + made['mav'].stop() + if 'p' in made: + made['p'].stop() + + +def _publish(port, data, chunk_pause=0.004): + """Send a stream as 1316-byte datagrams from one socket.""" + pub = Publisher(port) + try: + for dg in tsgen.TSGen().datagrams(data): + pub.sock.sendto(dg, ('127.0.0.1', port)) + time.sleep(chunk_pause) + finally: + pub.close() + return pub + + +@pytest.mark.integration +class TestTSIngest: + def test_stream_is_parsed_and_joinable(self, running): + p = running() + g = tsgen.TSGen() + data = g.stream(400, gop=10, psi_every=20) + _publish(VPORT, data) + + st = wait_stats(p, lambda s: s['join'] == 'ready') + assert st is not None, 'no stats line at all:\n%s' % p.log + assert st['join'] == 'ready', \ + 'scanner never reached a joinable state: %r\n%s' % (st, p.log) + assert st['pat'] > 0 and st['pmt'] > 0, st + assert st['rai'] > 0, st + assert st['vpid'] == tsgen.DEFAULT_VIDEO_PID, st + assert st['stype'] == tsgen.STREAM_H264, st + assert st['crc_err'] == 0, 'CRC errors on a clean stream: %r' % (st,) + assert st['bad_dgram'] == 0, 'good datagrams rejected: %r' % (st,) + + def test_hevc_stream_type_is_reported(self, running): + """The scanner must identify HEVC, which the browser path can't + play -- that distinction drives the viewer fallback later.""" + p = running() + g = tsgen.TSGen(stream_type=tsgen.STREAM_HEVC) + _publish(VPORT, g.stream(300, gop=10, psi_every=20)) + st = wait_stats(p, lambda s: s['join'] == 'ready') + assert st is not None and st['stype'] == tsgen.STREAM_HEVC, \ + '%r\n%s' % (st, p.log) + + def test_no_keyframes_means_not_joinable(self, running): + """PSI alone is not enough: without a random access point there + is nowhere a decoder could start.""" + p = running() + g = tsgen.TSGen() + out = bytearray() + for i in range(300): + if i % 20 == 0: + out += g.pat() + out += g.pmt() + out += g.video(key=False) + _publish(VPORT, bytes(out)) + + st = wait_stats(p, lambda s: s['pmt'] > 0) + assert st is not None, p.log + assert st['pat'] > 0 and st['pmt'] > 0, st + assert st['rai'] == 0, 'no keyframes were sent: %r' % (st,) + assert st['join'] == 'waiting', \ + 'claimed joinable with no random access point: %r' % (st,) + + def test_misaligned_datagrams_are_counted_and_dropped(self, running): + """Junk from the *established* publisher must be dropped. + + The same socket throughout: a fresh one would present a new + source port and be refused as a second publisher, which is a + different rule and would not exercise the ingest validation. + """ + p = running() + g = tsgen.TSGen() + dgs = g.datagrams(g.stream(100, gop=10, psi_every=20)) + expect_packets = len(dgs) * tsgen.PACKETS_PER_DATAGRAM + n_junk = 10 + + pub = Publisher(VPORT) + try: + for dg in dgs: + pub.sock.sendto(dg, ('127.0.0.1', VPORT)) + time.sleep(0.004) + for _ in range(n_junk): + # 201 bytes: not a multiple of 188, so not TS + pub.sock.sendto(b'\x47' + b'\x11' * 200, ('127.0.0.1', VPORT)) + time.sleep(0.02) + + st = wait_stats(p, lambda s: s['bad_dgram'] >= n_junk) + assert st is not None and st['bad_dgram'] == n_junk, \ + 'misaligned datagrams not counted: %r\n%s' % (st, p.log) + # Compare against what was sent, not against an earlier stats + # line -- those are emitted on a timer and can be sampled + # mid-stream. + assert st['packets'] == expect_packets, \ + 'junk reached the scanner: %d packets, expected %d' \ + % (st['packets'], expect_packets) + assert st['crc_err'] == 0, \ + 'garbage reached the PSI parser: %r' % (st,) + finally: + pub.close() + + def test_second_publisher_is_refused_with_its_own_reason(self, running): + """A second sender is refused because the slot is taken -- not + because its address failed the MAVLink check, which it passed.""" + p = running() + g = tsgen.TSGen() + first = Publisher(VPORT) + try: + for dg in g.datagrams(g.stream(60, gop=10, psi_every=20)): + first.sock.sendto(dg, ('127.0.0.1', VPORT)) + time.sleep(0.004) + assert p.wait_for(r'video slot 0 publisher'), p.log + + second = Publisher(VPORT) + try: + for dg in g.datagrams(g.stream(30, gop=10, psi_every=20)): + second.sock.sendto(dg, ('127.0.0.1', VPORT)) + time.sleep(0.004) + finally: + second.close() + + assert p.wait_for(r'another publisher holds this slot'), \ + 'second publisher not refused with a slot-busy reason:\n%s' \ + % p.log + assert 'address does not match' not in p.log, \ + 'refusal blamed the address, which was fine:\n%s' % p.log + finally: + first.close() + + def test_recovers_after_a_gap(self, running): + """A publisher that pauses and resumes must keep parsing. + + Datagram loss is normal on a lossy link; the scanner has to pick + the program back up rather than wedge. + """ + p = running() + g = tsgen.TSGen() + _publish(VPORT, g.stream(120, gop=10, psi_every=20)) + st = wait_stats(p, lambda s: s['join'] == 'ready') + assert st is not None and st['join'] == 'ready', p.log + first_rai = st['rai'] + + time.sleep(2.0) + _publish(VPORT, g.stream(120, gop=10, psi_every=20)) + st2 = wait_stats(p, lambda s: s['rai'] > first_rai) + assert st2 is not None and st2['rai'] > first_rai, \ + 'scanner stopped after a gap: %r -> %r\n%s' % (st, st2, p.log) + assert st2['join'] == 'ready', st2 + + +@pytest.mark.integration +class TestScannerSelftest: + def test_selftest_and_fuzz_pass(self): + """The in-process unit checks and a short fuzz run. + + Kept in the normal suite so a regression in the PSI parsing -- + lengths and CRCs taken straight off the wire -- fails here + rather than only in a dedicated campaign. + """ + r = subprocess.run([SUPPORTPROXY_BIN, '--selftest-video'], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, r.stdout + r.stderr + assert 'videots selftest: OK' in r.stdout + assert 'videostream selftest: OK' in r.stdout + assert 'videots fuzz: OK' in r.stdout + + @pytest.mark.parametrize('seed', [2, 3, 42]) + def test_fuzz_other_seeds(self, seed): + r = subprocess.run( + [SUPPORTPROXY_BIN, '--selftest-video', '4000', str(seed)], + capture_output=True, text=True, timeout=120) + assert r.returncode == 0, r.stdout + r.stderr diff --git a/tests/test_video_record.py b/tests/test_video_record.py new file mode 100644 index 0000000..edf49f1 --- /dev/null +++ b/tests/test_video_record.py @@ -0,0 +1,275 @@ +"""Video segment recording and the partitioned disk quotas. + +The guarantee worth testing hardest is that video can never evict +telemetry. Video is orders of magnitude larger per second than a tlog, +so a single mtime-sorted pool would let a few minutes of recording +delete a whole flight's telemetry. The two budgets are enforced +independently, and this file pins that down from both directions. +""" +import os +import re +import subprocess +import sys +import time + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import keydb_lib # noqa: E402 +import tsgen # noqa: E402 +from test_video_child import (Proxy, Publisher, _Mav, PORT_ENG, # noqa: E402 + PORT_USER, PASSPHRASE, VPORT) + + +def _workdir(tmp_path, record=True, quota_mb=0, env=None): + p = tmp_path / 'work' + p.mkdir() + db = keydb_lib.init_db(str(p / 'keys.tdb')) + db.transaction_start() + keydb_lib.add_entry(db, PORT_USER, PORT_ENG, 'vid', PASSPHRASE) + keydb_lib.set_flag(db, PORT_ENG, 'video') + keydb_lib.set_video_ports(db, PORT_ENG, [VPORT]) + if record: + keydb_lib.set_video_slot_flag(db, PORT_ENG, 0, 'record') + if quota_mb: + keydb_lib.set_video_quota(db, PORT_ENG, quota_mb) + db.transaction_prepare_commit() + db.transaction_commit() + db.close() + return p + + +def _today_dir(workdir): + d = workdir / 'logs' / str(PORT_ENG) / time.strftime('%Y-%m-%d', + time.localtime()) + return d + + +def _segments(workdir): + d = _today_dir(workdir) + if not d.is_dir(): + return [] + return sorted([f for f in d.iterdir() if f.name.endswith('.ts')], + key=lambda f: f.name) + + +def _telem(workdir): + d = _today_dir(workdir) + if not d.is_dir(): + return [] + return sorted([f for f in d.iterdir() + if f.suffix in ('.tlog', '.bin')], key=lambda f: f.name) + + +class Session: + """A proxy with a MAVLink session and an authorised video publisher.""" + + def __init__(self, workdir, env=None): + self.workdir = workdir + environ = os.environ.copy() + if env: + environ.update(env) + self._env_backup = os.environ.copy() + os.environ.update(env or {}) + try: + self.proxy = Proxy(workdir) + finally: + os.environ.clear() + os.environ.update(self._env_backup) + assert self.proxy.wait_for(r'video slot 0 listening'), self.proxy.log + self.mav = _Mav() + assert self.proxy.wait_for(r'have UDP conn1'), self.proxy.log + time.sleep(1.2) + self.pub = Publisher(VPORT) + + def publish(self, data, pause=0.003): + for dg in tsgen.TSGen().datagrams(data): + self.pub.sock.sendto(dg, ('127.0.0.1', VPORT)) + time.sleep(pause) + + def stop(self): + try: + self.pub.close() + except Exception: + pass + self.mav.stop() + self.proxy.stop() + + +@pytest.fixture +def session(tmp_path): + made = {} + + def _start(env=None, **kw): + wd = _workdir(tmp_path, **kw) + made['s'] = Session(wd, env=env) + return made['s'] + + yield _start + if 's' in made: + made['s'].stop() + + +@pytest.mark.integration +class TestRecording: + def test_segment_is_written_and_byte_exact(self, session): + """The recording must be the publisher's own bytes. + + Not merely 'a valid TS file': fan-out and recording both promise + the original stream, so the file has to appear verbatim in what + was sent. + """ + s = session() + g = tsgen.TSGen() + data = g.stream(400, gop=10, psi_every=20) + sent = b''.join(g.datagrams(data)) + s.publish(data) + + deadline = time.time() + 20 + while time.time() < deadline and not _segments(s.workdir): + time.sleep(0.5) + segs = _segments(s.workdir) + assert segs, 'no segment written:\n%s' % s.proxy.log + + # let the writer flush, then compare + time.sleep(2) + blob = segs[0].read_bytes() + assert len(blob) > 0 + assert blob[0] == 0x47, 'segment does not start with a sync byte' + assert len(blob) % 188 == 0, 'segment is not a whole number of packets' + assert blob in sent, \ + 'recording is not a verbatim span of what was published' + + def test_segment_name_and_slot(self, session): + s = session() + s.publish(tsgen.TSGen().stream(150, gop=10, psi_every=20)) + deadline = time.time() + 20 + while time.time() < deadline and not _segments(s.workdir): + time.sleep(0.5) + segs = _segments(s.workdir) + assert segs, s.proxy.log + assert re.match(r'^\d{4}_\d{2}_\d{2}_\d{2}:\d{2}:\d{2}(-\d+)?\.v1\.ts$', + segs[0].name), segs[0].name + + def test_no_recording_when_slot_flag_is_off(self, session): + s = session(record=False) + s.publish(tsgen.TSGen().stream(150, gop=10, psi_every=20)) + time.sleep(3) + assert not _segments(s.workdir), \ + 'recorded with the record flag off: %r' % (_segments(s.workdir),) + + def test_rotation_produces_multiple_segments(self, session): + s = session(env={'SUPPORTPROXY_VIDEO_SEGMENT_SECONDS': '2'}) + g = tsgen.TSGen() + for _ in range(6): + s.publish(g.stream(120, gop=10, psi_every=20), pause=0.01) + time.sleep(0.5) + + deadline = time.time() + 20 + while time.time() < deadline and len(_segments(s.workdir)) < 2: + time.sleep(0.5) + segs = _segments(s.workdir) + assert len(segs) >= 2, \ + 'expected rotation into several segments, got %r\n%s' \ + % ([f.name for f in segs], s.proxy.log) + # every segment must be independently usable + for f in segs: + blob = f.read_bytes() + if not blob: + continue + assert blob[0] == 0x47, '%s does not start at a packet' % f.name + assert len(blob) % 188 == 0, '%s is not packet-aligned' % f.name + + def test_stream_without_keyframes_still_rotates(self, session): + """A muxer that never signals a random access point must not + produce an unbounded segment -- the quota pass can never evict + the file currently being written.""" + s = session(env={'SUPPORTPROXY_VIDEO_SEGMENT_SECONDS': '2'}) + g = tsgen.TSGen() + out = bytearray() + for i in range(600): + if i % 20 == 0: + out += g.pat() + out += g.pmt() + out += g.video(key=False) # never a keyframe + data = bytes(out) + deadline = time.time() + 60 + while time.time() < deadline and len(_segments(s.workdir)) < 2: + s.publish(data, pause=0.004) + time.sleep(0.5) + segs = _segments(s.workdir) + assert len(segs) >= 2, \ + 'no rotation without keyframes (forced cut missing):\n%s' \ + % s.proxy.log + assert 'forced cut' in s.proxy.log, \ + 'expected a forced cut to be logged:\n%s' % s.proxy.log + + +@pytest.mark.integration +class TestQuotaPartition: + def test_video_quota_never_evicts_telemetry(self, session, tmp_path): + """The headline guarantee. + + A tiny video budget plus a fast cleanup interval must delete old + .ts segments and leave seeded .tlog/.bin files completely alone, + however old they are. + """ + s = session(env={ + 'SUPPORTPROXY_PORT2_VIDEO_QUOTA_BYTES': str(256 * 1024), + 'SUPPORTPROXY_VIDEO_SEGMENT_SECONDS': '1', + 'SUPPORTPROXY_CLEANUP_INTERVAL': '0.5', + # Without shrinking the grace, every segment a short test + # writes is still "live" and none is evictable -- the quota + # pass would correctly free nothing. + 'SUPPORTPROXY_ACTIVE_FILE_GRACE': '1', + }) + # seed telemetry files that are old enough to be quota candidates + d = _today_dir(s.workdir) + d.mkdir(parents=True, exist_ok=True) + old = time.time() - 3600 + seeded = [] + for name in ('2020_01_01_00:00:00.tlog', '2020_01_01_00:00:00.bin'): + f = d / name + f.write_bytes(b'\x00' * 4096) + os.utime(f, (old, old)) + seeded.append(f) + + g = tsgen.TSGen() + deadline = time.time() + 45 + while time.time() < deadline: + s.publish(g.stream(300, gop=10, psi_every=20), pause=0.002) + time.sleep(0.5) + if re.search(r'removed .* for video quota', s.proxy.log): + break + + assert re.search(r'removed .* for video quota', s.proxy.log), \ + 'video quota never fired:\n%s' % s.proxy.log[-4000:] + for f in seeded: + assert f.exists(), \ + '%s was evicted by the video quota:\n%s' % (f.name, + s.proxy.log[-4000:]) + assert 'for telemetry quota' not in s.proxy.log, \ + 'telemetry quota pass ran on video pressure:\n%s' % s.proxy.log + + def test_per_entry_quota_overrides_the_default(self, session, tmp_path): + """KeyEntry.video_quota_mb must win over the server default.""" + s = session(quota_mb=1, env={ # 1 MB + 'SUPPORTPROXY_PORT2_VIDEO_QUOTA_BYTES': str(4 * 1024 * 1024 * 1024), + 'SUPPORTPROXY_VIDEO_SEGMENT_SECONDS': '1', + 'SUPPORTPROXY_CLEANUP_INTERVAL': '0.5', + 'SUPPORTPROXY_ACTIVE_FILE_GRACE': '1', + }) + g = tsgen.TSGen() + deadline = time.time() + 45 + while time.time() < deadline: + s.publish(g.stream(300, gop=10, psi_every=20), pause=0.002) + time.sleep(0.5) + if re.search(r'removed .* for video quota', s.proxy.log): + break + assert re.search(r'removed .* for video quota', s.proxy.log), \ + 'per-entry quota did not override the larger default:\n%s' \ + % s.proxy.log[-4000:] diff --git a/tests/test_video_rtsp.py b/tests/test_video_rtsp.py new file mode 100644 index 0000000..855ae31 --- /dev/null +++ b/tests/test_video_rtsp.py @@ -0,0 +1,1008 @@ +"""RTSP ingest, spliced to a loopback ffmpeg. + +SupportProxy parses no RTSP: it keeps the public port, authorises the +source address, and hands the connection untouched to an ffmpeg on +loopback. The spike established why -- waiting to classify deadlocks +(RTSP is request/response), and answering OPTIONS ourselves makes +ffmpeg reject the following ANNOUNCE because its listener wants the +first request it sees to be CSeq 1. + +These need a real ffmpeg, so they skip without one. +""" +import os +import re +import shutil +import socket +import subprocess +import sys +import time + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import keydb_lib # noqa: E402 +import rtmp_client # noqa: E402 +from test_video_child import (Proxy, _Mav, PORT_ENG, PORT_USER, # noqa: E402 + PASSPHRASE, VPORT) + +pytestmark = pytest.mark.skipif(shutil.which('ffmpeg') is None, + reason='RTSP ingest needs ffmpeg') + +# A short clip generated once per session: enough to carry a couple of +# keyframes so the stream becomes joinable. +_CLIP = None + + +@pytest.fixture(scope='session') +def clip(tmp_path_factory): + """A real H.264 clip, so the backend does real work. + + Synthetic TS is right for the scanner tests, but this path hands + bytes to ffmpeg's RTSP demuxer and H.264 parser -- those need a + genuine elementary stream. + """ + d = tmp_path_factory.mktemp('clip') + path = str(d / 'clip.mp4') + subprocess.run([ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=15:duration=12', + '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '15', + '-pix_fmt', 'yuv420p', path, + ], check=True, capture_output=True) + return path + + +def _workdir(tmp_path, record=True, publish_pass=None, + rtmp_path=None): + p = tmp_path / 'work' + p.mkdir() + db = keydb_lib.init_db(str(p / 'keys.tdb')) + db.transaction_start() + keydb_lib.add_entry(db, PORT_USER, PORT_ENG, 'rtsp', PASSPHRASE) + keydb_lib.set_flag(db, PORT_ENG, 'video') + keydb_lib.set_video_ports(db, PORT_ENG, [VPORT]) + if record: + keydb_lib.set_video_slot_flag(db, PORT_ENG, 0, 'record') + if publish_pass: + keydb_lib.set_video_publish_pass(db, PORT_ENG, publish_pass) + if rtmp_path: + keydb_lib.set_video_rtmp_path(db, PORT_ENG, 0, rtmp_path) + db.transaction_prepare_commit() + db.transaction_commit() + db.close() + return p + + +class RtspSession: + def __init__(self, workdir, with_mav=True): + self.workdir = workdir + self.proxy = Proxy(workdir) + assert self.proxy.wait_for(r'video slot 0 listening'), self.proxy.log + self.mav = None + if with_mav: + self.mav = _Mav() + assert self.proxy.wait_for(r'have UDP conn1'), self.proxy.log + time.sleep(1.2) + self.pub = None + + def publish(self, clip, loop=True): + argv = ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-re'] + if loop: + argv += ['-stream_loop', '-1'] + argv += ['-i', clip, '-c:v', 'copy', '-an', + '-f', 'rtsp', '-rtsp_transport', 'tcp', + 'rtsp://127.0.0.1:%d/cam' % VPORT] + self.pub = subprocess.Popen(argv, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + return self.pub + + def publish_rtmp_burst(self, clip, path='PhoenixFPV/FPV', port=None): + """Publish as fast as the link allows -- no -re pacing. + + This is the case the paced publisher never exercised: a burst + fills the socket to the backend, and the old relay slept inside + the event loop retrying that write, which stopped it draining + ffmpeg's stdout and deadlocked the pair. + """ + argv = ['ffmpeg', '-hide_banner', '-loglevel', 'error', + '-stream_loop', '-1', '-i', clip, '-c:v', 'copy', '-an', + '-f', 'flv', + 'rtmp://127.0.0.1:%d/%s' % (port or VPORT, path)] + self.pub = subprocess.Popen(argv, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + return self.pub + + def publish_rtmp(self, clip, path='PhoenixFPV/FPV', loop=True, + port=None): + """Publish over RTMP, the way the camera does. + + The app and stream in the URL are what the proxy reads off the + wire, so this is also how a test picks the path and, with a + query on the stream name, the publish credential. + """ + argv = ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-re'] + if loop: + argv += ['-stream_loop', '-1'] + argv += ['-i', clip, '-c:v', 'copy', '-an', + '-f', 'flv', + 'rtmp://127.0.0.1:%d/%s' % (port or VPORT, path)] + self.pub = subprocess.Popen(argv, stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE) + return self.pub + + def stop_publisher(self): + if self.pub and self.pub.poll() is None: + self.pub.terminate() + try: + self.pub.wait(timeout=5) + except subprocess.TimeoutExpired: + self.pub.kill() + self.pub.wait(timeout=5) + self.pub = None + + def stop(self): + self.stop_publisher() + if self.mav: + self.mav.stop() + self.proxy.stop() + + +def _no_stray_ffmpeg(): + """No ffmpeg still pointed at our video port. + + A publisher started with -stream_loop -1 keeps retrying, so a + leftover from the previous test would otherwise take the slot the + next test is trying to publish into. + """ + out = subprocess.run(['pgrep', '-a', '-x', 'ffmpeg'], + capture_output=True, text=True).stdout + return not any(':%d/' % VPORT in ln for ln in out.splitlines()) + + +def _settle(timeout=45): + # 45s not 25: the backpressure tests run an unpaced -stream_loop -1 + # publisher, which under a loaded -j16 run takes longer to die than + # the paced ones this was sized for. + deadline = time.time() + timeout + while time.time() < deadline: + if (_no_stray_ffmpeg() and _port_free(VPORT) + and _port_free(PORT_USER) and _port_free(PORT_ENG)): + return True + time.sleep(0.3) + return False + + +def _settle_state(): + """What is still held, for an assertion message worth reading.""" + held = [name for name, port in (('video', VPORT), ('user', PORT_USER), + ('eng', PORT_ENG)) if not _port_free(port)] + out = subprocess.run(['pgrep', '-a', '-x', 'ffmpeg'], + capture_output=True, text=True).stdout + return 'ports held: %s; ffmpeg: %r' % (held or 'none', out.splitlines()) + + +def _port_free(port): + """True when nothing holds `port` (read from /proc/net/tcp). + + TIME_WAIT does not count. An accepted connection's local port *is* + the listening port, so refusing a publisher leaves the video port in + TIME_WAIT for 60 s -- longer than this waits -- while the proxy + (SO_REUSEADDR) can rebind it immediately. Counting it made the next + test fail for something that was never in its way. + """ + want = '%04X' % port + for proto in ('tcp', 'udp'): + try: + with open('/proc/net/' + proto) as f: + next(f) + for line in f: + f_ = line.split() + if f_[1].split(':')[1].upper() != want: + continue + if proto == 'tcp' and f_[3] == '06': # TIME_WAIT + continue + return False + except OSError: + pass + return True + + +@pytest.fixture +def session(tmp_path): + """One proxy per test. + + These tests share a port set and, unlike the other video files, + also leave ffmpeg subprocesses behind. Waiting for the video port + to be released before yielding keeps a slow teardown from failing + the next test rather than its own. + """ + _settle() + + made = {} + + def _start(**kw): + made['s'] = RtspSession(_workdir(tmp_path, **{ + k: v for k, v in kw.items() + if k in ('record', 'publish_pass')}), + with_mav=kw.get('with_mav', True)) + return made['s'] + + yield _start + if 's' in made: + made['s'].stop() + _settle() + + +def _ffmpeg_children(proxy): + """ffmpeg processes descended from this proxy.""" + out = subprocess.run(['pgrep', '-a', '-x', 'ffmpeg'], + capture_output=True, text=True).stdout + return [ln for ln in out.splitlines() if 'rtsp://127.0.0.1' in ln] + + +@pytest.mark.integration +class TestRtspIngest: + def test_publish_is_accepted_and_becomes_joinable(self, session, clip): + s = session() + s.publish(clip) + assert s.proxy.wait_for(r'RTSP backend pid \d+', timeout=20), s.proxy.log + assert s.proxy.wait_for(r'RTSP publisher', timeout=20), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + + st = re.findall(r'stats: (\d+) KiB, (\d+) pkts, pat=(\d+) pmt=(\d+) ' + r'rai=(\d+)', s.proxy.log) + assert st, s.proxy.log + kib, pkts, pat, pmt, rai = (int(x) for x in st[-1]) + assert pkts > 0 and pat > 0 and pmt > 0 and rai > 0, st[-1] + + def test_recording_is_written(self, session, clip): + s = session() + s.publish(clip) + assert s.proxy.wait_for(r'recording to .*\.v1\.ts', timeout=30), \ + s.proxy.log + d = (s.workdir / 'logs' / str(PORT_ENG) + / time.strftime('%Y-%m-%d', time.localtime())) + deadline = time.time() + 20 + segs = [] + while time.time() < deadline: + segs = [f for f in d.iterdir() if f.name.endswith('.v1.ts')] \ + if d.is_dir() else [] + if segs and segs[0].stat().st_size > 10000: + break + time.sleep(0.5) + assert segs and segs[0].stat().st_size > 10000, \ + 'no usable recording from an RTSP publish:\n%s' % s.proxy.log + blob = segs[0].read_bytes() + assert blob[0] == 0x47 and len(blob) % 188 == 0 + + def test_viewer_gets_the_rtsp_stream(self, session, clip): + """The whole point: an RTSP publisher feeds the same fan-out as + a UDP one.""" + import socket + s = session() + s.publish(clip) + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect(('127.0.0.1', VPORT)) + try: + sock.sendall(b'GET /v1.ts HTTP/1.1\r\nHost: x\r\n\r\n') + got = b'' + deadline = time.time() + 8 + while time.time() < deadline and len(got) < 40000: + try: + c = sock.recv(65536) + except socket.timeout: + continue + if not c: + break + got += c + finally: + sock.close() + assert b'200 OK' in got, got[:200] + body = got.split(b'\r\n\r\n', 1)[1] + assert len(body) > 10000, len(body) + assert body[0] == 0x47, 'viewer stream does not start at a packet' + + def test_publish_rejected_without_a_mavlink_session(self, session, clip): + s = session(with_mav=False) + s.publish(clip, loop=False) + assert s.proxy.wait_for(r'rejected .*no MAVLink session', timeout=20), \ + s.proxy.log + assert 'RTSP backend pid' not in s.proxy.log, \ + 'a backend was started for an unauthorised publisher' + + def test_second_publisher_is_refused(self, session, clip): + s = session() + s.publish(clip) + assert s.proxy.wait_for(r'RTSP publisher', timeout=20), s.proxy.log + second = subprocess.Popen( + ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-re', + '-i', clip, '-c:v', 'copy', '-an', '-f', 'rtsp', + '-rtsp_transport', 'tcp', 'rtsp://127.0.0.1:%d/cam2' % VPORT], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + assert s.proxy.wait_for(r'another publisher holds this slot', + timeout=20), s.proxy.log + finally: + second.terminate() + second.wait(timeout=5) + + def test_no_orphan_backend_after_the_publisher_leaves(self, session, clip): + s = session() + s.publish(clip) + assert s.proxy.wait_for(r'RTSP backend pid \d+', timeout=20), s.proxy.log + assert _ffmpeg_children(s.proxy), 'no backend running while publishing' + + s.stop_publisher() + assert s.proxy.wait_for(r'RTSP publisher gone', timeout=25), s.proxy.log + deadline = time.time() + 15 + while time.time() < deadline and _ffmpeg_children(s.proxy): + time.sleep(0.5) + assert not _ffmpeg_children(s.proxy), \ + 'backend outlived the publisher: %r' % _ffmpeg_children(s.proxy) + + def test_backend_dies_with_the_proxy(self, session, clip): + s = session() + s.publish(clip) + assert s.proxy.wait_for(r'RTSP backend pid \d+', timeout=20), s.proxy.log + assert _ffmpeg_children(s.proxy) + s.stop_publisher() + s.proxy.stop() + deadline = time.time() + 15 + while time.time() < deadline and _ffmpeg_children(s.proxy): + time.sleep(0.5) + assert not _ffmpeg_children(s.proxy), \ + 'backend outlived the proxy: %r' % _ffmpeg_children(s.proxy) + + +def _publish_with(url_suffix, clip, seconds=6): + return subprocess.Popen( + ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-re', + '-stream_loop', '-1', '-i', clip, '-c:v', 'copy', '-an', + '-f', 'rtsp', '-rtsp_transport', 'tcp', + 'rtsp://127.0.0.1:%d/cam%s' % (VPORT, url_suffix)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +@pytest.mark.integration +class TestPublishPassword: + """Path A: a publish password, with no MAVLink session anywhere. + + This is the path for CGNAT, for video on a different link from + telemetry, and for anyone who does not want address matching to be + the gate at all. The password replaces the MAVLink check rather + than adding to it. + + RTSP carries it in the request-line URI. That is the only place it + can go without us answering anything: Basic auth would mean + replying 401 and renumbering CSeq, which is exactly what makes the + opaque splice work. + """ + + def test_accepted_with_no_mavlink_session_at_all(self, session, clip): + s = session(with_mav=False, publish_pass='pubsecret') + s.pub = _publish_with('?pw=pubsecret', clip) + assert s.proxy.wait_for(r'RTSP publisher', timeout=25), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + assert 'no MAVLink session' not in s.proxy.log + + def test_wrong_password_refused(self, session, clip): + s = session(with_mav=False, publish_pass='pubsecret') + s.pub = _publish_with('?pw=wrong', clip) + assert s.proxy.wait_for(r'wrong publish password', timeout=25), \ + s.proxy.log + assert 'RTSP publisher' not in s.proxy.log + + def test_missing_password_says_so_precisely(self, session, clip): + """Distinct from 'wrong', and distinct from 'this transport + cannot carry one' -- an operator debugging this needs to know + which of the three it is.""" + s = session(with_mav=False, publish_pass='pubsecret') + s.pub = _publish_with('', clip) + assert s.proxy.wait_for(r'none was supplied', timeout=25), s.proxy.log + + def test_udp_says_it_cannot_carry_a_password(self, session, clip): + import socket + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import tsgen + s = session(with_mav=False, publish_pass='pubsecret') + g = tsgen.TSGen() + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + for dg in g.datagrams(g.stream(60, gop=10, psi_every=20)): + sock.sendto(dg, ('127.0.0.1', VPORT)) + time.sleep(0.003) + finally: + sock.close() + assert s.proxy.wait_for(r'cannot carry one', timeout=20), s.proxy.log + + def test_password_replaces_the_mavlink_check(self, session, clip): + """With a password set, a valid MAVLink session is not enough on + its own -- otherwise setting one would not actually tighten + anything for a transport that can carry it.""" + s = session(with_mav=True, # a session IS present + publish_pass='pubsecret') + s.pub = _publish_with('?pw=wrong', clip) + assert s.proxy.wait_for(r'wrong publish password', timeout=25), \ + s.proxy.log + assert 'RTSP publisher' not in s.proxy.log + + +class TestRtspPublisherRestart: + """Restarting an RTSP publisher, which is the case that mattered. + + A publish password forces RTSP -- plain MPEG-TS/UDP cannot carry a + credential -- so this is the transport a passworded entry actually + uses. The publisher-gone handling was added to the UDP idle-release + path only, and every test for it used a UDP publisher, so RTSP kept + the original bug: viewers stayed attached across a restart and were + fed a second stream's timestamps, which stalls a browser player for + good and then shows up as the viewer being lapped. + """ + + def test_viewer_is_ended_when_the_rtsp_publisher_goes(self, tmp_path, + clip): + s = RtspSession(_workdir(tmp_path)) + try: + s.publish(clip) + assert s.proxy.wait_for(r'RTSP publisher', timeout=25), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=30), s.proxy.log + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect(('127.0.0.1', VPORT)) + sock.sendall(b'GET /v1.ts HTTP/1.1\r\nHost: x\r\n\r\n') + got = sock.recv(65536) + assert b'200' in got, got[:200] + + s.stop_publisher() + assert s.proxy.wait_for(r'RTSP publisher gone', timeout=25), \ + s.proxy.log + + # The viewer must be closed, not left attached to a stream + # that has ended. + closed = False + deadline = time.time() + 15 + while time.time() < deadline: + try: + if sock.recv(65536) == b'': + closed = True + break + except socket.timeout: + break + except OSError: + closed = True + break + sock.close() + assert closed, 'viewer survived the RTSP publisher going away' + finally: + s.stop() + + def test_reason_is_logged_for_rtsp(self, tmp_path, clip): + s = RtspSession(_workdir(tmp_path)) + try: + s.publish(clip) + assert s.proxy.wait_for(r'join=ready', timeout=30), s.proxy.log + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect(('127.0.0.1', VPORT)) + sock.sendall(b'GET /v1.ts HTTP/1.1\r\nHost: x\r\n\r\n') + sock.recv(65536) + s.stop_publisher() + # Match the viewer's drop line, not close_rtsp's own + # "RTSP publisher gone" -- that one is logged either way and + # so proves nothing about the viewer being ended. + assert s.proxy.wait_for( + r'viewer disconnected .*\(publisher gone\)', timeout=25), \ + s.proxy.log + sock.close() + finally: + s.stop() + + +class TestRtmpIngest: + """RTMP publish, with the protocol spoken here rather than spliced. + + ffmpeg's RTMP listener answers FCPublish with a bare command name + and publish with nothing, which a real camera waits out and then + hangs up on, so this path terminates RTMP itself and hands the + backend FLV. The camera can publish over RTMP but not RTSP -- its + RTSP OPTIONS offers no ANNOUNCE -- so this is the transport the + direct camera stream actually uses. + """ + + def test_rtmp_publisher_is_ingested(self, tmp_path, clip): + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'RTMP publisher', timeout=25), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + finally: + s.stop() + + def test_it_is_not_mistaken_for_a_viewer(self, tmp_path, clip): + """An RTMP connection arrives in a viewer slot, because + classification only happens once bytes arrive. It must be + handed to the ingest splice, not answered as a viewer.""" + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'RTMP publisher', timeout=25), s.proxy.log + assert 'viewer disconnected' not in s.proxy.log + finally: + s.stop() + + def test_unconfigured_slot_takes_any_path(self, tmp_path, clip): + """With the protocol parsed here, the app and stream are read + off the wire rather than declared in advance, so a blank path + is no longer a misconfiguration -- the slot takes whatever the + camera publishes.""" + s = RtspSession(_workdir(tmp_path)) # no rtmp_path + try: + s.publish_rtmp(clip, path='whatever/stream') + assert s.proxy.wait_for(r'RTMP publishing whatever/stream', + timeout=25), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + finally: + s.stop() + + def test_a_configured_path_still_restricts(self, tmp_path, clip): + """Set, it is an access control: a publisher on another path is + refused rather than quietly taking the slot.""" + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp(clip, path='someone/else') + assert s.proxy.wait_for(r'slot expects PhoenixFPV/FPV', + timeout=25), s.proxy.log + assert 'join=ready' not in s.proxy.log + finally: + s.stop() + + def test_the_stream_is_recorded(self, tmp_path, clip): + wd = _workdir(tmp_path, record=True, rtmp_path='PhoenixFPV/FPV') + s = RtspSession(wd) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'recording to', timeout=40), s.proxy.log + s.stop_publisher() + time.sleep(2) + segs = list((wd / 'logs' / str(PORT_ENG)).rglob('*.v1.ts')) + assert segs, 'no recording written' + assert segs[0].stat().st_size > 10000 + finally: + s.stop() + + def test_squatters_cannot_deny_publishing(self, tmp_path, clip): + """An unauthenticated handshake must not own the publisher slot. + + One 0x03 byte classifies a connection as RTMP. If that reserved + the slot, a peer could take it, wait out the deadline and + reconnect for ever -- and letting a newcomer evict the incumbent + only makes it last-arrival-wins, which denies the camera just as + effectively. Handshakes negotiate side by side instead and the + slot is awarded on publish, after admission. + + The squatters are kept connected for the whole test, and keep + arriving after the publisher does, which is what the earlier + version of this test failed to do. + """ + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + squatters = [] + try: + for _ in range(3): + c = socket.create_connection(('127.0.0.1', VPORT), 5) + c.sendall(b'\x03') + squatters.append(c) + time.sleep(1.0) + s.publish_rtmp(clip) + # Keep squatting while the real publisher negotiates. + for _ in range(3): + try: + c = socket.create_connection(('127.0.0.1', VPORT), 5) + c.sendall(b'\x03') + squatters.append(c) + except OSError: + pass + time.sleep(0.3) + assert s.proxy.wait_for(r'RTMP publishing', timeout=30), \ + s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + finally: + for c in squatters: + try: + c.close() + except OSError: + pass + s.stop() + + def test_a_squatter_cannot_evict_a_live_publisher(self, tmp_path, clip): + """Once publishing, the slot is held against new handshakes.""" + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + squatters = [] + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + for _ in range(4): + c = socket.create_connection(('127.0.0.1', VPORT), 5) + c.sendall(b'\x03') + squatters.append(c) + time.sleep(3) + assert 'publisher gone' not in s.proxy.log, s.proxy.log + finally: + for c in squatters: + try: + c.close() + except OSError: + pass + s.stop() + + def test_a_silent_flood_does_not_block_classification(self, tmp_path, + clip): + """Sockets that never speak must not fill the viewer table. + + A publisher is classified from a viewer slot, so a flood of + silent connections used to stop one being recognised at all. + What bounds it is a per-source-address cap, not a reserve: a + publisher is indistinguishable at accept time, since any + credential it carries arrives later. Several addresses can still + fill the table between them -- see the per-IP cap in video.cpp. + """ + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + flood = [] + try: + # From another address: 127/8 is all loopback, so this is a + # different source to the publisher's 127.0.0.1, which is + # what the per-address cap keys on. + for _ in range(40): # more than the 32-entry table + try: + c = socket.socket() + c.bind(('127.0.0.2', 0)) + c.settimeout(5) + c.connect(('127.0.0.1', VPORT)) + flood.append(c) + except OSError: + break + time.sleep(1.5) + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'RTMP publishing', timeout=30), \ + s.proxy.log + finally: + for c in flood: + try: + c.close() + except OSError: + pass + s.stop() + + def test_publisher_row_reports_its_real_transport(self, tmp_path, clip): + """connections.tdb must not call an RTMP publisher UDP/MPEG-TS.""" + import conntdb_lib + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + time.sleep(6) # let a tick write the rows + rows = conntdb_lib.list_active( + str(wd / conntdb_lib.CONN_FILE), max_age_s=60) + pub = [r for r in rows + if r.role == conntdb_lib.CONN_ROLE_VIDEO_PUB] + assert pub, 'no video publisher row: %r' % (rows,) + assert pub[0].transport_name == 'tcp', pub[0].transport_name + assert pub[0].app_proto == conntdb_lib.CONN_APP_RTMP + finally: + s.stop() + + def _flv_of(self, clip, tmp_path): + """The clip as FLV, so its tags can be replayed over RTMP.""" + out = str(tmp_path / 'src.flv') + subprocess.run(['ffmpeg', '-v', 'error', '-i', clip, '-c:v', 'copy', + '-an', '-f', 'flv', out, '-y'], + check=True, capture_output=True) + return rtmp_client.read_flv_tags(out) + + def test_media_pipelined_with_publish_is_kept(self, tmp_path, clip): + """A publisher that does not wait for onStatus must still work. + + feed() drains everything buffered, so media in the same segment + as publish was parsed while publishing_ was still false and + dropped -- taking the AVC sequence header, and with it the + parameter sets, so the backend could not open the stream. ffmpeg + never sends this shape because it waits for onStatus first. + """ + tags = self._flv_of(clip, tmp_path) + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + pub = None + try: + pub = rtmp_client.RtmpPublisher('127.0.0.1', VPORT) + pub.handshake() + pub.connect() + # publish + the sequence header + the first frames, one write + pub.publish(first_tags=tags[:3], pipeline=True) + for ttype, ts, body in tags[3:]: + pub.send_tag(ttype, ts, body) + time.sleep(0.004) + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + finally: + if pub: + pub.close() + s.stop() + + def test_a_split_chunk_does_not_inflate_timestamps(self, tmp_path, clip): + """A chunk header split from its payload must not double its delta. + + The parser committed the header before checking the payload was + buffered, so a short read re-parsed it and applied the timestamp + delta again. Ordinary TCP segmentation is enough; the effect is + a recording longer than the media it contains. + """ + tags = [t for t in self._flv_of(clip, tmp_path) if t[0] == 9] + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + pub = None + try: + pub = rtmp_client.RtmpPublisher('127.0.0.1', VPORT) + pub.handshake() + pub.connect() + pub.publish(first_tags=tags[:1]) + prev = tags[0][1] + sent = 0 + # All of them: ffmpeg's default -analyzeduration is 5 s of + # media, so a shorter burst produces no output at all and + # the test would fail for the wrong reason. + for ttype, ts, body in tags[1:]: + # fmt 1 carries a delta, and every other one is split + pub.send_tag(ttype, ts - prev, body, fmt=1, + split=(sent % 2 == 1)) + prev = ts + sent += 1 + time.sleep(0.01) + expected = (prev - tags[0][1]) / 1000.0 + assert s.proxy.wait_for(r'recording to', timeout=40), s.proxy.log + time.sleep(2) + finally: + if pub: + pub.close() + s.stop() + + segs = list((wd / 'logs' / str(PORT_ENG)).rglob('*.v1.ts')) + assert segs, 'no recording written' + out = subprocess.run( + ['ffprobe', '-v', 'error', '-select_streams', 'v:0', + '-show_entries', 'packet=pts_time', '-of', 'csv=p=0', + str(segs[0])], capture_output=True, text=True).stdout + pts = [float(r.rstrip(',')) for r in out.strip().splitlines() + if r.rstrip(',')] + assert len(pts) > 10, 'too few packets to judge: %d' % len(pts) + span = pts[-1] - pts[0] + # Doubling the deltas on every other frame would put the span + # about 50% over; allow generous slack for the last frame. + assert span < expected * 1.25 + 0.3, ( + 'timestamp span %.2f s for %.2f s of media -- deltas applied ' + 'more than once' % (span, expected)) + + def test_h264_publisher_gets_the_nal_rewriting_filter(self, tmp_path, + clip): + """H.264 over RTMP must go through h264_metadata. + + ffmpeg's own AVCC to Annex-B conversion emits a zero-length NAL + ahead of every access unit for the real camera's stream, which + is invalid H.264: Chrome's MP4 parser refuses the sample + ("Failed to prepare video sample for decode") while Firefox + plays it regardless. h264_metadata rewrites the units and + removes them -- measured on camera capture, 8362 empty units to + none -- but it is codec-specific, so it must be chosen from the + codec the FLV names rather than applied blind. + + This asserts the choice, not the byte-level outcome: an ffmpeg + publisher does not reproduce whatever the camera does, so the + empty units simply do not appear in a synthetic stream (see + test_no_zero_length_nal_units, which is a general invariant + rather than a regression guard for this bug). + """ + s = RtspSession(_workdir(tmp_path)) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'bsf h264_metadata', timeout=25), \ + s.proxy.log + finally: + s.stop() + + def test_no_zero_length_nal_units(self, tmp_path, clip): + """Ingested video must contain no empty NAL units. + + A general invariant, not a regression guard: an ffmpeg publisher + does not reproduce the camera stream shape that made ffmpeg emit + them, so this passes with or without the filter that fixes it. + Kept because empty NAL units are invalid H.264 whatever produces + them, and only a byte check finds them -- Firefox plays them + happily and ffprobe reports no error. + """ + wd = _workdir(tmp_path, record=True) + s = RtspSession(wd) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'recording to', timeout=40), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + s.stop_publisher() + time.sleep(2) + segs = list((wd / 'logs' / str(PORT_ENG)).rglob('*.v1.ts')) + assert segs, 'no recording written' + finally: + s.stop() + + es = str(tmp_path / 'es.264') + subprocess.run(['ffmpeg', '-v', 'error', '-i', str(segs[0]), + '-c', 'copy', '-f', 'h264', es, '-y'], + check=True, capture_output=True) + data = open(es, 'rb').read() + starts = [] + i = 0 + while i < len(data) - 3: + if data[i] == 0 and data[i + 1] == 0: + if data[i + 2] == 1: + starts.append((i, 3)) + i += 3 + continue + if data[i + 2] == 0 and data[i + 3] == 1: + starts.append((i, 4)) + i += 4 + continue + i += 1 + empty = 0 + for k, (p, ln) in enumerate(starts): + end = starts[k + 1][0] if k + 1 < len(starts) else len(data) + if end - (p + ln) == 0: + empty += 1 + assert starts, 'no NAL units found' + assert empty == 0, ('%d of %d NAL units are zero-length' + % (empty, len(starts))) + + def test_publish_password_in_the_stream_key(self, tmp_path, clip): + """Parsing the protocol gives RTMP somewhere to carry a + credential: a query on the stream name, which is the single + "stream key" field a camera or OBS offers. + """ + s = RtspSession(_workdir(tmp_path, publish_pass='secret'), + with_mav=False) + try: + s.publish_rtmp(clip, path='PhoenixFPV/FPV?pw=secret') + assert s.proxy.wait_for(r'RTMP publishing PhoenixFPV/FPV', + timeout=25), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + finally: + s.stop() + + def test_publish_password_refuses_the_wrong_one(self, tmp_path, clip): + s = RtspSession(_workdir(tmp_path, publish_pass='secret'), + with_mav=False) + try: + s.publish_rtmp(clip, path='PhoenixFPV/FPV?pw=wrong') + assert s.proxy.wait_for(r'rejected', timeout=25), s.proxy.log + assert 'RTMP publishing' not in s.proxy.log + finally: + s.stop() + + def test_publish_password_refuses_when_absent(self, tmp_path, clip): + """No credential at all, with one required: still refused, and + with the reason that says one was missing rather than wrong.""" + s = RtspSession(_workdir(tmp_path, publish_pass='secret'), + with_mav=False) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'rejected', timeout=25), s.proxy.log + assert 'RTMP publishing' not in s.proxy.log + finally: + s.stop() + + def test_no_orphan_backend_after_the_rtmp_publisher_leaves( + self, tmp_path, clip): + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'RTMP publisher', timeout=25), s.proxy.log + s.stop_publisher() + assert s.proxy.wait_for(r'publisher gone', timeout=30), s.proxy.log + finally: + s.stop() + assert _settle(), ('a backend ffmpeg outlived the session -- %s' + % _settle_state()) + + def test_restart_ends_the_stream_for_viewers(self, tmp_path, clip): + """Same contract as RTSP: a new publisher is a new stream, so + viewers are ended rather than spliced onto it.""" + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp(clip) + assert s.proxy.wait_for(r'join=ready', timeout=40), s.proxy.log + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect(('127.0.0.1', VPORT)) + sock.sendall(b'GET /v1.ts HTTP/1.1\r\nHost: x\r\n\r\n') + assert b'200' in sock.recv(65536) + s.stop_publisher() + assert s.proxy.wait_for( + r'viewer disconnected .*\(publisher gone\)', timeout=30), \ + s.proxy.log + sock.close() + finally: + s.stop() + + +class TestSpliceBackpressure: + """The relay must never wait inside the event loop. + + It is the only thread: it also drains ffmpeg's stdout, so sleeping + on a short write to the backend stops that draining, ffmpeg's stdout + pipe fills, ffmpeg stops reading its input, and neither side moves + again. Only an unpaced publisher reaches that state. + """ + + def test_unpaced_publisher_still_produces_media(self, tmp_path, clip): + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp_burst(clip) + assert s.proxy.wait_for(r'RTMP publisher', timeout=25), s.proxy.log + assert s.proxy.wait_for(r'join=ready', timeout=60), s.proxy.log + finally: + s.stop() + + def test_unpaced_publisher_keeps_flowing(self, tmp_path, clip): + """join=ready once is not enough -- a deadlock can set in after + the first burst. Require the byte count to keep climbing.""" + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + try: + s.publish_rtmp_burst(clip) + assert s.proxy.wait_for(r'join=ready', timeout=60), s.proxy.log + first = _last_kib(s.proxy.log) + deadline = time.time() + 30 + while time.time() < deadline: + time.sleep(2) + if _last_kib(s.proxy.log) > first: + return + raise AssertionError( + 'ingest stalled at %d KiB -- the splice is wedged' % first) + finally: + s.stop() + + def test_a_viewer_that_never_reads_does_not_wedge_ingest(self, tmp_path, + clip): + """The other direction of the same hazard.""" + s = RtspSession(_workdir(tmp_path, rtmp_path='PhoenixFPV/FPV')) + sock = None + try: + s.publish_rtmp_burst(clip) + assert s.proxy.wait_for(r'join=ready', timeout=60), s.proxy.log + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect(('127.0.0.1', VPORT)) + sock.sendall(b'GET /v1.ts HTTP/1.1\r\nHost: x\r\n\r\n') + sock.recv(4096) # then deliberately stop reading + first = _last_kib(s.proxy.log) + deadline = time.time() + 30 + while time.time() < deadline: + time.sleep(2) + if _last_kib(s.proxy.log) > first: + return + raise AssertionError( + 'ingest stalled at %d KiB behind a silent viewer' % first) + finally: + if sock: + sock.close() + s.stop() + + +def _last_kib(log): + """KiB from the most recent stats line, or 0.""" + hits = re.findall(r'stats: (\d+) KiB', log) + return int(hits[-1]) if hits else 0 diff --git a/tests/test_video_view.py b/tests/test_video_view.py new file mode 100644 index 0000000..26f2feb --- /dev/null +++ b/tests/test_video_view.py @@ -0,0 +1,820 @@ +"""Video viewers: fan-out, join point, credentials and the drop policy. + +The two properties worth testing hardest: + + * fan-out is byte-exact. Both recording and viewing promise the + publisher's own bytes, so a viewer's stream has to appear verbatim + in what was published -- not merely "be valid MPEG-TS". + + * one slow viewer cannot hurt anyone else. That is meant to be true + by construction (the publisher never inspects viewer state), so the + test drives a viewer that never reads until it is lapped and checks + the others are untouched. +""" +import os +import socket +import subprocess +import sys +import threading +import time + +import pytest + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import keydb_lib # noqa: E402 +import tsgen # noqa: E402 +from test_video_child import (Proxy, Publisher, _Mav, PORT_ENG, # noqa: E402 + PORT_USER, PASSPHRASE, VPORT) + + +def _workdir(tmp_path, raw_tcp=False, viewer_pass=None): + p = tmp_path / 'work' + p.mkdir() + db = keydb_lib.init_db(str(p / 'keys.tdb')) + db.transaction_start() + keydb_lib.add_entry(db, PORT_USER, PORT_ENG, 'vid', PASSPHRASE) + keydb_lib.set_flag(db, PORT_ENG, 'video') + keydb_lib.set_video_ports(db, PORT_ENG, [VPORT]) + if raw_tcp: + keydb_lib.set_video_slot_flag(db, PORT_ENG, 0, 'raw_tcp') + if viewer_pass: + keydb_lib.set_video_viewer_pass(db, PORT_ENG, viewer_pass) + db.transaction_prepare_commit() + db.transaction_commit() + db.close() + return p + + +class Feed: + """Publishes continuously in the background, recording every byte sent.""" + + def __init__(self, port, gen=None): + self.port = port + self.gen = gen or tsgen.TSGen() + self.pub = Publisher(port) + self.sent = bytearray() + self._lock = threading.Lock() + self._stop = threading.Event() + self._t = None + + def burst(self, packets=200): + data = self.gen.stream(packets, gop=10, psi_every=20) + for dg in self.gen.datagrams(data): + self.pub.sock.sendto(dg, ('127.0.0.1', self.port)) + with self._lock: + self.sent += dg + time.sleep(0.002) + + def start(self, packets=200, pause=0.05): + def _run(): + while not self._stop.is_set(): + self.burst(packets) + time.sleep(pause) + self._t = threading.Thread(target=_run, daemon=True) + self._t.start() + + def snapshot(self): + with self._lock: + return bytes(self.sent) + + def stop(self): + self._stop.set() + if self._t: + self._t.join(timeout=5) + self.pub.close() + + +class Session: + def __init__(self, workdir, env=None): + self.workdir = workdir + backup = os.environ.copy() + os.environ.update(env or {}) + try: + self.proxy = Proxy(workdir) + finally: + os.environ.clear() + os.environ.update(backup) + assert self.proxy.wait_for(r'video slot 0 listening'), self.proxy.log + self.mav = _Mav() + assert self.proxy.wait_for(r'have UDP conn1'), self.proxy.log + time.sleep(1.2) + self.feed = Feed(VPORT) + + def wait_ready(self, timeout=25): + """Wait until the scanner reports a joinable stream.""" + deadline = time.time() + timeout + while time.time() < deadline: + self.feed.burst(120) + if 'join=ready' in self.proxy.log: + return True + time.sleep(0.3) + return 'join=ready' in self.proxy.log + + def stop(self): + try: + self.feed.stop() + except Exception: + pass + self.mav.stop() + self.proxy.stop() + + +@pytest.fixture +def session(tmp_path): + made = {} + + def _start(env=None, **kw): + wd = _workdir(tmp_path, **kw) + made['s'] = Session(wd, env=env) + return made['s'] + + yield _start + if 's' in made: + made['s'].stop() + + +def http_get(port, path='/v1.ts', headers='', timeout=5.0): + """Open an HTTP viewer and return (socket, response_head).""" + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect(('127.0.0.1', port)) + s.sendall(('GET %s HTTP/1.1\r\nHost: localhost\r\n%s\r\n' + % (path, headers)).encode()) + head = b'' + while b'\r\n\r\n' not in head: + chunk = s.recv(4096) + if not chunk: + break + head += chunk + sep = head.find(b'\r\n\r\n') + body = head[sep + 4:] if sep >= 0 else b'' + return s, head[:sep if sep >= 0 else len(head)], body + + +def read_for(sock, seconds, initial=b''): + """Drain a socket for a while and return what arrived.""" + out = bytearray(initial) + deadline = time.time() + seconds + sock.settimeout(0.5) + while time.time() < deadline: + try: + chunk = sock.recv(65536) + except socket.timeout: + continue + except OSError: + break + if not chunk: + break + out += chunk + return bytes(out) + + +@pytest.mark.integration +class TestHttpViewer: + def test_stream_is_served_and_byte_exact(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + s.feed.start() + sock, head, body = http_get(VPORT) + try: + assert b'200 OK' in head, head + assert b'video/mp2t' in head, head + data = read_for(sock, 4, body) + finally: + sock.close() + assert len(data) > 10000, 'viewer got almost nothing: %d' % len(data) + assert data[0] == 0x47, 'viewer stream does not start at a packet' + sent = s.feed.snapshot() + assert data in sent, \ + 'viewer stream is not a verbatim span of what was published' + + def test_join_starts_at_a_decodable_point(self, session): + """First bytes must be a PAT, with a PMT before any video payload. + + Serving from an arbitrary point looks exactly like a broken + stream to the client, so this is the property that decides + whether the feature seems to work at all. + """ + s = session() + assert s.wait_ready(), s.proxy.log + s.feed.start() + sock, head, body = http_get(VPORT) + try: + data = read_for(sock, 3, body) + finally: + sock.close() + assert len(data) >= 188 * 4, len(data) + + def pid_of(pkt): + return ((pkt[1] & 0x1F) << 8) | pkt[2] + + pkts = [data[i:i + 188] for i in range(0, len(data) - 187, 188)] + assert pkts[0][0] == 0x47 + assert pid_of(pkts[0]) == 0, \ + 'first packet is PID 0x%x, expected the PAT' % pid_of(pkts[0]) + + saw_pmt = False + for pkt in pkts[:40]: + pid = pid_of(pkt) + if pid == tsgen.DEFAULT_PMT_PID: + saw_pmt = True + if pid == tsgen.DEFAULT_VIDEO_PID: + assert saw_pmt, 'video payload arrived before any PMT' + break + assert saw_pmt, 'no PMT near the start of the viewer stream' + + def test_two_viewers_agree_on_overlapping_bytes(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + s.feed.start() + a, _, abody = http_get(VPORT) + time.sleep(0.5) + b, _, bbody = http_get(VPORT) + try: + da = read_for(a, 4, abody) + db = read_for(b, 4, bbody) + finally: + a.close() + b.close() + assert len(da) > 5000 and len(db) > 5000, (len(da), len(db)) + # b joined no earlier than a, so b's opening run must appear + # verbatim somewhere in a + probe = db[:4096] + assert probe in da or da[:4096] in db, \ + 'the two viewers disagree on the same stream' + + def test_404_for_a_wrong_path(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + sock, head, _ = http_get(VPORT, path='/nope') + sock.close() + assert b'404' in head, head + + def test_503_before_the_stream_is_joinable(self, session): + """No keyframe yet means no decodable start point; say so.""" + s = session() + g = tsgen.TSGen() + out = bytearray() + for i in range(80): + if i % 20 == 0: + out += g.pat() + out += g.pmt() + out += g.video(key=False) + for dg in g.datagrams(bytes(out)): + s.feed.pub.sock.sendto(dg, ('127.0.0.1', VPORT)) + time.sleep(0.003) + time.sleep(1.0) + sock, head, _ = http_get(VPORT) + sock.close() + assert b'503' in head, head + + +@pytest.mark.integration +class TestViewerCredentials: + def test_password_required_when_set(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + sock, head, _ = http_get(VPORT) + sock.close() + assert b'401' in head, head + + def test_password_accepted_in_query(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + sock, head, _ = http_get(VPORT, path='/v1.ts?pw=watchme') + sock.close() + assert b'200 OK' in head, head + + def test_password_accepted_via_basic_auth(self, session): + import base64 + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + cred = base64.b64encode(b'viewer:watchme').decode() + sock, head, _ = http_get(VPORT, + headers='Authorization: Basic %s\r\n' % cred) + sock.close() + assert b'200 OK' in head, head + + def test_wrong_password_refused(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + sock, head, _ = http_get(VPORT, path='/v1.ts?pw=nope') + sock.close() + assert b'401' in head, head + + +@pytest.mark.integration +class TestRawTcpViewer: + def test_raw_viewer_served_when_enabled(self, session): + s = session(raw_tcp=True) + assert s.wait_ready(), s.proxy.log + s.feed.start() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(8) + sock.connect(('127.0.0.1', VPORT)) + try: + data = read_for(sock, 6) # says nothing; detected on silence + finally: + sock.close() + assert len(data) > 5000, 'raw viewer got %d bytes' % len(data) + assert data[0] == 0x47 + assert data in s.feed.snapshot(), 'raw stream is not verbatim' + + def test_raw_viewer_refused_when_flag_off(self, session): + s = session(raw_tcp=False) + assert s.wait_ready(), s.proxy.log + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(8) + sock.connect(('127.0.0.1', VPORT)) + try: + data = read_for(sock, 5) + finally: + sock.close() + assert data == b'', 'raw viewer served with the flag off' + # wait for the line rather than reading the log straight away: + # the proxy's stdout is drained by a separate thread + assert s.proxy.wait_for(r'raw-TCP viewers not enabled'), s.proxy.log + + def test_raw_viewer_refused_when_a_password_is_set(self, session): + """Raw TCP has nowhere to carry a credential, so it must not be + a way around the viewer password.""" + s = session(raw_tcp=True, viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(8) + sock.connect(('127.0.0.1', VPORT)) + try: + data = read_for(sock, 5) + finally: + sock.close() + assert data == b'', 'raw viewer bypassed the viewer password' + assert s.proxy.wait_for(r'cannot carry the viewer password'), \ + s.proxy.log + + +@pytest.mark.integration +class TestSlowViewer: + def test_slow_viewer_dropped_without_disturbing_others(self, session): + """A viewer that never reads must be dropped, and must not cost + the other viewers a single byte. + + The ring is shrunk so lapping is reachable without pushing tens + of megabytes through the test. + """ + s = session(env={'SUPPORTPROXY_VIDEO_RING_BYTES': str(256 * 1024)}) + assert s.wait_ready(), s.proxy.log + + # A: connects, never reads. + slow, _, _ = http_get(VPORT) + # B: connects and reads continuously. + fast, _, fbody = http_get(VPORT) + + got = bytearray(fbody) + stop = threading.Event() + + def drain(): + fast.settimeout(0.5) + while not stop.is_set(): + try: + c = fast.recv(65536) + except socket.timeout: + continue + except OSError: + break + if not c: + break + got.extend(c) + + t = threading.Thread(target=drain, daemon=True) + t.start() + try: + # push well past the ring so the idle viewer is lapped + deadline = time.time() + 30 + while time.time() < deadline: + s.feed.burst(300) + if 'lapped' in s.proxy.log or 'chronically behind' in s.proxy.log: + break + time.sleep(1.0) + finally: + stop.set() + t.join(timeout=5) + slow.close() + fast.close() + + assert ('lapped' in s.proxy.log + or 'chronically behind' in s.proxy.log), \ + 'the idle viewer was never dropped:\n%s' % s.proxy.log[-3000:] + + data = bytes(got) + assert len(data) > 50000, 'the healthy viewer got %d bytes' % len(data) + # the surviving viewer's stream must still be a contiguous, + # verbatim run -- no hole where the other viewer was dropped + sent = s.feed.snapshot() + assert data in sent, \ + "the healthy viewer's stream has a gap or reordering" + + def test_viewer_cap_is_enforced(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + socks = [] + try: + refused = None + for _ in range(40): + sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sk.settimeout(5) + try: + sk.connect(('127.0.0.1', VPORT)) + except OSError: + sk.close() + break + sk.sendall(b'GET /v1.ts HTTP/1.1\r\nHost: x\r\n\r\n') + socks.append(sk) + time.sleep(1.5) + for sk in socks: + sk.settimeout(1.0) + try: + head = sk.recv(256) + except (socket.timeout, OSError): + continue + if b'503' in head and b'too many' in head.lower(): + refused = True + break + assert refused, \ + 'no viewer was refused past the cap:\n%s' % s.proxy.log[-2000:] + finally: + for sk in socks: + sk.close() + + +@pytest.mark.integration +class TestViewerCpu: + def test_idle_viewer_does_not_busy_spin(self, session): + """A caught-up viewer on a quiet stream must cost no CPU. + + EPOLLOUT armed permanently makes epoll_wait return immediately + for any writable socket. Measured before this was fixed: one + idle viewer burned a full core, which on a single-core VPS is + the whole machine. + """ + s = session() + assert s.wait_ready(), s.proxy.log + vpid = s.proxy.video_pid() + assert vpid is not None, s.proxy.log + + def ticks(): + with open('/proc/%d/stat' % vpid) as f: + parts = f.read().split() + return int(parts[13]) + int(parts[14]) # utime + stime + + sock, head, _ = http_get(VPORT) + assert b'200 OK' in head, head + try: + time.sleep(2) # let it settle and catch up + t0 = ticks() + time.sleep(4) # publisher is quiet throughout + t1 = ticks() + finally: + sock.close() + + # 400 ticks would be a full core over 4s; anything above a small + # fraction of that means we are spinning rather than sleeping. + used = t1 - t0 + assert used < 40, \ + 'idle viewer burned %d ticks in 4s (400 = one core)' % used + + +def ws_connect(port, target, timeout=8.0): + """Minimal WebSocket client: handshake, then read binary frames.""" + import base64 + key = base64.b64encode(b'v' * 16).decode() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + s.connect(('127.0.0.1', port)) + s.sendall(( + 'GET %s HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Key: %s\r\n' + 'Sec-WebSocket-Version: 13\r\n\r\n' % (target, key)).encode()) + head = b'' + try: + while b'\r\n\r\n' not in head: + c = s.recv(4096) + if not c: + break + head += c + except socket.timeout: + pass + sep = head.find(b'\r\n\r\n') + rest = head[sep + 4:] if sep >= 0 else b'' + return s, head[:sep if sep >= 0 else len(head)], rest + + +def ws_read_payload(sock, seconds, initial=b''): + """Read server->client frames and return the concatenated payload.""" + buf = bytearray(initial) + out = bytearray() + deadline = time.time() + seconds + sock.settimeout(0.5) + while time.time() < deadline: + try: + c = sock.recv(65536) + if not c: + break + buf += c + except socket.timeout: + pass + # decode as many complete frames as we have + while len(buf) >= 2: + ln = buf[1] & 0x7F + pos = 2 + if ln == 126: + if len(buf) < 4: + break + ln = int.from_bytes(buf[2:4], 'big') + pos = 4 + elif ln == 127: + if len(buf) < 10: + break + ln = int.from_bytes(buf[2:10], 'big') + pos = 10 + if (buf[1] & 0x80) != 0: + raise AssertionError('server masked a frame') + if len(buf) < pos + ln: + break + opcode = buf[0] & 0x0F + if opcode == 0x2: # binary: stream data + out += buf[pos:pos + ln] + del buf[:pos + ln] + return bytes(out) + + +def mint_token(workdir, port2, slot): + import sys as _sys + if _REPO_ROOT not in _sys.path: + _sys.path.insert(0, _REPO_ROOT) + from webadmin import videotoken + db = keydb_lib.open_db(str(workdir / 'keys.tdb')) + db.transaction_start() + try: + ke = keydb_lib.KeyEntry(port2) + assert ke.fetch(db) + return videotoken.mint(ke.secret_key, port2, slot) + finally: + db.transaction_cancel() + db.close() + + +@pytest.mark.integration +class TestWebSocketViewer: + def test_ws_viewer_receives_the_stream(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + s.feed.start() + tok = mint_token(s.workdir, PORT_ENG, 0) + sock, head, rest = ws_connect(VPORT, '/v1?t=%s' % tok) + try: + assert b'101' in head, head + data = ws_read_payload(sock, 5, rest) + finally: + sock.close() + assert len(data) > 10000, 'ws viewer got %d bytes' % len(data) + assert data[0] == 0x47, 'ws payload does not start at a TS packet' + assert data in s.feed.snapshot(), \ + 'ws payload is not a verbatim span of what was published' + + def test_ws_requires_a_credential_when_a_password_is_set(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + sock, head, _ = ws_connect(VPORT, '/v1') + sock.close() + assert b'101' not in head, \ + 'websocket upgraded without a credential: %r' % head + assert b'401' in head, head + + def test_ws_accepts_a_valid_token(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + tok = mint_token(s.workdir, PORT_ENG, 0) + sock, head, _ = ws_connect(VPORT, '/v1?t=%s' % tok) + sock.close() + assert b'101' in head, head + + def test_ws_rejects_a_tampered_token(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + tok = mint_token(s.workdir, PORT_ENG, 0) + bad = tok[:-1] + ('0' if tok[-1] != '0' else '1') + sock, head, _ = ws_connect(VPORT, '/v1?t=%s' % bad) + sock.close() + assert b'101' not in head, 'tampered token was accepted' + + def test_ws_accepts_the_viewer_password_too(self, session): + s = session(viewer_pass='watchme') + assert s.wait_ready(), s.proxy.log + sock, head, _ = ws_connect(VPORT, '/v1?pw=watchme') + sock.close() + assert b'101' in head, head + + +def _make_cert(workdir): + """Self-signed cert the proxy picks up from its cwd for TLS.""" + subprocess.run([ + 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', 'privkey.pem', '-out', 'fullchain.pem', + '-days', '2', '-subj', '/CN=localhost', + ], cwd=str(workdir), check=True, capture_output=True) + + +@pytest.mark.integration +class TestSecureWebSocketViewer: + def test_wss_viewer_receives_the_stream(self, tmp_path): + """A browser on an HTTPS admin page can only open wss://, so the + TLS path has to work, not just ws://.""" + import ssl + wd = _workdir(tmp_path) + _make_cert(wd) + s = Session(wd) + try: + assert s.wait_ready(), s.proxy.log + s.feed.start() + tok = mint_token(wd, PORT_ENG, 0) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw.settimeout(10) + raw.connect(('127.0.0.1', VPORT)) + sock = ctx.wrap_socket(raw, server_hostname='localhost') + try: + import base64 + key = base64.b64encode(b'w' * 16).decode() + sock.sendall(( + 'GET /v1?t=%s HTTP/1.1\r\n' + 'Host: localhost\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Key: %s\r\n' + 'Sec-WebSocket-Version: 13\r\n\r\n' % (tok, key)).encode()) + head = b'' + deadline = time.time() + 8 + while b'\r\n\r\n' not in head and time.time() < deadline: + try: + c = sock.recv(4096) + except (socket.timeout, ssl.SSLWantReadError): + continue + if not c: + break + head += c + assert b'101' in head, head + sep = head.find(b'\r\n\r\n') + data = ws_read_payload(sock, 5, head[sep + 4:]) + except Exception: + raise AssertionError('wss failed; proxy log:\n%s' % s.proxy.log) + finally: + sock.close() + assert len(data) > 5000, \ + 'wss viewer got %d bytes; proxy log:\n%s' % (len(data), + s.proxy.log) + assert data[0] == 0x47 + assert data in s.feed.snapshot(), 'wss payload not verbatim' + finally: + s.stop() + + +@pytest.mark.integration +class TestFragmentedRequest: + def test_http_request_split_across_packets(self, session): + """A viewer request may arrive in pieces; the parser has to + wait for the rest rather than give up or re-classify.""" + s = session() + assert s.wait_ready(), s.proxy.log + s.feed.start() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(8) + sock.connect(('127.0.0.1', VPORT)) + try: + req = b'GET /v1.ts HTTP/1.1\r\nHost: localhost\r\n\r\n' + for i in range(0, len(req), 7): # dribble it out + sock.sendall(req[i:i + 7]) + time.sleep(0.05) + head = b'' + deadline = time.time() + 6 + while b'\r\n\r\n' not in head and time.time() < deadline: + try: + c = sock.recv(4096) + except socket.timeout: + continue + if not c: + break + head += c + assert b'200 OK' in head, \ + 'fragmented request not served: %r\n%s' % (head[:200], + s.proxy.log) + finally: + sock.close() + + +@pytest.mark.integration +class TestPublisherRestart: + """Killing and restarting the publisher. + + Reported from a real session: the browser showed a gap and then + never resumed. A new publisher is a new stream -- PSI, continuity + counters and PTS all restart -- so appending its bytes to what a + viewer has already been given makes time jump backwards, and a + Media Source player stalls permanently rather than recovering. The + stream has to be *ended* so the client reconnects and rejoins. + """ + + def test_viewer_is_ended_when_the_publisher_goes(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + sock, head, body = http_get(VPORT) + assert b'200' in head, head + assert read_for(sock, 1.5, body), 'viewer got no bytes at all' + + s.feed.stop() + # The slot releases after VIDEO_PUB_IDLE_S (10s); allow the tick. + assert s.proxy.wait_for(r'publisher idle, releasing', timeout=25), \ + s.proxy.log + + # A clean end of stream: recv returns b'' rather than hanging or + # silently continuing into the next publisher's bytes. + sock.settimeout(10) + deadline = time.time() + 10 + closed = False + while time.time() < deadline: + try: + if sock.recv(65536) == b'': + closed = True + break + except socket.timeout: + break + except OSError: + closed = True + break + sock.close() + assert closed, 'viewer was left attached to a stream that ended' + + def test_the_reason_is_logged(self, session): + s = session() + assert s.wait_ready(), s.proxy.log + sock, head, body = http_get(VPORT) + read_for(sock, 1.0, body) + s.feed.stop() + assert s.proxy.wait_for(r'publisher gone', timeout=25), s.proxy.log + sock.close() + + def test_a_new_viewer_can_join_the_restarted_stream(self, session): + """The point of the whole thing: after a restart, watching + works again.""" + s = session() + assert s.wait_ready(), s.proxy.log + first, head, body = http_get(VPORT) + read_for(first, 1.0, body) + + s.feed.stop() + assert s.proxy.wait_for(r'publisher idle, releasing', timeout=25), \ + s.proxy.log + first.close() + + # Restart the publisher exactly as a user re-running the tool + # would: a fresh socket, a fresh stream. + s.feed = Feed(VPORT) + assert s.wait_ready(), s.proxy.log + second, head2, body2 = http_get(VPORT) + assert b'200' in head2, head2 + got = read_for(second, 3.0, body2) + second.close() + assert got, 'no bytes from the restarted stream' + assert got[0] == 0x47, 'restarted stream did not begin on a TS packet' + + def test_restarted_stream_is_not_spliced_onto_the_old_one(self, session): + """The ring restarts with the stream, so a viewer joining after + a restart must not be served bytes from before it.""" + s = session() + assert s.wait_ready(), s.proxy.log + before = s.feed.snapshot() + assert before + + s.feed.stop() + assert s.proxy.wait_for(r'publisher idle, releasing', timeout=25), \ + s.proxy.log + + s.feed = Feed(VPORT) + assert s.wait_ready(), s.proxy.log + sock, head, body = http_get(VPORT) + got = read_for(sock, 3.0, body) + sock.close() + assert got + # Everything served must come from the new publisher's bytes. + new_bytes = s.feed.snapshot() + assert got in new_bytes, \ + 'served bytes are not a span of the restarted stream' diff --git a/tests/tsgen.py b/tests/tsgen.py new file mode 100644 index 0000000..46eb9ce --- /dev/null +++ b/tests/tsgen.py @@ -0,0 +1,176 @@ +"""Synthetic MPEG-TS generator for the video tests. + +Nothing in SupportProxy decodes video -- the scanner only reads PSI and +adaptation fields -- so a generator with filler payload is enough for +every scanner and fan-out assertion, and it gives byte-exact control +over where the join anchors are. That is worth more than a recorded +sample here: with a real file you cannot say "put a keyframe exactly +here" and then assert a viewer started exactly there. + +Real-codec checks (does the recording actually decode?) use a real +fixture instead; see the phase 3 tests. +""" +import struct + +PACKET_SIZE = 188 +SYNC = 0x47 + +PAT_PID = 0x0000 +DEFAULT_PMT_PID = 0x1000 +DEFAULT_VIDEO_PID = 0x0100 + +STREAM_H264 = 0x1B +STREAM_HEVC = 0x24 + +# A datagram of 7 packets is what every MPEG-TS/UDP sender produces. +PACKETS_PER_DATAGRAM = 7 +DATAGRAM_SIZE = PACKET_SIZE * PACKETS_PER_DATAGRAM + + +def crc32_mpeg(data): + """MPEG-2 section CRC: poly 0x04C11DB7, MSB-first, init 0xFFFFFFFF.""" + crc = 0xFFFFFFFF + for b in data: + crc ^= b << 24 + for _ in range(8): + crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if crc & 0x80000000 \ + else (crc << 1) & 0xFFFFFFFF + return crc + + +class TSGen: + def __init__(self, pmt_pid=DEFAULT_PMT_PID, video_pid=DEFAULT_VIDEO_PID, + stream_type=STREAM_H264): + self.pmt_pid = pmt_pid + self.video_pid = video_pid + self.stream_type = stream_type + self._cc = {} + + def _next_cc(self, pid): + c = self._cc.get(pid, 0) + self._cc[pid] = (c + 1) & 0x0F + return c + + def _packet(self, pid, payload, pusi=False, rai=False): + """One 188-byte packet. Payload is padded with an adaptation + field so it always lands at the end of the packet.""" + assert len(payload) <= PACKET_SIZE - 4 + hdr = bytearray(4) + hdr[0] = SYNC + hdr[1] = ((0x40 if pusi else 0) | ((pid >> 8) & 0x1F)) + hdr[2] = pid & 0xFF + cc = self._next_cc(pid) + + stuff = PACKET_SIZE - 4 - len(payload) + if rai or stuff > 0: + # adaptation field present, plus payload + hdr[3] = 0x30 | cc + af_len = stuff - 1 + if af_len < 0: + raise ValueError('payload too long for an adaptation field') + af = bytearray([af_len]) + if af_len > 0: + af.append(0x40 if rai else 0x00) # flags + af.extend(b'\xff' * (af_len - 1)) + return bytes(hdr) + bytes(af) + bytes(payload) + hdr[3] = 0x10 | cc + return bytes(hdr) + bytes(payload) + + def _section_packet(self, pid, section): + """Wrap a complete PSI section in a single packet. + + PSI packets are padded with trailing 0xFF after the section, not + with an adaptation field -- that is what real muxers emit, and a + fixture that used an adaptation field here would be testing a + packet layout nothing actually sends. + """ + payload = b'\x00' + section # pointer_field + payload += b'\xff' * (PACKET_SIZE - 4 - len(payload)) + hdr = bytearray(4) + hdr[0] = SYNC + hdr[1] = 0x40 | ((pid >> 8) & 0x1F) # PUSI + hdr[2] = pid & 0xFF + hdr[3] = 0x10 | self._next_cc(pid) # payload only, no AF + return bytes(hdr) + payload + + def pat(self, version=0): + body = bytearray() + body += b'\x00' # table_id + body += b'\x00\x00' # length, patched + body += b'\x00\x01' # ts id + body += bytes([0xC1 | (version << 1)]) + body += b'\x00\x00' # section numbers + body += b'\x00\x01' # program 1 + body += bytes([0xE0 | ((self.pmt_pid >> 8) & 0x1F), + self.pmt_pid & 0xFF]) + return self._section_packet(PAT_PID, self._finish(body)) + + def pmt(self, version=0, extra_streams=()): + body = bytearray() + body += b'\x02' + body += b'\x00\x00' + body += b'\x00\x01' + body += bytes([0xC1 | (version << 1)]) + body += b'\x00\x00' + body += bytes([0xE0 | ((self.video_pid >> 8) & 0x1F), + self.video_pid & 0xFF]) # PCR PID + body += b'\xF0\x00' # program_info_len + body += bytes([self.stream_type, + 0xE0 | ((self.video_pid >> 8) & 0x1F), + self.video_pid & 0xFF, + 0xF0, 0x00]) + for stype, pid in extra_streams: + body += bytes([stype, 0xE0 | ((pid >> 8) & 0x1F), pid & 0xFF, + 0xF0, 0x00]) + return self._section_packet(self.pmt_pid, self._finish(body)) + + @staticmethod + def _finish(body): + """Patch section_length and append the CRC.""" + section_length = len(body) - 3 + 4 + body[1] = 0xB0 | ((section_length >> 8) & 0x0F) + body[2] = section_length & 0xFF + return bytes(body) + struct.pack('>I', crc32_mpeg(bytes(body))) + + def video(self, key=False, size=160): + """One video packet. `key` sets both a keyframe payload and the + adaptation field's random_access_indicator.""" + pes = bytearray() + pes += b'\x00\x00\x01\xe0' # PES start, video stream id + pes += b'\x00\x00' # unbounded length + pes += b'\x80\x00\x00' # flags, no PTS + if self.stream_type == STREAM_HEVC: + # HEVC NAL header is 2 bytes, type is bits 6..1 + pes += b'\x00\x00\x01' + bytes([(35 if key else 1) << 1, 0x01]) + else: + pes += b'\x00\x00\x01' + bytes([0x09 if key else 0x41]) + pes += b'\x10' + pes += b'\xAA' * max(0, size - len(pes)) + return self._packet(self.video_pid, bytes(pes[:size]), + pusi=True, rai=key) + + def stream(self, packets, gop=10, psi_every=20): + """A run of `packets` video packets with PSI interleaved. + + Returns the whole stream as bytes. Every `gop`-th video packet + is a keyframe, and PAT+PMT are emitted every `psi_every` + packets, mirroring what real muxers do. + """ + out = bytearray() + for i in range(packets): + if i % psi_every == 0: + out += self.pat() + out += self.pmt() + out += self.video(key=(i % gop == 0)) + return bytes(out) + + def datagrams(self, data): + """Split a stream into 1316-byte datagrams, as udpsink would. + + Any trailing partial datagram is dropped rather than sent short: + a real sender emits whole 7-packet groups, and the ingest path + rejects anything that is not a multiple of 188 anyway. + """ + n = len(data) // DATAGRAM_SIZE + return [data[i * DATAGRAM_SIZE:(i + 1) * DATAGRAM_SIZE] + for i in range(n)] diff --git a/video.cpp b/video.cpp new file mode 100644 index 0000000..cb09a28 --- /dev/null +++ b/video.cpp @@ -0,0 +1,1583 @@ +/* + The per-entry video child. See video.h for why it is independent of + the MAVLink session. + + Phase 1 scope: process lifecycle, port binding, publisher admission + and connections.tdb rows. There is no media path yet -- accepted + bytes are counted and discarded -- so the process-model risk lands + and can be tested on its own. + */ +#include "video.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "conntdb.h" +#include "util.h" +#include "videoauth.h" +#include "videostream.h" +#include "videorec.h" +#include "videots.h" +#include "videortmp.h" +#include "videortsp.h" +#include "videoview.h" + +#define VIDEO_MAX_EPOLL_EVENTS 64 + +// A publisher that goes quiet for this long releases its slot, so a +// replacement can take over after a genuine disconnect. +#define VIDEO_PUB_IDLE_S 10 + +/* + Viewer table entries one source address may hold. Publishers are + classified from a viewer slot, so without a cap a flood from one host + fills the table and denies publishing outright. An operator watching + three slots with a reconnect in flight needs a handful. + */ +#define VIDEO_MAX_VIEWERS_PER_IP 8 + +/* + Concurrent RTMP handshakes per slot. More than one so a squatter + cannot lock the camera out, few enough that the parser work an + unauthenticated peer can start is bounded. + */ +#define VIDEO_MAX_PENDING_RTMP 4 + +// How often the child re-reads its own keys.tdb record and re-writes +// its connections.tdb rows. Matches the MAVLink child's cadence. +#define VIDEO_TICK_S 5 + +// Rate limit for "rejected" logging, so a flood of unauthorised packets +// can't turn into a flood of stdout writes (which would block the child +// once the pipe fills). +#define VIDEO_LOG_MIN_INTERVAL_S 1 + +bool video_entry_wants_child(uint32_t flags, const uint32_t *video_ports) +{ + if ((flags & KEY_FLAG_VIDEO) == 0) { + return false; + } + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + if (video_ports[i] != 0) { + return true; + } + } + return false; +} + +namespace { + +/* + One direction of the splice. + + The relay used to retry a short write by sleeping inside the event + loop. That loop is the only thread: it also drains ffmpeg's stdout, so + sleeping there deadlocks -- the publisher fills the socket to ffmpeg, + we stop servicing epoll, ffmpeg's stdout pipe fills, ffmpeg stops + reading its input, and the socket never drains. A paced publisher + (ffmpeg -re, as the tests use) never triggers it; a real camera bursts. + */ +struct SpliceQueue { + std::vector buf; + size_t sent = 0; + bool armed = false; // EPOLLOUT currently armed on the sink + + size_t pending(void) const { return buf.size() - sent; } + void clear(void) { buf.clear(); sent = 0; armed = false; } + void compact(void) + { + if (sent == buf.size()) { + buf.clear(); + sent = 0; + } else if (sent > 65536) { + buf.erase(buf.begin(), buf.begin() + long(sent)); + sent = 0; + } + } +}; + +// Stop reading a direction once this much is already queued for it, so +// the backlog is bounded and TCP applies the backpressure upstream +// instead of this process buffering without limit. +#define SPLICE_QUEUE_MAX (1u * 1024 * 1024) + +/* + One RTMP handshake that has not published yet. + + It owns nothing but its socket: no slot, no backend, no ring. Only + when publish arrives and passes admission is one of these promoted. + */ +struct PendingRtmp { + int fd = -1; + std::unique_ptr sess; + SpliceQueue out; // responses owed to this peer + time_t since = 0; + uint32_t ip_be = 0; + uint16_t port_be = 0; + + bool active(void) const { return fd >= 0; } +}; + +struct Slot { + uint32_t port = 0; + int udp_fd = -1; + int tcp_fd = -1; + + // current publisher, if any + bool has_pub = false; + uint32_t pub_ip_be = 0; + uint16_t pub_port_be = 0; + time_t pub_since = 0; + time_t pub_last = 0; + uint64_t pub_bytes = 0; + + // rate-limited rejection logging + time_t last_reject_log = 0; + uint32_t rejects = 0; + + // RTSP or RTMP publisher, while one holds the slot + RtspBackend rtsp; + int rtsp_client_fd = -1; + SpliceQueue to_backend; // bytes read from the client, owed to ffmpeg + SpliceQueue to_client; // and the other way + /* + The RTMP session that owns the slot, once one has published and + been admitted. Null until then. + */ + std::unique_ptr rtmp; + + /* + Handshakes in progress. These deliberately do NOT hold the slot. + + Classification costs one byte, so if a pending handshake owned the + slot an unauthenticated peer could take it, wait out its deadline + and reconnect, denying publishing indefinitely -- and letting a + newcomer evict the incumbent only turns that into last-arrival + wins, which is the same denial. Several negotiate side by side + instead, and the slot is awarded on publish, after admission. + */ + PendingRtmp pending[VIDEO_MAX_PENDING_RTMP]; + + // media + VideoRing ring; + TSScanner scanner; + VideoWriter rec; + VideoViewer viewers[VIDEO_MAX_VIEWERS]; + bool viewer_out_armed[VIDEO_MAX_VIEWERS] {}; + uint32_t viewers_seen = 0; + uint32_t viewers_dropped = 0; + bool recording = false; + uint64_t last_anchor = 0; + bool had_anchor = false; + uint64_t bad_datagrams = 0; + bool warned_204 = false; +}; + +class VideoChild { +public: + VideoChild(int port2, int ready_fd) : + port2_(port2), ready_fd_(ready_fd), auth_(port2) {} + + void run(void) __attribute__((noreturn)); + +private: + int port2_; + int ready_fd_; + VideoAuth auth_; + struct KeyEntry ke_ {}; + Slot slots_[KEY_MAX_VIDEO_PORTS]; + int epfd_ = -1; + time_t last_tick_ = 0; + + bool load_entry(void); + int bind_slots(void); + void signal_ready(int err); + void handle_udp(Slot &s, int idx); + void handle_tcp(Slot &s, int idx); + void tick(time_t now); + void write_conn_rows(time_t now); + void log_reject(Slot &s, int idx, uint32_t ip_be, video_admit_t r, + time_t now); + void ingest(Slot &s, int idx, const uint8_t *buf, size_t n); + void ingest_stream(Slot &s, int idx, const uint8_t *buf, size_t n); + void handle_rtsp(Slot &s, int idx, int fd, + struct sockaddr_in &from, time_t now, + splice_proto_t proto); + void close_rtsp(Slot &s, int idx, const char *why); + bool pump_rtsp(Slot &s, int idx, int fd, time_t now); + bool pump_rtmp(Slot &s, int idx, int fd, time_t now); + bool rtmp_start_backend(Slot &s, int idx); + bool rtmp_drain_owner(Slot &s, int idx, bool alive); + void close_pending(Slot &s, int idx, PendingRtmp &p, const char *why); + bool pump_pending(Slot &s, int idx, PendingRtmp &p, time_t now); + bool promote_pending(Slot &s, int idx, PendingRtmp &p, time_t now); + void latch_publisher(Slot &s, int idx, time_t now); + bool splice_flush(int to_fd, SpliceQueue &q); + void splice_arm(int to_fd, SpliceQueue &q); + void epoll_add_viewer(VideoViewer &v); + void epoll_sync_viewer(VideoViewer &v, bool &armed); + void drop_viewer(Slot &s, int idx, VideoViewer &v); + void end_stream(Slot &s, int idx, const char *why); + void pump_viewers(Slot &s, int idx, time_t now); +}; + +bool VideoChild::load_entry(void) +{ + auto *db = db_open(); + if (db == nullptr) { + return false; + } + struct KeyEntry k {}; + bool ok = db_load_key(db, port2_, k); + db_close(db); + if (ok) { + ke_ = k; + } + return ok; +} + +void VideoChild::signal_ready(int err) +{ + if (ready_fd_ < 0) { + return; + } + uint8_t b = uint8_t(err > 255 ? 255 : err); + ssize_t n = ::write(ready_fd_, &b, 1); + (void)n; + close(ready_fd_); + ready_fd_ = -1; +} + +int VideoChild::bind_slots(void) +{ + int first_err = 0; + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + const uint32_t port = ke_.video_ports[i]; + if (port == 0) { + continue; + } + slots_[i].port = port; + slots_[i].udp_fd = open_socket_in_udp(int(port)); + if (slots_[i].udp_fd == -1 && first_err == 0) { + first_err = errno ? errno : EADDRINUSE; + } + slots_[i].tcp_fd = open_socket_in_tcp(int(port)); + if (slots_[i].tcp_fd == -1 && first_err == 0) { + first_err = errno ? errno : EADDRINUSE; + } + if (slots_[i].udp_fd != -1 || slots_[i].tcp_fd != -1) { + printf("[%d] video slot %d listening on %u%s\n", + port2_, i, unsigned(port), + (slots_[i].udp_fd == -1 || slots_[i].tcp_fd == -1) + ? " (partially)" : ""); + } else { + printf("[%d] video slot %d failed to bind %u - %s\n", + port2_, i, unsigned(port), strerror(errno)); + } + } + return first_err; +} + +void VideoChild::log_reject(Slot &s, int idx, uint32_t ip_be, + video_admit_t r, time_t now) +{ + s.rejects++; + if (now - s.last_reject_log < VIDEO_LOG_MIN_INTERVAL_S) { + return; + } + s.last_reject_log = now; + struct in_addr a {}; + a.s_addr = ip_be; + printf("[%d] video slot %d rejected %s: %s (%u so far)\n", + port2_, idx, inet_ntoa(a), video_admit_str(r), unsigned(s.rejects)); +} + +/* + Accept one datagram's worth of publisher bytes. + + A datagram must be a whole number of 188-byte TS packets starting with + the sync byte -- that is what every MPEG-TS/UDP sender produces (7 + packets, 1316 bytes, is the norm). Anything else is dropped and + counted rather than fed to the scanner, so a misconfigured sender + shows up as a clear count instead of a stream that half works. + */ +void VideoChild::ingest(Slot &s, int idx, const uint8_t *buf, size_t n) +{ + if (n < TS_PACKET_SIZE || buf[0] != TS_SYNC_BYTE + || (n % TS_PACKET_SIZE) != 0 + || (n > TS_PACKET_SIZE && buf[TS_PACKET_SIZE] != TS_SYNC_BYTE)) { + // 204-byte packets are DVB's TS-with-Reed-Solomon. Feeding them + // to a 188-byte parser produces nonsense, so say so once. + if (!s.warned_204 && (n % 204) == 0 && n >= 204 + && buf[0] == TS_SYNC_BYTE) { + s.warned_204 = true; + printf("[%d] video slot %d: 204-byte (DVB) packets are not " + "supported; send 188-byte MPEG-TS\n", port2_, idx); + } + s.bad_datagrams++; + return; + } + s.pub_bytes += n; + // Scanner first: it needs the offset this data will occupy, and the + // ring write is what makes that offset meaningful. + const uint64_t before = s.ring.write_pos(); + s.scanner.feed(buf, n, before); + s.ring.write(buf, n); + + if (!s.recording || s.rec.stopped()) { + return; + } + const time_t now = time(nullptr); + + /* + Cut segments at a join boundary so every segment is playable from + its first byte. The anchor only moves when a new random access + point arrives, so "the anchor advanced into this datagram" is the + signal that we are standing on one. + + A stream whose muxer never signals one must still rotate, hence + the overshoot fallback -- otherwise the segment grows forever and + the quota pass can never evict it. + */ + if (s.rec.rotation_due(now)) { + uint64_t anchor = 0; + const bool have = s.scanner.join_offset(anchor); + const bool at_boundary = have && s.had_anchor && anchor > s.last_anchor + && anchor >= before; + if (at_boundary || s.rec.rotation_overdue(now)) { + s.rec.rotate(now, at_boundary); + } + } + uint64_t anchor_now = 0; + if (s.scanner.join_offset(anchor_now)) { + s.last_anchor = anchor_now; + s.had_anchor = true; + } + s.rec.write(buf, n, now); +} + +/* + Byte-stream ingest, for a source that is not datagram-framed (the + RTSP backend's stdout). The 188-alignment rule that guards the UDP + path does not apply -- packets straddle reads by nature -- and the + scanner and ring already carry partial packets across calls. + */ +void VideoChild::ingest_stream(Slot &s, int idx, const uint8_t *buf, size_t n) +{ + s.pub_bytes += n; + const uint64_t before = s.ring.write_pos(); + s.scanner.feed(buf, n, before); + s.ring.write(buf, n); + + if (!s.recording || s.rec.stopped()) { + return; + } + const time_t now = time(nullptr); + if (s.rec.rotation_due(now)) { + uint64_t anchor = 0; + const bool have = s.scanner.join_offset(anchor); + const bool at_boundary = have && s.had_anchor && anchor > s.last_anchor + && anchor >= before; + if (at_boundary || s.rec.rotation_overdue(now)) { + s.rec.rotate(now, at_boundary); + } + } + uint64_t anchor_now = 0; + if (s.scanner.join_offset(anchor_now)) { + s.last_anchor = anchor_now; + s.had_anchor = true; + } + s.rec.write(buf, n, now); + (void)idx; +} + +void VideoChild::handle_udp(Slot &s, int idx) +{ + uint8_t buf[2048]; + struct sockaddr_in from {}; + socklen_t fromlen = sizeof(from); + ssize_t n = recvfrom(s.udp_fd, buf, sizeof(buf), 0, + (struct sockaddr *)&from, &fromlen); + if (n <= 0) { + return; + } + const time_t now = time(nullptr); + + // A datagram from the established publisher needs no re-check: the + // admission decision was made when the tuple latched. + if (s.has_pub && s.pub_ip_be == uint32_t(from.sin_addr.s_addr) + && s.pub_port_be == from.sin_port) { + s.pub_last = now; + ingest(s, idx, buf, size_t(n)); + return; + } + + // Plain MPEG-TS over UDP carries no credential, so path A can't + // apply here; admit() falls through to the MAVLink-session check + // unless a publish password is set, in which case UDP can't satisfy + // it and the datagram is refused. + video_admit_t r = auth_.admit(ke_, uint32_t(from.sin_addr.s_addr), + nullptr, now); + if (r != VIDEO_ADMIT_OK) { + log_reject(s, idx, uint32_t(from.sin_addr.s_addr), r, now); + return; + } + if ((s.has_pub && now - s.pub_last <= VIDEO_PUB_IDLE_S) + || s.rtsp.running() || s.rtsp_client_fd >= 0) { + // One publisher at a time: a second sender behind the same NAT + // must not be able to interleave into the stream. This is not + // an authorisation failure -- the sender may be perfectly + // entitled to publish -- so it gets its own reason rather than + // borrowing one that would send an operator hunting an address + // mismatch that isn't there. + // + // The connection tests are part of it, not just has_pub: a + // spliced or RTMP publisher owns the slot for as long as its + // connection lives, and testing has_pub alone let a UDP sender + // latch on top of a live one and mix two streams into one ring. + // rtsp_client_fd covers an RTMP handshake still in progress, + // which has no backend and no latched publisher yet. + log_reject(s, idx, uint32_t(from.sin_addr.s_addr), + VIDEO_ADMIT_SLOT_BUSY, now); + return; + } + s.has_pub = true; + s.pub_ip_be = uint32_t(from.sin_addr.s_addr); + s.pub_port_be = from.sin_port; + s.pub_since = now; + s.pub_last = now; + s.pub_bytes = 0; + s.ring.init(video_ring_bytes()); + s.scanner = TSScanner(); + s.had_anchor = false; + s.recording = (video_slot_opts(ke_.video_flags, unsigned(idx)) + & VIDEO_SLOT_RECORD) != 0; + if (s.recording) { + s.rec.configure(uint32_t(port2_), idx, + (ke_.flags & KEY_FLAG_USE_TZ) != 0, + ke_.tz_offset_hours, "logs", ke_.video_quota_mb); + } + printf("[%d] video slot %d publisher %s\n", + port2_, idx, addr_to_str(from)); + ingest(s, idx, buf, size_t(n)); + last_tick_ = 0; // snapshot connections.tdb promptly +} + +void VideoChild::handle_tcp(Slot &s, int idx) +{ + struct sockaddr_in from {}; + socklen_t fromlen = sizeof(from); + int fd = accept(s.tcp_fd, (struct sockaddr *)&from, &fromlen); + if (fd == -1) { + return; + } + const time_t now = time(nullptr); + + /* + A TCP connection here is a viewer. Viewers are not publishers and + are not subject to the publisher admission check: they present a + viewer password (or the slot is open), and in either case they can + only ever reach a stream that an authorised publisher is feeding. + */ + /* + A publisher arrives through this table too -- RTSP and RTMP are + only recognised once bytes arrive, so the connection has to be + held somewhere first. That means a flood of connections can fill + the table and stop a publisher from even being classified. + + Cap how many one address may hold. It cannot be a reserve for + "probable publishers": a publisher is indistinguishable at accept + time, since the credential it might carry arrives later. A per + address cap needs none of that, and one host can no longer take + the table on its own. + */ + int free_slot = -1; + int from_this_ip = 0; + for (int v = 0; v < VIDEO_MAX_VIEWERS; v++) { + if (s.viewers[v].active() + && s.viewers[v].peer_ip_be() == uint32_t(from.sin_addr.s_addr)) { + from_this_ip++; + } + } + if (from_this_ip < VIDEO_MAX_VIEWERS_PER_IP) { + for (int v = 0; v < VIDEO_MAX_VIEWERS; v++) { + if (!s.viewers[v].active()) { + free_slot = v; + break; + } + } + } + if (free_slot < 0) { + // Say why rather than dropping silently; a viewer that just + // disconnects with no explanation is impossible to diagnose. + const std::string body = http_simple_response( + 503, "too many viewers", "text/plain", + "This stream already has the maximum number of viewers.\n"); + (void)::send(fd, body.data(), body.size(), MSG_NOSIGNAL); + close(fd); + printf("[%d] video slot %d viewer from %s refused: slot full\n", + port2_, idx, addr_to_str(from)); + return; + } + s.viewers[free_slot].start(fd, port2_, uint32_t(from.sin_addr.s_addr), + from.sin_port, now); + s.viewer_out_armed[free_slot] = false; + s.viewers_seen++; + epoll_add_viewer(s.viewers[free_slot]); + printf("[%d] video slot %d viewer from %s connected\n", + port2_, idx, addr_to_str(from)); +} + +/* + Take a slot for a publisher whose media arrives on the backend's + stdout rather than as datagrams. Everything downstream -- the ring, + the scanner, the recorder, the ConnEntry row -- is the same. + */ +void VideoChild::latch_publisher(Slot &s, int idx, time_t now) +{ + s.has_pub = true; + s.pub_since = now; + s.pub_last = now; + s.pub_bytes = 0; + s.ring.init(video_ring_bytes()); + s.scanner = TSScanner(); + s.had_anchor = false; + s.recording = (video_slot_opts(ke_.video_flags, unsigned(idx)) + & VIDEO_SLOT_RECORD) != 0; + if (s.recording) { + s.rec.configure(uint32_t(port2_), idx, + (ke_.flags & KEY_FLAG_USE_TZ) != 0, + ke_.tz_offset_hours, "logs", ke_.video_quota_mb); + } + last_tick_ = 0; +} + +void VideoChild::handle_rtsp(Slot &s, int idx, int fd, + struct sockaddr_in &from, time_t now, + splice_proto_t proto) +{ + /* + RTMP is not spliced: we speak it ourselves, and the credential + only arrives with publish. So the connection is parked in the + pending pool, which owns no part of the slot, and admission + happens later. + */ + if (proto == SPLICE_RTMP) { + int free_i = -1; + int oldest_i = -1; + for (int i = 0; i < VIDEO_MAX_PENDING_RTMP; i++) { + if (!s.pending[i].active()) { + free_i = i; + break; + } + if (oldest_i < 0 || s.pending[i].since < s.pending[oldest_i].since) { + oldest_i = i; + } + } + if (free_i < 0) { + // Pool full: drop the oldest, which has had the longest to + // publish and has not. Bounded either way, and a squatter + // cannot pin every entry against a camera that retries. + close_pending(s, idx, s.pending[oldest_i], "pending pool full"); + free_i = oldest_i; + } + PendingRtmp &p = s.pending[free_i]; + fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); + p.fd = fd; + p.sess.reset(new RtmpSession()); + p.out.clear(); + p.since = now; + p.ip_be = uint32_t(from.sin_addr.s_addr); + p.port_be = from.sin_port; + printf("[%d] video slot %d RTMP publisher %s connecting\n", + port2_, idx, addr_to_str(from)); + struct epoll_event ev {}; + ev.events = EPOLLIN | EPOLLRDHUP; + ev.data.fd = fd; + epoll_ctl(epfd_, EPOLL_CTL_ADD, fd, &ev); + return; + } + + /* + Read a publish password out of the request-line URI, if there is + one, e.g. rtsp://host:port/cam?pw=secret. Peek only -- the splice + must still see the exchange from byte zero. + + This is the one place RTSP can carry a credential without us + answering anything: proper Basic auth would mean replying 401 and + renumbering CSeq, which is exactly what makes the opaque splice + work. ffmpeg passes the query through untouched (verified) and + its own listener ignores it. + + A password in a URL is normally a bad idea, but this URL is + configured on an aircraft rather than typed into a browser, so it + does not end up in history or a Referer header. + */ + uint8_t line[512] {}; + std::string pw; + const ssize_t ln = ::recv(fd, line, sizeof(line) - 1, MSG_PEEK); + if (ln > 0) { + const std::string req(reinterpret_cast(line), size_t(ln)); + const size_t eol = req.find('\r'); + const std::string first = req.substr(0, eol == std::string::npos + ? req.size() : eol); + const size_t q = first.find("?pw="); + if (q != std::string::npos) { + size_t end = first.find_first_of(" &", q + 4); + if (end == std::string::npos) { + end = first.size(); + } + pw = http_url_decode(first.substr(q + 4, end - (q + 4))); + } + } + + // Publishers are authorised; viewers are not, and on this port an + // RTSP connection is a publisher (we do not parse enough to tell + // them apart -- see videortsp.h). + const video_admit_t r = auth_.admit(ke_, uint32_t(from.sin_addr.s_addr), + pw.c_str(), now); + if (r != VIDEO_ADMIT_OK) { + log_reject(s, idx, uint32_t(from.sin_addr.s_addr), r, now); + close(fd); + return; + } + if (s.rtsp.running() || s.rtsp_client_fd >= 0 + || (s.has_pub && now - s.pub_last <= VIDEO_PUB_IDLE_S)) { + log_reject(s, idx, uint32_t(from.sin_addr.s_addr), + VIDEO_ADMIT_SLOT_BUSY, now); + close(fd); + return; + } + + const bool want_audio = + (video_entry_opts(ke_.video_flags) & VIDEO_OPT_AUDIO) != 0; + if (!s.rtsp.start(port2_, idx, want_audio, proto)) { + close(fd); + return; + } + fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK); + s.rtsp_client_fd = fd; + s.pub_ip_be = uint32_t(from.sin_addr.s_addr); + s.pub_port_be = from.sin_port; + latch_publisher(s, idx, now); + printf("[%d] video slot %d %s publisher %s\n", + port2_, idx, splice_proto_name(proto), addr_to_str(from)); + + for (int watch : { s.rtsp_client_fd, s.rtsp.backend_fd(), + s.rtsp.media_fd() }) { + if (watch < 0) { + continue; + } + struct epoll_event ev {}; + ev.events = EPOLLIN | EPOLLRDHUP; + ev.data.fd = watch; + epoll_ctl(epfd_, EPOLL_CTL_ADD, watch, &ev); + } + last_tick_ = 0; +} + +void VideoChild::close_rtsp(Slot &s, int idx, const char *why) +{ + if (!s.rtsp.running() && s.rtsp_client_fd < 0 && !s.rtmp) { + return; + } + // The backend's proto is only meaningful once it started, and an + // RTMP session can end before that -- during the handshake, or on a + // refused publish. + printf("[%d] video slot %d %s publisher gone (%s)\n", + port2_, idx, + s.rtmp ? "RTMP" : splice_proto_name(s.rtsp.proto()), why); + for (int watch : { s.rtsp_client_fd, s.rtsp.backend_fd(), + s.rtsp.media_fd() }) { + if (watch >= 0) { + epoll_ctl(epfd_, EPOLL_CTL_DEL, watch, nullptr); + } + } + if (s.rtsp_client_fd >= 0) { + close(s.rtsp_client_fd); + s.rtsp_client_fd = -1; + } + s.rtmp.reset(); + s.to_backend.clear(); + s.to_client.clear(); + s.rtsp.stop(); + s.rec.close_segment(); + s.recording = false; + s.has_pub = false; + // Same reasoning as the UDP idle-release path: the next publisher + // is a different stream, so viewers have to be ended rather than + // spliced onto it. Missing it here left RTSP -- the transport a + // publish password forces you onto -- with the original bug. + end_stream(s, idx, "publisher gone"); +} + +/* + Move bytes between the publisher and the backend, and pull muxed + MPEG-TS off the backend's stdout into the normal ingest path. + Returns false when the session is over. + */ +/* + Push whatever is queued for one direction. Never waits: a short write + leaves the remainder queued and EPOLLOUT armed. + */ +bool VideoChild::splice_flush(int to_fd, SpliceQueue &q) +{ + while (q.pending() > 0) { + const ssize_t w = ::send(to_fd, q.buf.data() + q.sent, q.pending(), + MSG_NOSIGNAL); + if (w > 0) { + q.sent += size_t(w); + continue; + } + if (w < 0 && errno == EINTR) { + continue; + } + if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { + break; // still owed; EPOLLOUT will bring us back + } + // A zero return is not progress either -- looping on it spun + // forever in the previous version, because the offset never + // advanced. + return false; + } + q.compact(); + return true; +} + +// EPOLLOUT only while something is queued. Armed unconditionally it +// would make epoll_wait return immediately for ever on an idle splice, +// which is the same idle-viewer CPU burn measured earlier. +void VideoChild::splice_arm(int to_fd, SpliceQueue &q) +{ + const bool want = q.pending() > 0; + if (want == q.armed || to_fd < 0) { + return; + } + struct epoll_event ev {}; + ev.events = uint32_t(EPOLLIN | EPOLLRDHUP) + | (want ? uint32_t(EPOLLOUT) : 0u); + ev.data.fd = to_fd; + if (epoll_ctl(epfd_, EPOLL_CTL_MOD, to_fd, &ev) == 0) { + q.armed = want; + } +} + +/* + Close one pending handshake and forget it. It owns no slot state, so + there is nothing else to unwind. + */ +void VideoChild::close_pending(Slot &s, int idx, PendingRtmp &p, + const char *why) +{ + (void)s; + if (!p.active()) { + return; + } + struct in_addr a {}; + a.s_addr = p.ip_be; + printf("[%d] video slot %d RTMP handshake from %s ended (%s)\n", + port2_, idx, inet_ntoa(a), why); + epoll_ctl(epfd_, EPOLL_CTL_DEL, p.fd, nullptr); + close(p.fd); + p.fd = -1; + p.sess.reset(); + p.out.clear(); + p.since = 0; +} + +/* + A pending handshake has published. Authorise it and, if the slot is + free, hand it over. + + Returns false if this connection is finished either way. + */ +bool VideoChild::promote_pending(Slot &s, int idx, PendingRtmp &p, + time_t now) +{ + RtmpSession &r = *p.sess; + + const video_admit_t a = auth_.admit(ke_, p.ip_be, r.password().c_str(), + now); + if (a != VIDEO_ADMIT_OK) { + log_reject(s, idx, p.ip_be, a, now); + r.reject_publish("NetStream.Publish.Denied", video_admit_str(a)); + return false; + } + + /* + An RTMP path on the slot is an optional restriction: we read the + app and stream off the wire rather than telling ffmpeg what to + expect. Left blank the slot takes whatever is published. + */ + char want[sizeof(ke_.video_rtmp_path[0]) + 1] {}; + memcpy(want, ke_.video_rtmp_path[idx], sizeof(ke_.video_rtmp_path[idx])); + want[sizeof(want) - 1] = '\0'; + if (want[0] != '\0' && r.path() != want) { + printf("[%d] video slot %d RTMP publisher refused: published %s, " + "slot expects %s\n", port2_, idx, r.path().c_str(), want); + r.reject_publish("NetStream.Publish.Denied", + "stream path does not match this slot"); + return false; + } + + // Authorised -- but the slot may have been taken while this one was + // still negotiating. + if (s.rtsp.running() || s.rtsp_client_fd >= 0 + || (s.has_pub && now - s.pub_last <= VIDEO_PUB_IDLE_S)) { + log_reject(s, idx, p.ip_be, VIDEO_ADMIT_SLOT_BUSY, now); + r.reject_publish("NetStream.Publish.Denied", + "another publisher holds this slot"); + return false; + } + + // Hand the socket and the session to the slot. + s.rtsp_client_fd = p.fd; + s.rtmp = std::move(p.sess); + s.to_client = std::move(p.out); + s.to_backend.clear(); + p.fd = -1; + p.out.clear(); + p.since = 0; + + s.pub_ip_be = p.ip_be; + s.pub_port_be = p.port_be; + s.rtmp->accept_publish(); + latch_publisher(s, idx, now); + printf("[%d] video slot %d RTMP publishing %s\n", + port2_, idx, s.rtmp->path().c_str()); + /* + Parsing stopped at publish so this authorisation could happen in + order. Whatever the publisher pipelined behind it -- for a client + that does not wait for onStatus, that includes the sequence header + and its parameter sets -- is still buffered, so pick it up now + rather than waiting for a read that may never come. + */ + if (!rtmp_drain_owner(s, idx, s.rtmp->resume())) { + close_rtsp(s, idx, "RTMP session ended during promotion"); + return true; + } + /* + Push the responses out. accept_publish() queued the + NetStream.Publish.Start the client is waiting on, and nothing else + runs until the next epoll event -- which a publisher that waits + for onStatus before sending anything will never cause. Leaving it + queued is exactly the stall this whole path exists to fix. + */ + if (!splice_flush(s.rtsp_client_fd, s.to_client)) { + close_rtsp(s, idx, "connection closed"); + return true; + } + splice_arm(s.rtsp_client_fd, s.to_client); + return true; +} + +/* + Drive one pending handshake. Returns false when it is finished. + */ +bool VideoChild::pump_pending(Slot &s, int idx, PendingRtmp &p, time_t now) +{ + if (!splice_flush(p.fd, p.out)) { + return false; + } + uint8_t buf[8192]; + const ssize_t n = ::recv(p.fd, buf, sizeof(buf), 0); + if (n == 0) { + return false; + } + if (n < 0) { + if (!(errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) { + return false; + } + splice_arm(p.fd, p.out); + return true; + } + + RtmpSession &r = *p.sess; + bool alive = r.feed(buf, size_t(n), now); + bool promoted = false; + if (r.publish_pending()) { + promoted = promote_pending(s, idx, p, now); + if (promoted) { + return false; // the slot owns it now; stop pumping as pending + } + alive = false; // refused: flush the status, then close + } + if (!r.to_peer().empty()) { + p.out.buf.insert(p.out.buf.end(), r.to_peer().begin(), + r.to_peer().end()); + r.to_peer().clear(); + } + // Media before publish is not ours to keep, and the session drops it. + r.to_flv().clear(); + if (!splice_flush(p.fd, p.out)) { + return false; + } + if (!alive) { + if (r.error()[0] != '\0') { + printf("[%d] video slot %d RTMP: %s\n", port2_, idx, r.error()); + } + return false; + } + splice_arm(p.fd, p.out); + return true; +} + +/* + Start the backend now that the publisher's codec is known. Returns + false if it could not be started. + */ +bool VideoChild::rtmp_start_backend(Slot &s, int idx) +{ + /* + h264_metadata rewrites the NAL units, which drops the zero-length + one ffmpeg's own AVCC to Annex-B conversion puts ahead of every + access unit for this camera. Chrome's MP4 parser refuses a sample + containing it ("Failed to prepare video sample for decode"); + Firefox plays it. It is H.264-only, hence the codec test. + */ + const char *vbsf = s.rtmp->video_codec() == RTMP_VCODEC_H264 + ? "h264_metadata" : nullptr; + const bool want_audio = + (video_entry_opts(ke_.video_flags) & VIDEO_OPT_AUDIO) != 0; + if (!s.rtsp.start(port2_, idx, want_audio, SPLICE_RTMP, vbsf)) { + return false; + } + for (int watch : { s.rtsp.backend_fd(), s.rtsp.media_fd() }) { + if (watch < 0) { + continue; + } + struct epoll_event ev {}; + ev.events = EPOLLIN | EPOLLRDHUP; + ev.data.fd = watch; + epoll_ctl(epfd_, EPOLL_CTL_ADD, watch, &ev); + } + return true; +} + +/* + Drive a native RTMP publisher: client bytes in, responses out, FLV to + the backend. Returns false when the session is over. + */ +/* + Move what the owning session produced into the queues, and start the + backend once the stream has named its codec. + + `alive` is what feed()/resume() returned. Shared with promotion, + which has to run exactly this after accepting a publish: the client + may have pipelined its sequence header into the same segment, and + parsing stops at publish so authorisation can happen in order. + */ +bool VideoChild::rtmp_drain_owner(Slot &s, int idx, bool alive) +{ + RtmpSession &r = *s.rtmp; + // A second publish on the owning connection is a protocol error the + // session rejects; there is nothing to authorise here, because + // promotion did that before this session reached the slot. + const bool refused = r.publish_pending(); + + if (!r.to_peer().empty()) { + s.to_client.buf.insert(s.to_client.buf.end(), + r.to_peer().begin(), r.to_peer().end()); + r.to_peer().clear(); + } + /* + Queue unconditionally. The backend may not exist yet -- it waits + for the codec -- and the FLV header and sequence header arrive + before it does; dropping them left the backend with a stream it + could not open. Reads are gated on SPLICE_QUEUE_MAX, so this stays + bounded. + */ + if (!r.to_flv().empty()) { + s.to_backend.buf.insert(s.to_backend.buf.end(), + r.to_flv().begin(), r.to_flv().end()); + r.to_flv().clear(); + } + if (r.publishing() && !s.rtsp.running() + && r.video_codec() != RTMP_VCODEC_NONE + && !rtmp_start_backend(s, idx)) { + return false; + } + if (refused || !alive) { + splice_flush(s.rtsp_client_fd, s.to_client); + if (r.error()[0] != '\0') { + printf("[%d] video slot %d RTMP: %s\n", port2_, idx, r.error()); + } + return false; + } + return true; +} + +bool VideoChild::pump_rtmp(Slot &s, int idx, int fd, time_t now) +{ + uint8_t buf[16384]; + if (fd == s.rtsp.media_fd()) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n == 0) { + return false; + } + if (n < 0) { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; + } + s.pub_last = now; + ingest_stream(s, idx, buf, size_t(n)); + return true; + } + + // The backend's stdin: write-only, so an event here is either room + // to write or the backend having gone away. + if (fd == s.rtsp.backend_fd()) { + if (!splice_flush(fd, s.to_backend)) { + return false; + } + splice_arm(fd, s.to_backend); + return true; + } + + if (fd != s.rtsp_client_fd || !s.rtmp) { + return true; + } + + if (!splice_flush(fd, s.to_client)) { + return false; + } + if (s.rtsp.running() && !splice_flush(s.rtsp.backend_fd(), s.to_backend)) { + return false; + } + + /* + Read only while both queues have room. Gating on the backend alone + let a peer that stops reading its responses keep sending commands, + turning its own bounded input into an unbounded to_client. + */ + if (s.to_backend.pending() < SPLICE_QUEUE_MAX + && s.to_client.pending() < SPLICE_QUEUE_MAX) { + const ssize_t n = ::recv(fd, buf, sizeof(buf), 0); + if (n == 0) { + return false; + } + if (n < 0 && !(errno == EAGAIN || errno == EWOULDBLOCK + || errno == EINTR)) { + return false; + } + if (n > 0) { + s.pub_last = now; + if (!rtmp_drain_owner(s, idx, s.rtmp->feed(buf, size_t(n), now))) { + return false; + } + } + } + + if (!splice_flush(fd, s.to_client)) { + return false; + } + if (s.rtsp.running()) { + if (!splice_flush(s.rtsp.backend_fd(), s.to_backend)) { + return false; + } + splice_arm(s.rtsp.backend_fd(), s.to_backend); + } + splice_arm(fd, s.to_client); + return true; +} + +bool VideoChild::pump_rtsp(Slot &s, int idx, int fd, time_t now) +{ + if (s.rtmp) { + return pump_rtmp(s, idx, fd, now); + } + uint8_t buf[16384]; + if (fd == s.rtsp_client_fd || fd == s.rtsp.backend_fd()) { + const int from_fd = fd; + const int to_fd = (fd == s.rtsp_client_fd) ? s.rtsp.backend_fd() + : s.rtsp_client_fd; + if (to_fd < 0) { + return false; + } + SpliceQueue &out = (fd == s.rtsp_client_fd) ? s.to_backend + : s.to_client; + SpliceQueue &in = (fd == s.rtsp_client_fd) ? s.to_client + : s.to_backend; + + // Drain anything owed in both directions first: this fd may have + // woken us for EPOLLOUT rather than EPOLLIN. + if (!splice_flush(to_fd, out)) { + return false; + } + if (!splice_flush(from_fd, in)) { + return false; + } + + // Only read while the sink has room. Leaving bytes in the socket + // is what pushes back on the publisher, instead of buffering + // without limit here. + if (out.pending() < SPLICE_QUEUE_MAX) { + const ssize_t n = ::recv(from_fd, buf, sizeof(buf), 0); + if (n == 0) { + return false; + } + if (n < 0 && !(errno == EAGAIN || errno == EWOULDBLOCK + || errno == EINTR)) { + return false; + } + if (n > 0) { + out.buf.insert(out.buf.end(), buf, buf + n); + if (!splice_flush(to_fd, out)) { + return false; + } + s.pub_last = now; + } + } + + splice_arm(to_fd, out); + splice_arm(from_fd, in); + return true; + } + if (fd == s.rtsp.media_fd()) { + const ssize_t n = ::read(fd, buf, sizeof(buf)); + if (n == 0) { + return false; + } + if (n < 0) { + return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR; + } + s.pub_last = now; + // The backend writes a byte stream, not datagrams, so packets + // straddle reads -- which the scanner and ring already handle. + ingest_stream(s, idx, buf, size_t(n)); + return true; + } + return true; +} + +void VideoChild::epoll_add_viewer(VideoViewer &v) +{ + struct epoll_event ev {}; + ev.events = EPOLLIN | EPOLLRDHUP; + ev.data.fd = v.fd(); + epoll_ctl(epfd_, EPOLL_CTL_ADD, v.fd(), &ev); +} + +// Arm or disarm EPOLLOUT to match whether this viewer is blocked. +// Leaving it armed permanently makes epoll_wait return immediately for +// any writable socket, which burns a core per idle viewer. +void VideoChild::epoll_sync_viewer(VideoViewer &v, bool &armed) +{ + const bool want = v.wants_write(); + if (want == armed) { + return; + } + struct epoll_event ev {}; + ev.events = uint32_t(EPOLLIN | EPOLLRDHUP) + | (want ? uint32_t(EPOLLOUT) : 0u); + ev.data.fd = v.fd(); + if (epoll_ctl(epfd_, EPOLL_CTL_MOD, v.fd(), &ev) == 0) { + armed = want; + } +} + +void VideoChild::drop_viewer(Slot &s, int idx, VideoViewer &v) +{ + if (!v.active()) { + return; + } + printf("[%d] video slot %d viewer disconnected after %llu KiB (%s)\n", + port2_, idx, (unsigned long long)(v.bytes_sent() / 1024), + v.drop_reason()[0] ? v.drop_reason() : "closed"); + epoll_ctl(epfd_, EPOLL_CTL_DEL, v.fd(), nullptr); + v.close(); + s.viewers_dropped++; + last_tick_ = 0; +} + +/* + End the current stream on a slot. + + A publisher going away ends the stream its viewers are watching. The + next publisher is a *different* stream: PSI, continuity counters and + PTS/PCR all restart, so its bytes cannot simply be appended to what + the viewers have already been given. A player fed that sees time jump + backwards and stalls for good -- which looks exactly like "the video + never came back" rather than like a disconnect. + + So viewers are closed here and get a clean end of stream. A client + that wants to keep watching reconnects and joins the new stream at its + own first keyframe. The ring and scanner are reset with them, so no + stale anchor from the old stream can be handed to whoever joins next. + */ +void VideoChild::end_stream(Slot &s, int idx, const char *why) +{ + for (int v = 0; v < VIDEO_MAX_VIEWERS; v++) { + VideoViewer &vw = s.viewers[v]; + if (vw.active()) { + vw.set_drop_reason(why); + drop_viewer(s, idx, vw); + } + } + s.ring.reset(); + s.scanner.reset(); + s.last_anchor = 0; + s.had_anchor = false; + s.bad_datagrams = 0; + s.warned_204 = false; + s.pub_bytes = 0; +} + +// Push ring bytes to every viewer on this slot. Called after ingest and +// on every loop iteration, so a viewer whose socket drained between +// epoll wakeups still makes progress. +void VideoChild::pump_viewers(Slot &s, int idx, time_t now) +{ + for (int v = 0; v < VIDEO_MAX_VIEWERS; v++) { + VideoViewer &vw = s.viewers[v]; + if (!vw.active()) { + continue; + } + if (vw.kind() == VVK_RTSP || vw.kind() == VVK_RTMP) { + // A publisher, not a viewer. Take the socket -- untouched, + // since the detect phase only ever peeked -- and splice it. + struct sockaddr_in from {}; + from.sin_family = AF_INET; + from.sin_addr.s_addr = vw.peer_ip_be(); + from.sin_port = vw.peer_port_be(); + const splice_proto_t proto = vw.kind() == VVK_RTMP + ? SPLICE_RTMP : SPLICE_RTSP; + const int fd = vw.release_fd(); + epoll_ctl(epfd_, EPOLL_CTL_DEL, fd, nullptr); + s.viewer_out_armed[v] = false; + handle_rtsp(s, idx, fd, from, now, proto); + continue; + } + if (vw.state() == VV_DETECT && vw.kind() == VVK_WS) { + if (!vw.begin_ws_pump(ke_, idx, s.ring, s.scanner, now)) { + drop_viewer(s, idx, vw); + } + continue; + } + if (vw.state() == VV_DETECT) { + if (now - vw.connected_at() >= VIDEO_DETECT_SILENCE_S) { + if (!vw.detect_timeout(ke_, idx, s.ring, s.scanner, now)) { + drop_viewer(s, idx, vw); + continue; + } + } else { + continue; + } + } + if (!vw.on_writable(s.ring, now)) { + drop_viewer(s, idx, vw); + continue; + } + epoll_sync_viewer(vw, s.viewer_out_armed[v]); + } +} + +void VideoChild::write_conn_rows(time_t now) +{ + auto *db = conn_db_open_transaction(); + if (db == nullptr) { + return; + } + // Only our own index range: the MAVLink child snapshots 0..999 the + // same way, and whole-port2 deletes here would erase its rows. + conn_delete_index_range(db, port2_, VIDEO_CONN_INDEX_BASE, INT32_MAX); + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + Slot &s = slots_[i]; + if (!s.has_pub) { + continue; + } + struct ConnEntry e {}; + e.magic = CONN_MAGIC; + e.connected_at = uint64_t(s.pub_since); + e.last_update = uint64_t(now); + e.port2 = port2_; + e.conn_index = VIDEO_PUB_INDEX(i); + e.pid = uint32_t(getpid()); + e.rx_msgs = uint32_t(s.pub_bytes / 1024); // KiB for video rows + e.peer_ip_be = s.pub_ip_be; + e.peer_port_be = s.pub_port_be; + /* + Report what the publisher actually is. Every video row used to + say UDP/MPEG-TS, which is only true of the datagram path -- an + operator looking at a stuck RTSP or RTMP publisher was told it + was something it was not. + */ + const bool tcp_pub = s.rtmp || s.rtsp.running() || + s.rtsp_client_fd >= 0; + e.transport = tcp_pub ? CONN_TRANSPORT_TCP : CONN_TRANSPORT_UDP; + e.is_user = 0; + e.role = CONN_ROLE_VIDEO_PUB; + e.stream_idx = uint8_t(i); + e.app_proto = s.rtmp ? CONN_APP_RTMP + : (tcp_pub ? CONN_APP_RTSP : CONN_APP_MPEGTS); + conn_write(db, e); + } + conn_db_close_commit(db); +} + +void VideoChild::tick(time_t now) +{ + // Policy that doesn't need a rebind (credentials, grace, quota) is + // picked up here. Anything that changes binding -- the enable bit, + // the ports, the slot options -- makes the parent re-fork us + // instead, so we never have to rebind under our own feet. + if (load_entry()) { + auth_.invalidate(); + } + // Sample the MAVLink session while it is alive, so the grace window + // still has a last-known-good to age out once the session child + // exits and its connections.tdb row disappears. + auth_.observe(now); + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + Slot &s = slots_[i]; + if (s.rtsp.running() && s.rtsp.reap()) { + close_rtsp(s, i, "backend exited"); + } + /* + Time out handshakes that never publish. The session bounds + itself once bytes arrive; this covers the peer that connects + and then says nothing at all. + */ + for (int k = 0; k < VIDEO_MAX_PENDING_RTMP; k++) { + PendingRtmp &p = s.pending[k]; + if (p.active() && p.since != 0 + && now - p.since > RTMP_PREPUBLISH_MAX_S) { + close_pending(s, i, p, "handshake timed out"); + } + } + /* + Accepted, but never said what it is sending. There is no + backend and no media, yet its control messages keep refreshing + the idle timer, so nothing else would ever reclaim the slot. + */ + if (s.rtmp && s.rtmp->publishing() && !s.rtsp.running() + && s.rtmp->publishing_since() != 0 + && now - s.rtmp->publishing_since() > RTMP_CODEC_DEADLINE_S) { + close_rtsp(s, i, "no video codec after publish"); + continue; + } + if (s.has_pub && now - s.pub_last > VIDEO_PUB_IDLE_S) { + printf("[%d] video slot %d publisher idle, releasing\n", + port2_, i); + /* + A spliced publisher has to be torn down, not just + forgotten. Clearing has_pub alone left the client socket, + the backend socket and the ffmpeg all alive, so + rtsp.running() went on refusing every replacement as + slot-busy until the child was killed by hand -- and UDP + admission, which tests only has_pub, could meanwhile claim + the slot underneath a splice that was still feeding it. + */ + if (s.rtsp.running() || s.rtsp_client_fd >= 0) { + close_rtsp(s, i, "publisher idle"); + continue; // close_rtsp does the rest, including + // end_stream and the recorder + } + s.has_pub = false; + // Close the segment on disconnect rather than leaving it + // open: the file is complete, and an open file is not + // evictable by the quota pass. + s.rec.close_segment(); + s.recording = false; + end_stream(s, i, "publisher gone"); + } + } + // One line per active publisher. This is the only window onto the + // scanner until viewers exist, so it carries what a operator (and + // the tests) need: whether a viewer could join, and why not. + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + Slot &s = slots_[i]; + if (!s.has_pub) { + continue; + } + const TSStats &st = s.scanner.stats(); + uint64_t join = 0; + const bool can_join = s.scanner.join_offset(join); + printf("[%d] video slot %d stats: %llu KiB, %llu pkts, pat=%llu " + "pmt=%llu rai=%llu cc_err=%llu crc_err=%llu bad_dgram=%llu " + "vpid=0x%x stype=0x%02x join=%s\n", + port2_, i, + (unsigned long long)(s.pub_bytes / 1024), + (unsigned long long)st.packets, + (unsigned long long)st.pat_seen, + (unsigned long long)st.pmt_seen, + (unsigned long long)st.rai_seen, + (unsigned long long)st.cc_errors, + (unsigned long long)st.crc_errors, + (unsigned long long)s.bad_datagrams, + unsigned(s.scanner.video_pid()), + unsigned(s.scanner.video_stream_type()), + can_join ? "ready" : "waiting"); + } + write_conn_rows(now); + last_tick_ = now; +} + +void VideoChild::run(void) +{ + if (!load_entry()) { + printf("[%d] video: no keys.tdb entry\n", port2_); + signal_ready(ENOENT); + _exit(1); + } + + int err = bind_slots(); + signal_ready(err); + + epfd_ = epoll_create1(0); + if (epfd_ == -1) { + printf("[%d] video: epoll_create1 failed - %s\n", + port2_, strerror(errno)); + _exit(1); + } + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + for (int fd : { slots_[i].udp_fd, slots_[i].tcp_fd }) { + if (fd == -1) { + continue; + } + struct epoll_event ev {}; + ev.events = EPOLLIN; + ev.data.fd = fd; + epoll_ctl(epfd_, EPOLL_CTL_ADD, fd, &ev); + } + } + + while (true) { + struct epoll_event events[VIDEO_MAX_EPOLL_EVENTS]; + int nev = epoll_wait(epfd_, events, VIDEO_MAX_EPOLL_EVENTS, 1000); + if (nev == -1 && errno != EINTR) { + printf("[%d] video: epoll_wait failed - %s\n", + port2_, strerror(errno)); + break; + } + const time_t evnow = time(nullptr); + for (int e = 0; e < nev; e++) { + const int fd = events[e].data.fd; + bool matched = false; + for (int i = 0; i < KEY_MAX_VIDEO_PORTS && !matched; i++) { + if (slots_[i].udp_fd == fd) { + handle_udp(slots_[i], i); + matched = true; + } else if (slots_[i].tcp_fd == fd) { + handle_tcp(slots_[i], i); + matched = true; + } + } + if (matched) { + continue; + } + // a pending RTMP handshake: owns no slot state, so it is + // matched before the splice fds and closed on its own. + for (int i = 0; i < KEY_MAX_VIDEO_PORTS && !matched; i++) { + Slot &s = slots_[i]; + for (int k = 0; k < VIDEO_MAX_PENDING_RTMP; k++) { + PendingRtmp &p = s.pending[k]; + if (!p.active() || p.fd != fd) { + continue; + } + matched = true; + if ((events[e].events & (EPOLLHUP | EPOLLERR)) + || !pump_pending(s, i, p, evnow)) { + // Promotion moves the fd out and clears p, so an + // entry that is no longer active was handed on + // rather than dropped. + if (p.active()) { + close_pending(s, i, p, "connection closed"); + } + } + break; + } + } + if (matched) { + continue; + } + // an RTSP splice fd + for (int i = 0; i < KEY_MAX_VIDEO_PORTS && !matched; i++) { + Slot &s = slots_[i]; + if (fd != s.rtsp_client_fd && fd != s.rtsp.backend_fd() + && fd != s.rtsp.media_fd()) { + continue; + } + matched = true; + if ((events[e].events & (EPOLLHUP | EPOLLERR)) + || !pump_rtsp(s, i, fd, evnow)) { + close_rtsp(s, i, "connection closed"); + } + } + if (matched) { + continue; + } + // a viewer socket + for (int i = 0; i < KEY_MAX_VIDEO_PORTS && !matched; i++) { + Slot &s = slots_[i]; + for (int v = 0; v < VIDEO_MAX_VIEWERS; v++) { + VideoViewer &vw = s.viewers[v]; + if (!vw.active() || vw.fd() != fd) { + continue; + } + matched = true; + bool ok = true; + if (events[e].events & (EPOLLHUP | EPOLLERR)) { + ok = false; + } else { + if (events[e].events & (EPOLLIN | EPOLLRDHUP)) { + ok = vw.on_readable(ke_, i, s.ring, s.scanner, + evnow); + } + if (ok && (events[e].events & EPOLLOUT)) { + ok = vw.on_writable(s.ring, evnow); + } + } + if (!ok) { + drop_viewer(s, i, vw); + } + break; + } + } + } + + // Push to viewers every iteration, not only on EPOLLOUT: new + // ring data has no fd event of its own, and a viewer whose + // socket had room all along would otherwise never be fed. + const time_t now = time(nullptr); + for (int i = 0; i < KEY_MAX_VIDEO_PORTS; i++) { + if (slots_[i].udp_fd != -1 || slots_[i].tcp_fd != -1) { + pump_viewers(slots_[i], i, now); + } + } + if (now - last_tick_ >= VIDEO_TICK_S) { + tick(now); + } + // The parent may have died between our PDEATHSIG being armed + // and now; PDEATHSIG only fires on a parent that was alive when + // it was set. + if (getppid() == 1) { + break; + } + } + + conn_remove_video(port2_); + _exit(0); +} + +} // namespace + +void video_child_main(int port2, int ready_fd) +{ + VideoChild child(port2, ready_fd); + child.run(); +} diff --git a/video.h b/video.h new file mode 100644 index 0000000..dbc6692 --- /dev/null +++ b/video.h @@ -0,0 +1,39 @@ +/* + The per-entry video child. + + Unlike the per-port-pair MAVLink child, this is a long-lived direct + child of the parent, forked from reload_ports() and respawned by + check_children(). Its lifetime is deliberately independent of any + MAVLink session: the MAVLink child idles out after 10 s, and video has + to survive that, and has to work with no MAVLink session at all when + the entry has a publish password. + + Because the *child* binds the video ports, a video port simply does not + exist unless the entry has video enabled -- the enable is structural + rather than a policy check somewhere in a packet path. + */ +#pragma once + +#include +#include + +#include "keydb.h" + +// Number of listening fds a video child opens per configured slot +// (one UDP, one TCP). +#define VIDEO_FDS_PER_SLOT 2 + +/* + Entry point for the video child. Called after the caller has done fd + sanitation in the forked child. Never returns: it _exit()s. + + ready_fd is the write end of a pipe the parent reads once to learn + whether every configured port bound. A single byte is written: 0 for + success, or errno for the first failure. The fd is closed either way, + so the parent's read never blocks if the child dies first. + */ +void video_child_main(int port2, int ready_fd) __attribute__((noreturn)); + +// True if this entry should have a video child: video enabled and at +// least one port configured. +bool video_entry_wants_child(uint32_t flags, const uint32_t *video_ports); diff --git a/videoauth.cpp b/videoauth.cpp new file mode 100644 index 0000000..b101960 --- /dev/null +++ b/videoauth.cpp @@ -0,0 +1,200 @@ +/* + Video publisher admission. See videoauth.h for the model. + */ +#include "videoauth.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "conntdb.h" + +// How long a cached connections.tdb read stays usable. Short enough that +// a session appearing or moving is noticed promptly, long enough that a +// datagram burst doesn't turn into a burst of tdb opens. +#define VIDEO_AUTH_CACHE_S 2 + +const char *video_admit_str(video_admit_t r) +{ + switch (r) { + case VIDEO_ADMIT_OK: return "ok"; + case VIDEO_ADMIT_NO_SESSION: return "no MAVLink session"; + case VIDEO_ADMIT_STALE_SESSION: return "MAVLink session too old"; + case VIDEO_ADMIT_WRONG_IP: return "address does not match the MAVLink session"; + case VIDEO_ADMIT_UNAUTH: return "MAVLink session not signature-validated"; + case VIDEO_ADMIT_BAD_PASSWORD: return "wrong publish password"; + case VIDEO_ADMIT_SLOT_BUSY: return "another publisher holds this slot"; + case VIDEO_ADMIT_NO_CREDENTIAL: + return "a publish password is set, and plain MPEG-TS/UDP cannot carry " + "one -- publish over RTSP, or clear the password"; + case VIDEO_ADMIT_MISSING_PASSWORD: + return "a publish password is set but none was supplied -- add " + "?pw=... to the RTSP URL"; + } + return "unknown"; +} + +bool video_password_matches(const uint8_t stored[32], const char *candidate) +{ + // An all-zero key is the "unset" sentinel, never a hash to match. + bool any = false; + for (int i = 0; i < 32; i++) { + any |= stored[i] != 0; + } + if (!any || candidate == nullptr || *candidate == '\0') { + return false; + } + uint8_t want[SHA256_DIGEST_LENGTH]; + SHA256((const unsigned char *)candidate, strlen(candidate), want); + return CRYPTO_memcmp(want, stored, sizeof(want)) == 0; +} + +bool VideoAuth::refresh(time_t now) +{ + auto *db = conn_db_open(); + if (db == nullptr) { + // Can't read: leave whatever we had and let the caller decide. + // Failing closed here would drop a live publisher every time the + // file is briefly locked. + fetched_at_ = now; + return false; + } + struct ConnEntry e {}; + if (conn_get_user(db, port2_, e)) { + have_ = true; + peer_ip_be_ = e.peer_ip_be; + authenticated_ = e.authenticated != 0; + seen_at_ = now; + } + // No row: do NOT forget what we last saw. The row disappears when + // the session child exits, which is exactly when the grace window + // is supposed to keep video alive. check_session() ages seen_at_ + // out instead. + conn_db_close(db); + fetched_at_ = now; + return true; +} + +video_admit_t VideoAuth::check_session(const struct KeyEntry &ke, + uint32_t peer_ip_be, time_t now) const +{ + if (!have_) { + return VIDEO_ADMIT_NO_SESSION; + } + if (peer_ip_be_ != peer_ip_be) { + return VIDEO_ADMIT_WRONG_IP; + } + const uint32_t grace = ke.video_mav_grace_s ? ke.video_mav_grace_s + : VIDEO_MAV_GRACE_DEFAULT_S; + // Measured from when we last saw the session live, not from a field + // in a record that no longer exists. This is what lets a publisher + // start (or restart) during a telemetry outage. + if (now > seen_at_ + time_t(grace)) { + return VIDEO_ADMIT_STALE_SESSION; + } + if ((ke.flags & KEY_FLAG_BIDI_SIGN) != 0 && !authenticated_) { + return VIDEO_ADMIT_UNAUTH; + } + return VIDEO_ADMIT_OK; +} + +video_admit_t VideoAuth::admit(const struct KeyEntry &ke, uint32_t peer_ip_be, + const char *password, time_t now) +{ + // Path A: a publish password, when set, is sufficient on its own. + bool have_pw = false; + for (int i = 0; i < 32; i++) { + have_pw |= ke.video_publish_key[i] != 0; + } + if (have_pw) { + /* + The password replaces the MAVLink-session check rather than + adding to it: an operator sets one precisely because they do + not want address matching to be the gate. + + Say so clearly when the transport had no way to present it. + "wrong publish password" is true but useless to someone whose + udpsink never sent one. + */ + if (password == nullptr) { + return VIDEO_ADMIT_NO_CREDENTIAL; + } + if (*password == '\0') { + return VIDEO_ADMIT_MISSING_PASSWORD; + } + return video_password_matches(ke.video_publish_key, password) + ? VIDEO_ADMIT_OK : VIDEO_ADMIT_BAD_PASSWORD; + } + + // Path B: match a recent MAVLink session. + if (fetched_at_ == 0 || now - fetched_at_ >= VIDEO_AUTH_CACHE_S) { + refresh(now); + } + video_admit_t r = check_session(ke, peer_ip_be, now); + if (r != VIDEO_ADMIT_OK) { + // The cache may simply predate a session that just came up. + // Re-read once before rejecting, but only if the cached copy + // isn't already fresh -- otherwise a stream of unauthorised + // packets would drive one tdb open each. + if (now - fetched_at_ > 0 && refresh(now)) { + r = check_session(ke, peer_ip_be, now); + } + } + return r; +} + +bool video_token_valid(const struct KeyEntry &ke, int port2, int slot, + const char *token, time_t now) +{ + if (token == nullptr || *token == '\0') { + return false; + } + const char *dot = strchr(token, '.'); + if (dot == nullptr) { + return false; + } + char *endp = nullptr; + errno = 0; + const long long expiry = strtoll(token, &endp, 10); + if (errno != 0 || endp != dot || expiry <= 0) { + return false; + } + if (now > time_t(expiry)) { + return false; + } + const char *mac_hex = dot + 1; + if (strlen(mac_hex) != 64) { + return false; + } + + char msg[128]; + const int mlen = snprintf(msg, sizeof(msg), "video-view|%d|%d|%lld", + port2, slot, expiry); + if (mlen <= 0 || size_t(mlen) >= sizeof(msg)) { + return false; + } + + uint8_t want[32]; + unsigned int want_len = 0; + if (HMAC(EVP_sha256(), ke.secret_key, int(sizeof(ke.secret_key)), + reinterpret_cast(msg), size_t(mlen), + want, &want_len) == nullptr || want_len != sizeof(want)) { + return false; + } + + uint8_t got[32]; + for (int i = 0; i < 32; i++) { + char pair[3] = { mac_hex[i * 2], mac_hex[i * 2 + 1], 0 }; + char *e2 = nullptr; + const long v = strtol(pair, &e2, 16); + if (e2 != pair + 2) { + return false; + } + got[i] = uint8_t(v); + } + return CRYPTO_memcmp(want, got, sizeof(want)) == 0; +} diff --git a/videoauth.h b/videoauth.h new file mode 100644 index 0000000..6e06d34 --- /dev/null +++ b/videoauth.h @@ -0,0 +1,119 @@ +/* + Video publisher admission. + + Two independent paths, neither tied to the MAVLink process tree: + + A. publish password -- accepted standalone, no MAVLink needed. For + CGNAT, split-egress (video on a second link) and video-only use. + + B. MAVLink session -- with no publish password set, a publisher is + accepted when a MAVLink session for this entry was seen from the + same IPv4 address within the entry's grace window. The grace is + what lets video ride through a telemetry dropout instead of being + revoked by a link flap. On a bidi entry the session must also have + been signature-validated. + + Path B reads connections.tdb, which makes that file an authorisation + input. It sits alongside keys.tdb in the proxy's working directory and + both are 0600, so anyone who can forge one can forge the other; but + peer_ip_be / authenticated must only ever be written by the session + child. + */ +#pragma once + +#include +#include + +#include "keydb.h" + +enum video_admit_t { + VIDEO_ADMIT_OK = 0, + VIDEO_ADMIT_NO_SESSION, // no MAVLink session record at all + VIDEO_ADMIT_STALE_SESSION, // last seen longer ago than the grace window + VIDEO_ADMIT_WRONG_IP, // session exists, but from another address + VIDEO_ADMIT_UNAUTH, // bidi entry whose session never authenticated + VIDEO_ADMIT_BAD_PASSWORD, // publish password set and wrong/absent + VIDEO_ADMIT_SLOT_BUSY, // another publisher already holds the slot + VIDEO_ADMIT_NO_CREDENTIAL, // a publish password is set, but this + // transport cannot carry one at all + VIDEO_ADMIT_MISSING_PASSWORD, // transport could carry one; none given +}; + +const char *video_admit_str(video_admit_t r); + +/* + Cached view of the entry's MAVLink session. + + Re-reading connections.tdb per packet is not an option: a per-packet + tdb open under load is what caused the lock-contention stall that + commit 6fea59a fixed on the MAVLink side. So the state is cached and + refreshed on a timer, with one forced refresh allowed when a decision + would otherwise be a rejection -- so a publisher that starts moments + after its MAVLink session does not have to wait out the timer. + */ +class VideoAuth { +public: + explicit VideoAuth(int port2) : port2_(port2) {} + + /* + Decide whether `peer_ip_be` may publish. + + `password` distinguishes three cases, and the difference is what + the operator sees in the log: + nullptr the transport cannot carry a credential at all (UDP) + "" it could, but none was supplied (RTSP with no ?pw=) + "..." a credential to check + */ + video_admit_t admit(const struct KeyEntry &ke, uint32_t peer_ip_be, + const char *password, time_t now); + + // Force the next lookup to re-read, e.g. after a config change. + void invalidate(void) { fetched_at_ = 0; } + + // Sample the MAVLink session state even when no video traffic is + // flowing. Without this the child only ever looks on a packet, so a + // session that comes and goes between publishes is never observed + // and the grace window has nothing to work from. + void observe(time_t now) { refresh(now); } + +private: + int port2_; + /* + Last-known-good session, which deliberately OUTLIVES the record. + + connections.tdb only holds a row while the session child is alive; + when it idles out the row is deleted. If the absence of a row meant + "no session", the grace window could never apply -- the case it + exists for is precisely a MAVLink session that has gone away. So we + remember what we last saw and age it out ourselves. + */ + bool have_ = false; // have we ever seen a session? + uint32_t peer_ip_be_ = 0; + bool authenticated_ = false; + time_t seen_at_ = 0; // when the session was last observed live + time_t fetched_at_ = 0; // when connections.tdb was last read + + bool refresh(time_t now); + video_admit_t check_session(const struct KeyEntry &ke, + uint32_t peer_ip_be, time_t now) const; +}; + +// Constant-time compare of a candidate password against a stored +// sha256. Returns false when the stored key is all-zero (unset). +bool video_password_matches(const uint8_t stored[32], const char *candidate); + +/* + Verify a short-lived viewer token minted by the web admin. + + Format: ".", the MAC taken over + "video-view|||" with the entry's MAVLink secret + key. That key is already shared between the web admin and this + process, so browser playback needs no new secret, no new state and + no new file -- and the browser never sees the key itself. + + The token exists because the alternative is putting the viewer + password in a browser URL, where it lands in history, logs and + Referer headers. + */ +bool video_token_valid(const struct KeyEntry &ke, int port2, int slot, + const char *token, time_t now); diff --git a/videorec.cpp b/videorec.cpp new file mode 100644 index 0000000..b1be74a --- /dev/null +++ b/videorec.cpp @@ -0,0 +1,262 @@ +/* + Video segment recording. See videorec.h. + */ +#include "videorec.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cleanup.h" +#include "session.h" + +// Drop written pages from the page cache after this much. A few GB of +// video would otherwise evict everything useful on a small VPS. +#define VIDEO_FADVISE_CHUNK (8u * 1024 * 1024) + +uint32_t video_segment_seconds(void) +{ + static uint32_t cached = 0; + if (cached == 0) { + cached = VIDEO_SEGMENT_SECONDS_DEFAULT; + const char *env = getenv("SUPPORTPROXY_VIDEO_SEGMENT_SECONDS"); + if (env != nullptr && *env != '\0') { + char *endp = nullptr; + errno = 0; + long v = strtol(env, &endp, 10); + if (errno == 0 && endp != env && *endp == '\0' && v > 0) { + cached = uint32_t(v); + } + } + } + return cached; +} + +uint64_t video_segment_bytes(void) +{ + static uint64_t cached = 0; + if (cached == 0) { + cached = VIDEO_SEGMENT_BYTES_DEFAULT; + const char *env = getenv("SUPPORTPROXY_VIDEO_SEGMENT_BYTES"); + if (env != nullptr && *env != '\0') { + char *endp = nullptr; + errno = 0; + long long v = strtoll(env, &endp, 10); + if (errno == 0 && endp != env && *endp == '\0' && v > 0) { + cached = uint64_t(v); + } + } + } + return cached; +} + +VideoWriter::~VideoWriter(void) +{ + close_segment(); +} + +void VideoWriter::configure(uint32_t port2, int slot, bool use_tz, + float tz_offset, const char *base_dir, + uint32_t quota_mb) +{ + port2_ = port2; + slot_ = slot; + use_tz_ = use_tz; + tz_offset_ = tz_offset; + base_dir_ = base_dir != nullptr ? base_dir : "logs"; + quota_mb_ = quota_mb; +} + +bool VideoWriter::open_segment(time_t now) +{ + if (stopped_) { + return false; + } + if (!video_have_free_space(base_dir_.c_str())) { + printf("[%u] video slot %d: filesystem too full, recording stopped\n", + unsigned(port2_), slot_); + stopped_ = true; + return false; + } + + // Make room before writing rather than after: the quota pass would + // otherwise only notice on its hourly tick, by which point the + // budget has been exceeded for most of an hour. + log_cleanup_port2_video_quota(port2_, base_dir_.c_str(), + quota_mb_ != 0 + ? off_t(quota_mb_) * 1024 * 1024 : 0, + off_t(video_segment_bytes())); + + char datedir[16]; + char name[64]; + session_time_strings(now, use_tz_, tz_offset_, + datedir, sizeof(datedir), name, sizeof(name)); + + char dir[1024]; + snprintf(dir, sizeof(dir), "%s/%u/%s", base_dir_.c_str(), + unsigned(port2_), datedir); + // mkpath_0700 returns 0 on success and -1 on failure, like mkdir -- + // not a bool. The other callers all test "< 0". + if (mkpath_0700(dir) < 0) { + printf("[%u] video slot %d: cannot create %s - %s\n", + unsigned(port2_), slot_, dir, strerror(errno)); + return false; + } + + /* + session_unique_basename() picks a free name, but it checks with + stat() and then hands the name back -- and after exhausting its + fallbacks it returns the plain base, occupied or not. Neither is + safe here, so the name is only a starting point: the open is + O_EXCL and a collision just tries again. + */ + for (int attempt = 0; attempt < 8; attempt++) { + char base[64]; + snprintf(base, sizeof(base), "%s", name); + session_unique_basename(base_dir_.c_str(), port2_, datedir, + base, sizeof(base)); + char path[1200]; + snprintf(path, sizeof(path), "%s/%s.v%d.ts", dir, base, slot_ + 1); + const int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (fd >= 0) { + fd_ = fd; + path_ = path; + date_dir_ = dir; + seg_start_ = now; + seg_bytes_ = 0; + fadvise_mark_ = 0; + rotate_wanted_ = 0; + segments_++; + printf("[%u] video slot %d recording to %s\n", + unsigned(port2_), slot_, path); + return true; + } + if (errno != EEXIST) { + printf("[%u] video slot %d: cannot open %s - %s\n", + unsigned(port2_), slot_, path, strerror(errno)); + if (errno == ENOSPC || errno == EDQUOT) { + stopped_ = true; + } + return false; + } + // Someone took the name between the check and the open. Nudge + // the timestamp so the next candidate differs. + now++; + session_time_strings(now, use_tz_, tz_offset_, + datedir, sizeof(datedir), name, sizeof(name)); + } + printf("[%u] video slot %d: no free segment name\n", + unsigned(port2_), slot_); + return false; +} + +bool VideoWriter::write(const uint8_t *buf, size_t n, time_t now) +{ + if (stopped_) { + return false; + } + if (fd_ < 0 && !open_segment(now)) { + return false; + } + + size_t off = 0; + while (off < n) { + const ssize_t w = ::write(fd_, buf + off, n - off); + if (w < 0) { + if (errno == EINTR) { + continue; + } + printf("[%u] video slot %d: write failed - %s; " + "recording stopped (the live stream is unaffected)\n", + unsigned(port2_), slot_, strerror(errno)); + // A full disk must not take the stream down with it: stop + // recording, keep relaying. + close_segment(); + stopped_ = true; + return false; + } + off += size_t(w); + } + seg_bytes_ += n; + total_bytes_ += n; + + if (seg_bytes_ - fadvise_mark_ >= VIDEO_FADVISE_CHUNK) { + // Written data is never read back here, so let the kernel drop + // it rather than evict everything else on a small box. + posix_fadvise(fd_, off_t(fadvise_mark_), + off_t(seg_bytes_ - fadvise_mark_), POSIX_FADV_DONTNEED); + fadvise_mark_ = seg_bytes_; + } + return true; +} + +bool VideoWriter::rotation_due(time_t now) +{ + if (fd_ < 0) { + return false; + } + const bool due = (now - seg_start_) >= time_t(video_segment_seconds()) + || seg_bytes_ >= video_segment_bytes(); + if (due && rotate_wanted_ == 0) { + // Remember when we first wanted to cut. Without this the + // overshoot fallback never fires, and a stream whose muxer + // never signals a random access point grows one segment + // without bound -- which the quota pass can never evict, + // because it is the file being written. + rotate_wanted_ = now; + } + return due; +} + +bool VideoWriter::rotation_overdue(time_t now) const +{ + return rotate_wanted_ != 0 + && (now - rotate_wanted_) >= VIDEO_ROTATE_OVERSHOOT_S; +} + +void VideoWriter::rotate(time_t now, bool clean) +{ + if (fd_ < 0) { + return; + } + printf("[%u] video slot %d rotating after %llu bytes (%s cut)\n", + unsigned(port2_), slot_, (unsigned long long)seg_bytes_, + clean ? "clean" : "forced"); + close_segment(); + rotate_wanted_ = 0; + (void)now; +} + +void VideoWriter::close_segment(void) +{ + if (fd_ < 0) { + return; + } + const int fd = fd_; + fd_ = -1; + + /* + fsync off the event loop. A 512 MiB segment can take a long time + to flush, and doing it inline would stall every stream and viewer + in this process. The fd is shared with the child across fork, so + the child's fsync covers our writes; we close our copy at once. + */ + const pid_t pid = fork(); + if (pid == 0) { + fsync(fd); + close(fd); + _exit(0); + } + if (pid < 0) { + // Couldn't fork: better a stall than an unflushed segment. + fsync(fd); + } + close(fd); +} diff --git a/videorec.h b/videorec.h new file mode 100644 index 0000000..046b862 --- /dev/null +++ b/videorec.h @@ -0,0 +1,97 @@ +/* + Video segment recording. + + Segments are written raw: the publisher's own bytes, byte for byte, + into logs///.v.ts. That makes a recording + a file VLC and ffplay open by double-click, append-only, and + truncation-tolerant -- a killed process leaves a playable file with + no finalisation step, which fragmented MP4 does not. + + Each segment is its own timestamped file rather than a part of a + numbered set, so retention, natural sorting and the download route in + the web UI all work on it unmodified. + + Why segments at all: the quota pass cannot delete the file currently + being written (mtime within ACTIVE_FILE_GRACE_S), so one long file + per session would make the whole session un-evictable and the quota + pass would find nothing to free. Ten-minute segments bound the + un-evictable working set at (segment + grace) * bitrate. + */ +#pragma once + +#include +#include +#include +#include + +#include + +// Cut a segment after this long, or this many bytes, whichever first. +#define VIDEO_SEGMENT_SECONDS_DEFAULT 600 +#define VIDEO_SEGMENT_BYTES_DEFAULT (512u * 1024 * 1024) + +// Once a rotation is due, wait this long for a clean cut point before +// cutting anyway. A stream whose muxer never signals a random access +// point must still rotate, or the segment grows without bound. +#define VIDEO_ROTATE_OVERSHOOT_S 30 + +class VideoWriter { +public: + ~VideoWriter(void); + + void configure(uint32_t port2, int slot, bool use_tz, float tz_offset, + const char *base_dir, uint32_t quota_mb); + + bool is_open(void) const { return fd_ >= 0; } + + // Append. Returns false if recording has stopped (disk full); the + // caller keeps streaming regardless -- losing the recording must + // never take the live stream with it. + bool write(const uint8_t *buf, size_t n, time_t now); + + // True when this segment has run long enough or grown big enough. + // Not const: it records *when* rotation first became due, which is + // what the overshoot fallback below measures against. + bool rotation_due(time_t now); + + // True once we have waited long enough that the next write should + // cut whether or not it is a clean boundary. + bool rotation_overdue(time_t now) const; + + // Close the current segment and start a new one on the next write. + // `clean` records whether the cut landed on a join boundary. + void rotate(time_t now, bool clean); + + void close_segment(void); + + const std::string &path(void) const { return path_; } + uint64_t segment_bytes(void) const { return seg_bytes_; } + uint64_t total_bytes(void) const { return total_bytes_; } + uint32_t segments(void) const { return segments_; } + bool stopped(void) const { return stopped_; } + +private: + uint32_t port2_ = 0; + int slot_ = 0; + bool use_tz_ = false; + float tz_offset_ = 0; + std::string base_dir_ = "logs"; + uint32_t quota_mb_ = 0; + + int fd_ = -1; + std::string path_; + std::string date_dir_; + time_t seg_start_ = 0; + time_t rotate_wanted_ = 0; // when rotation first became due + uint64_t seg_bytes_ = 0; + uint64_t total_bytes_ = 0; + uint32_t segments_ = 0; + uint64_t fadvise_mark_ = 0; + bool stopped_ = false; // disk full; no further segments + + bool open_segment(time_t now); +}; + +// Segment limits, overridable for tests. +uint32_t video_segment_seconds(void); +uint64_t video_segment_bytes(void); diff --git a/videortmp.cpp b/videortmp.cpp new file mode 100644 index 0000000..4cc9577 --- /dev/null +++ b/videortmp.cpp @@ -0,0 +1,1008 @@ +/* + Native RTMP publish ingest. See videortmp.h. + */ +#include "videortmp.h" + +#include +#include + +#include "httpreq.h" + +namespace { + +// ---------------------------------------------------------------- AMF0 + +enum { + AMF_NUMBER = 0x00, + AMF_BOOLEAN = 0x01, + AMF_STRING = 0x02, + AMF_OBJECT = 0x03, + AMF_NULL = 0x05, + AMF_UNDEFINED = 0x06, + AMF_REFERENCE = 0x07, + AMF_ECMA_ARRAY = 0x08, + AMF_OBJECT_END = 0x09, + AMF_STRICT_ARRAY = 0x0a, + AMF_DATE = 0x0b, + AMF_LONG_STRING = 0x0c, +}; + +double be_double(const uint8_t *p) +{ + uint64_t v = 0; + for (int i = 0; i < 8; i++) { + v = (v << 8) | p[i]; + } + double d; + memcpy(&d, &v, sizeof(d)); + return d; +} + +void put_be_double(std::vector &b, double d) +{ + uint64_t v; + memcpy(&v, &d, sizeof(v)); + for (int i = 7; i >= 0; i--) { + b.push_back(uint8_t((v >> (i * 8)) & 0xff)); + } +} + +void amf_num(std::vector &b, double d) +{ + b.push_back(AMF_NUMBER); + put_be_double(b, d); +} + +void amf_str(std::vector &b, const char *s) +{ + const size_t n = strlen(s); + b.push_back(AMF_STRING); + b.push_back(uint8_t((n >> 8) & 0xff)); + b.push_back(uint8_t(n & 0xff)); + b.insert(b.end(), s, s + n); +} + +void amf_key(std::vector &b, const char *s) +{ + const size_t n = strlen(s); + b.push_back(uint8_t((n >> 8) & 0xff)); + b.push_back(uint8_t(n & 0xff)); + b.insert(b.end(), s, s + n); +} + +void amf_null(std::vector &b) { b.push_back(AMF_NULL); } + +void amf_obj_end(std::vector &b) +{ + b.push_back(0); + b.push_back(0); + b.push_back(AMF_OBJECT_END); +} + +/* + Skip one AMF0 value. Returns false on anything malformed or truncated, + which is what stops a hostile peer steering us past the buffer. + */ +bool amf_skip(const uint8_t *p, size_t n, size_t &i, int depth = 0); + +bool amf_skip_object_body(const uint8_t *p, size_t n, size_t &i, int depth) +{ + while (true) { + if (i + 2 > n) { + return false; + } + const size_t klen = (size_t(p[i]) << 8) | p[i + 1]; + i += 2; + if (klen == 0) { + if (i >= n || p[i] != AMF_OBJECT_END) { + return false; + } + i++; + return true; + } + if (i + klen > n) { + return false; + } + i += klen; + if (!amf_skip(p, n, i, depth + 1)) { + return false; + } + } +} + +bool amf_skip(const uint8_t *p, size_t n, size_t &i, int depth) +{ + if (depth > 8 || i >= n) { + return false; + } + const uint8_t m = p[i++]; + switch (m) { + case AMF_NUMBER: + i += 8; + return i <= n; + case AMF_BOOLEAN: + i += 1; + return i <= n; + case AMF_STRING: { + if (i + 2 > n) { + return false; + } + const size_t len = (size_t(p[i]) << 8) | p[i + 1]; + i += 2 + len; + return i <= n; + } + case AMF_LONG_STRING: { + if (i + 4 > n) { + return false; + } + size_t len = 0; + for (int k = 0; k < 4; k++) { + len = (len << 8) | p[i + k]; + } + i += 4 + len; + return i <= n; + } + case AMF_NULL: + case AMF_UNDEFINED: + return true; + case AMF_REFERENCE: + i += 2; + return i <= n; + case AMF_DATE: + i += 10; + return i <= n; + case AMF_OBJECT: + return amf_skip_object_body(p, n, i, depth); + case AMF_ECMA_ARRAY: + i += 4; + if (i > n) { + return false; + } + return amf_skip_object_body(p, n, i, depth); + case AMF_STRICT_ARRAY: { + if (i + 4 > n) { + return false; + } + size_t cnt = 0; + for (int k = 0; k < 4; k++) { + cnt = (cnt << 8) | p[i + k]; + } + i += 4; + if (cnt > n) { + return false; // more elements than bytes left + } + for (size_t k = 0; k < cnt; k++) { + if (!amf_skip(p, n, i, depth + 1)) { + return false; + } + } + return true; + } + case AMF_OBJECT_END: + return true; + default: + return false; + } +} + +bool amf_read_string(const uint8_t *p, size_t n, size_t &i, std::string &out) +{ + if (i >= n || p[i] != AMF_STRING) { + return false; + } + i++; + if (i + 2 > n) { + return false; + } + const size_t len = (size_t(p[i]) << 8) | p[i + 1]; + i += 2; + if (i + len > n) { + return false; + } + out.assign(reinterpret_cast(p + i), len); + i += len; + return true; +} + +bool amf_read_number(const uint8_t *p, size_t n, size_t &i, double &out) +{ + if (i >= n || p[i] != AMF_NUMBER) { + return false; + } + if (i + 9 > n) { + return false; + } + out = be_double(p + i + 1); + i += 9; + return true; +} + +/* + Pull named string members out of an AMF0 object or ECMA array. Only + string values are of interest (app, tcUrl); everything else is skipped. + */ +bool amf_object_strings(const uint8_t *p, size_t n, size_t &i, + const char *k1, std::string &v1, + const char *k2, std::string &v2) +{ + if (i >= n) { + return false; + } + const uint8_t m = p[i++]; + if (m == AMF_ECMA_ARRAY) { + i += 4; + } else if (m != AMF_OBJECT) { + i--; + return amf_skip(p, n, i); + } + while (true) { + if (i + 2 > n) { + return false; + } + const size_t klen = (size_t(p[i]) << 8) | p[i + 1]; + i += 2; + if (klen == 0) { + if (i >= n || p[i] != AMF_OBJECT_END) { + return false; + } + i++; + return true; + } + if (i + klen > n) { + return false; + } + const std::string key(reinterpret_cast(p + i), klen); + i += klen; + std::string sv; + const size_t save = i; + if (i < n && p[i] == AMF_STRING && amf_read_string(p, n, i, sv)) { + if (key == k1) { + v1 = sv; + } else if (key == k2) { + v2 = sv; + } + continue; + } + i = save; + if (!amf_skip(p, n, i)) { + return false; + } + } +} + +/* + Split "name?pw=secret" (or "&password=") into the bare name and the + credential. Cameras put the stream key in a single field, so a query on + the stream name is the only place RTMP has to carry one. + */ +void split_credential(std::string &name, std::string &pw) +{ + const size_t q = name.find_first_of("?&"); + if (q == std::string::npos) { + return; + } + const std::string query = name.substr(q + 1); + name.resize(q); + size_t at = 0; + while (at < query.size()) { + size_t end = query.find('&', at); + if (end == std::string::npos) { + end = query.size(); + } + const std::string kv = query.substr(at, end - at); + const size_t eq = kv.find('='); + if (eq != std::string::npos) { + const std::string k = kv.substr(0, eq); + if (k == "pw" || k == "password" || k == "key") { + pw = http_url_decode(kv.substr(eq + 1)); + } + } + at = end + 1; + } +} + +} // namespace + +// ------------------------------------------------------------- session + +bool RtmpSession::fail(const char *why) +{ + error_ = why; + state_ = RTMP_DEAD; + return false; +} + +void RtmpSession::compact(void) +{ + if (in_pos_ == in_.size()) { + in_.clear(); + in_pos_ = 0; + } else if (in_pos_ > 65536) { + in_.erase(in_.begin(), in_.begin() + long(in_pos_)); + in_pos_ = 0; + } +} + +std::string RtmpSession::path(void) const +{ + if (app_.empty()) { + return stream_; + } + if (stream_.empty()) { + return app_; + } + return app_ + "/" + stream_; +} + +bool RtmpSession::feed(const uint8_t *buf, size_t n, time_t now) +{ + if (state_ == RTMP_DEAD) { + return false; + } + if (started_ == 0) { + started_ = now; + } + now_ = now; + total_in_ += n; + if (!publishing_ + && (total_in_ > RTMP_PREPUBLISH_MAX_BYTES + || now - started_ > RTMP_PREPUBLISH_MAX_S)) { + return fail("did not publish in time"); + } + if (n > 0) { + in_.insert(in_.end(), buf, buf + n); + } + return run_parser(); +} + +/* + Continue on bytes already buffered. Needed because parsing stops at + publish so the caller can authorise in order: whatever the publisher + pipelined behind it is still sitting in the input buffer, and without + this it would wait for a read that may never come. + */ +bool RtmpSession::resume(void) +{ + if (state_ == RTMP_DEAD) { + return false; + } + return run_parser(); +} + +bool RtmpSession::run_parser(void) +{ + while (true) { + const size_t before = in_pos_; + if (state_ == RTMP_WANT_C0C1 || state_ == RTMP_WANT_C2) { + if (!do_handshake()) { + return state_ != RTMP_DEAD; + } + } else if (state_ == RTMP_CHUNKS) { + if (!parse_chunks()) { + return state_ != RTMP_DEAD; + } + } else { + return false; + } + /* + Stop on publish. Media in the same read -- which a publisher + that does not wait for onStatus sends -- would otherwise be + parsed while publishing_ is still false and dropped by + on_media(), losing the sequence header and its parameter sets. + */ + if (publish_pending_) { + break; + } + if (in_pos_ == before) { + break; // no progress: need more bytes + } + } + compact(); + return true; +} + +/* + The simple handshake: S1 is a timestamp, a zero word and filler, and S2 + echoes C1. No digest, because nothing here plays back to Flash. + */ +bool RtmpSession::do_handshake(void) +{ + if (state_ == RTMP_WANT_C0C1) { + if (avail() < 1537) { + return false; + } + const uint8_t *p = cur(); + if (p[0] != 3) { + return fail("unsupported RTMP version"); + } + std::vector s; + s.reserve(1 + 1536 + 1536); + s.push_back(3); + // S1: zero time, zero, then filler. The peer only echoes it. + s.insert(s.end(), 1536, 0); + for (size_t i = 8; i < 1536; i++) { + s[1 + i] = uint8_t(i & 0xff); + } + s.insert(s.end(), p + 1, p + 1537); // S2 echoes C1 + to_peer_.insert(to_peer_.end(), s.begin(), s.end()); + in_pos_ += 1537; + saw_c0c1_ = true; + state_ = RTMP_WANT_C2; + return true; + } + if (avail() < 1536) { + return false; + } + in_pos_ += 1536; // C2, not validated + state_ = RTMP_CHUNKS; + return true; +} + +bool RtmpSession::parse_chunks(void) +{ + const size_t start = in_pos_; + const uint8_t *p = in_.data(); + size_t i = in_pos_; + const size_t end = in_.size(); + + if (i >= end) { + return false; + } + const uint8_t b0 = p[i]; + const uint8_t fmt = uint8_t(b0 >> 6); + uint32_t csid = b0 & 0x3f; + size_t hdr = 1; + if (csid == 0) { + if (i + 2 > end) { + return false; + } + csid = 64 + p[i + 1]; + hdr = 2; + } else if (csid == 1) { + if (i + 3 > end) { + return false; + } + csid = 64u + p[i + 1] + 256u * p[i + 2]; + hdr = 3; + } + if (csid > RTMP_MAX_CHUNK_STREAM) { + return fail("chunk stream id out of range"); + } + if (cs_.size() <= csid) { + cs_.resize(csid + 1); + } + ChunkStream &c = cs_[csid]; + + static const size_t mh[4] = { 11, 7, 3, 0 }; + if (i + hdr + mh[fmt] > end) { + return false; + } + const uint8_t *h = p + i + hdr; + + /* + Decode into locals and commit only once the whole chunk is here. + + Committing as we go looked harmless because an incomplete chunk + leaves in_pos_ at the header and simply returns for more bytes -- + but that means the next feed() re-parses the same header and + applies its timestamp delta a second time. Ordinary TCP + segmentation is enough to trigger it, no malformed input needed. + */ + uint32_t new_ts = c.ts; + uint32_t new_delta = c.delta; + uint32_t new_len = c.len; + uint8_t new_type = c.type; + uint32_t new_sid = c.sid; + bool new_ext_ts = c.ext_ts; + + uint32_t ts_field = c.ts; + if (fmt <= 2) { + ts_field = (uint32_t(h[0]) << 16) | (uint32_t(h[1]) << 8) | h[2]; + } + if (fmt == 0) { + new_len = (uint32_t(h[3]) << 16) | (uint32_t(h[4]) << 8) | h[5]; + new_type = h[6]; + new_sid = uint32_t(h[7]) | (uint32_t(h[8]) << 8) + | (uint32_t(h[9]) << 16) | (uint32_t(h[10]) << 24); + } else if (fmt == 1) { + new_len = (uint32_t(h[3]) << 16) | (uint32_t(h[4]) << 8) | h[5]; + new_type = h[6]; + } + size_t pos = i + hdr + mh[fmt]; + + /* + An extended timestamp follows the header when the 24-bit field is + saturated. fmt 3 has no field of its own, so it repeats the + extension whenever the message it continues used one -- the usual + interop trap, and the reason ext_ts is remembered per chunk stream. + */ + const bool want_ext = (fmt <= 2 && ts_field == 0xffffff) + || (fmt == 3 && c.ext_ts); + uint32_t ext = 0; + if (want_ext) { + if (pos + 4 > end) { + return false; + } + ext = (uint32_t(p[pos]) << 24) | (uint32_t(p[pos + 1]) << 16) + | (uint32_t(p[pos + 2]) << 8) | p[pos + 3]; + pos += 4; + } + if (fmt <= 2) { + new_ext_ts = (ts_field == 0xffffff); + const uint32_t t = new_ext_ts ? ext : ts_field; + if (fmt == 0) { + new_ts = t; + new_delta = 0; + } else { + new_delta = t; + new_ts = c.ts + t; + } + } else if (c.acc.empty()) { + // A fresh message on a fmt-3 header repeats the last delta. + new_ts = c.ts + c.delta; + } + + if (new_len > RTMP_MAX_MESSAGE_BYTES) { + return fail("message too large"); + } + /* + Only fmt 3 may continue a message. Anything else while this chunk + stream still owes bytes is a protocol violation -- and accepting + it silently spliced two wire messages into one, because the + length and type were replaced while the old payload stayed in the + accumulator. Abort Message is how a peer legitimately discards a + partial message; it is handled in on_message(). + */ + if (fmt != 3 && !c.acc.empty()) { + return fail("header restarts a message already in progress"); + } + const size_t remaining = new_len - c.acc.size(); + const size_t take = remaining < in_chunk_ ? remaining : in_chunk_; + if (pos + take > end) { + return false; // wait for the rest of this chunk + } + + if (assembly_bytes_ + take > RTMP_MAX_ASSEMBLY_BYTES) { + return fail("too many partial messages"); + } + + /* + Acknowledge once per window. A publisher that asked for a window + and never sees one is entitled to stop sending -- which presents + as a stream that runs for a while and then stalls, with nothing in + the log to say why. + */ + bytes_in_ += (pos - i) + take; + if (ack_window_ != 0 && bytes_in_ - acked_ >= ack_window_) { + acked_ = bytes_in_; + const uint32_t seq = uint32_t(acked_ & 0xffffffffu); + const uint8_t ack[4] = { + uint8_t((seq >> 24) & 0xff), uint8_t((seq >> 16) & 0xff), + uint8_t((seq >> 8) & 0xff), uint8_t(seq & 0xff), + }; + send_msg(2, 3, 0, ack, sizeof(ack)); + } + + // Whole chunk is buffered: now it is safe to advance the state. + c.ts = new_ts; + c.delta = new_delta; + c.len = new_len; + c.type = new_type; + c.sid = new_sid; + c.ext_ts = new_ext_ts; + c.acc.insert(c.acc.end(), p + pos, p + pos + take); + assembly_bytes_ += take; + pos += take; + in_pos_ = pos; + + if (c.acc.size() >= c.len) { + std::vector msg; + msg.swap(c.acc); + assembly_bytes_ -= msg.size(); + if (!on_message(c, msg.data(), msg.size())) { + return false; + } + } + if (to_peer_.size() > RTMP_MAX_OUT_BYTES) { + return fail("peer is not reading its responses"); + } + return in_pos_ > start; +} + +bool RtmpSession::on_message(ChunkStream &c, const uint8_t *p, size_t n) +{ + switch (c.type) { + case 1: // Set Chunk Size + if (n < 4) { + return fail("short SetChunkSize"); + } + { + const uint32_t v = ((uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) + | (uint32_t(p[2]) << 8) | p[3]) & 0x7fffffff; + if (v == 0 || v > RTMP_MAX_CHUNK_SIZE) { + return fail("bad chunk size"); + } + in_chunk_ = v; + } + return true; + case 2: // Abort Message + /* + The peer discarding a partial message on a chunk stream. The + spec's way out of the state the check in parse_chunks() + otherwise treats as fatal. + */ + if (n >= 4) { + const uint32_t id = (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) + | (uint32_t(p[2]) << 8) | p[3]; + if (id < cs_.size()) { + assembly_bytes_ -= cs_[id].acc.size(); + cs_[id].acc.clear(); + } + } + return true; + case 3: // Acknowledgement from the peer + return true; + case 5: // Window Acknowledgement Size + if (n >= 4) { + ack_window_ = (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) + | (uint32_t(p[2]) << 8) | p[3]; + } + return true; + case 6: // Set Peer Bandwidth + // Carries a window plus a limit type; the window is what we owe + // acknowledgements against. + if (n >= 4) { + ack_window_ = (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) + | (uint32_t(p[2]) << 8) | p[3]; + } + return true; + case 4: // User Control + if (n >= 2) { + const uint32_t ev = (uint32_t(p[0]) << 8) | p[1]; + if (ev == 6 && n >= 6) { // PingRequest + uint8_t pong[6] = { 0, 7, p[2], p[3], p[4], p[5] }; + send_msg(2, 4, 0, pong, sizeof(pong)); + } + } + return true; + case 8: // audio + case 9: // video + case 18: // AMF0 data (metadata) + on_media(c.type, c.ts, p, n); + return true; + case 20: // AMF0 command + return on_command(c, p, n); + case 17: // AMF3 command + return true; // ignored; publishers use AMF0 + default: + return true; + } +} + +void RtmpSession::send_msg(uint8_t csid, uint8_t type, uint32_t sid, + const uint8_t *p, size_t n, uint32_t ts) +{ + std::vector &o = to_peer_; + o.push_back(csid); // fmt 0 + o.push_back(uint8_t((ts >> 16) & 0xff)); + o.push_back(uint8_t((ts >> 8) & 0xff)); + o.push_back(uint8_t(ts & 0xff)); + o.push_back(uint8_t((n >> 16) & 0xff)); + o.push_back(uint8_t((n >> 8) & 0xff)); + o.push_back(uint8_t(n & 0xff)); + o.push_back(type); + o.push_back(uint8_t(sid & 0xff)); + o.push_back(uint8_t((sid >> 8) & 0xff)); + o.push_back(uint8_t((sid >> 16) & 0xff)); + o.push_back(uint8_t((sid >> 24) & 0xff)); + size_t at = 0; + while (at < n) { + if (at != 0) { + o.push_back(uint8_t(0xc0 | csid)); + } + const size_t take = (n - at) < RTMP_OUT_CHUNK_SIZE + ? (n - at) : RTMP_OUT_CHUNK_SIZE; + o.insert(o.end(), p + at, p + at + take); + at += take; + } +} + +void RtmpSession::send_amf(uint8_t csid, uint32_t sid, + const std::vector &b) +{ + send_msg(csid, 20, sid, b.data(), b.size()); +} + +bool RtmpSession::on_command(ChunkStream &c, const uint8_t *p, size_t n) +{ + size_t i = 0; + std::string cmd; + if (!amf_read_string(p, n, i, cmd)) { + return fail("unparseable command"); + } + double txn = 0; + amf_read_number(p, n, i, txn); + + if (cmd == "connect") { + /* + Once only. Repeating it is not a legal phase transition and + each one costs several responses, which is free amplification + for a peer that never reads them. + */ + if (connected_) { + return fail("repeated connect"); + } + connected_ = true; + std::string tc_url; + amf_object_strings(p, n, i, "app", app_, "tcUrl", tc_url); + split_credential(app_, password_); + if (password_.empty() && !tc_url.empty()) { + std::string ignored = tc_url; + std::string pw; + split_credential(ignored, pw); + password_ = pw; + } + // Window Ack Size, Set Peer Bandwidth, Stream Begin, chunk size. + const uint8_t win[4] = { 0x00, 0x26, 0x25, 0xa0 }; + send_msg(2, 5, 0, win, sizeof(win)); + const uint8_t bw[5] = { 0x00, 0x26, 0x25, 0xa0, 0x02 }; + send_msg(2, 6, 0, bw, sizeof(bw)); + const uint8_t begin[6] = { 0, 0, 0, 0, 0, 0 }; + send_msg(2, 4, 0, begin, sizeof(begin)); + const uint8_t cs[4] = { + uint8_t((RTMP_OUT_CHUNK_SIZE >> 24) & 0xff), + uint8_t((RTMP_OUT_CHUNK_SIZE >> 16) & 0xff), + uint8_t((RTMP_OUT_CHUNK_SIZE >> 8) & 0xff), + uint8_t(RTMP_OUT_CHUNK_SIZE & 0xff), + }; + send_msg(2, 1, 0, cs, sizeof(cs)); + + std::vector b; + amf_str(b, "_result"); + amf_num(b, txn); + b.push_back(AMF_OBJECT); + amf_key(b, "fmsVer"); + amf_str(b, "FMS/3,0,1,123"); + amf_key(b, "capabilities"); + amf_num(b, 31); + amf_obj_end(b); + b.push_back(AMF_OBJECT); + amf_key(b, "level"); + amf_str(b, "status"); + amf_key(b, "code"); + amf_str(b, "NetConnection.Connect.Success"); + amf_key(b, "description"); + amf_str(b, "Connection succeeded."); + amf_key(b, "objectEncoding"); + amf_num(b, 0); + amf_obj_end(b); + send_amf(3, 0, b); + + std::vector d; + amf_str(d, "onBWDone"); + amf_num(d, 0); + amf_null(d); + amf_num(d, 8192); + send_amf(3, 0, d); + return true; + } + + if (cmd == "releaseStream" || cmd == "FCUnpublish" + || cmd == "deleteStream" || cmd == "closeStream") { + if (cmd == "releaseStream") { + std::vector b; + amf_str(b, "_result"); + amf_num(b, txn); + amf_null(b); + send_amf(3, 0, b); + return true; + } + // A publisher tearing down: let the caller notice the close. + if (publishing_ && (cmd == "deleteStream" || cmd == "FCUnpublish")) { + return fail("publisher unpublished"); + } + return true; + } + + if (cmd == "FCPublish") { + std::string name; + size_t j = i; + amf_skip(p, n, j); // command object, usually null + amf_read_string(p, n, j, name); + /* + The response ffmpeg gets wrong: it writes the command name and + stops. A camera that waits for the status object here simply + never publishes, which is the whole reason this file exists. + */ + std::vector b; + amf_str(b, "onFCPublish"); + amf_num(b, 0); + amf_null(b); + b.push_back(AMF_OBJECT); + amf_key(b, "level"); + amf_str(b, "status"); + amf_key(b, "code"); + amf_str(b, "NetStream.Publish.Start"); + amf_key(b, "description"); + amf_str(b, name.empty() ? "Publishing." : name.c_str()); + amf_obj_end(b); + send_amf(3, 0, b); + return true; + } + + if (cmd == "createStream") { + std::vector b; + amf_str(b, "_result"); + amf_num(b, txn); + amf_null(b); + amf_num(b, publish_sid_); + send_amf(3, 0, b); + return true; + } + + if (cmd == "publish") { + if (publishing_ || publish_pending_) { + return fail("second publish on one connection"); + } + if (!connected_) { + return fail("publish before connect"); + } + std::string name; + size_t j = i; + amf_skip(p, n, j); // command object + if (!amf_read_string(p, n, j, name)) { + return fail("publish without a stream name"); + } + stream_ = name; + std::string pw; + split_credential(stream_, pw); + if (!pw.empty()) { + password_ = pw; + } + publish_txn_ = txn; + publish_sid_ = c.sid != 0 ? c.sid : 1; + publish_pending_ = true; + return true; // the caller authorises, then answers + } + + if (cmd == "play" || cmd == "play2") { + return fail("this port accepts publishers only"); + } + return true; // unknown commands are ignored +} + +void RtmpSession::accept_publish(void) +{ + if (!publish_pending_) { + return; + } + publish_pending_ = false; + publishing_ = true; + publishing_since_ = now_ != 0 ? now_ : started_; + + // Stream Begin for the publishing stream, then the status the + // client is waiting on. + uint8_t begin[6] = { 0, 0, 0, 0, 0, 0 }; + begin[2] = uint8_t((publish_sid_ >> 24) & 0xff); + begin[3] = uint8_t((publish_sid_ >> 16) & 0xff); + begin[4] = uint8_t((publish_sid_ >> 8) & 0xff); + begin[5] = uint8_t(publish_sid_ & 0xff); + send_msg(2, 4, 0, begin, sizeof(begin)); + + std::vector b; + amf_str(b, "onStatus"); + amf_num(b, 0); + amf_null(b); + b.push_back(AMF_OBJECT); + amf_key(b, "level"); + amf_str(b, "status"); + amf_key(b, "code"); + amf_str(b, "NetStream.Publish.Start"); + amf_key(b, "description"); + amf_str(b, stream_.empty() ? "Publishing." + : (stream_ + " is now published.").c_str()); + amf_key(b, "clientid"); + amf_num(b, 1); + amf_obj_end(b); + send_msg(5, 20, publish_sid_, b.data(), b.size()); + + write_flv_header(); +} + +void RtmpSession::reject_publish(const char *code, const char *description) +{ + publish_pending_ = false; + std::vector b; + amf_str(b, "onStatus"); + amf_num(b, publish_txn_); + amf_null(b); + b.push_back(AMF_OBJECT); + amf_key(b, "level"); + amf_str(b, "error"); + amf_key(b, "code"); + amf_str(b, code); + amf_key(b, "description"); + amf_str(b, description); + amf_obj_end(b); + send_msg(5, 20, publish_sid_, b.data(), b.size()); + error_ = description; + state_ = RTMP_DEAD; +} + +// ----------------------------------------------------------------- FLV + +void RtmpSession::write_flv_header(void) +{ + if (flv_header_written_) { + return; + } + flv_header_written_ = true; + // "FLV", version 1, audio+video present, 9-byte header, then the + // zero PreviousTagSize the first tag follows. + static const uint8_t h[13] = { + 'F', 'L', 'V', 0x01, 0x05, 0x00, 0x00, 0x00, 0x09, + 0x00, 0x00, 0x00, 0x00, + }; + to_flv_.insert(to_flv_.end(), h, h + sizeof(h)); +} + +void RtmpSession::write_flv_tag(uint8_t type, uint32_t ts, + const uint8_t *p, size_t n) +{ + const size_t at = to_flv_.size(); + to_flv_.push_back(type); + to_flv_.push_back(uint8_t((n >> 16) & 0xff)); + to_flv_.push_back(uint8_t((n >> 8) & 0xff)); + to_flv_.push_back(uint8_t(n & 0xff)); + to_flv_.push_back(uint8_t((ts >> 16) & 0xff)); + to_flv_.push_back(uint8_t((ts >> 8) & 0xff)); + to_flv_.push_back(uint8_t(ts & 0xff)); + to_flv_.push_back(uint8_t((ts >> 24) & 0xff)); // extended byte + to_flv_.push_back(0); + to_flv_.push_back(0); + to_flv_.push_back(0); + to_flv_.insert(to_flv_.end(), p, p + n); + const uint32_t tagsz = uint32_t(to_flv_.size() - at); + to_flv_.push_back(uint8_t((tagsz >> 24) & 0xff)); + to_flv_.push_back(uint8_t((tagsz >> 16) & 0xff)); + to_flv_.push_back(uint8_t((tagsz >> 8) & 0xff)); + to_flv_.push_back(uint8_t(tagsz & 0xff)); +} + +void RtmpSession::on_media(uint8_t type, uint32_t ts, + const uint8_t *p, size_t n) +{ + if (!publishing_ || n == 0) { + return; // pre-publish media is not ours to keep + } + if (type == 9 && vcodec_ == RTMP_VCODEC_NONE) { + /* + Legacy tag header: codec id in the low nibble, 7 being AVC. + Enhanced RTMP sets the high bit and carries a FourCC instead, + which is how HEVC arrives. + */ + if ((p[0] & 0x80) == 0) { + vcodec_ = (p[0] & 0x0f) == 7 ? RTMP_VCODEC_H264 + : RTMP_VCODEC_OTHER; + } else if (n >= 5) { + vcodec_ = memcmp(p + 1, "avc1", 4) == 0 ? RTMP_VCODEC_H264 + : RTMP_VCODEC_OTHER; + } + } + if (type == 18) { + /* + RTMP sends metadata as @setDataFrame("onMetaData", {...}); FLV + wants the onMetaData call on its own, so drop the wrapper. + */ + static const char tag[] = "@setDataFrame"; + const size_t skip = 3 + sizeof(tag) - 1; + if (n > skip && p[0] == AMF_STRING + && p[1] == 0 && p[2] == uint8_t(sizeof(tag) - 1) + && memcmp(p + 3, tag, sizeof(tag) - 1) == 0) { + p += skip; + n -= skip; + } + } + media_bytes_ += n; + write_flv_tag(type, ts, p, n); +} diff --git a/videortmp.h b/videortmp.h new file mode 100644 index 0000000..7b0488c --- /dev/null +++ b/videortmp.h @@ -0,0 +1,239 @@ +/* + Native RTMP publish ingest. + + ffmpeg cannot be the RTMP server for real cameras. Its listener exists + to talk to its own client, so it answers FCPublish with a bare + "onFCPublish" -- the command name and nothing else, no transaction id, + no null, no status object -- and answers publish with nothing at all. + Measured against the Phoenix camera: the negotiation completes, ffmpeg + grants stream id 1, and the camera then waits 5 s for a response it can + parse and hangs up without sending a frame. A byte-identical exchange + reproduced against a local ffmpeg, and the same camera published 74 MB + to a server that answers properly, so the fault is not in the relay. + + Linking libavformat instead of forking ffmpeg would not help: that is + the same rtmpproto.c on the wire. + + So we speak RTMP ourselves -- handshake, chunk demux, and the six + commands a publisher uses -- and convert the media messages to FLV, + which ffmpeg is happy to demux from a pipe. That drops the loopback + port the splice needed, and with it the connect race and the TOCTOU + window on a multi-user host. + + Only the publish direction is implemented. This is not an RTMP server: + it never plays, seeks or pauses, and a client that asks to is dropped. + + An unauthorised peer drives this parser -- admission needs the password + out of connect/publish, which arrive after the handshake -- so the + pre-publish phase is bounded in both bytes and seconds, every length + from the wire is checked against what is actually buffered, and message + assembly is capped. + */ +#pragma once + +#include +#include +#include + +#include +#include + +// What we announce, and the largest outbound chunk we write. Every +// message we send is smaller, so outbound messages are single-chunk. +#define RTMP_OUT_CHUNK_SIZE 4096 + +// The protocol default until a peer says otherwise. +#define RTMP_DEFAULT_CHUNK_SIZE 128 + +// A publisher that has not reached publish inside these bounds is +// dropped: until then it is unauthenticated. +#define RTMP_PREPUBLISH_MAX_BYTES (256u * 1024) +#define RTMP_PREPUBLISH_MAX_S 20 + +// Largest single RTMP message we will assemble. +#define RTMP_MAX_MESSAGE_BYTES (4u * 1024 * 1024) + +/* + Refuse a peer-announced chunk size above this. The spec allows up to + 2^31-1, but a chunk has to be buffered whole before it can be parsed, + so honouring that would let three bytes from an unauthenticated peer + set the size of our input buffer. Real senders use 4 KiB (ffmpeg, + OBS) to 64 KiB. + */ +#define RTMP_MAX_CHUNK_SIZE (1u * 1024 * 1024) + +/* + Highest chunk stream id we keep state for. The 2-byte form can express + 65599, and each one costs a ChunkStream, so the cap is what stops a + 3-byte header allocating megabytes. Publishers use single digits. + */ +#define RTMP_MAX_CHUNK_STREAM 255 + +/* + Total bytes held in partial messages across every chunk stream. + + RTMP_MAX_MESSAGE_BYTES is per chunk stream, so without this a peer can + open one partial message on each of them and hold the product -- and + none of it completes, so the backend queue stays empty and nothing + downstream notices. Before publish the cumulative input limit covers + it; after publish only this does. + */ +#define RTMP_MAX_ASSEMBLY_BYTES (8u * 1024 * 1024) + +/* + Cap on responses owed to the peer. A client that stops reading while + still sending commands would otherwise turn its own bounded input into + unbounded memory here. + */ +#define RTMP_MAX_OUT_BYTES (256u * 1024) + +/* + A publisher that has been accepted but has not said what codec it is + sending by now is not going to. Until it does there is no backend and + no media, but it holds the slot -- and control messages alone keep + refreshing the idle timer. + */ +#define RTMP_CODEC_DEADLINE_S 15 + +/* + Which video codec the publisher is sending, once a video tag has said + so. The backend needs this before it starts: ffmpeg's own AVCC to + Annex-B conversion emits a zero-length NAL unit ahead of each access + unit for this camera's stream shape (one NAL per frame, parameter sets + only in the sequence header). Chrome's MP4 parser rejects that -- + "Failed to prepare video sample for decode" -- while Firefox tolerates + it. -bsf:v h264_metadata rewrites the units and removes them, but it + is H.264-only, so it must not be applied blind. + */ +enum rtmp_vcodec_t { + RTMP_VCODEC_NONE = 0, // no video tag seen yet + RTMP_VCODEC_H264, + RTMP_VCODEC_OTHER, // HEVC and friends, via enhanced RTMP +}; + +enum rtmp_state { + RTMP_WANT_C0C1 = 0, + RTMP_WANT_C2, + RTMP_CHUNKS, + RTMP_DEAD, +}; + +/* + One publisher's connection state. + + feed() takes bytes off the socket and appends to two output buffers the + caller drains: to_peer() for RTMP responses, to_flv() for media. The + session never writes to a descriptor itself, so the caller keeps all + the backpressure and epoll logic it already has for the RTSP splice. + */ +class RtmpSession { +public: + // Consume client bytes. False means the session is over. + bool feed(const uint8_t *buf, size_t n, time_t now); + + /* + Continue on already-buffered bytes. feed() stops as soon as publish + arrives so the caller can authorise in order; call this once it + has, or anything the publisher pipelined behind publish sits + unparsed until the next read. + */ + bool resume(void); + + // True once the client has issued publish and been accepted. + bool publishing(void) const { return publishing_; } + + /* + Set once publish arrives, before publishing() goes true: the caller + authorises the stream and then calls accept_publish() or reject(). + */ + bool publish_pending(void) const { return publish_pending_; } + const std::string &app(void) const { return app_; } + const std::string &stream(void) const { return stream_; } + const std::string &password(void) const { return password_; } + + // "app/stream", for matching against a configured path. + std::string path(void) const; + + void accept_publish(void); + void reject_publish(const char *code, const char *description); + + std::vector &to_peer(void) { return to_peer_; } + std::vector &to_flv(void) { return to_flv_; } + + uint64_t media_bytes(void) const { return media_bytes_; } + const char *error(void) const { return error_; } + + // Set once the first video tag names a codec. + rtmp_vcodec_t video_codec(void) const { return vcodec_; } + + // When publish was accepted, for the codec deadline. 0 until then. + time_t publishing_since(void) const { return publishing_since_; } + +private: + struct ChunkStream { + uint32_t ts = 0; // absolute, after applying deltas + uint32_t delta = 0; // last delta, reused by fmt 3 + uint32_t len = 0; + uint8_t type = 0; + uint32_t sid = 0; + bool ext_ts = false; // header carried an extended timestamp + std::vector acc; // partial message + }; + + rtmp_state state_ = RTMP_WANT_C0C1; + std::vector in_; + size_t in_pos_ = 0; + std::vector to_peer_; + std::vector to_flv_; + + uint32_t in_chunk_ = RTMP_DEFAULT_CHUNK_SIZE; + /* + Acknowledgement window the peer asked for, and how much we have + taken since the last acknowledgement we sent. A publisher that + sets a window and never sees a type-3 back is entitled to stop + sending, which shows up as a camera that streams for a while and + then stalls. + */ + uint32_t ack_window_ = 0; + uint64_t bytes_in_ = 0; + uint64_t acked_ = 0; + std::vector cs_; // indexed by chunk stream id + + std::string app_; + std::string stream_; + std::string password_; + double publish_txn_ = 0; + uint32_t publish_sid_ = 1; + bool publishing_ = false; + bool publish_pending_ = false; + bool connected_ = false; // connect already answered + time_t publishing_since_ = 0; + size_t assembly_bytes_ = 0; // sum of every chunk stream's acc + rtmp_vcodec_t vcodec_ = RTMP_VCODEC_NONE; + bool flv_header_written_ = false; + bool saw_c0c1_ = false; + time_t started_ = 0; + time_t now_ = 0; // last time seen by feed() + uint64_t total_in_ = 0; + uint64_t media_bytes_ = 0; + const char *error_ = ""; + + bool run_parser(void); + bool fail(const char *why); + void compact(void); + size_t avail(void) const { return in_.size() - in_pos_; } + const uint8_t *cur(void) const { return in_.data() + in_pos_; } + + bool do_handshake(void); + bool parse_chunks(void); + bool on_message(ChunkStream &c, const uint8_t *p, size_t n); + bool on_command(ChunkStream &c, const uint8_t *p, size_t n); + void on_media(uint8_t type, uint32_t ts, const uint8_t *p, size_t n); + + void send_msg(uint8_t csid, uint8_t type, uint32_t sid, + const uint8_t *p, size_t n, uint32_t ts = 0); + void send_amf(uint8_t csid, uint32_t sid, const std::vector &b); + void write_flv_header(void); + void write_flv_tag(uint8_t type, uint32_t ts, + const uint8_t *p, size_t n); +}; diff --git a/videortsp.cpp b/videortsp.cpp new file mode 100644 index 0000000..248c2de --- /dev/null +++ b/videortsp.cpp @@ -0,0 +1,372 @@ +/* + RTSP ingest by splicing to a loopback ffmpeg. See videortsp.h. + */ +#include "videortsp.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RtspBackend::~RtspBackend(void) +{ + stop(); +} + +int RtspBackend::pick_loopback_port(void) +{ + const int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) { + return -1; + } + struct sockaddr_in a {}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = 0; + if (bind(s, (struct sockaddr *)&a, sizeof(a)) != 0) { + ::close(s); + return -1; + } + socklen_t alen = sizeof(a); + if (getsockname(s, (struct sockaddr *)&a, &alen) != 0) { + ::close(s); + return -1; + } + const int port = ntohs(a.sin_port); + ::close(s); + return port; +} + +const char *splice_proto_name(splice_proto_t p) +{ + return p == SPLICE_RTMP ? "RTMP" : "RTSP"; +} + +bool RtspBackend::start(int port2, int slot, bool want_audio, + splice_proto_t proto, const char *vbsf) +{ + port2_ = port2; + slot_ = slot; + proto_ = proto; + + // RTSP splices into a loopback listener; RTMP is fed FLV on stdin. + int lport = 0; + if (proto == SPLICE_RTSP) { + lport = pick_loopback_port(); + if (lport <= 0) { + printf("[%d] video slot %d: no loopback port for the RTSP " + "backend\n", port2_, slot_); + return false; + } + } + + int media[2] = { -1, -1 }; + if (pipe(media) != 0) { + printf("[%d] video slot %d: pipe failed - %s\n", + port2_, slot_, strerror(errno)); + return false; + } + /* + A socketpair rather than a pipe: the caller pushes FLV through the + same queue it uses for the RTSP splice, and that writes with + send(MSG_NOSIGNAL), which fails ENOTSOCK on a pipe. The child sees + it as fd 0 either way. + */ + int feed[2] = { -1, -1 }; + if (proto == SPLICE_RTMP + && socketpair(AF_UNIX, SOCK_STREAM, 0, feed) != 0) { + ::close(media[0]); + ::close(media[1]); + printf("[%d] video slot %d: socketpair failed - %s\n", + port2_, slot_, strerror(errno)); + return false; + } + + char url[128]; + if (proto == SPLICE_RTMP) { + snprintf(url, sizeof(url), "pipe:0"); + } else { + snprintf(url, sizeof(url), "rtsp://127.0.0.1:%d/", lport); + } + + const pid_t pid = fork(); + if (pid < 0) { + ::close(media[0]); + ::close(media[1]); + if (feed[0] >= 0) { + ::close(feed[0]); + ::close(feed[1]); + } + printf("[%d] video slot %d: fork failed - %s\n", + port2_, slot_, strerror(errno)); + return false; + } + if (pid == 0) { + // die with us rather than lingering on a crash + prctl(PR_SET_PDEATHSIG, SIGTERM); + if (getppid() == 1) { + _exit(0); + } + /* + The backend parses SDP, RTP and codec bitstreams from a peer + we have only address-authorised. Fixed argv stops shell + injection; these stop a parser bug from becoming worse. + */ + prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + struct rlimit rl {}; + rl.rlim_cur = rl.rlim_max = RTSP_BACKEND_MEM_BYTES; + setrlimit(RLIMIT_AS, &rl); + rl.rlim_cur = rl.rlim_max = RTSP_BACKEND_CPU_SECONDS; + setrlimit(RLIMIT_CPU, &rl); + /* + Deliberately no RLIMIT_NPROC. It bounds processes and threads + per *UID*, not per process, so a small value is instantly + exceeded by whatever else the account is already running -- + ffmpeg then fails at pthread_create and produces no output at + all. It is the wrong tool for sandboxing one child. + */ + + /* + Unchecked, a failed dup2 would leave the child running with + our stdout -- writing MPEG-TS into the proxy's log. Die + instead; the parent sees the exit and reports it. + */ + ::close(media[0]); + if (dup2(media[1], STDOUT_FILENO) == -1) { + _exit(126); + } + ::close(media[1]); + if (proto == SPLICE_RTMP) { + ::close(feed[1]); + if (dup2(feed[0], STDIN_FILENO) == -1) { + _exit(126); + } + ::close(feed[0]); + } else { + const int devnull = open("/dev/null", O_RDONLY); + if (devnull >= 0) { + if (dup2(devnull, STDIN_FILENO) == -1) { + _exit(126); + } + ::close(devnull); + } + } + + /* + Drop every other inherited descriptor. + + Without this the backend keeps the accepted publisher socket + open -- visible in ss as ffmpeg and supportproxy both holding + the same public connection. The damage is not the leak itself + but that the socket never fully closes, so ffmpeg never sees + the end of its input, never exits, and running() stays true -- + which refuses every later publisher on that slot as slot-busy + until the child is killed by hand. + */ +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3, ~0U, 0) != 0) +#endif + { + /* + Close up to the real limit, not 4096. The old bound only + applied when RLIMIT_NOFILE was under 65536, so on a host + with a high limit every descriptor above 4095 survived -- + which is the case this loop exists to cover. + */ + struct rlimit nof {}; + long maxfd = 4096; + if (getrlimit(RLIMIT_NOFILE, &nof) == 0 + && nof.rlim_cur != RLIM_INFINITY) { + maxfd = long(nof.rlim_cur); + } else { + const long n = sysconf(_SC_OPEN_MAX); + maxfd = (n > 0) ? n : 65536; + } + for (int f = 3; f < maxfd; f++) { + ::close(f); + } + } + + /* + -an by default: audio is rarely useful from an aircraft, and + dropping it keeps the muxed stream video-only. With audio on, + it must be re-encoded -- pcm_s16be cannot be carried in + MPEG-TS at all (ffmpeg emits it as private data that probes + back as bin_data), so "copy" would silently destroy it. + */ + const char *audio1 = want_audio ? "-c:a" : "-an"; + const char *audio2 = want_audio ? "aac" : nullptr; + + const char *argv[40]; + int n = 0; + argv[n++] = "ffmpeg"; + argv[n++] = "-hide_banner"; + argv[n++] = "-nostdin"; + argv[n++] = "-loglevel"; + argv[n++] = "warning"; + if (proto == SPLICE_RTMP) { + // live_flv rather than flv: the stream never ends, and the + // plain flv demuxer waits for a file it will never see. + // No -timeout: that is a socket option, and this is a pipe. + argv[n++] = "-protocol_whitelist"; + argv[n++] = "file,pipe"; + argv[n++] = "-f"; + argv[n++] = "live_flv"; + } else { + argv[n++] = "-protocol_whitelist"; + argv[n++] = "file,rtp,udp,tcp"; + argv[n++] = "-rtsp_flags"; + argv[n++] = "listen"; + argv[n++] = "-rtsp_transport"; + argv[n++] = "tcp"; + argv[n++] = "-timeout"; + argv[n++] = "10000000"; + } + argv[n++] = "-i"; + argv[n++] = url; + argv[n++] = "-map"; + argv[n++] = "0"; + argv[n++] = "-c:v"; + argv[n++] = "copy"; + if (vbsf != nullptr && vbsf[0] != '\0') { + argv[n++] = "-bsf:v"; + argv[n++] = vbsf; + } + argv[n++] = audio1; + if (audio2 != nullptr) { + argv[n++] = audio2; + } + /* + Live output, so do not let the muxer sit on data. The default + 32 KiB AVIO buffer is most of a second at this camera's ~0.4 + Mbit/s, and muxdelay/muxpreload add their own offset on top. + Measured as part of the join-to-live delay. + */ + argv[n++] = "-muxdelay"; + argv[n++] = "0"; + argv[n++] = "-muxpreload"; + argv[n++] = "0"; + argv[n++] = "-flush_packets"; + argv[n++] = "1"; + argv[n++] = "-f"; + argv[n++] = "mpegts"; + argv[n++] = "pipe:1"; + argv[n] = nullptr; + + execvp("ffmpeg", const_cast(argv)); + // Only reached if ffmpeg is missing. + _exit(127); + } + + ::close(media[1]); + pid_ = pid; + media_fd_ = media[0]; + fcntl(media_fd_, F_SETFL, fcntl(media_fd_, F_GETFL, 0) | O_NONBLOCK); + + if (proto == SPLICE_RTMP) { + ::close(feed[0]); + backend_fd_ = feed[1]; + fcntl(backend_fd_, F_SETFL, + fcntl(backend_fd_, F_GETFL, 0) | O_NONBLOCK); + printf("[%d] video slot %d RTMP backend pid %d (FLV on stdin, " + "bsf %s)\n", port2_, slot_, int(pid_), + (vbsf != nullptr && vbsf[0] != '\0') ? vbsf : "none"); + return true; + } + + // Retry-connect until the backend's listener is up. It bound the + // port after we picked it, so a short race is expected. + const int step_ms = 20; + for (int waited = 0; waited < RTSP_BACKEND_READY_MS; waited += step_ms) { + const int s = socket(AF_INET, SOCK_STREAM, 0); + if (s < 0) { + break; + } + struct sockaddr_in a {}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = htons(uint16_t(lport)); + if (connect(s, (struct sockaddr *)&a, sizeof(a)) == 0) { + int one = 1; + setsockopt(s, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + fcntl(s, F_SETFL, fcntl(s, F_GETFL, 0) | O_NONBLOCK); + backend_fd_ = s; + printf("[%d] video slot %d RTSP backend pid %d on 127.0.0.1:%d\n", + port2_, slot_, int(pid_), lport); + return true; + } + ::close(s); + if (reap()) { + printf("[%d] video slot %d: RTSP backend exited before it " + "listened (is ffmpeg installed?)\n", port2_, slot_); + stop(); + return false; + } + struct timespec ts { 0, step_ms * 1000000L }; + nanosleep(&ts, nullptr); + } + printf("[%d] video slot %d: RTSP backend never became connectable\n", + port2_, slot_); + stop(); + return false; +} + +bool RtspBackend::reap(void) +{ + if (pid_ <= 0) { + return true; + } + int status = 0; + const pid_t r = waitpid(pid_, &status, WNOHANG); + if (r == pid_) { + if (WIFEXITED(status) && WEXITSTATUS(status) == 127) { + printf("[%d] video slot %d: ffmpeg not found; RTSP ingest needs " + "it installed\n", port2_, slot_); + } + pid_ = -1; + return true; + } + if (r < 0 && errno == ECHILD) { + pid_ = -1; + return true; + } + return false; +} + +void RtspBackend::stop(void) +{ + if (backend_fd_ >= 0) { + ::close(backend_fd_); + backend_fd_ = -1; + } + if (media_fd_ >= 0) { + ::close(media_fd_); + media_fd_ = -1; + } + if (pid_ > 0) { + kill(pid_, SIGTERM); + // Give it a moment to go on its own, then insist. + for (int i = 0; i < 50; i++) { + if (reap()) { + return; + } + struct timespec ts { 0, 10 * 1000000L }; + nanosleep(&ts, nullptr); + } + kill(pid_, SIGKILL); + int status = 0; + waitpid(pid_, &status, 0); + pid_ = -1; + } +} diff --git a/videortsp.h b/videortsp.h new file mode 100644 index 0000000..2ee8bd2 --- /dev/null +++ b/videortsp.h @@ -0,0 +1,108 @@ +/* + RTSP and RTMP ingest, by splicing to a loopback ffmpeg. + + SupportProxy parses no RTSP at all. It keeps the public port, does the + admission check on the source address, then hands the connection -- + untouched, from its very first byte -- to an ffmpeg bound on + 127.0.0.1, and reads MPEG-TS back from its stdout. + + The spike established why it has to work this way. RTSP is + request/response, so waiting to see ANNOUNCE before classifying + deadlocks: the publisher sends OPTIONS and waits for a reply that + never comes. Answering OPTIONS ourselves fails differently -- ffmpeg's + listener requires the first request it sees to be CSeq 1, so it + rejects an ANNOUNCE numbered 2. Splicing from byte zero sidesteps + both: ffmpeg sees the whole exchange, starting at CSeq 1. + + Consequence, and the reason RTSP *egress* is a separate phase: with no + RTSP parsing we cannot tell a publisher from a viewer, so an RTSP + connection to a video port is treated as a publisher. + + A native RTP depacketiser plus TS muxer was measured at ~2300-3000 + lines against ~150 for this, would still not carry the PCM audio the + Phoenix camera sends, and an H.264-only cut would not even cover the + workload (one of the two real streams is HEVC). + + RTMP does not splice. ffmpeg's RTMP listener answers a real camera + badly enough that it never sends a frame (see videortmp.h), so + SupportProxy speaks RTMP itself and hands the backend FLV on stdin. + The backend is still an ffmpeg child with the same sandbox; only the + input side differs, and RTMP needs no loopback port at all. + */ +#pragma once + +#include +#include +#include +#include + +#include + +// Give up if the backend has not become connectable in this long. +#define RTSP_BACKEND_READY_MS 3000 + +// Resource limits applied to the backend: it parses untrusted SDP, RTP +// and codec bitstreams, and fixed argv only stops shell injection, not +// a bug in those parsers. Note RLIMIT_NPROC is *not* among them -- it +// is per-UID rather than per-process, so it breaks the child without +// bounding anything useful. +#define RTSP_BACKEND_MEM_BYTES (512u * 1024 * 1024) +#define RTSP_BACKEND_CPU_SECONDS 3600 + +enum splice_proto_t { + SPLICE_RTSP = 0, + SPLICE_RTMP, +}; + +const char *splice_proto_name(splice_proto_t p); + +class RtspBackend { +public: + ~RtspBackend(void); + + /* + Launch ffmpeg. For RTSP it also connects to the backend's loopback + listener; for RTMP the backend reads FLV from a pipe we own, so + there is nothing to connect to. Returns false if it could not be + started. + */ + /* + `vbsf`, when set, is a video bitstream filter applied on the way + through -- h264_metadata for RTMP H.264, which rewrites the NAL + units and drops the zero-length one ffmpeg's own AVCC to Annex-B + conversion emits for some cameras. It is codec-specific, so the + caller must know the codec before passing it. + */ + bool start(int port2, int slot, bool want_audio, + splice_proto_t proto = SPLICE_RTSP, + const char *vbsf = nullptr); + + splice_proto_t proto(void) const { return proto_; } + + bool running(void) const { return pid_ > 0; } + + /* + Where the publisher's bytes go: a socket spliced with the client + for RTSP, the write end of the backend's stdin for RTMP. Only the + RTSP one is readable. + */ + int backend_fd(void) const { return backend_fd_; } + bool backend_readable(void) const { return proto_ == SPLICE_RTSP; } + int media_fd(void) const { return media_fd_; } + pid_t pid(void) const { return pid_; } + + // Reap if the child has exited. Returns true if it is now gone. + bool reap(void); + + void stop(void); + +private: + pid_t pid_ = -1; + int backend_fd_ = -1; // RTSP control/data, spliced with the client + int media_fd_ = -1; // ffmpeg stdout: MPEG-TS + int port2_ = 0; + int slot_ = 0; + splice_proto_t proto_ = SPLICE_RTSP; + + static int pick_loopback_port(void); +}; diff --git a/videostream.cpp b/videostream.cpp new file mode 100644 index 0000000..7644208 --- /dev/null +++ b/videostream.cpp @@ -0,0 +1,178 @@ +/* + Per-slot stream buffering. See videostream.h. + */ +#include "videostream.h" + +#include +#include +#include +#include + +size_t video_ring_bytes(void) +{ + static size_t cached = 0; + if (cached == 0) { + cached = VIDEO_RING_DEFAULT; + const char *env = getenv("SUPPORTPROXY_VIDEO_RING_BYTES"); + if (env != nullptr && *env != '\0') { + char *endp = nullptr; + errno = 0; + long long v = strtoll(env, &endp, 10); + if (errno == 0 && endp != env && *endp == '\0' && v > 0) { + cached = size_t(v); + } + } + } + return cached; +} + +bool VideoRing::init(size_t bytes) +{ + if (bytes < 4096) { + bytes = 4096; + } + buf_.assign(bytes, 0); + write_pos_ = 0; + return true; +} + +void VideoRing::write(const uint8_t *buf, size_t n) +{ + const size_t cap = buf_.size(); + if (cap == 0 || n == 0) { + return; + } + if (n >= cap) { + // Only the tail can survive; skip the part that would be + // overwritten before this call even returned. + buf += n - cap; + write_pos_ += n - cap; + n = cap; + } + const size_t start = size_t(write_pos_ % cap); + const size_t first = (cap - start) < n ? (cap - start) : n; + memcpy(&buf_[start], buf, first); + if (n > first) { + memcpy(&buf_[0], buf + first, n - first); + } + write_pos_ += n; +} + +uint64_t VideoRing::oldest(void) const +{ + const size_t cap = buf_.size(); + return write_pos_ > cap ? write_pos_ - cap : 0; +} + +bool VideoRing::resident(uint64_t pos) const +{ + return pos >= oldest() && pos <= write_pos_; +} + +size_t VideoRing::read_at(uint64_t pos, uint8_t *out, size_t n) const +{ + const size_t cap = buf_.size(); + if (cap == 0 || !resident(pos)) { + return 0; + } + const uint64_t avail64 = write_pos_ - pos; + const size_t avail = avail64 > n ? n : size_t(avail64); + if (avail == 0) { + return 0; + } + const size_t start = size_t(pos % cap); + const size_t first = (cap - start) < avail ? (cap - start) : avail; + memcpy(out, &buf_[start], first); + if (avail > first) { + memcpy(out + first, &buf_[0], avail - first); + } + return avail; +} + +// ----------------------------------------------------------- selftest + +#define RCHECK(cond, msg) do { \ + if (!(cond)) { \ + printf("videostream selftest FAIL: %s\n", msg); \ + return 1; \ + } \ + } while (0) + +int videostream_selftest(void) +{ + // basic append and read-back + { + VideoRing r; + r.init(4096); + uint8_t in[300]; + for (size_t i = 0; i < sizeof(in); i++) { + in[i] = uint8_t(i); + } + r.write(in, sizeof(in)); + RCHECK(r.write_pos() == 300, "write_pos advanced"); + RCHECK(r.oldest() == 0, "nothing evicted yet"); + uint8_t out[300] {}; + RCHECK(r.read_at(0, out, sizeof(out)) == 300, "read back all"); + RCHECK(memcmp(in, out, sizeof(in)) == 0, "bytes match"); + // a partial read from the middle + RCHECK(r.read_at(100, out, 50) == 50, "partial read"); + RCHECK(memcmp(out, in + 100, 50) == 0, "partial bytes match"); + } + + // wrap: the ring must stay byte-exact across the seam + { + VideoRing r; + r.init(4096); + uint8_t chunk[1000]; + for (int pass = 0; pass < 10; pass++) { + for (size_t i = 0; i < sizeof(chunk); i++) { + chunk[i] = uint8_t(pass * 31 + i); + } + r.write(chunk, sizeof(chunk)); + } + RCHECK(r.write_pos() == 10000, "wrapped write_pos"); + RCHECK(r.oldest() == 10000 - 4096, "oldest tracks eviction"); + RCHECK(!r.resident(0), "evicted offset is not resident"); + RCHECK(r.resident(r.oldest()), "oldest is resident"); + + // the last chunk must read back exactly, spanning the seam + uint8_t out[1000] {}; + RCHECK(r.read_at(9000, out, 1000) == 1000, "read last chunk"); + for (size_t i = 0; i < sizeof(out); i++) { + RCHECK(out[i] == uint8_t(9 * 31 + i), "wrapped bytes match"); + } + } + + // a write larger than the ring keeps the tail, not the head + { + VideoRing r; + r.init(4096); + std::vector big(10000); + for (size_t i = 0; i < big.size(); i++) { + big[i] = uint8_t(i); + } + r.write(big.data(), big.size()); + RCHECK(r.write_pos() == 10000, "oversized write advances fully"); + RCHECK(r.oldest() == 10000 - 4096, "oversized write evicts"); + uint8_t out[16] {}; + RCHECK(r.read_at(10000 - 16, out, 16) == 16, "tail readable"); + for (size_t i = 0; i < 16; i++) { + RCHECK(out[i] == uint8_t(10000 - 16 + i), "tail bytes are the tail"); + } + } + + // reading an evicted position must fail rather than return garbage + { + VideoRing r; + r.init(4096); + uint8_t chunk[5000] {}; + r.write(chunk, sizeof(chunk)); + uint8_t out[16] {}; + RCHECK(r.read_at(0, out, sizeof(out)) == 0, "evicted read returns 0"); + RCHECK(r.read_at(r.write_pos() + 1, out, sizeof(out)) == 0, + "future read returns 0"); + } + + printf("videostream selftest: OK\n"); + return 0; +} diff --git a/videostream.h b/videostream.h new file mode 100644 index 0000000..b8a38f1 --- /dev/null +++ b/videostream.h @@ -0,0 +1,66 @@ +/* + Per-slot stream buffering. + + The publisher's bytes go into a ring and nothing else happens to them: + fan-out and recording both hand out the original bytes, so the write + path must never block, allocate, or look at viewer state. That is what + makes "one slow viewer cannot stall the publisher or the others" true + by construction rather than by care. + + Positions are absolute stream offsets, not indices, so a viewer that + falls behind is detected by arithmetic (write_pos - read_pos > size) + rather than by trying to reason about wrap. + */ +#pragma once + +#include +#include +#include + +// Default ring size. The rule that matters is +// ring >= 2 * GOP_seconds * bitrate +// or a late viewer has no random access point to start from. Measured +// on the Phoenix camera: ~4 s GOP at ~2.45 Mbit/s, so 8 MiB holds about +// 26 s -- roughly six GOPs. A higher-bitrate publisher with a long GOP +// needs the rule applied, not this constant. +#define VIDEO_RING_DEFAULT (8u * 1024 * 1024) + +// Ring size actually used, honouring SUPPORTPROXY_VIDEO_RING_BYTES. +// Overridable because lapping a viewer is otherwise only reachable by +// pushing megabytes through a test. +size_t video_ring_bytes(void); + +class VideoRing { +public: + bool init(size_t bytes); + + // Append. Never blocks and never fails; the oldest bytes are simply + // overwritten. A write larger than the ring keeps only its tail. + void write(const uint8_t *buf, size_t n); + + uint64_t write_pos(void) const { return write_pos_; } + size_t capacity(void) const { return buf_.size(); } + + // Earliest offset still held. + uint64_t oldest(void) const; + + // True if `pos` is still resident (and not in the future). + bool resident(uint64_t pos) const; + + // Copy up to `n` bytes from `pos`. Returns how many were copied, + // which is 0 if `pos` has already been overwritten. + size_t read_at(uint64_t pos, uint8_t *out, size_t n) const; + + // Drop everything held and restart the offset space at 0. + // + // Only safe with no viewers attached: they hold absolute offsets, + // and rewinding write_pos_ under one would make its position look + // like the far future. The caller drops viewers first. + void reset(void) { write_pos_ = 0; } + +private: + std::vector buf_; + uint64_t write_pos_ = 0; +}; + +int videostream_selftest(void); diff --git a/videots.cpp b/videots.cpp new file mode 100644 index 0000000..00e27d6 --- /dev/null +++ b/videots.cpp @@ -0,0 +1,842 @@ +/* + MPEG-TS scanner. See videots.h for what it is for. + + Every multi-byte field is assembled a byte at a time. Casting a packet + buffer to a wider integer is undefined for an unaligned address and + trips -Wcast-align, and packet buffers are never aligned in general. + */ +#include "videots.h" + +#include +#include + +// ---------------------------------------------------------------- CRC + +static uint32_t crc_table[256]; +static bool crc_table_built = false; + +static void build_crc_table(void) +{ + for (uint32_t i = 0; i < 256; i++) { + uint32_t c = i << 24; + for (int k = 0; k < 8; k++) { + c = (c & 0x80000000u) ? ((c << 1) ^ 0x04C11DB7u) : (c << 1); + } + crc_table[i] = c; + } + crc_table_built = true; +} + +uint32_t ts_crc32(const uint8_t *data, size_t n) +{ + if (!crc_table_built) { + build_crc_table(); + } + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < n; i++) { + crc = (crc << 8) ^ crc_table[((crc >> 24) ^ data[i]) & 0xFF]; + } + return crc; +} + +// ------------------------------------------------------- PSIAssembler + +const uint8_t *PSIAssembler::feed(const uint8_t *payload, size_t n, bool pusi, + size_t &out_len, uint64_t &crc_errors) +{ + if (n == 0) { + return nullptr; + } + if (pusi) { + // A unit start carries a pointer_field: the number of bytes of + // the *previous* section still to come before this one starts. + const uint8_t ptr = payload[0]; + if (size_t(ptr) + 1 > n) { + reset(); + return nullptr; + } + payload += 1 + ptr; + n -= 1 + ptr; + len_ = 0; + active_ = true; + want_ = 0; + } else if (!active_) { + // Continuation with no section open: nothing to append to. + return nullptr; + } + + if (n == 0) { + return nullptr; + } + const size_t space = sizeof(buf_) - len_; + const size_t take = n < space ? n : space; + memcpy(buf_ + len_, payload, take); + len_ += take; + + if (want_ == 0) { + if (len_ < 3) { + return nullptr; + } + // section_length is 12 bits and excludes the 3 bytes before it + want_ = (size_t(buf_[1] & 0x0F) << 8 | buf_[2]) + 3; + if (want_ > sizeof(buf_) || want_ < 4) { + reset(); + return nullptr; + } + } + if (len_ < want_) { + return nullptr; + } + + const size_t seclen = want_; + active_ = false; + len_ = 0; + want_ = 0; + + // The trailing 4 bytes are the CRC, and it covers everything before + // them. A section that fails is dropped: acting on a corrupt PMT + // would point the scanner at the wrong PID. + if (ts_crc32(buf_, seclen) != 0) { + crc_errors++; + return nullptr; + } + out_len = seclen; + return buf_; +} + +// ------------------------------------------------------ random access + +bool ts_payload_is_random_access(const uint8_t *p, size_t n, + uint8_t stream_type) +{ + if (stream_type != TS_STREAM_H264 && stream_type != TS_STREAM_HEVC) { + // Only H.264/HEVC are inspected; for anything else fall back to + // trusting the RAI bit. + return true; + } + // Walk Annex-B start codes. The payload here begins with a PES + // header, so scan rather than assuming an offset. + for (size_t i = 0; i + 4 < n; i++) { + if (p[i] != 0 || p[i + 1] != 0 || p[i + 2] != 1) { + continue; + } + const uint8_t b = p[i + 3]; + if (stream_type == TS_STREAM_H264) { + const uint8_t nal = b & 0x1F; + // 9 = access unit delimiter, 7 = SPS, 8 = PPS, 5 = IDR + if (nal == 9 || nal == 7 || nal == 8 || nal == 5) { + return true; + } + } else { + const uint8_t nal = (b >> 1) & 0x3F; + // 35 = AUD, 32/33/34 = VPS/SPS/PPS, 16..21 = IRAP slices + if (nal == 35 || (nal >= 32 && nal <= 34) + || (nal >= 16 && nal <= 21)) { + return true; + } + } + } + return false; +} + +void TSScanner::reset(void) +{ + stats_ = TSStats(); + pat_asm_.reset(); + pmt_asm_.reset(); + pmt_pid_ = 0; + have_pat_ = false; + have_pmt_ = false; + pat_version_ = 0xFF; + pmt_version_ = 0xFF; + video_pid_ = 0; + video_stream_type_ = 0; + last_pat_off_ = 0; + last_pmt_off_ = 0; + have_pat_off_ = false; + have_pmt_off_ = false; + anchor_ = 0; + have_anchor_ = false; + memset(cc_, 0, sizeof(cc_)); + memset(cc_seen_, 0, sizeof(cc_seen_)); + partial_len_ = 0; + partial_off_ = 0; + synced_ = false; +} + +bool TSScanner::stream_type_playable(uint8_t st) +{ + // What a browser MSE player can make use of. HEVC is deliberately + // excluded: mpegts.js can demux it but MSE support is absent on + // most desktops. + return st == TS_STREAM_H264 || st == TS_STREAM_AAC_ADTS + || st == TS_STREAM_AAC_LATM; +} + +// ----------------------------------------------------------- scanning + +void TSScanner::parse_pat(const uint8_t *sec, size_t n, uint64_t off) +{ + if (n < 12 || sec[0] != 0x00) { + return; + } + const uint8_t version = (sec[5] >> 1) & 0x1F; + const bool current = (sec[5] & 1) != 0; + if (!current) { + return; + } + stats_.pat_seen++; + last_pat_off_ = off; + have_pat_off_ = true; + + if (have_pat_ && version == pat_version_) { + return; // unchanged; nothing to re-parse + } + pat_version_ = version; + + // program entries run from byte 8 to the CRC + const size_t end = n - 4; + for (size_t i = 8; i + 4 <= end; i += 4) { + const uint16_t prog = uint16_t(sec[i]) << 8 | sec[i + 1]; + const uint16_t pid = (uint16_t(sec[i + 2] & 0x1F) << 8) | sec[i + 3]; + if (prog != 0) { + if (pmt_pid_ != pid) { + // program moved: the old PMT no longer describes us + have_pmt_ = false; + pmt_version_ = 0xFF; + pmt_asm_.reset(); + } + pmt_pid_ = pid; + have_pat_ = true; + return; // single-program streams only, which is what we get + } + } +} + +void TSScanner::parse_pmt(const uint8_t *sec, size_t n, uint64_t off) +{ + if (n < 16 || sec[0] != 0x02) { + return; + } + const uint8_t version = (sec[5] >> 1) & 0x1F; + const bool current = (sec[5] & 1) != 0; + if (!current) { + return; + } + stats_.pmt_seen++; + last_pmt_off_ = off; + have_pmt_off_ = true; + + if (have_pmt_ && version == pmt_version_) { + return; + } + pmt_version_ = version; + + const size_t prog_info_len = (size_t(sec[10] & 0x0F) << 8) | sec[11]; + size_t i = 12 + prog_info_len; + const size_t end = n - 4; + uint16_t vpid = 0; + uint8_t vst = 0; + while (i + 5 <= end) { + const uint8_t st = sec[i]; + const uint16_t pid = (uint16_t(sec[i + 1] & 0x1F) << 8) | sec[i + 2]; + const size_t es_len = (size_t(sec[i + 3] & 0x0F) << 8) | sec[i + 4]; + if (vpid == 0 && (st == TS_STREAM_H264 || st == TS_STREAM_HEVC + || st == TS_STREAM_MPEG2_VIDEO)) { + vpid = pid; + vst = st; + } + i += 5 + es_len; + } + if (vpid != 0) { + video_pid_ = vpid; + video_stream_type_ = vst; + have_pmt_ = true; + } +} + +void TSScanner::packet(const uint8_t *p, uint64_t off) +{ + stats_.packets++; + if (p[0] != TS_SYNC_BYTE) { + stats_.bad_sync++; + synced_ = false; + return; + } + const bool pusi = (p[1] & 0x40) != 0; + const uint16_t pid = (uint16_t(p[1] & 0x1F) << 8) | p[2]; + const uint8_t afc = (p[3] >> 4) & 0x03; + const uint8_t cc = p[3] & 0x0F; + + if (afc == 0 || afc == 2) { + // no payload; CC does not advance + } else { + if (cc_seen_[pid] && cc != uint8_t((cc_[pid] + 1) & 0x0F)) { + stats_.cc_errors++; + } + cc_[pid] = cc; + cc_seen_[pid] = true; + } + + size_t off_in = 4; + bool rai = false; + if (afc == 2 || afc == 3) { + const uint8_t af_len = p[4]; + if (af_len > 0 && 5 + size_t(af_len) <= TS_PACKET_SIZE) { + rai = (p[5] & 0x40) != 0; + } + off_in = 5 + size_t(af_len); + if (off_in > TS_PACKET_SIZE) { + return; + } + } + if (afc == 0 || afc == 2 || off_in >= TS_PACKET_SIZE) { + return; // no payload + } + const uint8_t *payload = p + off_in; + const size_t plen = TS_PACKET_SIZE - off_in; + + if (pid == 0) { + size_t seclen = 0; + const uint8_t *sec = pat_asm_.feed(payload, plen, pusi, seclen, + stats_.crc_errors); + if (sec != nullptr) { + parse_pat(sec, seclen, off); + } + return; + } + if (have_pat_ && pid == pmt_pid_) { + size_t seclen = 0; + const uint8_t *sec = pmt_asm_.feed(payload, plen, pusi, seclen, + stats_.crc_errors); + if (sec != nullptr) { + parse_pmt(sec, seclen, off); + } + return; + } + if (have_pmt_ && pid == video_pid_ && rai) { + // Confirm the RAI bit against the payload. A muxer may set it + // inaccurately, and serving a viewer from a point the decoder + // cannot start at looks exactly like a broken stream. + if (!pusi || ts_payload_is_random_access(payload, plen, + video_stream_type_)) { + stats_.rai_seen++; + if (have_pat_off_ && have_pmt_off_ + && last_pat_off_ <= off && last_pmt_off_ <= off) { + // Start at whichever of the two came first, so the + // viewer sees PAT and PMT before the access point. + anchor_ = last_pat_off_ < last_pmt_off_ ? last_pat_off_ + : last_pmt_off_; + have_anchor_ = true; + } + } + } +} + +void TSScanner::feed(const uint8_t *buf, size_t n, uint64_t base) +{ + stats_.bytes += n; + size_t i = 0; + + // finish a packet split across feed() calls + if (partial_len_ > 0) { + const size_t need = TS_PACKET_SIZE - partial_len_; + const size_t take = n < need ? n : need; + memcpy(partial_ + partial_len_, buf, take); + partial_len_ += take; + i += take; + if (partial_len_ < TS_PACKET_SIZE) { + return; + } + packet(partial_, partial_off_); + partial_len_ = 0; + } + + while (i < n) { + if (!synced_) { + // Hunt for a sync byte that is followed by another one a + // packet later, so a 0x47 inside a payload doesn't fool us. + size_t j = i; + bool found = false; + while (j < n) { + if (buf[j] == TS_SYNC_BYTE) { + const size_t next = j + TS_PACKET_SIZE; + if (next >= n || buf[next] == TS_SYNC_BYTE) { + found = true; + break; + } + } + j++; + } + if (!found) { + return; // no plausible start in this chunk + } + if (j != i) { + stats_.resyncs++; + } + i = j; + synced_ = true; + } + const size_t avail = n - i; + if (avail < TS_PACKET_SIZE) { + memcpy(partial_, buf + i, avail); + partial_len_ = avail; + partial_off_ = base + i; + return; + } + packet(buf + i, base + i); + i += TS_PACKET_SIZE; + } +} + +bool TSScanner::join_offset(uint64_t &out) const +{ + if (!have_anchor_ || !have_pmt_) { + return false; + } + out = anchor_; + return true; +} + +// ----------------------------------------------------------- selftest + +#include + +namespace { + +struct TSBuilder { + uint8_t cc[8192] {}; + + // Append one 188-byte packet. + void pkt(uint8_t *out, uint16_t pid, bool pusi, const uint8_t *payload, + size_t plen, bool rai) + { + memset(out, 0xFF, TS_PACKET_SIZE); + out[0] = TS_SYNC_BYTE; + out[1] = uint8_t((pusi ? 0x40 : 0) | ((pid >> 8) & 0x1F)); + out[2] = uint8_t(pid & 0xFF); + size_t body = 4; + if (rai) { + out[3] = uint8_t(0x30 | (cc[pid] & 0x0F)); // AF + payload + const size_t af_len = TS_PACKET_SIZE - 5 - plen; + out[4] = uint8_t(af_len); + out[5] = 0x40; // RAI + for (size_t i = 6; i < 5 + af_len; i++) { + out[i] = 0xFF; + } + body = 5 + af_len; + } else { + out[3] = uint8_t(0x10 | (cc[pid] & 0x0F)); // payload only + } + cc[pid] = uint8_t((cc[pid] + 1) & 0x0F); + if (payload != nullptr && plen > 0) { + memcpy(out + body, payload, plen); + } + } + + static void finish_section(uint8_t *sec, size_t body_len) + { + // body_len counts from table_id through the last byte before CRC + const uint32_t crc = ts_crc32(sec, body_len); + sec[body_len + 0] = uint8_t(crc >> 24); + sec[body_len + 1] = uint8_t(crc >> 16); + sec[body_len + 2] = uint8_t(crc >> 8); + sec[body_len + 3] = uint8_t(crc); + } + + void pat(uint8_t *out, uint16_t pmt_pid, uint8_t version) + { + uint8_t sec[64] {}; + sec[0] = 0x00; // table_id + const size_t body = 12; // through the program entry + sec[1] = uint8_t(0xB0 | (((body + 4 - 3) >> 8) & 0x0F)); + sec[2] = uint8_t((body + 4 - 3) & 0xFF); + sec[3] = 0x00; sec[4] = 0x01; // transport_stream_id + sec[5] = uint8_t(0xC1 | (version << 1)); + sec[6] = 0x00; sec[7] = 0x00; + sec[8] = 0x00; sec[9] = 0x01; // program_number 1 + sec[10] = uint8_t(0xE0 | ((pmt_pid >> 8) & 0x1F)); + sec[11] = uint8_t(pmt_pid & 0xFF); + finish_section(sec, body); + uint8_t payload[TS_PACKET_SIZE] {}; + payload[0] = 0x00; // pointer_field + memcpy(payload + 1, sec, body + 4); + pkt(out, 0, true, payload, body + 4 + 1, false); + } + + void pmt(uint8_t *out, uint16_t pmt_pid, uint16_t vpid, uint8_t stype, + uint8_t version) + { + uint8_t sec[64] {}; + sec[0] = 0x02; + const size_t body = 17; // through the one ES entry + sec[1] = uint8_t(0xB0 | (((body + 4 - 3) >> 8) & 0x0F)); + sec[2] = uint8_t((body + 4 - 3) & 0xFF); + sec[3] = 0x00; sec[4] = 0x01; + sec[5] = uint8_t(0xC1 | (version << 1)); + sec[6] = 0x00; sec[7] = 0x00; + sec[8] = uint8_t(0xE0 | ((vpid >> 8) & 0x1F)); + sec[9] = uint8_t(vpid & 0xFF); // PCR PID + sec[10] = 0xF0; sec[11] = 0x00; // program_info_length 0 + sec[12] = stype; + sec[13] = uint8_t(0xE0 | ((vpid >> 8) & 0x1F)); + sec[14] = uint8_t(vpid & 0xFF); + sec[15] = 0xF0; sec[16] = 0x00; // ES_info_length 0 + finish_section(sec, body); + uint8_t payload[TS_PACKET_SIZE] {}; + payload[0] = 0x00; + memcpy(payload + 1, sec, body + 4); + pkt(out, pmt_pid, true, payload, body + 4 + 1, false); + } + + // `key` picks the payload (keyframe NAL vs a plain slice); `rai` + // controls the adaptation field's random_access_indicator. They are + // separate so a stream that lies -- RAI set on a non-keyframe -- + // can be built. + /* + A PMT with `n_es` elementary streams, split across as many packets + as it needs: one PUSI packet then continuation packets. Without + this, every section fits in one packet and the multi-packet + reassembly path is never exercised at all. + + Returns how many packets were written. + */ + size_t pmt_split(uint8_t *out, uint16_t pmt_pid, uint16_t vpid, + uint8_t stype, uint8_t version, size_t n_es, + size_t max_packets) + { + if (max_packets == 0) { + return 0; + } + uint8_t sec[TS_MAX_SECTION] {}; + size_t k = 0; + sec[k++] = 0x02; + k += 2; // length, patched below + sec[k++] = 0x00; sec[k++] = 0x01; + sec[k++] = uint8_t(0xC1 | (version << 1)); + sec[k++] = 0x00; sec[k++] = 0x00; + sec[k++] = uint8_t(0xE0 | ((vpid >> 8) & 0x1F)); + sec[k++] = uint8_t(vpid & 0xFF); + sec[k++] = 0xF0; sec[k++] = 0x00; + // first ES entry is the video one + sec[k++] = stype; + sec[k++] = uint8_t(0xE0 | ((vpid >> 8) & 0x1F)); + sec[k++] = uint8_t(vpid & 0xFF); + sec[k++] = 0xF0; sec[k++] = 0x00; + for (size_t e = 1; e < n_es && k + 5 + 4 < sizeof(sec); e++) { + const uint16_t pid = uint16_t(0x200 + e); + sec[k++] = TS_STREAM_PRIVATE; + sec[k++] = uint8_t(0xE0 | ((pid >> 8) & 0x1F)); + sec[k++] = uint8_t(pid & 0xFF); + sec[k++] = 0xF0; sec[k++] = 0x00; + } + const size_t body = k; + const size_t section_length = body + 4 - 3; + sec[1] = uint8_t(0xB0 | ((section_length >> 8) & 0x0F)); + sec[2] = uint8_t(section_length & 0xFF); + finish_section(sec, body); + const size_t total = body + 4; + + // first packet carries the pointer_field, the rest are + // continuations with no pointer field and PUSI clear + size_t written = 0; + size_t off = 0; + bool first = true; + while (off < total && written < max_packets) { + uint8_t payload[TS_PACKET_SIZE] {}; + size_t plen = 0; + if (first) { + payload[plen++] = 0x00; // pointer_field + } + const size_t room = (TS_PACKET_SIZE - 4) - plen; + const size_t take = (total - off) < room ? (total - off) : room; + memcpy(payload + plen, sec + off, take); + plen += take; + off += take; + pkt(out + written * TS_PACKET_SIZE, pmt_pid, first, + payload, plen, false); + written++; + first = false; + } + return written; + } + + void video(uint8_t *out, uint16_t vpid, bool key, + uint8_t stype = TS_STREAM_H264, int rai = -1) + { + // A PES header followed by an access-unit delimiter, so the + // RAI confirmation has something real to find. + uint8_t payload[32] {}; + size_t n = 0; + payload[n++] = 0x00; payload[n++] = 0x00; payload[n++] = 0x01; + payload[n++] = 0xE0; // PES video stream id + payload[n++] = 0x00; payload[n++] = 0x00; // PES length (unbounded) + payload[n++] = 0x80; payload[n++] = 0x00; payload[n++] = 0x00; + payload[n++] = 0x00; payload[n++] = 0x00; payload[n++] = 0x01; + if (stype == TS_STREAM_HEVC) { + // HEVC NAL header is two bytes and the type is bits 6..1: + // 35 = AUD, 1 = TRAIL_R. An H.264 AUD byte here would + // decode as type 4 and be rejected, which is the point of + // confirming the RAI bit against the payload at all. + payload[n++] = key ? uint8_t(35 << 1) : uint8_t(1 << 1); + payload[n++] = 0x01; + } else { + payload[n++] = key ? 0x09 : 0x41; // AUD / non-IDR slice + } + payload[n++] = 0x10; + pkt(out, vpid, true, payload, n, rai < 0 ? key : rai != 0); + } +}; + +#define CHECK(cond, msg) do { \ + if (!(cond)) { \ + printf("videots selftest FAIL: %s\n", msg); \ + return 1; \ + } \ + } while (0) + +} // namespace + +int videots_selftest(void) +{ + // CRC over a known-good section must come out zero when the CRC + // itself is included -- that is how a receiver validates it. + { + uint8_t sec[16] {}; + sec[0] = 0x00; sec[1] = 0xB0; sec[2] = 0x0D; + TSBuilder::finish_section(sec, 12); + CHECK(ts_crc32(sec, 16) == 0, "CRC self-check"); + } + + // A stream of PAT, PMT, non-key, key must yield an anchor at the PAT. + { + TSBuilder b; + uint8_t s[TS_PACKET_SIZE * 4] {}; + b.pat(s + 0 * TS_PACKET_SIZE, 0x100, 0); + b.pmt(s + 1 * TS_PACKET_SIZE, 0x100, 0x101, TS_STREAM_H264, 0); + b.video(s + 2 * TS_PACKET_SIZE, 0x101, false); + b.video(s + 3 * TS_PACKET_SIZE, 0x101, true); + + TSScanner sc; + sc.feed(s, sizeof(s), 0); + CHECK(sc.have_program(), "PAT+PMT parsed"); + CHECK(sc.video_pid() == 0x101, "video PID"); + CHECK(sc.video_stream_type() == TS_STREAM_H264, "stream type"); + CHECK(sc.stats().pat_seen == 1, "one PAT"); + CHECK(sc.stats().pmt_seen == 1, "one PMT"); + CHECK(sc.stats().rai_seen == 1, "one RAI"); + CHECK(sc.stats().cc_errors == 0, "no CC errors"); + CHECK(sc.stats().crc_errors == 0, "no CRC errors"); + uint64_t off = 1; + CHECK(sc.join_offset(off), "anchor found"); + CHECK(off == 0, "anchor is the PAT offset"); + } + + // A keyframe before any PSI must NOT produce an anchor: the viewer + // would have no PMT and so no idea which PID carries video. + { + TSBuilder b; + uint8_t s[TS_PACKET_SIZE * 2] {}; + b.video(s + 0, 0x101, true); + b.video(s + TS_PACKET_SIZE, 0x101, true); + TSScanner sc; + sc.feed(s, sizeof(s), 0); + uint64_t off = 0; + CHECK(!sc.join_offset(off), "no anchor without PSI"); + } + + // Feeding one byte at a time must give the same result as one go: + // packets split across reads are the normal case on TCP. + { + TSBuilder b; + uint8_t s[TS_PACKET_SIZE * 4] {}; + b.pat(s + 0 * TS_PACKET_SIZE, 0x100, 0); + b.pmt(s + 1 * TS_PACKET_SIZE, 0x100, 0x101, TS_STREAM_H264, 0); + b.video(s + 2 * TS_PACKET_SIZE, 0x101, false); + b.video(s + 3 * TS_PACKET_SIZE, 0x101, true); + TSScanner sc; + for (size_t i = 0; i < sizeof(s); i++) { + sc.feed(s + i, 1, i); + } + CHECK(sc.have_program(), "byte-at-a-time PSI"); + CHECK(sc.stats().rai_seen == 1, "byte-at-a-time RAI"); + uint64_t off = 1; + CHECK(sc.join_offset(off) && off == 0, "byte-at-a-time anchor"); + } + + // A corrupt PMT must be rejected rather than believed. + { + TSBuilder b; + uint8_t s[TS_PACKET_SIZE * 2] {}; + b.pat(s, 0x100, 0); + b.pmt(s + TS_PACKET_SIZE, 0x100, 0x101, TS_STREAM_H264, 0); + s[TS_PACKET_SIZE + 20] ^= 0xFF; // flip a payload byte + TSScanner sc; + sc.feed(s, sizeof(s), 0); + CHECK(!sc.have_program(), "corrupt PMT rejected"); + CHECK(sc.stats().crc_errors == 1, "CRC error counted"); + } + + // Garbage before the stream must be resynced past, not misparsed. + { + TSBuilder b; + uint8_t s[64 + TS_PACKET_SIZE * 4] {}; + memset(s, 0x47, 64); // sync bytes that aren't + b.pat(s + 64 + 0 * TS_PACKET_SIZE, 0x100, 0); + b.pmt(s + 64 + 1 * TS_PACKET_SIZE, 0x100, 0x101, TS_STREAM_H264, 0); + b.video(s + 64 + 2 * TS_PACKET_SIZE, 0x101, false); + b.video(s + 64 + 3 * TS_PACKET_SIZE, 0x101, true); + TSScanner sc; + sc.feed(s, sizeof(s), 0); + CHECK(sc.have_program(), "resync found the program"); + uint64_t off = 0; + CHECK(sc.join_offset(off) && off == 64, "anchor after resync"); + } + + // A PMT version change that moves the video PID must be followed. + { + TSBuilder b; + uint8_t s[TS_PACKET_SIZE * 4] {}; + b.pat(s + 0, 0x100, 0); + b.pmt(s + TS_PACKET_SIZE, 0x100, 0x101, TS_STREAM_H264, 0); + b.pmt(s + 2 * TS_PACKET_SIZE, 0x100, 0x102, TS_STREAM_HEVC, 1); + b.video(s + 3 * TS_PACKET_SIZE, 0x102, true, TS_STREAM_HEVC); + TSScanner sc; + sc.feed(s, sizeof(s), 0); + CHECK(sc.video_pid() == 0x102, "PMT version change followed"); + CHECK(sc.video_stream_type() == TS_STREAM_HEVC, "new stream type"); + CHECK(sc.stats().rai_seen == 1, "RAI on the new PID"); + } + + // A muxer that sets RAI on a non-keyframe must not fool us: the + // bit is a hint, and serving a viewer from a point the decoder + // cannot start at is indistinguishable from a broken stream. + { + TSBuilder b; + uint8_t s[TS_PACKET_SIZE * 3] {}; + b.pat(s + 0, 0x100, 0); + b.pmt(s + TS_PACKET_SIZE, 0x100, 0x101, TS_STREAM_H264, 0); + // non-keyframe payload, but the RAI bit set: a well-formed + // packet that lies about being a random access point. + b.video(s + 2 * TS_PACKET_SIZE, 0x101, false, TS_STREAM_H264, 1); + TSScanner sc; + sc.feed(s, sizeof(s), 0); + CHECK(sc.have_program(), "false-RAI case parsed PSI"); + uint64_t off = 0; + CHECK(!sc.join_offset(off), "false RAI must not produce an anchor"); + } + + printf("videots selftest: OK\n"); + return 0; +} + +// --------------------------------------------------------------- fuzz + +namespace { + +// xorshift32: tiny, deterministic, and no dependency on the platform's +// rand() so a failing seed reproduces anywhere. +struct Rng { + uint32_t s; + explicit Rng(uint32_t seed) : s(seed ? seed : 1) {} + uint32_t next(void) + { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + return s; + } + uint32_t below(uint32_t n) { return n ? next() % n : 0; } +}; + +} // namespace + +int videots_fuzz(unsigned iterations, uint32_t seed) +{ + for (unsigned it = 0; it < iterations; it++) { + Rng rng(seed + it); + + // Build a valid stream, then corrupt it. + TSBuilder b; + const size_t npkt = 8 + rng.below(24); + std::vector s(npkt * TS_PACKET_SIZE); + for (size_t i = 0; i < npkt; i++) { + uint8_t *p = &s[i * TS_PACKET_SIZE]; + switch (i % 4) { + case 0: b.pat(p, 0x100, uint8_t(rng.below(32))); break; + case 1: + if (rng.below(2) == 0) { + // a PMT big enough to span several packets, so the + // reassembly path is fuzzed too + const size_t used = b.pmt_split( + p, 0x100, 0x101, TS_STREAM_H264, + uint8_t(rng.below(32)), 4 + rng.below(240), + npkt - i); + i += used > 0 ? used - 1 : 0; + } else { + b.pmt(p, 0x100, 0x101, TS_STREAM_H264, + uint8_t(rng.below(32))); + } + break; + default: b.video(p, 0x101, (i % 8) == 3); break; + } + } + + const unsigned mutations = rng.below(24); + for (unsigned m = 0; m < mutations; m++) { + const size_t off = rng.below(uint32_t(s.size())); + switch (rng.below(4)) { + case 0: s[off] ^= uint8_t(1u << rng.below(8)); break; // bit flip + case 1: s[off] = uint8_t(rng.next()); break; // byte set + case 2: s[off] = TS_SYNC_BYTE; break; // spurious sync byte + case 3: s[off] = 0xFF; break; // saturate a length field + } + } + if (rng.below(4) == 0) { + s.resize(1 + rng.below(uint32_t(s.size()))); // truncate + } + + // Feed in irregular chunks: a packet split across reads is the + // normal case, and it is where an assembler most easily breaks. + TSScanner sc; + size_t i = 0; + while (i < s.size()) { + size_t chunk = 1 + rng.below(400); + if (i + chunk > s.size()) { + chunk = s.size() - i; + } + sc.feed(&s[i], chunk, i); + i += chunk; + } + + // Invariants that must hold whatever the input was. + uint64_t off = 0; + if (sc.join_offset(off)) { + if (!sc.have_program()) { + printf("videots fuzz FAIL (seed %u, iter %u): anchor without " + "a program\n", seed, it); + return 1; + } + if (off >= s.size()) { + printf("videots fuzz FAIL (seed %u, iter %u): anchor %llu " + "past end %zu\n", seed, it, + (unsigned long long)off, s.size()); + return 1; + } + } + if (sc.have_program() && sc.video_pid() == 0) { + printf("videots fuzz FAIL (seed %u, iter %u): program with no " + "video PID\n", seed, it); + return 1; + } + const TSStats &st = sc.stats(); + if (st.packets * TS_PACKET_SIZE > st.bytes + TS_PACKET_SIZE) { + printf("videots fuzz FAIL (seed %u, iter %u): counted more " + "packets than bytes fed\n", seed, it); + return 1; + } + } + printf("videots fuzz: OK (%u iterations from seed %u)\n", + iterations, seed); + return 0; +} diff --git a/videots.h b/videots.h new file mode 100644 index 0000000..79c8ec8 --- /dev/null +++ b/videots.h @@ -0,0 +1,165 @@ +/* + MPEG-TS scanner. + + Watches a transport stream going past and works out where a late + viewer may safely start. It never modifies the stream: fan-out and + recording hand out the publisher's bytes verbatim, so everything here + is observation only. + + A viewer cannot just start at "now". It needs, in order: + + PAT to learn which PID carries the program map + PMT to learn which PID carries video, and its codec + a random access point, so the decoder has a frame to start from + + Measured against both real publishers (a Phoenix camera via RTSP and + a gstreamer mpegtsmux/udpsink pipeline): PAT repeats about every + 0.1 s, and the adaptation field's random_access_indicator is set on + exactly the keyframes. Parameter sets (SPS/PPS) repeat at every IDR + in both, so a viewer starting at PAT->PMT->RAI has everything it + needs. That is not guaranteed by the spec, so the RAI is confirmed by + looking for an access-unit delimiter or parameter set in the payload + rather than trusted on its own. + */ +#pragma once + +#include +#include + +#define TS_PACKET_SIZE 188 +#define TS_SYNC_BYTE 0x47 + +// PSI section sizes: section_length is 12 bits, and the 3 bytes before +// it are not counted, so a section is at most 1024+3 bytes. +#define TS_MAX_SECTION 1027 + +// MPEG-2 stream_type values we care about telling apart. +#define TS_STREAM_MPEG2_VIDEO 0x02 +#define TS_STREAM_PRIVATE 0x06 +#define TS_STREAM_AAC_ADTS 0x0F +#define TS_STREAM_AAC_LATM 0x11 +#define TS_STREAM_H264 0x1B +#define TS_STREAM_HEVC 0x24 + +struct TSStats { + uint64_t bytes = 0; + uint64_t packets = 0; + uint64_t bad_sync = 0; // packets that did not start with 0x47 + uint64_t resyncs = 0; // times we had to hunt for the sync byte + uint64_t cc_errors = 0; // continuity counter discontinuities + uint64_t pat_seen = 0; + uint64_t pmt_seen = 0; + uint64_t rai_seen = 0; // random access points on the video PID + uint64_t crc_errors = 0; // PSI sections that failed CRC +}; + +/* + Reassembles one PSI section spread across TS packets. A PAT or a small + PMT usually fits in a single packet, but nothing guarantees it, and a + scanner that assumed so would silently misparse a larger PMT. + */ +class PSIAssembler { +public: + void reset(void) { len_ = 0; want_ = 0; active_ = false; } + // Feed one packet's PSI payload. Returns a complete, CRC-checked + // section (and its length) or nullptr. + const uint8_t *feed(const uint8_t *payload, size_t n, bool pusi, + size_t &out_len, uint64_t &crc_errors); + +private: + uint8_t buf_[TS_MAX_SECTION] {}; + size_t len_ = 0; + size_t want_ = 0; + bool active_ = false; +}; + +class TSScanner { +public: + // Feed a contiguous run of stream bytes. `base` is the absolute + // stream offset of buf[0] -- the same coordinate the ring uses, so + // a join offset can be handed straight to a viewer. + void feed(const uint8_t *buf, size_t n, uint64_t base); + + // True once a PAT and a matching PMT have been parsed. + bool have_program(void) const { return have_pmt_; } + + // Absolute offset a late viewer should start at. False when no + // usable anchor has been seen (yet, or ever). + bool join_offset(uint64_t &out) const; + + uint16_t video_pid(void) const { return video_pid_; } + uint8_t video_stream_type(void) const { return video_stream_type_; } + uint16_t pmt_pid(void) const { return pmt_pid_; } + const TSStats &stats(void) const { return stats_; } + + // True if this stream_type is one a browser MSE player can use. + static bool stream_type_playable(uint8_t st); + + // Forget everything about the current stream. + // + // A new publisher is a new stream: its PSI, continuity counters and + // timestamps all restart. Carrying the old state over would hand a + // joiner an anchor pointing into the previous stream's bytes, and + // would count every counter restart as a continuity error. + void reset(void); + +private: + TSStats stats_; + PSIAssembler pat_asm_; + PSIAssembler pmt_asm_; + + uint16_t pmt_pid_ = 0; + bool have_pat_ = false; + bool have_pmt_ = false; + uint8_t pat_version_ = 0xFF; + uint8_t pmt_version_ = 0xFF; + + uint16_t video_pid_ = 0; + uint8_t video_stream_type_ = 0; + + // Offsets of the most recent PAT and PMT packets, and the anchor + // computed when a random access point follows both. + uint64_t last_pat_off_ = 0; + uint64_t last_pmt_off_ = 0; + bool have_pat_off_ = false; + bool have_pmt_off_ = false; + uint64_t anchor_ = 0; + bool have_anchor_ = false; + + // continuity counters, indexed by PID + uint8_t cc_[8192] {}; + bool cc_seen_[8192] {}; + + // partial packet carried across feed() calls + uint8_t partial_[TS_PACKET_SIZE] {}; + size_t partial_len_ = 0; + uint64_t partial_off_ = 0; + bool synced_ = false; + + void packet(const uint8_t *p, uint64_t off); + void parse_pat(const uint8_t *sec, size_t n, uint64_t off); + void parse_pmt(const uint8_t *sec, size_t n, uint64_t off); +}; + +// MPEG-2 section CRC32 (poly 0x04C11DB7, MSB-first, init 0xFFFFFFFF). +uint32_t ts_crc32(const uint8_t *data, size_t n); + +// True if a video-PID payload starting at `p` looks like a real random +// access point: an access-unit delimiter, a parameter set, or an IRAP +// slice. Used to confirm the adaptation field's RAI bit. +bool ts_payload_is_random_access(const uint8_t *p, size_t n, + uint8_t stream_type); + +// Run the in-process scanner self-checks. Returns 0 on success. +int videots_selftest(void); + +/* + Mutation fuzz over the scanner. Builds a valid stream, corrupts it in + a seeded, reproducible way, feeds it in randomly-sized chunks and + checks the scanner neither crashes nor reports an anchor it cannot + back up. PSI parsing with lengths and CRCs taken from the wire is + exactly the code where a hand-written happy-path test proves least. + + Returns 0 if every iteration held. + */ +int videots_fuzz(unsigned iterations, uint32_t seed); diff --git a/videoview.cpp b/videoview.cpp new file mode 100644 index 0000000..caa615e --- /dev/null +++ b/videoview.cpp @@ -0,0 +1,494 @@ +/* + Video viewers. See videoview.h. + */ +#include "videoview.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "videoauth.h" + +bool video_viewer_authorised(const struct KeyEntry &ke, + const std::string &password) +{ + bool has_pw = false; + for (int i = 0; i < 32; i++) { + has_pw |= ke.video_viewer_key[i] != 0; + } + if (!has_pw) { + return true; // open viewing + } + return video_password_matches(ke.video_viewer_key, password.c_str()); +} + +void VideoViewer::start(int fd, int port2, uint32_t peer_ip_be, + uint16_t peer_port_be, time_t now) +{ + fd_ = fd; + port2_ = port2; + state_ = VV_DETECT; + kind_ = VVK_UNKNOWN; + peer_ip_be_ = peer_ip_be; + peer_port_be_ = peer_port_be; + connected_at_ = now; + last_progress_ = now; + behind_since_ = 0; + out_.clear(); + out_sent_ = 0; + read_pos_ = 0; + streaming_ = false; + bytes_sent_ = 0; + blocked_ = false; + ws_ = nullptr; + ws_ready_ = false; + drop_reason_ = ""; + + int one = 1; + setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); + // Deliberately modest: a large kernel buffer hides backpressure and + // we would not notice a viewer falling behind until much later. + int snd = 256 * 1024; + setsockopt(fd_, SOL_SOCKET, SO_SNDBUF, &snd, sizeof(snd)); + fcntl(fd_, F_SETFL, fcntl(fd_, F_GETFL, 0) | O_NONBLOCK); +} + +void VideoViewer::close(void) +{ + if (ws_ != nullptr) { + delete ws_; // frees SSL objects; the fd is ours to close + ws_ = nullptr; + } + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; + } + state_ = VV_CLOSING; +} + +void VideoViewer::fail(int code, const char *reason, const char *text) +{ + if (kind_ == VVK_HTTP) { + out_ = http_simple_response(code, reason, "text/plain", + std::string(text) + "\n"); + } + drop_reason_ = reason; + out_sent_ = 0; + state_ = VV_RESPONDING; + streaming_ = false; +} + +bool VideoViewer::begin_stream(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + bool http, time_t now) +{ + uint64_t anchor = 0; + if (!scanner.join_offset(anchor)) { + // Refusing beats serving from an arbitrary point: without a + // PAT, a PMT and a random access point ahead of it, the client + // decodes garbage and the stream looks broken rather than + // not-ready-yet. + fail(503, "stream not ready", + "No decodable start point yet. The publisher has not sent a " + "keyframe with program information."); + return true; + } + if (!ring.resident(anchor)) { + anchor = ring.oldest(); + } + read_pos_ = anchor; + streaming_ = true; + last_progress_ = now; + behind_since_ = 0; + + if (http) { + // No Content-Length: the stream is unbounded and ends when the + // connection does. + out_ = "HTTP/1.1 200 OK\r\n" + "Content-Type: video/mp2t\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n" + "\r\n"; + out_sent_ = 0; + state_ = VV_RESPONDING; + } else { + out_.clear(); + out_sent_ = 0; + state_ = VV_STREAMING; + } + (void)ke; + (void)slot; + return true; +} + +bool VideoViewer::on_readable(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + time_t now) +{ + if (state_ != VV_DETECT) { + if (kind_ == VVK_WS) { + // Let the WebSocket consume control frames (ping/close). + uint8_t sink[2048]; + const ssize_t got = ws_->recv(sink, sizeof(sink)); + if (got < 0) { + drop_reason_ = "websocket closed"; + return false; + } + return true; + } + // A streaming viewer has nothing to say; drain and ignore rather + // than letting it steer us. + uint8_t sink[2048]; + const ssize_t got = ::recv(fd_, sink, sizeof(sink), 0); + if (got == 0) { + drop_reason_ = "peer closed"; + return false; + } + return true; + } + + /* + Classify once. A WebSocket viewer stays in VV_DETECT for the whole + handshake, so the state check above is not enough on its own: a + later read would see a mid-handshake TLS record (0x17...) rather + than the ClientHello, fail the TLS test, and re-classify the + connection as HTTP. The WebSocket then never gets driven and the + viewer is dropped by the silence timeout. + */ + if (kind_ == VVK_WS) { + // Already handed to WebSocket; it drives itself from the pump. + // Note this must NOT short-circuit an HTTP viewer: its request + // may arrive fragmented, and the parse below has to see the + // rest of it. + return true; + } + + /* + Peek, do not consume. A WebSocket upgrade has to stay in the + socket: the WebSocket class reads and answers the handshake + itself. Only a plain-HTTP request is read off, once we know that + is what it is. + */ + uint8_t buf[2048]; + const ssize_t n = ::recv(fd_, buf, sizeof(buf), MSG_PEEK); + if (n == 0) { + drop_reason_ = "peer closed"; + return false; + } + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { + return true; + } + drop_reason_ = "read error"; + return false; + } + + /* + An RTSP request line means this is a publisher, not a viewer. + It has to be recognised here rather than at accept(): a publisher + connects and only then sends OPTIONS, so at accept there is + usually nothing to look at, and classifying on an empty peek + misfiled publishers as viewers -- which then answered their + OPTIONS with "405 method not allowed". + */ + static const char *rtsp_methods[] = { + "OPTIONS ", "ANNOUNCE ", "DESCRIBE ", "SETUP ", "PLAY ", + "RECORD ", "TEARDOWN ", "GET_PARAMETER ", "SET_PARAMETER ", + }; + for (const char *m : rtsp_methods) { + const size_t len = strlen(m); + if (size_t(n) >= len && memcmp(buf, m, len) == 0) { + kind_ = VVK_RTSP; + return true; // the child takes the socket from here + } + } + + /* + RTMP publish. The client speaks first with handshake C0, a single + version byte, effectively always 0x03. One byte is enough to be + unambiguous here: MPEG-TS starts 0x47, every RTSP and HTTP method + is an ASCII letter, and a TLS ClientHello starts 0x16 (its 0x03 is + the *second* byte, not the first). + */ + if (n >= 1 && buf[0] == 0x03) { + kind_ = VVK_RTMP; + return true; // the child takes the socket from here + } + + // TLS ClientHello: WebSocket handles the whole thing from here. + static const uint8_t tls_hello[3] = { 0x16, 0x03, 0x01 }; + if (n >= 3 && memcmp(buf, tls_hello, 3) == 0) { + kind_ = VVK_WS; + return true; // begin_ws() runs from the pump once bytes arrive + } + + kind_ = VVK_HTTP; + HttpRequest peek; + const int r = peek.feed(buf, size_t(n)); + if (r < 0) { + fail(400, "bad request", "Malformed request."); + (void)::recv(fd_, buf, size_t(n), 0); + return true; + } + if (r == 0) { + return true; // wait for the rest, still unconsumed + } + + // A WebSocket upgrade: hand the untouched socket to WebSocket. + if (!peek.header("Upgrade").empty() + && peek.header("Upgrade").find("ebsocket") != std::string::npos) { + if (!ws_authorise(ke, slot, peek)) { + // No handshake, no upgrade: an unauthorised browser gets a + // plain HTTP error it can actually display. + (void)::recv(fd_, buf, size_t(n), 0); + kind_ = VVK_HTTP; + fail(401, "unauthorized", "A viewer token or password is required."); + return true; + } + kind_ = VVK_WS; + return true; + } + + // Plain HTTP from here: now it is safe to consume the request. + (void)::recv(fd_, buf, size_t(n), 0); + req_ = peek; + + if (req_.method() != "GET") { + fail(405, "method not allowed", "Only GET is served here."); + return true; + } + + // Path selects the slot: /v1.ts .. /v3.ts. The connection already + // arrived on this slot's port, so the path only has to agree. + char want[32]; + snprintf(want, sizeof(want), "/v%d.ts", slot + 1); + if (req_.path() != want && req_.path() != "/" && req_.path() != "/stream.ts") { + fail(404, "not found", "Try /v1.ts on this port."); + return true; + } + + std::string pw = req_.query("pw"); + if (pw.empty()) { + pw = http_basic_password(req_.header("Authorization")); + } + if (!video_viewer_authorised(ke, pw)) { + fail(401, "unauthorized", "A viewer password is required."); + return true; + } + return begin_stream(ke, slot, ring, scanner, true, now); +} + +bool VideoViewer::detect_timeout(const struct KeyEntry &ke, int slot, + const VideoRing &ring, + const TSScanner &scanner, time_t now) +{ + if (state_ != VV_DETECT) { + return true; + } + // Silence means a raw client: ffplay tcp://host:port connects and + // waits. There is nowhere in an opaque byte stream to carry a + // credential, so this is only offered when the slot allows it and + // no viewer password is set. + kind_ = VVK_RAW; + const uint32_t opts = video_slot_opts(ke.video_flags, unsigned(slot)); + if ((opts & VIDEO_SLOT_RAW_TCP) == 0) { + drop_reason_ = "raw-TCP viewers not enabled on this slot"; + return false; + } + bool has_pw = false; + for (int i = 0; i < 32; i++) { + has_pw |= ke.video_viewer_key[i] != 0; + } + if (has_pw) { + drop_reason_ = "raw TCP cannot carry the viewer password"; + return false; + } + return begin_stream(ke, slot, ring, scanner, false, now); +} + +bool VideoViewer::flush(time_t now) +{ + while (out_sent_ < out_.size()) { + const ssize_t w = ::send(fd_, out_.data() + out_sent_, + out_.size() - out_sent_, MSG_NOSIGNAL); + if (w < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + blocked_ = true; + return true; + } + if (errno == EINTR) { + continue; + } + drop_reason_ = "write error"; + return false; + } + out_sent_ += size_t(w); + last_progress_ = now; + } + out_.clear(); + out_sent_ = 0; + blocked_ = false; + return true; +} + +bool VideoViewer::on_writable(const VideoRing &ring, time_t now) +{ + if (!flush(now)) { + return false; + } + if (state_ == VV_RESPONDING) { + if (out_.empty()) { + if (streaming_) { + state_ = VV_STREAMING; + } else { + // an error response has gone out; we are done + return false; + } + } else { + return true; + } + } + if (state_ != VV_STREAMING) { + return true; + } + + // Lapped: the bytes this viewer still needs have been overwritten. + // Resyncing in place would leave a silent gap, which is worse than + // a clean disconnect -- every client reconnects, none recovers from + // a hole it was not told about. + if (!ring.resident(read_pos_)) { + drop_reason_ = "fell too far behind (lapped)"; + return false; + } + + size_t budget = VIDEO_VIEWER_WRITE_CHUNK; + while (budget > 0 && read_pos_ < ring.write_pos()) { + uint8_t chunk[16384]; + const size_t want = budget < sizeof(chunk) ? budget : sizeof(chunk); + const size_t got = ring.read_at(read_pos_, chunk, want); + if (got == 0) { + break; + } + const ssize_t w = ws_ != nullptr + ? ws_->send(chunk, got) + : ::send(fd_, chunk, got, MSG_NOSIGNAL); + if (w == 0 && ws_ != nullptr) { + blocked_ = true; + break; // queued inside the WebSocket, retry later + } + if (w < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + blocked_ = true; + break; + } + if (errno == EINTR) { + continue; + } + drop_reason_ = "write error"; + return false; + } + read_pos_ += uint64_t(w); + bytes_sent_ += uint64_t(w); + budget -= size_t(w); + last_progress_ = now; + if (size_t(w) < got) { + blocked_ = true; + break; // socket full + } + blocked_ = false; + } + if (read_pos_ >= ring.write_pos()) { + blocked_ = false; // caught up: nothing is waiting on the socket + } + + const uint64_t lag = ring.write_pos() - read_pos_; + if (lag > ring.capacity() / 2) { + if (behind_since_ == 0) { + behind_since_ = now; + } else if (now - behind_since_ > 5) { + // Chronically behind: drop it before it is lapped, so the + // disconnect is clean rather than mid-gap. + drop_reason_ = "chronically behind"; + return false; + } + } else { + behind_since_ = 0; + } + if (lag > 0 && now - last_progress_ > VIDEO_VIEWER_STUCK_S) { + drop_reason_ = "stalled"; + return false; + } + return true; +} + +bool VideoViewer::wants_write(void) const +{ + return blocked_ || out_sent_ < out_.size(); +} + +bool VideoViewer::ws_authorise(const struct KeyEntry &ke, int slot, + const HttpRequest &req) +{ + // A token is the normal path for the browser player: it keeps the + // viewer password out of a URL that lands in history and logs. + const std::string tok = req.query("t"); + if (!tok.empty() + && video_token_valid(ke, port2_, slot, tok.c_str(), time(nullptr))) { + return true; + } + std::string pw = req.query("pw"); + if (pw.empty()) { + pw = http_basic_password(req.header("Authorization")); + } + return video_viewer_authorised(ke, pw); +} + +bool VideoViewer::begin_ws(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + time_t now) +{ + if (ws_ == nullptr) { + // WebSocket reads and answers the handshake off the socket + // itself, which is why the detect phase only peeked. + ws_ = new WebSocket(fd_); + } + if (!ws_ready_) { + // Pump the handshake along. recv() returning < 0 means the + // link failed; 0 just means "not finished yet". + uint8_t sink[512]; + const ssize_t r = ws_->recv(sink, sizeof(sink)); + if (r < 0) { + drop_reason_ = "websocket handshake failed"; + return false; + } + if (ws_->request_target().empty()) { + return true; // handshake still in progress + } + ws_ready_ = true; + // Redacted: a viewer may authenticate with ?pw=, which is a + // long-lived credential, and the log is both kept on disk and + // rendered into the admin server page. + printf("video: websocket viewer on %s%s\n", + http_redact_target(ws_->request_target()).c_str(), + ws_->is_SSL() ? " (TLS)" : ""); + } + uint64_t anchor = 0; + if (!scanner.join_offset(anchor)) { + return true; // wait for a decodable start point + } + if (!ring.resident(anchor)) { + anchor = ring.oldest(); + } + read_pos_ = anchor; + streaming_ = true; + state_ = VV_STREAMING; + last_progress_ = now; + (void)ke; + (void)slot; + return true; +} diff --git a/videoview.h b/videoview.h new file mode 100644 index 0000000..80b100a --- /dev/null +++ b/videoview.h @@ -0,0 +1,179 @@ +/* + Video viewers. + + A viewer receives the publisher's bytes verbatim. It starts at the + join anchor the scanner computed -- the PAT before the most recent + confirmed random access point -- so the stream is decodable from the + first byte it sees rather than from wherever "now" happens to be. + + The publisher never waits on a viewer. Each viewer holds an absolute + position into the ring and is dropped if it falls far enough behind + that its data has been overwritten. That is the whole reason the ring + uses absolute offsets: "you have been lapped" is arithmetic, not a + guess about wrap. + */ +#pragma once + +#include +#include +#include + +#include + +#include "httpreq.h" +#include "websocket.h" +#include "keydb.h" +#include "videostream.h" +#include "videots.h" + +// Per-slot viewer cap. The limit that bites first is egress bandwidth, +// not CPU: 32 viewers of an 8 Mbit/s stream is 256 Mbit/s. +#define VIDEO_MAX_VIEWERS 32 + +// How much to hand one viewer per loop iteration. Bounds the latency +// every other viewer and the publisher see. +#define VIDEO_VIEWER_WRITE_CHUNK 65536 + +// Queue past which a viewer is considered wedged rather than slow. +#define VIDEO_VIEWER_STUCK_S 10 + +// A connection that says nothing at all for this long is a raw viewer: +// ffplay tcp://... connects and waits. Every other client we serve +// speaks first. +#define VIDEO_DETECT_SILENCE_S 2 + +enum viewer_state { + VV_DETECT = 0, // deciding what this connection is + VV_RESPONDING, // sending an HTTP response header or an error + VV_STREAMING, // handing out ring bytes + VV_CLOSING, // flush what is queued, then close +}; + +enum viewer_kind { + VVK_UNKNOWN = 0, + VVK_HTTP, // GET /vN.ts + VVK_RAW, // raw TCP, no framing + VVK_WS, // WebSocket (or WSS), binary frames + VVK_RTSP, // RTSP: handed to the ingest splice, not served + VVK_RTMP, // RTMP publish: same, a different backend +}; + +class VideoViewer { +public: + void start(int fd, int port2, uint32_t peer_ip_be, + uint16_t peer_port_be, time_t now); + void close(void); + ~VideoViewer(void) { close(); } + + /* + Give up the socket without closing it. Used when the connection + turns out to be an RTSP publisher: classification only happens + once bytes arrive, so it necessarily starts life in a viewer slot. + */ + int release_fd(void) + { + const int fd = fd_; + fd_ = -1; + state_ = VV_CLOSING; + return fd; + } + bool active(void) const { return fd_ >= 0; } + int fd(void) const { return fd_; } + + // Readable: consume the request. Returns false if the viewer should + // be dropped. + bool on_readable(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + time_t now); + + // Writable (or just a poll tick): push bytes. Returns false when the + // viewer should be dropped. + bool on_writable(const VideoRing &ring, time_t now); + + // Called when the detect deadline passes with nothing received. + bool detect_timeout(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + time_t now); + + /* + True only when we have bytes we could not push -- i.e. the socket + applied backpressure. EPOLLOUT must not be armed simply because a + viewer exists: a caught-up viewer on a quiet stream would then + make epoll_wait return immediately for ever, which measured as a + full CPU core burned by one idle viewer. + + New ring data needs no EPOLLOUT of its own: the ingest socket + wakes the loop, and the pump runs on every iteration. + */ + // Drive a WebSocket viewer's handshake and start of stream. + bool begin_ws_pump(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + time_t now) + { + return begin_ws(ke, slot, ring, scanner, now); + } + + bool wants_write(void) const; + viewer_state state(void) const { return state_; } + viewer_kind kind(void) const { return kind_; } + uint32_t peer_ip_be(void) const { return peer_ip_be_; } + uint16_t peer_port_be(void) const { return peer_port_be_; } + time_t connected_at(void) const { return connected_at_; } + uint64_t bytes_sent(void) const { return bytes_sent_; } + const char *drop_reason(void) const { return drop_reason_; } + + // Only ever a string literal: the field is a borrowed pointer and + // outlives nothing. + void set_drop_reason(const char *why) { drop_reason_ = why; } + +private: + int fd_ = -1; + int port2_ = 0; + viewer_state state_ = VV_DETECT; + viewer_kind kind_ = VVK_UNKNOWN; + uint32_t peer_ip_be_ = 0; + uint16_t peer_port_be_ = 0; + time_t connected_at_ = 0; + time_t last_progress_ = 0; + time_t behind_since_ = 0; + + HttpRequest req_; + std::string out_; // pending response bytes + size_t out_sent_ = 0; + + uint64_t read_pos_ = 0; + bool streaming_ = false; + uint64_t bytes_sent_ = 0; + bool blocked_ = false; // last send hit EAGAIN + /* + Set for a WebSocket viewer. The handshake is left in the socket + for WebSocket to consume: it reads and answers the upgrade + itself, so the detect phase peeks rather than consuming, and + only a plain-HTTP viewer's request is actually read off. + */ + WebSocket *ws_ = nullptr; + bool ws_ready_ = false; + const char *drop_reason_ = ""; + + bool begin_stream(const struct KeyEntry &ke, int slot, + const VideoRing &ring, const TSScanner &scanner, + bool http, time_t now); + void fail(int code, const char *reason, const char *text); + bool flush(time_t now); + // A WS viewer must present a token (or the viewer password) before + // we complete the upgrade. + bool ws_authorise(const struct KeyEntry &ke, int slot, + const HttpRequest &req); + bool begin_ws(const struct KeyEntry &ke, int slot, const VideoRing &ring, + const TSScanner &scanner, time_t now); +}; + +/* + Is this viewer credential acceptable for the entry? + + An unset viewer password means open viewing -- but note that a viewer + still cannot reach a stream that has no publisher, and a publisher + still had to be authorised, so "open" is narrower than it sounds. + */ +bool video_viewer_authorised(const struct KeyEntry &ke, + const std::string &password); From ee89c97f61e07f3727fcc8e1a8196dcdd634d30f Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Tue, 4 Aug 2026 17:30:12 +1000 Subject: [PATCH 4/6] webadmin: video settings, playback, log management and a server page Video settings on the owner and admin forms, with ports allocated by an admin -- they share one listening-port namespace, so letting every owner pick invites collisions -- and everything else owner-controlled. Browser playback is WebSocket to mpegts.js, vendored rather than loaded from a CDN so an external script cannot change what runs in an operator's browser. It authenticates with a short-lived token signed with the entry's existing MAVLink key, minted on demand: a token rendered once at page load is refused by every reconnect after the first minute, which presents as "it only works if I reload". The player's failure handling took several passes, each of which looked right. A publisher going away is a clean close, reported as LOADING_COMPLETE, so listening only for ERROR missed the one case reconnect exists for. The video element is kept across reconnects, because replacing it drops Picture-in-Picture. Only an unsupported codec is permanent -- treating every MEDIA_ERROR that way gave up on the first transient append failure, which Chrome raises far more readily than Firefox, so an H.264 stream Firefox was playing reported "cannot decode, probably HEVC" in Chrome and latched. And progress comes from the element's own events, not MEDIA_INFO: that only fires once MediaInfo.isComplete(), which requires hasAudio to be exactly true or false, and it starts null and is set only when audio metadata arrives -- so a video-only stream never fires it, leaving the status stuck and the error counter never reset. Recordings can be deleted, one at a time or a whole day. An owner deletes from their own entry and an admin from any, using the same auth decorators and path grammar as the listing: the owner routes take no port2, so there is nothing there to tamper with, and only names SESSION_RE accepts are ever unlinked. Each directory component is opened O_NOFOLLOW and the unlink is relative to that descriptor, so a symlink swapped in along the path cannot redirect it. A file the daemon still has open is refused -- asked of the kernel through /proc, not guessed from mtime, because a quiet session's log can go untouched for longer than any grace window while still being written, and unlinking it would not stop the write. Adds a server page: the daemon's log tailing live, and a restart. The restart signals rather than shelling out, since the unit sets Restart=always and runs as the same user as gunicorn, so systemctl and the sudo it would need are avoided. Which process to signal is an identity question, not a search: systemd's MainPID when it answers, otherwise a supportproxy process with this installation's working directory whose parent is not also supportproxy -- min(pid) alone would pick up a second instance on the host or a reparented session child. The signal goes through a pidfd where available, so the gap between finding the pid and using it cannot land on a recycled number. The tail is polled rather than streamed -- sync workers would be tied up for as long as the page is open -- bounded at both ends, and it carries the file identity so a rotation that truncates and regrows past the old offset is reported rather than silently skipped. Credentials are redacted again here, for logs written before the daemon learned to. Row actions are icons -- download, delete, play -- as inline SVG rather than an icon font or emoji, which the pages cannot fetch and which render inconsistently; a bin glyph in particular is missing on plenty of systems. Play is last because it is the only one not on every row, so the others stay put. Each carries a title and an aria-label. --- tests/webadmin/test_connections.py | 5 +- tests/webadmin/test_kill_connection.py | 5 +- tests/webadmin/test_log_delete.py | 178 ++++++++ tests/webadmin/test_log_routes.py | 56 +++ tests/webadmin/test_log_video.py | 367 ++++++++++++++++ tests/webadmin/test_system.py | 251 +++++++++++ tests/webadmin/test_tooltips.py | 205 +++++++++ tests/webadmin/test_video_page.py | 469 +++++++++++++++++++++ tests/webadmin/test_video_ui.py | 441 +++++++++++++++++++ webadmin/__init__.py | 4 + webadmin/config.py | 6 + webadmin/connections.py | 9 + webadmin/forms.py | 339 ++++++++++++--- webadmin/logs.py | 424 ++++++++++++++++++- webadmin/proxylog.py | 252 +++++++++++ webadmin/routes_admin.py | 15 +- webadmin/routes_owner.py | 6 + webadmin/routes_system.py | 67 +++ webadmin/routes_video.py | 115 +++++ webadmin/static/style.css | 121 ++++++ webadmin/static/vendor/mpegts.js/LICENSE | 202 +++++++++ webadmin/static/vendor/mpegts.js/VERSION | 17 + webadmin/static/vendor/mpegts.js/mpegts.js | 9 + webadmin/static/video-toggle.js | 78 ++++ webadmin/templates/_macros.html | 61 +++ webadmin/templates/_video_fields.html | 107 +++++ webadmin/templates/admin_edit.html | 28 +- webadmin/templates/admin_list.html | 19 +- webadmin/templates/admin_logs.html | 27 +- webadmin/templates/admin_system.html | 90 ++++ webadmin/templates/base.html | 3 + webadmin/templates/log_play.html | 55 +++ webadmin/templates/login.html | 5 +- webadmin/templates/owner.html | 24 +- webadmin/templates/owner_logs.html | 30 +- webadmin/templates/video.html | 316 ++++++++++++++ webadmin/videoform.py | 127 ++++++ webadmin/videotoken.py | 51 +++ 38 files changed, 4469 insertions(+), 115 deletions(-) create mode 100644 tests/webadmin/test_log_delete.py create mode 100644 tests/webadmin/test_log_video.py create mode 100644 tests/webadmin/test_system.py create mode 100644 tests/webadmin/test_tooltips.py create mode 100644 tests/webadmin/test_video_page.py create mode 100644 tests/webadmin/test_video_ui.py create mode 100644 webadmin/proxylog.py create mode 100644 webadmin/routes_system.py create mode 100644 webadmin/routes_video.py create mode 100644 webadmin/static/vendor/mpegts.js/LICENSE create mode 100644 webadmin/static/vendor/mpegts.js/VERSION create mode 100644 webadmin/static/vendor/mpegts.js/mpegts.js create mode 100644 webadmin/static/video-toggle.js create mode 100644 webadmin/templates/_macros.html create mode 100644 webadmin/templates/_video_fields.html create mode 100644 webadmin/templates/admin_system.html create mode 100644 webadmin/templates/log_play.html create mode 100644 webadmin/templates/video.html create mode 100644 webadmin/videoform.py create mode 100644 webadmin/videotoken.py diff --git a/tests/webadmin/test_connections.py b/tests/webadmin/test_connections.py index 4fb0921..519a23b 100644 --- a/tests/webadmin/test_connections.py +++ b/tests/webadmin/test_connections.py @@ -22,7 +22,9 @@ def _pack_entry(*, port2, conn_index, peer_ip, peer_port, transport, - is_user, connected_at, last_update, rx=0, tx=0, pid=12345): + is_user, connected_at, last_update, rx=0, tx=0, pid=12345, + role=conn_db.CONN_ROLE_MAVLINK, stream_idx=0, + app_proto=conn_db.CONN_APP_MAVLINK, authenticated=0): return struct.pack( conn_db.PACK_FORMAT, conn_db.CONN_MAGIC, connected_at, last_update, @@ -32,6 +34,7 @@ def _pack_entry(*, port2, conn_index, peer_ip, peer_port, transport, socket.htons(peer_port), transport, 1 if is_user else 0, 0, 0, # flags + pad + role, stream_idx, app_proto, authenticated, ) diff --git a/tests/webadmin/test_kill_connection.py b/tests/webadmin/test_kill_connection.py index 21c3637..e8cdc32 100644 --- a/tests/webadmin/test_kill_connection.py +++ b/tests/webadmin/test_kill_connection.py @@ -23,7 +23,9 @@ def _pack_entry(*, port2, conn_index, peer_ip, peer_port, transport, is_user, connected_at, last_update, rx=0, tx=0, - pid=12345, flags=0): + pid=12345, flags=0, role=conn_db.CONN_ROLE_MAVLINK, + stream_idx=0, app_proto=conn_db.CONN_APP_MAVLINK, + authenticated=0): return struct.pack( conn_db.PACK_FORMAT, conn_db.CONN_MAGIC, connected_at, last_update, @@ -33,6 +35,7 @@ def _pack_entry(*, port2, conn_index, peer_ip, peer_port, transport, socket.htons(peer_port), transport, 1 if is_user else 0, flags, 0, # flags, _pad + role, stream_idx, app_proto, authenticated, ) diff --git a/tests/webadmin/test_log_delete.py b/tests/webadmin/test_log_delete.py new file mode 100644 index 0000000..4099ef4 --- /dev/null +++ b/tests/webadmin/test_log_delete.py @@ -0,0 +1,178 @@ +"""Deleting recordings from the web UI. + +Destructive and reachable by every owner, so the access boundary and the +path handling matter more than the markup: an owner may only ever reach +their own entry, and nothing outside the session-name grammar may be +removed however the request is spelled. +""" +import os +import time + +import pytest + +from webadmin import create_app + +from _test_helpers import (ALICE_PASS, ALICE_PORT1, ALICE_PORT2, BOB_PASS, + BOB_PORT1, BOB_PORT2, login_as) + +DATE = '2026-08-03' +NAME = '2026_08_03_10:00:00.tlog' +VIDEO = '2026_08_03_10:00:00.v1.ts' + + +@pytest.fixture +def logs_app(keydb_path, tmp_path): + return create_app({ + 'TESTING': True, + 'WTF_CSRF_ENABLED': False, + 'SESSION_COOKIE_SECURE': False, + 'KEYDB_PATH': keydb_path, + 'LOGS_DIR': str(tmp_path / 'logs'), + 'SECRET_KEY': 'test', + }) + + +@pytest.fixture +def logs_client(logs_app): + return logs_app.test_client() + + +def _seed(app, port2, names=(NAME, VIDEO), age_s=3600): + d = os.path.join(app.config['LOGS_DIR'], str(port2), DATE) + os.makedirs(d, exist_ok=True) + for n in names: + p = os.path.join(d, n) + with open(p, 'wb') as f: + f.write(b'x' * 128) + old = time.time() - age_s + os.utime(p, (old, old)) + return d + + +class TestOwnerDelete: + def test_owner_deletes_own_recording(self, logs_client, logs_app, + keydb_path): + d = _seed(logs_app, ALICE_PORT2) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + r = logs_client.post('/me/logs/%s/%s/delete' % (DATE, NAME), + follow_redirects=True) + assert r.status_code == 200 + assert not os.path.exists(os.path.join(d, NAME)) + assert os.path.exists(os.path.join(d, VIDEO)), 'deleted too much' + + def test_owner_deletes_a_video(self, logs_client, logs_app, keydb_path): + d = _seed(logs_app, ALICE_PORT2) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + logs_client.post('/me/logs/%s/%s/delete' % (DATE, VIDEO), + follow_redirects=True) + assert not os.path.exists(os.path.join(d, VIDEO)) + + def test_owner_deletes_a_whole_day(self, logs_client, logs_app, + keydb_path): + d = _seed(logs_app, ALICE_PORT2) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + r = logs_client.post('/me/logs/%s/delete' % DATE, + follow_redirects=True) + assert 'Deleted 2 files' in r.get_data(as_text=True) + assert not os.path.isdir(d), 'emptied date dir should be removed' + + def test_owner_cannot_touch_another_entry(self, logs_client, logs_app, + keydb_path): + """The owner routes carry no port2, so there is nothing to + tamper with -- assert the other entry's files survive.""" + other = _seed(logs_app, BOB_PORT2) + _seed(logs_app, ALICE_PORT2) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + logs_client.post('/me/logs/%s/delete' % DATE, follow_redirects=True) + assert os.path.exists(os.path.join(other, NAME)) + + +class TestAdminDelete: + def test_admin_deletes_any_entry(self, logs_client, logs_app, keydb_path): + d = _seed(logs_app, ALICE_PORT2) + login_as(logs_client, BOB_PORT1, BOB_PASS) # bob is admin + logs_client.post('/admin/logs/%d/%s/%s/delete' + % (ALICE_PORT2, DATE, NAME), follow_redirects=True) + assert not os.path.exists(os.path.join(d, NAME)) + + def test_non_admin_is_refused(self, logs_client, logs_app, keydb_path): + d = _seed(logs_app, BOB_PORT2) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) # alice is not admin + r = logs_client.post('/admin/logs/%d/%s/%s/delete' + % (BOB_PORT2, DATE, NAME)) + assert r.status_code == 403 + assert os.path.exists(os.path.join(d, NAME)) + + +class TestRefusals: + def test_a_file_still_being_written_is_kept(self, logs_client, logs_app, + keydb_path): + """Unlinking a file the daemon still holds does not stop it + writing -- the space stays used and the file just disappears + from the listing.""" + d = _seed(logs_app, ALICE_PORT2, names=(NAME,), age_s=0) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + r = logs_client.post('/me/logs/%s/%s/delete' % (DATE, NAME), + follow_redirects=True) + assert 'still being written' in r.get_data(as_text=True) + assert os.path.exists(os.path.join(d, NAME)) + + def test_day_delete_keeps_active_files_and_says_so(self, logs_client, + logs_app, keydb_path): + d = _seed(logs_app, ALICE_PORT2, names=(NAME,), age_s=3600) + _seed(logs_app, ALICE_PORT2, names=(VIDEO,), age_s=0) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + r = logs_client.post('/me/logs/%s/delete' % DATE, + follow_redirects=True) + body = r.get_data(as_text=True) + assert 'Deleted 1 file' in body + assert '1 left in place' in body + assert os.path.exists(os.path.join(d, VIDEO)) + + def test_unrelated_files_are_never_removed(self, logs_client, logs_app, + keydb_path): + """Only names the session grammar accepts are touched, so a + whole-day delete cannot take anything else in the directory.""" + d = _seed(logs_app, ALICE_PORT2) + keep = os.path.join(d, 'notes.txt') + with open(keep, 'w') as f: + f.write('keep me') + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + logs_client.post('/me/logs/%s/delete' % DATE, follow_redirects=True) + assert os.path.exists(keep) + assert os.path.isdir(d), 'dir with survivors must not be removed' + + @pytest.mark.parametrize('bad', [ + '../../etc/passwd', + '..%2f..%2fetc%2fpasswd', + 'session1.tlog/../../../x', + ]) + def test_traversal_is_refused(self, logs_client, logs_app, keydb_path, + bad): + _seed(logs_app, ALICE_PORT2) + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + r = logs_client.post('/me/logs/%s/%s/delete' % (DATE, bad)) + assert r.status_code in (400, 404, 308) + + def test_bad_date_is_refused(self, logs_client, logs_app, keydb_path): + login_as(logs_client, ALICE_PORT1, ALICE_PASS) + r = logs_client.post('/me/logs/..%2f..%2fetc/delete') + assert r.status_code in (400, 404, 308) + + +class TestCsrf: + def test_delete_requires_a_token(self, keydb_path, tmp_path): + app = create_app({ + 'TESTING': True, + 'WTF_CSRF_ENABLED': True, + 'SESSION_COOKIE_SECURE': False, + 'KEYDB_PATH': keydb_path, + 'LOGS_DIR': str(tmp_path / 'logs'), + 'SECRET_KEY': 'csrftest', + }) + d = _seed(app, ALICE_PORT2) + c = app.test_client() + login_as(c, ALICE_PORT1, ALICE_PASS) + r = c.post('/me/logs/%s/%s/delete' % (DATE, NAME)) + assert r.status_code == 400 + assert os.path.exists(os.path.join(d, NAME)) diff --git a/tests/webadmin/test_log_routes.py b/tests/webadmin/test_log_routes.py index bf3f072..9629651 100644 --- a/tests/webadmin/test_log_routes.py +++ b/tests/webadmin/test_log_routes.py @@ -542,3 +542,59 @@ def test_admin_routes_redirect_to_login(self, client): # require_admin aborts 403 for unauthenticated _refresh_role: # they're not logged in, so role check fails. Acceptable: 403. assert r.status_code == 403 + + +# --------------------------------------------------------------------------- +# video segments in the log browser +# --------------------------------------------------------------------------- + +class TestVideoSegments: + """Recordings live beside the tlogs and must be browsable the same way.""" + + def test_owner_sees_and_downloads_a_segment(self, client, logs_dir): + seed_session(logs_dir, ALICE_PORT2, '2026-08-01', + '2026_08_01_10:00:00.v1.ts', b'\x47VIDEO') + login_as(client, ALICE_PORT1, ALICE_PASS) + html = client.get('/me/logs/2026-08-01/').get_data(as_text=True) + assert '2026_08_01_10:00:00.v1.ts' in html + + r = client.get('/me/logs/2026-08-01/2026_08_01_10:00:00.v1.ts') + assert r.status_code == 200 + assert r.get_data() == b'\x47VIDEO' + + def test_all_three_slots_are_listed(self, client, logs_dir): + for slot in (1, 2, 3): + seed_session(logs_dir, ALICE_PORT2, '2026-08-01', + '2026_08_01_10:00:00.v%d.ts' % slot, b'\x47') + login_as(client, ALICE_PORT1, ALICE_PASS) + html = client.get('/me/logs/2026-08-01/').get_data(as_text=True) + for slot in (1, 2, 3): + assert '2026_08_01_10:00:00.v%d.ts' % slot in html + + def test_segments_sort_with_the_collision_suffix(self, client, logs_dir): + """The -N ordering fix must apply to the compound .vN.ts + extension too, not just .tlog/.bin.""" + for name in ('2026_08_01_10:00:00-10.v1.ts', + '2026_08_01_10:00:00.v1.ts', + '2026_08_01_10:00:00-2.v1.ts'): + seed_session(logs_dir, ALICE_PORT2, '2026-08-01', name, b'\x47') + login_as(client, ALICE_PORT1, ALICE_PASS) + html = client.get('/me/logs/2026-08-01/').get_data(as_text=True) + first = html.index('2026_08_01_10:00:00.v1.ts') + second = html.index('2026_08_01_10:00:00-2.v1.ts') + tenth = html.index('2026_08_01_10:00:00-10.v1.ts') + assert first < second < tenth, \ + 'video segments not in natural collision order' + + @pytest.mark.parametrize('bad', [ + '2026_08_01_10:00:00.v4.ts', # slot out of range + '2026_08_01_10:00:00.ts', # no slot + '2026_08_01_10:00:00.v1.tsx', # not a segment + 'evil.ts', + ]) + def test_non_segment_names_are_refused(self, client, logs_dir, bad): + seed_session(logs_dir, ALICE_PORT2, '2026-08-01', bad, b'X') + login_as(client, ALICE_PORT1, ALICE_PASS) + r = client.get('/me/logs/2026-08-01/%s' % bad) + assert r.status_code == 404, \ + '%s should not be servable' % bad diff --git a/tests/webadmin/test_log_video.py b/tests/webadmin/test_log_video.py new file mode 100644 index 0000000..fcd9e1d --- /dev/null +++ b/tests/webadmin/test_log_video.py @@ -0,0 +1,367 @@ +"""Watching video from the logs view. + +A recorded segment is only useful if you can actually look at it, so the +logs listing offers "watch" beside "download" for video files and a link +to the live player for the entry. An admin reaches any entry's stream; +an owner reaches only their own. +""" +import os +import subprocess +import time + +import pytest + +import keydb_lib + +from _test_helpers import (ALICE_PASS, ALICE_PORT1, ALICE_PORT2, BOB_PASS, + BOB_PORT1, BOB_PORT2, login_as) + +from webadmin import create_app + + +@pytest.fixture +def logs_dir(tmp_path): + p = tmp_path / 'logs' + p.mkdir() + return p + + +@pytest.fixture +def app(keydb_path, logs_dir): + """Point LOGS_DIR at a per-test tmpdir. + + The default fixture leaves it as the relative 'logs', which resolves + under the per-*worker* directory the root conftest chdirs into -- + shared by every test in that worker, so seeded files leak between + them and a "this file is absent" assertion silently passes or fails + on whatever ran first. + """ + return create_app({ + 'TESTING': True, + 'WTF_CSRF_ENABLED': False, + 'SESSION_COOKIE_SECURE': False, + 'KEYDB_PATH': keydb_path, + 'LOGS_DIR': str(logs_dir), + 'SECRET_KEY': 'test', + }) + +DATE = '2026-08-02' +VIDEO = '2026_08_02_11:11:19.v1.ts' +VIDEO2 = '2026_08_02_11:13:24-2.v1.ts' +TLOG = '2026_08_02_11:09:10.tlog' +VPORT = 40001 + +# A tiny but structurally real MPEG-TS payload: sync byte, then filler. +TS_BYTES = (bytes([0x47, 0x40, 0x00, 0x10]) + b'\xff' * 184) * 4 + + +def _seed_logs(app, port2, names=(VIDEO, TLOG)): + root = os.path.join(app.config['LOGS_DIR'], str(port2), DATE) + os.makedirs(root, exist_ok=True) + for n in names: + with open(os.path.join(root, n), 'wb') as f: + f.write(TS_BYTES if n.endswith('.ts') else b'\xfd' * 100) + return root + + +def _seed_real_ts(app, port2): + """Seed a genuinely decodable segment, generated with ffmpeg. + + The synthetic TS_BYTES above is structurally valid but contains no + actual video, so a remux of it produces nothing -- these tests need + a file ffmpeg can really read. + """ + import shutil as _sh + if _sh.which('ffmpeg') is None: + return None + root = os.path.join(app.config['LOGS_DIR'], str(port2), DATE) + os.makedirs(root, exist_ok=True) + dest = os.path.join(root, VIDEO) + subprocess.run( + ['ffmpeg', '-hide_banner', '-loglevel', 'error', '-f', 'lavfi', + '-i', 'testsrc2=size=128x72:rate=10', '-t', '1', + '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '10', + '-pix_fmt', 'yuv420p', '-f', 'mpegts', dest, '-y'], + check=True) + return root + + +def _enable_video(keydb_path, port2, ports=(VPORT, 0, 0)): + db = keydb_lib.open_db(keydb_path) + db.transaction_start() + ke = keydb_lib.KeyEntry(port2) + ke.fetch(db) + ke.flags |= keydb_lib.FLAG_VIDEO + ke.video_ports = list(ports) + ke.store(db) + db.transaction_prepare_commit() + db.transaction_commit() + db.close() + + +class TestWatchLinkInLogsView: + def test_video_file_offers_watch(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/logs/%d/%s/' % (ALICE_PORT2, DATE)) \ + .get_data(as_text=True) + assert 'aria-label="Play"' in html + assert VIDEO in html + + def test_tlog_offers_download_only(self, client, app, keydb_path): + """A .tlog has nothing to watch; offering a player would be a + dead link.""" + _seed_logs(app, ALICE_PORT2, names=(TLOG,)) + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/logs/%d/%s/' % (ALICE_PORT2, DATE)) \ + .get_data(as_text=True) + assert 'aria-label="Download"' in html + assert 'aria-label="Play"' not in html + + def test_owner_sees_watch_on_their_own(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, ALICE_PORT1, ALICE_PASS) + html = client.get('/me/logs/%s/' % DATE).get_data(as_text=True) + assert 'aria-label="Play"' in html + + def test_collision_suffixed_video_is_recognised(self, client, app, + keydb_path): + """-2.v1.ts is a real filename the recorder produces on a + same-second collision, and it must still be playable.""" + _seed_logs(app, ALICE_PORT2, names=(VIDEO2,)) + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/logs/%d/%s/' % (ALICE_PORT2, DATE)) \ + .get_data(as_text=True) + assert 'aria-label="Play"' in html + + +class TestWatchPage: + def test_admin_can_open_any_entry(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/watch' + % (ALICE_PORT2, DATE, VIDEO)) + assert r.status_code == 200 + html = r.get_data(as_text=True) + assert ' on a remuxed MP4, not a JS player: this must + # keep working with JavaScript disabled. + assert 'play.mp4' in html + + def test_owner_can_open_their_own(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, ALICE_PORT1, ALICE_PASS) + r = client.get('/me/logs/%s/%s/watch' % (DATE, VIDEO)) + assert r.status_code == 200 + + def test_watching_a_tlog_is_refused(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/watch' + % (ALICE_PORT2, DATE, TLOG)) + assert r.status_code == 404 + + def test_traversal_is_refused(self, client, app, keydb_path): + login_as(client, BOB_PORT1, BOB_PASS) + for bad in ('..%2f..%2fevil.v1.ts', 'evil.v1.ts%00', '../evil.v1.ts'): + r = client.get('/admin/logs/%d/%s/%s/watch' + % (ALICE_PORT2, DATE, bad)) + assert r.status_code in (301, 308, 404), bad + + +class TestStreamRoute: + def test_serves_inline_not_as_a_download(self, client, app, keydb_path): + """A player cannot use a Content-Disposition: attachment.""" + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/stream' + % (ALICE_PORT2, DATE, VIDEO)) + assert r.status_code == 200 + assert 'attachment' not in r.headers.get('Content-Disposition', '') + assert r.headers['Content-Type'].startswith('video/') + assert r.get_data() == TS_BYTES + + def test_supports_range_so_seeking_works(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/stream' + % (ALICE_PORT2, DATE, VIDEO), + headers={'Range': 'bytes=0-187'}) + assert r.status_code == 206 + assert len(r.get_data()) == 188 + + def test_recordings_are_not_cached(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/stream' + % (ALICE_PORT2, DATE, VIDEO)) + assert 'no-store' in r.headers['Cache-Control'] + + def test_streaming_a_tlog_is_refused(self, client, app, keydb_path): + """Otherwise the inline path becomes a way to render raw + telemetry in a browser tab.""" + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/stream' + % (ALICE_PORT2, DATE, TLOG)) + assert r.status_code == 404 + + +class TestAccessControl: + def test_owner_cannot_stream_another_entry(self, client, app, + keydb_path): + """The owner route resolves port2 from the session, so there is + no parameter to tamper with -- assert that stays true.""" + _seed_logs(app, BOB_PORT2) + _seed_logs(app, ALICE_PORT2, names=()) + login_as(client, ALICE_PORT1, ALICE_PASS) + r = client.get('/me/logs/%s/%s/stream' % (DATE, VIDEO)) + assert r.status_code == 404 + + def test_owner_cannot_use_the_admin_route(self, client, app, keydb_path): + _seed_logs(app, BOB_PORT2) + login_as(client, ALICE_PORT1, ALICE_PASS) + r = client.get('/admin/logs/%d/%s/%s/stream' + % (BOB_PORT2, DATE, VIDEO)) + assert r.status_code in (302, 403) + + def test_anonymous_is_refused(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + for url in ('/admin/logs/%d/%s/%s/stream' % (ALICE_PORT2, DATE, VIDEO), + '/me/logs/%s/%s/stream' % (DATE, VIDEO)): + r = client.get(url) + assert r.status_code in (302, 401, 403), url + + +class TestLiveVideoLinks: + def test_admin_list_links_to_each_entry_with_video(self, client, + keydb_path): + """The video page already accepted ?port2= for admins; nothing + linked to it, so reaching another entry's stream meant editing + the URL by hand.""" + _enable_video(keydb_path, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/').get_data(as_text=True) + assert 'port2=%d' % ALICE_PORT2 in html + + def test_no_video_link_for_an_entry_without_video(self, client, + keydb_path): + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/').get_data(as_text=True) + assert '/video/?port2=' not in html + + def test_logs_page_links_to_the_live_player(self, client, app, + keydb_path): + _enable_video(keydb_path, ALICE_PORT2) + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/logs/%d/' % ALICE_PORT2) \ + .get_data(as_text=True) + assert 'watch live video' in html + + def test_no_live_link_when_no_port_is_allocated(self, client, app, + keydb_path): + """Video enabled but no port bound means nothing to watch.""" + _enable_video(keydb_path, ALICE_PORT2, ports=(0, 0, 0)) + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/logs/%d/' % ALICE_PORT2) \ + .get_data(as_text=True) + assert 'watch live video' not in html + + def test_admin_can_open_another_entrys_live_player(self, client, + keydb_path): + _enable_video(keydb_path, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/video/?port2=%d' % ALICE_PORT2) + assert r.status_code == 200 + assert str(VPORT) in r.get_data(as_text=True) + + def test_owner_cannot_open_another_entrys_live_player(self, client, + keydb_path): + _enable_video(keydb_path, BOB_PORT2) + login_as(client, ALICE_PORT1, ALICE_PASS) + assert client.get('/video/?port2=%d' % BOB_PORT2).status_code == 403 + + +class TestRemuxToMp4: + """Browsers cannot demux MPEG-TS, so the recording is remuxed to + fragmented MP4 on the way out. It is a stream copy, so this costs + no decoding.""" + + def _skip_without_ffmpeg(self): + import shutil + if shutil.which('ffmpeg') is None: + pytest.skip('ffmpeg not installed') + + def test_serves_a_real_mp4(self, client, app, keydb_path, tmp_path): + self._skip_without_ffmpeg() + root = _seed_real_ts(app, ALICE_PORT2) + assert root + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, VIDEO)) + assert r.status_code == 200 + assert r.headers['Content-Type'].startswith('video/mp4') + data = r.get_data() + # An MP4 starts with a box length then 'ftyp'. + assert data[4:8] == b'ftyp', data[:16] + assert len(data) > 1000 + + def test_inline_not_attachment(self, client, app, keydb_path): + self._skip_without_ffmpeg() + _seed_real_ts(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, VIDEO)) + assert 'attachment' not in r.headers.get('Content-Disposition', '') + + def test_not_cached(self, client, app, keydb_path): + self._skip_without_ffmpeg() + _seed_real_ts(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, VIDEO)) + assert 'no-store' in r.headers['Cache-Control'] + + def test_tlog_is_refused(self, client, app, keydb_path): + _seed_logs(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, TLOG)) + assert r.status_code == 404 + + def test_missing_file_is_404_not_a_hanging_ffmpeg(self, client, app, + keydb_path): + _seed_logs(app, ALICE_PORT2, names=()) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, VIDEO)) + assert r.status_code == 404 + + def test_owner_route_is_scoped_to_their_own_entry(self, client, app, + keydb_path): + self._skip_without_ffmpeg() + _seed_real_ts(app, BOB_PORT2) + login_as(client, ALICE_PORT1, ALICE_PASS) + r = client.get('/me/logs/%s/%s/play.mp4' % (DATE, VIDEO)) + assert r.status_code == 404 + + def test_no_ffmpeg_left_running_afterwards(self, client, app, + keydb_path): + """The generator kills ffmpeg in a finally, so a client that + disconnects mid-stream cannot leak one.""" + self._skip_without_ffmpeg() + import subprocess as sp + _seed_real_ts(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + before = sp.run(['pgrep', '-c', '-x', 'ffmpeg'], + capture_output=True, text=True).stdout.strip() or '0' + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, VIDEO)) + r.get_data() + r.close() + time.sleep(0.5) + after = sp.run(['pgrep', '-c', '-x', 'ffmpeg'], + capture_output=True, text=True).stdout.strip() or '0' + assert int(after) <= int(before) diff --git a/tests/webadmin/test_system.py b/tests/webadmin/test_system.py new file mode 100644 index 0000000..7972c1d --- /dev/null +++ b/tests/webadmin/test_system.py @@ -0,0 +1,251 @@ +"""The server page: the daemon's own log, and restarting it. + +Both are admin-only and both are more dangerous than the rest of the +UI -- the log carries whatever the daemon printed, and the restart drops +every live session -- so the access checks matter more than the markup. +""" +import os + +import pytest + +from webadmin import proxylog + +from webadmin import create_app + +from _test_helpers import (ALICE_PASS, ALICE_PORT1, BOB_PASS, BOB_PORT1, + login_as) + + +@pytest.fixture +def csrf_client(keydb_path): + """CSRF on, as production runs it.""" + return create_app({ + 'TESTING': True, + 'WTF_CSRF_ENABLED': True, + 'SESSION_COOKIE_SECURE': False, + 'KEYDB_PATH': keydb_path, + 'SECRET_KEY': 'csrftest', + }).test_client() + + +def _write_log(app, text): + path = proxylog.log_path(app) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'w') as f: + f.write(text) + return path + + +class TestAccess: + def test_owner_cannot_see_the_server_page(self, client, keydb_path): + login_as(client, ALICE_PORT1, ALICE_PASS) + assert client.get('/admin/system/').status_code == 403 + + def test_owner_cannot_read_the_log(self, client, keydb_path): + login_as(client, ALICE_PORT1, ALICE_PASS) + assert client.get('/admin/system/log').status_code == 403 + + def test_owner_cannot_restart(self, client, keydb_path): + login_as(client, ALICE_PORT1, ALICE_PASS) + assert client.post('/admin/system/restart').status_code == 403 + + def test_logged_out_is_refused(self, client, keydb_path): + r = client.get('/admin/system/') + assert r.status_code in (302, 403) + + def test_admin_sees_the_page(self, client, keydb_path): + login_as(client, BOB_PORT1, BOB_PASS) + r = client.get('/admin/system/') + assert r.status_code == 200 + assert 'Restart proxy' in r.get_data(as_text=True) + + +class TestLogTail: + def test_first_fetch_returns_the_tail(self, client, app, keydb_path): + _write_log(app, 'alpha\nbravo\ncharlie\n') + login_as(client, BOB_PORT1, BOB_PASS) + d = client.get('/admin/system/log').get_json() + assert 'charlie' in d['text'] + assert d['offset'] > 0 + + def test_incremental_fetch_returns_only_new_bytes(self, client, app, + keydb_path): + path = _write_log(app, 'one\n') + login_as(client, BOB_PORT1, BOB_PASS) + first = client.get('/admin/system/log').get_json() + with open(path, 'a') as f: + f.write('two\n') + second = client.get( + '/admin/system/log?offset=%d' % first['offset']).get_json() + assert second['text'] == 'two\n' + assert not second['restarted'] + + def test_rotation_is_reported_not_silently_appended(self, client, app, + keydb_path): + """copytruncate leaves the file shorter than the reader's offset. + + Without noticing, the page would append forever to an offset + past the end and quietly show nothing new. + """ + path = _write_log(app, 'x' * 5000) + login_as(client, BOB_PORT1, BOB_PASS) + first = client.get('/admin/system/log').get_json() + with open(path, 'w') as f: # truncate, as logrotate does + f.write('after rotation\n') + d = client.get( + '/admin/system/log?offset=%d' % first['offset']).get_json() + assert d['restarted'] + assert 'after rotation' in d['text'] + + def test_rotation_is_caught_even_if_the_log_regrows(self, client, app, + keydb_path): + """Truncated in place, then grown past the reader's offset. + + This is what copytruncate does, and neither size nor inode sees + it: the file is longer than the old offset again, and the inode + never changed. Only the contents did. + """ + path = _write_log(app, 'x' * 200 + '\n') + login_as(client, BOB_PORT1, BOB_PASS) + first = client.get('/admin/system/log').get_json() + before = os.stat(path).st_ino + with open(path, 'w') as f: # truncate in place + f.write('y' * 5000 + '\nnew generation\n') + assert os.stat(path).st_ino == before, 'meant to keep the inode' + d = client.get('/admin/system/log?offset=%d&ident=%s' + % (first['offset'], first['ident'])).get_json() + assert d['restarted'] + assert 'new generation' in d['text'] + + def test_rotation_is_caught_when_the_inode_is_reused(self, client, app, + keydb_path): + """Replaced by a new file that happens to get the old inode. + + Observed on CI: unlink-and-create handed the freed inode + straight back, so an identity built only from (dev, inode) saw + no change and the page appended the new generation to the old + as though it were contiguous. + """ + path = _write_log(app, 'x' * 200 + '\n') + login_as(client, BOB_PORT1, BOB_PASS) + first = client.get('/admin/system/log').get_json() + os.unlink(path) + with open(path, 'w') as f: + f.write('z' * 5000 + '\nsecond generation\n') + d = client.get('/admin/system/log?offset=%d&ident=%s' + % (first['offset'], first['ident'])).get_json() + assert d['restarted'] + assert 'second generation' in d['text'] + + def test_a_viewer_password_is_redacted(self, client, app, keydb_path): + """Not just the 60-second token: ?pw= is a long-lived + credential, and the old pattern knew nothing about it.""" + _write_log(app, 'video: websocket viewer on /v1?pw=hunter2 (TLS)\n') + login_as(client, BOB_PORT1, BOB_PASS) + d = client.get('/admin/system/log').get_json() + assert 'hunter2' not in d['text'] + assert '' in d['text'] + + def test_an_uppercase_token_is_redacted(self, client, app, keydb_path): + """The old pattern demanded lowercase hex after a literal dot.""" + _write_log(app, 'viewer on /v1?t=1785664967.DEADBEEFCAFE0123\n') + login_as(client, BOB_PORT1, BOB_PASS) + d = client.get('/admin/system/log').get_json() + assert 'DEADBEEFCAFE0123' not in d['text'] + + def test_viewer_tokens_are_redacted(self, client, app, keydb_path): + _write_log(app, 'video: websocket viewer on /v1?t=1785664967.' + 'deadbeefcafe0123 (TLS)\n') + login_as(client, BOB_PORT1, BOB_PASS) + d = client.get('/admin/system/log').get_json() + assert 'deadbeefcafe0123' not in d['text'] + assert '' in d['text'] + + def test_missing_log_is_not_an_error(self, client, app, keydb_path): + path = proxylog.log_path(app) + if os.path.exists(path): + os.unlink(path) + login_as(client, BOB_PORT1, BOB_PASS) + d = client.get('/admin/system/log').get_json() + assert d['text'] == '' + + +class TestRestart: + def test_restart_requires_csrf(self, csrf_client, keydb_path): + login_as(csrf_client, BOB_PORT1, BOB_PASS) + r = csrf_client.post('/admin/system/restart', data={}) + assert r.status_code == 400 + + def test_restart_reports_when_no_daemon_is_running(self, client, + keydb_path, + monkeypatch): + monkeypatch.setattr(proxylog, 'find_daemon', lambda w=None: None) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.post('/admin/system/restart', follow_redirects=True) + assert 'no running supportproxy process' in r.get_data(as_text=True) + + def test_restart_signals_the_pid_it_found(self, client, keydb_path, + monkeypatch): + sent = {} + monkeypatch.setattr(proxylog, 'find_daemon', lambda w=None: 4242) + # Force the non-pidfd path so the fake kill is what runs. + monkeypatch.delattr(proxylog.os, 'pidfd_open', raising=False) + monkeypatch.setattr(proxylog, '_comm', + lambda pid: proxylog.SUPPORTPROXY_COMM) + monkeypatch.setattr(proxylog.os, 'kill', + lambda pid, sig: sent.update(pid=pid, sig=sig)) + login_as(client, BOB_PORT1, BOB_PASS) + r = client.post('/admin/system/restart', follow_redirects=True) + assert sent == {'pid': 4242, 'sig': proxylog.signal.SIGTERM} + assert '4242' in r.get_data(as_text=True) + + +class TestFindDaemon: + """Which process gets signalled. + + min(pid) over everything named supportproxy is not an identity: it + picks up a second instance on the same host, and a session child + that was reparented when its own parent exited. Both mean the + restart hits the wrong process. + """ + + def _tree(self, monkeypatch, tree, cwds): + monkeypatch.setattr(proxylog, '_systemd_main_pid', lambda u=None: None) + monkeypatch.setattr(proxylog.os, 'listdir', + lambda p: [str(k) for k in tree]) + monkeypatch.setattr(proxylog, '_comm', + lambda pid: tree.get(pid, (None, None))[0]) + monkeypatch.setattr(proxylog, '_ppid', + lambda pid: tree.get(pid, (None, None))[1]) + monkeypatch.setattr(proxylog, '_cwd', lambda pid: cwds.get(pid)) + + def test_prefers_the_parent_over_its_children(self, monkeypatch): + tree = {100: (proxylog.SUPPORTPROXY_COMM, 1), + 101: (proxylog.SUPPORTPROXY_COMM, 100), + 102: (proxylog.SUPPORTPROXY_COMM, 100), + 200: ('something-else', 1)} + self._tree(monkeypatch, tree, {p: '/srv/proxy' for p in tree}) + assert proxylog.find_daemon('/srv/proxy') == 100 + + def test_ignores_another_instance(self, monkeypatch): + """A staging daemon with a lower pid must not be signalled.""" + tree = {50: (proxylog.SUPPORTPROXY_COMM, 1), + 100: (proxylog.SUPPORTPROXY_COMM, 1)} + self._tree(monkeypatch, tree, + {50: '/srv/staging', 100: '/srv/proxy'}) + assert proxylog.find_daemon('/srv/proxy') == 100 + + def test_ignores_a_reparented_child(self, monkeypatch): + """An orphaned session child has ppid 1 and the right cwd, so + only the comm of its parent distinguished it before -- and once + reparented there is no such parent. It must not win on pid.""" + tree = {90: (proxylog.SUPPORTPROXY_COMM, 1), + 100: (proxylog.SUPPORTPROXY_COMM, 1)} + self._tree(monkeypatch, tree, {90: '/other', 100: '/srv/proxy'}) + assert proxylog.find_daemon('/srv/proxy') == 100 + + def test_systemd_is_authoritative(self, monkeypatch): + monkeypatch.setattr(proxylog, '_systemd_main_pid', lambda u=None: 777) + monkeypatch.setattr(proxylog, '_comm', + lambda pid: proxylog.SUPPORTPROXY_COMM) + assert proxylog.find_daemon('/srv/proxy') == 777 diff --git a/tests/webadmin/test_tooltips.py b/tests/webadmin/test_tooltips.py new file mode 100644 index 0000000..4db2416 --- /dev/null +++ b/tests/webadmin/test_tooltips.py @@ -0,0 +1,205 @@ +"""Per-field help tooltips. + +Every option that is not self-explanatory carries its explanation in the +WTForms `description`, which templates/_macros.html renders as a tooltip +beside the label. Two things are worth guarding: that the tooltips +actually reach the page, and that a newly added option cannot quietly +ship without one. +""" +import re + +from _test_helpers import (ALICE_PASS, ALICE_PORT1, ALICE_PORT2, BOB_PASS, + BOB_PORT1, login_as) + +from webadmin import forms + +# Fields that legitimately have no `description`. +# +# The per-slot video booleans are documented by hand in +# _video_fields.html instead: three slots x three options would mean nine +# near-identical strings in forms.py, and the row is rendered by hand +# there anyway. +_NO_DESCRIPTION_NEEDED = {'submit', 'csrf_token'} + + +def _documented(form_cls): + """(fields needing a description, fields having one).""" + form = form_cls(meta={'csrf': False}) + need, have = set(), set() + for field in form: + name = field.name + if name in _NO_DESCRIPTION_NEEDED: + continue + if re.match(r'^video_(srt|record|rawtcp)_\d$', name): + continue + need.add(name) + if field.description: + have.add(name) + return need, have + + +class TestEveryOptionIsDocumented: + """A new option must not ship without help text.""" + + def test_admin_edit_form(self, app): + with app.test_request_context(): + need, have = _documented(forms.AdminEditForm) + assert need - have == set(), 'fields with no description' + + def test_owner_edit_form(self, app): + with app.test_request_context(): + need, have = _documented(forms.OwnerEditForm) + assert need - have == set(), 'fields with no description' + + def test_admin_add_form(self, app): + with app.test_request_context(): + need, have = _documented(forms.AdminAddForm) + assert need - have == set(), 'fields with no description' + + def test_login_form(self, app): + with app.test_request_context(): + need, have = _documented(forms.LoginForm) + assert need - have == set(), 'fields with no description' + + def test_the_check_would_catch_a_missing_one(self, app): + """Guard the guard: a field with no description must be caught, + or these tests pass for the wrong reason.""" + class Undocumented(forms.LoginForm): + pass + Undocumented.mystery = forms.BooleanField('Mystery option') + with app.test_request_context(): + need, have = _documented(Undocumented) + assert 'mystery' in need - have + + +class TestTooltipsRender: + def test_admin_edit_page_has_tooltips(self, client, keydb_path): + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/%d' % ALICE_PORT2).get_data(as_text=True) + assert html.count('class="tip"') > 10 + + def test_owner_page_has_tooltips(self, client, keydb_path): + login_as(client, ALICE_PORT1, ALICE_PASS) + html = client.get('/me/').get_data(as_text=True) + assert html.count('class="tip"') > 8 + + def test_login_page_has_tooltips(self, client): + html = client.get('/login').get_data(as_text=True) + assert 'class="tip"' in html + + def test_add_entry_form_has_tooltips(self, client, keydb_path): + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/').get_data(as_text=True) + assert 'class="tip"' in html + + def test_specific_help_text_reaches_the_page(self, client, keydb_path): + """Spot-check that the detail that used to be in the label is + still shown, just moved into the tooltip.""" + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/%d' % ALICE_PORT2).get_data(as_text=True) + assert 'LOG_BACKEND_TYPE' in html # binlog + assert 'keeps them forever' in html # retention + assert 'replays' in html # reset timestamp + + def test_the_whole_row_is_the_target_not_a_marker(self, client, + keydb_path): + """Aiming at a one-em "?" to read a sentence is more work than + the sentence is worth.""" + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/%d' % ALICE_PORT2).get_data(as_text=True) + assert 'class="help"' not in html, 'the ? marker is gone' + assert '>?<' not in html + + def test_reachable_without_a_pointer(self, client, keydb_path): + """Focusing the input is what shows it for keyboard users, and + aria-describedby ties the text to the control.""" + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/%d' % ALICE_PORT2).get_data(as_text=True) + assert 'aria-describedby=' in html + assert ':focus-within' in _css_rules(client) + + def test_video_slot_options_are_documented_in_the_template( + self, client, keydb_path): + """These three are exempt from the forms.py check, so make sure + they really are documented where they are rendered.""" + login_as(client, BOB_PORT1, BOB_PASS) + html = client.get('/admin/%d' % ALICE_PORT2).get_data(as_text=True) + assert 'cannot share a port' in html # SRT + assert 'timestamped .ts segments' in html # record + assert 'ffplay tcp://' in html # raw TCP viewers + + def test_description_is_escaped(self, app): + """Descriptions are trusted text today, but they are rendered + into HTML, so the macro must not become an injection point if one + ever contains a bracket.""" + from markupsafe import Markup + from flask import render_template_string + + class F(forms.LoginForm): + pass + F.evil = forms.StringField('Evil', + description='') + with app.test_request_context(): + f = F(meta={'csrf': False}) + out = render_template_string( + '{% from "_macros.html" import row %}{{ row(form.evil) }}', + form=f) + assert ' +{% endblock %} diff --git a/webadmin/templates/base.html b/webadmin/templates/base.html index d5c0e27..d64847c 100644 --- a/webadmin/templates/base.html +++ b/webadmin/templates/base.html @@ -20,8 +20,10 @@

{{ config.WEBUI_TITLE }}

{% if session.get('is_admin') %} all entries connections + server {% endif %} my entry + video
@@ -56,5 +58,6 @@

{{ config.WEBUI_TITLE }}

+ diff --git a/webadmin/templates/log_play.html b/webadmin/templates/log_play.html new file mode 100644 index 0000000..38a3f51 --- /dev/null +++ b/webadmin/templates/log_play.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}{{ name }} — {{ config.WEBUI_TITLE }}{% endblock %} +{% block content %} +

{{ name }}

+

+ ← back to {{ date }} · + download +

+ +{# Browsers cannot demux MPEG-TS, so the recording is remuxed to + fragmented MP4 on the way out -- a stream copy, no decoding. That + plays in a plain