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/README.md b/README.md index 49953e9..624c991 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ For more information on using the support proxy see https://support.ardupilot.or - Supports WebSocket and WebSocket+SSL TCP connections for both user and support engineer - supports up to 8 simultaneous connections by support engineer +- Optional video proxying alongside the MAVLink link, with recording ## How It Works @@ -32,6 +33,59 @@ secure, authenticated connections. Both sides can optionally use WebSocket+SSL to get a fully encrypted link. +## Video + +Optional, off unless an entry has it enabled. A user points a camera at +one of their entry's video ports and any number of ground stations can +watch, with the same NAT traversal and per-entry credentials the MAVLink +side already provides. Recordings land beside the tlogs under +`logs///` and are covered by the same retention. + +Video is deliberately independent of the MAVLink session: it survives a +telemetry dropout, and with a publish password it needs no MAVLink at +all. + +**Ports.** Up to three per entry, allocated by an admin from the web UI +(suggested from 40001). Each carries one stream, on TCP and UDP. +**These are public listening ports and must be open in the firewall.** + +**Publishing.** + +| Transport | Credential | +|---|---| +| MPEG-TS over UDP | none possible — see below | +| RTSP | `?pw=` on the request URI | +| RTMP | `?pw=` on the stream key, e.g. `FPV?pw=secret` | + +Plain MPEG-TS over UDP has nowhere to carry a password, so it is +admitted on the MAVLink-session path only: a publisher is accepted when +a MAVLink session for the entry was seen from the same address within +the grace window. On a non-bidi entry *any* datagram latches the user +side, so a scanner between flights can become the authorised address and +the aircraft's video is then refused until the grace expires. **Entries +used for video should set `bidi_sign` or a publish password.** + +**Watching.** In the browser from the web UI, or outside it with the +`ffplay`/`vlc` command the page offers. The browser player needs H.264: +Chrome and Firefox will not decode HEVC in Media Source Extensions on +desktop Linux, and nothing here transcodes. + +**Disk.** Video has its own budget, separate from telemetry, so a busy +camera can never evict a user's tlogs. Set it per entry in the web UI; +a free-space floor stops recording before the disk fills. + +**Log rotation.** The daemon's own log is not rotated by default. +Install the supplied config once, as root: + +```bash +sudo install -m 644 scripts/supportproxy.logrotate \ + /etc/logrotate.d/supportproxy +``` + +It uses `copytruncate`, which is required rather than preferred when the +daemon's stdout is a file systemd holds open — see the comments in that +file. + ## Building ### Prerequisites @@ -40,6 +94,9 @@ Both sides can optionally use WebSocket+SSL to get a fully encrypted link. # Ubuntu/Debian sudo apt install libtdb-dev python3-tdb python3-venv gcc g++ git libssl-dev +# Only if video is used: RTSP and RTMP ingest hand the stream to an +# ffmpeg child for demuxing. Plain MPEG-TS over UDP needs nothing extra. +sudo apt install ffmpeg ``` ### Get the source @@ -65,7 +122,60 @@ source venv/bin/activate pip install pymavlink ``` -### Building SupportProxy +### Video + +Optional, off unless an entry has it enabled. A user points a camera at +one of their entry's video ports and any number of ground stations can +watch, with the same NAT traversal and per-entry credentials the MAVLink +side already provides. Recordings land beside the tlogs under +`logs///` and are covered by the same retention. + +Video is deliberately independent of the MAVLink session: it survives a +telemetry dropout, and with a publish password it needs no MAVLink at +all. + +**Ports.** Up to three per entry, allocated by an admin from the web UI +(suggested from 40001). Each carries one stream, on TCP and UDP. +**These are public listening ports and must be open in the firewall.** + +**Publishing.** + +| Transport | Credential | +|---|---| +| MPEG-TS over UDP | none possible — see below | +| RTSP | `?pw=` on the request URI | +| RTMP | `?pw=` on the stream key, e.g. `FPV?pw=secret` | + +Plain MPEG-TS over UDP has nowhere to carry a password, so it is +admitted on the MAVLink-session path only: a publisher is accepted when +a MAVLink session for the entry was seen from the same address within +the grace window. On a non-bidi entry *any* datagram latches the user +side, so a scanner between flights can become the authorised address and +the aircraft's video is then refused until the grace expires. **Entries +used for video should set `bidi_sign` or a publish password.** + +**Watching.** In the browser from the web UI, or outside it with the +`ffplay`/`vlc` command the page offers. The browser player needs H.264: +Chrome and Firefox will not decode HEVC in Media Source Extensions on +desktop Linux, and nothing here transcodes. + +**Disk.** Video has its own budget, separate from telemetry, so a busy +camera can never evict a user's tlogs. Set it per entry in the web UI; +a free-space floor stops recording before the disk fills. + +**Log rotation.** The daemon's own log is not rotated by default. +Install the supplied config once, as root: + +```bash +sudo install -m 644 scripts/supportproxy.logrotate \ + /etc/logrotate.d/supportproxy +``` + +It uses `copytruncate`, which is required rather than preferred when the +daemon's stdout is a file systemd holds open — see the comments in that +file. + +## Building SupportProxy ```bash # Build everything (initializes submodules, generates headers, compiles) @@ -207,7 +317,60 @@ netstat -ln | grep ":1000[0-9]" SupportProxy can also be run using Docker for easier deployment and management. -### Building the Docker Image +### Video + +Optional, off unless an entry has it enabled. A user points a camera at +one of their entry's video ports and any number of ground stations can +watch, with the same NAT traversal and per-entry credentials the MAVLink +side already provides. Recordings land beside the tlogs under +`logs///` and are covered by the same retention. + +Video is deliberately independent of the MAVLink session: it survives a +telemetry dropout, and with a publish password it needs no MAVLink at +all. + +**Ports.** Up to three per entry, allocated by an admin from the web UI +(suggested from 40001). Each carries one stream, on TCP and UDP. +**These are public listening ports and must be open in the firewall.** + +**Publishing.** + +| Transport | Credential | +|---|---| +| MPEG-TS over UDP | none possible — see below | +| RTSP | `?pw=` on the request URI | +| RTMP | `?pw=` on the stream key, e.g. `FPV?pw=secret` | + +Plain MPEG-TS over UDP has nowhere to carry a password, so it is +admitted on the MAVLink-session path only: a publisher is accepted when +a MAVLink session for the entry was seen from the same address within +the grace window. On a non-bidi entry *any* datagram latches the user +side, so a scanner between flights can become the authorised address and +the aircraft's video is then refused until the grace expires. **Entries +used for video should set `bidi_sign` or a publish password.** + +**Watching.** In the browser from the web UI, or outside it with the +`ffplay`/`vlc` command the page offers. The browser player needs H.264: +Chrome and Firefox will not decode HEVC in Media Source Extensions on +desktop Linux, and nothing here transcodes. + +**Disk.** Video has its own budget, separate from telemetry, so a busy +camera can never evict a user's tlogs. Set it per entry in the web UI; +a free-space floor stops recording before the disk fills. + +**Log rotation.** The daemon's own log is not rotated by default. +Install the supplied config once, as root: + +```bash +sudo install -m 644 scripts/supportproxy.logrotate \ + /etc/logrotate.d/supportproxy +``` + +It uses `copytruncate`, which is required rather than preferred when the +daemon's stdout is a file systemd holds open — see the comments in that +file. + +## Building the Docker Image ```bash docker build -f docker/Dockerfile -t ap-supportproxy . 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 = " +#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/keydb.h b/keydb.h index e75ccd0..0b12579 100644 --- a/keydb.h +++ b/keydb.h @@ -34,6 +34,53 @@ #define KEY_FLAG_TLOG (1u << 2) // record per-connection MAVProxy-format tlogs #define KEY_FLAG_BINLOG (1u << 3) // record ArduPilot bin logs over MAVLink #define KEY_FLAG_USE_TZ (1u << 4) // name logs with tz_offset_hours; else server local +#define KEY_FLAG_VIDEO (1u << 5) // video proxying enabled for this entry + +#define KEY_MAX_VIDEO_PORTS 3 + +/* + KeyEntry.video_flags: one byte of options per video slot, plus a + byte of entry-wide options. A byte per slot (rather than packing bits + tightly) keeps the shift arithmetic obvious and leaves room to grow + without another schema change. + + bits 0-7 slot 0 + bits 8-15 slot 1 + bits 16-23 slot 2 + bits 24-31 entry-wide + */ +#define VIDEO_SLOT_BITS 8 +#define VIDEO_SLOT_SHIFT(slot) ((slot) * VIDEO_SLOT_BITS) + +// per-slot bits +#define VIDEO_SLOT_SRT (1u << 0) // UDP side speaks SRT, not plain MPEG-TS +#define VIDEO_SLOT_RECORD (1u << 1) // write .ts segments under logs/ +#define VIDEO_SLOT_RAW_TCP (1u << 2) // allow raw-TCP viewers (no credential) + +// entry-wide bits, stored in the top byte +#define VIDEO_OPT_SHIFT 24 +#define VIDEO_OPT_AUDIO (1u << 0) // carry audio from RTSP ingest as AAC. + // Default off: audio is rarely useful + // from an aircraft, and dropping it + // keeps the muxed TS video-only. + +static inline uint32_t video_slot_opts(uint32_t video_flags, unsigned slot) +{ + if (slot >= 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/scripts/deploy_video.sh b/scripts/deploy_video.sh new file mode 100755 index 0000000..5cd48f1 --- /dev/null +++ b/scripts/deploy_video.sh @@ -0,0 +1,256 @@ +#!/bin/bash +# Stage the video feature onto a server, alongside whatever else is +# already running there. +# +# Usage: +# scripts/deploy_video.sh [user@]host # pre-flight only +# scripts/deploy_video.sh [user@]host --apply # deploy + configure +# scripts/deploy_video.sh [user@]host --apply --entry 11025 --ports 12001 +# +# Deliberately dry-run by default: it restarts the proxy and edits +# keys.tdb, so it should not do either because someone ran it to look. +# +# What it does NOT touch, ever: +# ~/Video/ mediamtx's install and its recordings. The two +# systems run side by side until the new one is +# proven, so its ports and its 22 GB of captures +# are none of our business. +# ~/proxy/*.pem the WSS certs. +# logs/ existing tlogs and bin logs. +set -u + +HOST="" +APPLY=0 +ENTRY="" +PORTS="" +RECORD=1 +BIDI=0 +PUBPASS="" +VIEWPASS="" + +while [ $# -gt 0 ]; do + case "$1" in + --apply) APPLY=1 ;; + --entry) ENTRY="$2"; shift ;; + --ports) PORTS="$2"; shift ;; + --no-record) RECORD=0 ;; + --bidi) BIDI=1 ;; + --publish-pass) PUBPASS="$2"; shift ;; + --viewer-pass) VIEWPASS="$2"; shift ;; + -h|--help) sed -n '2,25p' "$0"; exit 0 ;; + *) HOST="$1" ;; + esac + shift +done + +if [ -z "$HOST" ]; then + echo "usage: $0 [user@]host [--apply] [--entry PORT2] [--ports P1[,P2,P3]]" >&2 + exit 1 +fi + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +# Ports for mediamtx, so we can say plainly when a chosen video port +# would collide with it while both systems are running. +MEDIAMTX_PORTS="1935 8322 11002 11032 11033 10002 10003" + +say() { printf ' %s\n' "$*"; } +head2() { printf '\n=== %s ===\n' "$*"; } +fail() { printf '\nABORT: %s\n' "$*" >&2; exit 1; } + +# ---------------------------------------------------------------- pre-flight + +head2 "$HOST: pre-flight" + +PRE=$(ssh "$HOST" bash -se <<'SSH_EOF' 2>&1 +set -u +printf 'os=%s\n' "$( . /etc/os-release && echo "$PRETTY_NAME" )" +printf 'ffmpeg=%s\n' "$(command -v ffmpeg || echo MISSING)" +printf 'freekb=%s\n' "$(df -Pk "$HOME" | awk 'NR==2{print $4}')" +printf 'mediamtx=%s\n' "$(pgrep -x mediamtx >/dev/null && echo running || echo stopped)" +printf 'systemd=%s\n' "$(systemctl is-enabled supportproxy.service 2>/dev/null || echo no)" +# What the build actually needs is mavgen.py on PATH (regen_headers.sh), +# whether that comes from a venv or a --user install. Testing for the +# venv instead would fail on a host where pymavlink is installed +# system-wide, which is how this one is set up. +printf 'mavgen=%s\n' "$(command -v mavgen.py || echo MISSING)" +for p in libtdb-dev libssl-dev; do + printf 'pkg_%s=%s\n' "$p" "$(dpkg -l "$p" 2>/dev/null | awk '/^ii/{print "ok"}' | head -1)" +done +printf 'bound=%s\n' "$(ss -lntuH 2>/dev/null | awk '{print $5}' | sed 's/.*://' | sort -un | tr '\n' ',')" +SSH_EOF +) || fail "cannot reach $HOST" + +get() { echo "$PRE" | sed -n "s/^$1=//p" | head -1; } + +say "os: $(get os)" +say "ffmpeg: $(get ffmpeg)" +say "free disk: $(( $(get freekb) / 1024 / 1024 )) GiB" +say "mediamtx: $(get mediamtx)" +say "systemd: $(get systemd)" +say "mavgen: $(get mavgen)" + +BOUND=",$(get bound)" +FREEKB=$(get freekb) + +[ "$(get mavgen)" != MISSING ] || fail "mavgen.py not on PATH; regen_headers.sh needs pymavlink installed" +[ -n "$(get pkg_libtdb-dev)" ] || fail "libtdb-dev missing on the server" +[ -n "$(get pkg_libssl-dev)" ] || fail "libssl-dev missing on the server" + +# 4 GiB: the default per-entry video budget is 4 GiB and the recorder +# refuses to open a segment below a 2 GiB floor, so anything less and +# recording would never start. +if [ "$FREEKB" -lt $((6 * 1024 * 1024)) ]; then + fail "only $(( FREEKB / 1024 / 1024 )) GiB free; want >= 6 GiB (4 GiB video quota + the 2 GiB floor)" +fi + +FFMPEG_OK=1 +if [ "$(get ffmpeg)" = MISSING ]; then + FFMPEG_OK=0 + say "" + say "ffmpeg is NOT installed. MPEG-TS/UDP publish, all viewers and" + say "recording work without it; only RTSP ingest needs it. There is no" + say "passwordless sudo here, so install it yourself:" + say " ssh $HOST sudo apt install ffmpeg" +fi + +# ------------------------------------------------------------------- ports + +PORTS="${PORTS:-12001}" +head2 "video ports: $PORTS" +IFS=',' read -ra PORT_LIST <<< "$PORTS" +for p in "${PORT_LIST[@]}"; do + case "$BOUND" in + *",$p,"*) fail "port $p is already bound on $HOST" ;; + esac + for m in $MEDIAMTX_PORTS; do + if [ "$p" = "$m" ]; then + fail "port $p is one of mediamtx's ($MEDIAMTX_PORTS) and both must keep running" + fi + done + say "$p free, and clear of mediamtx" +done + +# ------------------------------------------------------------------ entries + +head2 "entries on $HOST" +ssh "$HOST" "cd ~/proxy && python3 -c \" +import sys; sys.path.insert(0, '\$HOME/SupportProxy') +import keydb_lib +db = keydb_lib.open_db('keys.tdb'); db.transaction_start() +try: + for e in keydb_lib.list_entries(db): + print(' %5d/%-5d %-18s %s' % (e.port1, e.port2, e.name, + ','.join(e.flag_names()) or '(no flags)')) +finally: + db.transaction_cancel(); db.close() +\"" 2>&1 | head -30 + +if [ -z "$ENTRY" ]; then + head2 "no --entry given" + say "Pre-flight only. Re-run with, for example:" + say " $0 $HOST --apply --entry 11025 --ports $PORTS" + say "" + say "" + say "Publisher auth, pick one:" + say "" + say " (a) --publish-pass SECRET password only, no MAVLink needed." + say " Publish with rtsp://HOST:PORT/cam?pw=SECRET." + say " NOTE: the password REPLACES the address check, and plain" + say " MPEG-TS/UDP cannot carry one -- with a password set, UDP" + say " publish is refused. Use RTSP." + say "" + say " (b) nothing address must match a recent MAVLink" + say " session. Works for UDP and RTSP. But without bidi ANY" + say " datagram latches conn1, and this server sees hundreds of" + say " distinct scanners doing exactly that -- a scanner holding" + say " conn1 makes the aircraft's own publish be refused until it" + say " ages out (grace window, 60s default)." + say "" + say " (c) --bidi as (b), but only a signature-checked" + say " session can latch conn1, so scanners cannot interfere." + say " Changes the entry: the aircraft must sign from then on." + exit 0 +fi + +if [ "$APPLY" != 1 ]; then + head2 "dry run" + say "Would deploy the current working tree to $HOST and then:" + say " entry $ENTRY: video ports $PORTS" + [ "$BIDI" = 1 ] && say " entry $ENTRY: bidi_sign ON" + [ -n "$PUBPASS" ] && say " entry $ENTRY: publish password SET (UDP publish will be refused)" + [ -n "$VIEWPASS" ] && say " entry $ENTRY: viewer password SET" + [ "$RECORD" = 1 ] && say " slot 1: record ON" + if [ "$BIDI" = 0 ] && [ -z "$PUBPASS" ]; then + say "" + say " WARNING: no bidi and no publish password -- a scanner that" + say " latches conn1 will block the aircraft's video publish." + fi + say "Re-run with --apply to do it." + exit 0 +fi + +# ------------------------------------------------------------------- deploy + +head2 "$HOST: backing up keys.tdb" +ssh "$HOST" 'cd ~/proxy && cp -a keys.tdb "keys.tdb.before-video.$(date +%Y%m%d%H%M%S)" && ls -1t keys.tdb.before-video.* | head -1' \ + || fail "could not back up keys.tdb" + +head2 "$HOST: sync, build, restart" +"$SCRIPT_DIR/update_server.sh" "$HOST" || fail "update_server.sh failed; nothing was reconfigured" + +head2 "$HOST: verifying the new build" +ssh "$HOST" '~/SupportProxy/supportproxy --selftest-video' \ + || fail "the deployed binary failed its own self-test" + +# ---------------------------------------------------------------- configure + +head2 "$HOST: configuring entry $ENTRY" +ssh "$HOST" bash -se </dev/null | grep -q ":\$p "; then + echo " video port \$p is listening" + else + echo " video port \$p NOT listening -- check ~/proxy/proxy.log" + fi +done +echo " mediamtx: \$(pgrep -x mediamtx >/dev/null && echo 'still running (untouched)' || echo 'stopped (as it was)')" +grep -E 'video (slot|child)' ~/proxy/proxy.log | tail -4 | sed 's/^/ /' +SSH_EOF + +head2 "done" +if [ -n "$PUBPASS" ]; then + say "Publish to: rtsp://$HOST:${PORT_LIST[0]}/cam?pw=$PUBPASS" + say " (UDP publish is refused while a publish password is set)" +else + say "Publish to: udp://$HOST:${PORT_LIST[0]} (MPEG-TS, e.g. gstreamer udpsink)" + if [ "$FFMPEG_OK" = 1 ]; then + say " or: rtsp://$HOST:${PORT_LIST[0]}/cam" + else + say " RTSP publish needs ffmpeg installed first." + fi +fi +# Without the flags ffplay spends its default 5s analyzeduration before +# showing anything, which reads as the proxy being slow. +say "Watch with: ffplay -fflags nobuffer -flags low_delay -framedrop \\" +say " -probesize 500000 -analyzeduration 1000000 \\" +say " http://$HOST:${PORT_LIST[0]}/v1.ts" +say " or: the video page in the web admin" +say "" +say "mediamtx is untouched and still on its own ports." diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a71a220..dae8de3 100755 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -35,7 +35,17 @@ 'tests/test_conn2_slot_orphan.py', 'tests/test_drop_lost_request.py', 'tests/test_websocket_decode.py', - 'tests/test_ws_handshake_ordering.py']), + 'tests/test_ws_handshake_ordering.py', + 'tests/test_websocket_framing.py', + 'tests/test_bidi_video_preauth.py', + 'tests/test_video_schema.py', + 'tests/test_video_ports.py', + 'tests/test_video_child.py', + 'tests/test_video_ingest.py', + 'tests/test_video_record.py', + 'tests/test_video_view.py', + 'tests/test_video_rtsp.py', + 'tests/test_video_testtool.py']), ('Webadmin Tests', ['tests/webadmin/']), ] diff --git a/scripts/setup_ci.sh b/scripts/setup_ci.sh index b76a865..cb66bd6 100755 --- a/scripts/setup_ci.sh +++ b/scripts/setup_ci.sh @@ -12,6 +12,10 @@ cd "$(dirname "$0")/.." echo "=== Setting up CI environment for SupportProxy tests ===" +# ffmpeg is a real test dependency, not a convenience. RTSP and RTMP +# ingest hand the stream to an ffmpeg child, and the video tests skip +# themselves without it -- so leaving it out means the whole ingest, +# recording and viewer suite silently does not run in CI. echo "Installing system dependencies..." sudo apt-get update sudo apt-get install -y \ @@ -24,7 +28,8 @@ sudo apt-get install -y \ python3-tdb \ python3-pip \ python3-venv \ - libtdb1 + libtdb1 \ + ffmpeg # Check if virtual environment exists if [ ! -d "venv" ]; then diff --git a/scripts/supportproxy.logrotate b/scripts/supportproxy.logrotate new file mode 100644 index 0000000..f7d111a --- /dev/null +++ b/scripts/supportproxy.logrotate @@ -0,0 +1,43 @@ +# Rotation for the daemon and web admin activity logs. +# +# Install as root: +# install -m 644 scripts/supportproxy.logrotate \ +# /etc/logrotate.d/supportproxy +# +# copytruncate is required, not optional. supportproxy.service writes +# its log with systemd's StandardOutput=append:, so systemd holds the +# descriptor for the life of the process -- renaming the file would +# leave the daemon writing to an unlinked inode until the next restart, +# and the visible log would stop growing with nothing to show why. +# Copying and then truncating keeps the descriptor valid, and because it +# is opened O_APPEND the next write lands at the new start. +# +# The trade: anything written between the copy and the truncate is lost +# -- not merely a line, but whatever the daemon emits in that window, +# which on a busy proxy with a large log is not nothing. The alternative +# is restarting the daemon to make it reopen, which drops every live +# session, so losing a moment of log is the lesser cost. + +# Rotated on size, not on a schedule: this is a debugging log whose rate +# depends entirely on what the proxy is doing, so bounding the disk it +# can take is the useful guarantee. logrotate's own timer decides how +# often the size is checked -- daily on a stock Debian/Ubuntu -- and +# specifying `daily` here as well would be dead text, since `size` +# overrides it (logrotate says so itself in -d output). +/home/fire/proxy/proxy.log /home/fire/proxy/webui.log { + size 64M + rotate 14 + # No delaycompress: that exists to avoid compressing a file still + # being appended to, but copytruncate means the rotated file is a + # finished copy the moment it exists. Deferring would just keep an + # uncompressed generation on disk for no benefit. + compress + missingok + notifempty + copytruncate + su fire fire + # No `create`: logrotate ignores it under copytruncate, because the + # live file is never replaced. The permissions on proxy.log are + # whatever the daemon's umask made them; this stanza cannot set or + # repair them, and pretending otherwise would be misleading. +} diff --git a/scripts/test_video.py b/scripts/test_video.py new file mode 100755 index 0000000..61406d0 --- /dev/null +++ b/scripts/test_video.py @@ -0,0 +1,889 @@ +#!/usr/bin/env python3 +"""Generate a test video stream for a SupportProxy video port, and pull it +back to check what arrived. + +The picture carries a test pattern plus a running wall clock and elapsed +timer burned into the frame, so a viewer anywhere can see at a glance +whether the stream is live, how far behind it is, and whether it froze. + + # publish a 720p H.264 test pattern over MPEG-TS/UDP + scripts/test_video.py publish --host neon --port 40001 + + # publish over RTSP with a publish password + scripts/test_video.py publish --host neon --port 40001 \ + --transport rtsp --publish-pass secret + + # pull it back and report on what arrived + scripts/test_video.py view --host neon --port 40001 --viewer-pass hunter2 + + # do both and print a pass/fail + scripts/test_video.py check --host neon --port 40001 + + # what can this machine do? + scripts/test_video.py caps + +Publish transports are the ones the proxy actually accepts: MPEG-TS over +UDP, and RTSP. A plain TCP connection to a video port is treated as a +viewer, so there is no raw-TCP publish; SRT is not implemented yet. +""" +import argparse +import base64 +import json +import os +import re +import shlex +import shutil +import signal +import socket +import subprocess +import sys +import threading +import time + +TS_PACKET = 188 +# What a well-behaved MPEG-TS/UDP sender emits: 7 packets per datagram, +# which is also SRT's default payload size for the same reason. +TS_UDP_PAYLOAD = 7 * TS_PACKET + +# Stream types we recognise in a PMT, for reporting. +STREAM_TYPES = { + 0x02: 'MPEG-2 video', 0x03: 'MP2 audio', 0x04: 'MP3 audio', + 0x0F: 'AAC', 0x1B: 'H.264', 0x24: 'HEVC', 0x81: 'AC-3', + 0x06: 'private data', +} + + +def log(msg): + sys.stderr.write('%s\n' % msg) + sys.stderr.flush() + + +def die(msg, code=2): + log('error: %s' % msg) + sys.exit(code) + + +# ----------------------------------------------------------------- caps + +def have(prog): + return shutil.which(prog) is not None + + +def ffmpeg_has_filter(name): + if not have('ffmpeg'): + return False + try: + out = subprocess.run(['ffmpeg', '-hide_banner', '-filters'], + capture_output=True, text=True, + timeout=20).stdout + except (OSError, subprocess.SubprocessError): + return False + return re.search(r'^\s*\S+\s+%s\s' % re.escape(name), out, re.M) is not None + + +def gst_has(element): + if not have('gst-inspect-1.0'): + return False + return subprocess.run(['gst-inspect-1.0', element], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL).returncode == 0 + + +def capabilities(): + caps = { + 'ffmpeg': have('ffmpeg'), + 'ffprobe': have('ffprobe'), + 'ffplay': have('ffplay'), + 'gstreamer': have('gst-launch-1.0'), + } + caps['ffmpeg_drawtext'] = ffmpeg_has_filter('drawtext') + caps['ffmpeg_testsrc2'] = ffmpeg_has_filter('testsrc2') + caps['gst_clockoverlay'] = gst_has('clockoverlay') + caps['gst_rtspclientsink'] = gst_has('rtspclientsink') + return caps + + +def pick_encoder(want, transport): + """Resolve --encoder auto against what is installed and what the + transport needs.""" + caps = capabilities() + ff_ok = caps['ffmpeg'] and caps['ffmpeg_testsrc2'] + gst_ok = caps['gstreamer'] and caps['gst_clockoverlay'] + # GStreamer can only publish RTSP with rtspclientsink, which lives in + # the -bad plugin set and is often absent. + if transport == 'rtsp': + gst_ok = gst_ok and caps['gst_rtspclientsink'] + + if want == 'ffmpeg': + if not ff_ok: + die('ffmpeg not usable here (need ffmpeg with testsrc2); ' + 'run "%s caps"' % sys.argv[0]) + return 'ffmpeg' + if want == 'gst': + if not gst_ok: + die('gstreamer not usable for %s here; run "%s caps"' + % (transport, sys.argv[0])) + return 'gst' + if ff_ok: + return 'ffmpeg' + if gst_ok: + return 'gst' + die('neither ffmpeg nor gstreamer is usable here; run "%s caps"' + % sys.argv[0]) + + +# ------------------------------------------------------------- publisher + +def publish_url(a): + if a.transport == 'udp': + # pkt_size makes ffmpeg emit 7-packet datagrams rather than + # splitting a TS packet across two, which the proxy rejects. + return 'udp://%s:%d?pkt_size=%d' % (a.host, a.port, TS_UDP_PAYLOAD) + # The publish password rides in the request-line query. It is the only + # place RTSP can carry one without us parsing the session, and ffmpeg + # passes the query through untouched. + url = 'rtsp://%s:%d/%s' % (a.host, a.port, a.rtsp_path.lstrip('/')) + if a.publish_pass: + url += '?pw=%s' % a.publish_pass + return url + + +def ffmpeg_publish_cmd(a): + vcodec = {'h264': 'libx264', 'hevc': 'libx265'}[a.codec] + # The clock is the point of the exercise: wall time proves the stream + # is live, the elapsed timer proves it is not looping or stalled. + # + # The text needs BOTH single quotes around it AND a backslash on the + # colon in %{pts:hms} -- measured, not guessed. Quotes alone still + # split the filter arg at that colon, and the backslash alone does + # too. %{localtime} takes no argument so has no colon to escape. + fs = _font_size(a) + # x is clamped rather than plain (w-text_w)/2: a line wider than the + # frame would otherwise centre to a negative x and lose characters + # off BOTH edges, which is how the clock ends up unreadable at small + # sizes. The comma in max() is a filter-arg separator, so it escapes. + common = ('fontcolor=white:fontsize=%d:box=1:boxcolor=black@0.6:' + 'boxborderw=6:x=max(0\\,(w-text_w)/2)' % fs) + filters = ["drawtext=text='%s':%s:y=h-text_h-%d" + % ("%{localtime} +%{pts\\:hms}", common, max(8, fs // 3))] + if a.label: + # Its own line, so a long label cannot push the clock off-frame. + filters.append("drawtext=text='%s':%s:y=%d" + % (_ff_escape(a.label), common, max(8, fs // 3))) + drawtext = ','.join(filters) + + cmd = ['ffmpeg', '-hide_banner', '-nostdin', '-loglevel', a.loglevel, + '-re', + '-f', 'lavfi', '-i', + 'testsrc2=size=%s:rate=%d' % (a.size, a.fps)] + if a.audio: + # A tone, so an audio track exists to exercise the audio path. + cmd += ['-f', 'lavfi', '-i', + 'sine=frequency=440:sample_rate=48000'] + vf = drawtext + if ffmpeg_has_filter('drawtext'): + cmd += ['-vf', vf] + else: + log('note: ffmpeg has no drawtext filter (needs libfreetype); ' + 'publishing the pattern without a clock overlay') + cmd += ['-c:v', vcodec, '-preset', 'ultrafast', '-tune', 'zerolatency', + '-b:v', a.bitrate, '-maxrate', a.bitrate, + '-bufsize', a.bitrate, + # A short GOP means a viewer joining late finds an anchor + # quickly; the proxy needs a keyframe to start anyone. + '-g', str(a.fps * a.gop_seconds), + '-pix_fmt', 'yuv420p'] + if a.audio: + cmd += ['-c:a', 'aac', '-b:a', '96k'] + else: + cmd += ['-an'] + if a.duration: + cmd += ['-t', str(a.duration)] + + if a.transport == 'udp': + cmd += ['-f', 'mpegts', '-muxdelay', '0', '-flush_packets', '1'] + else: + cmd += ['-f', 'rtsp', '-rtsp_transport', 'tcp'] + cmd += [publish_url(a)] + return cmd + + +def _font_size(a): + """--font-size 0 means scale to the frame: the clock has to stay + readable at 320x180 and not fill the screen at 1080p.""" + if a.font_size: + return a.font_size + try: + height = int(a.size.split('x')[1]) + except (IndexError, ValueError): + return 24 + return max(12, height // 20) + + +def _ff_escape(s): + """Escape --label for use inside the single-quoted drawtext value. + + A single quote cannot be escaped inside a single-quoted section of an + ffmpeg filter argument -- it has to close the quote, escape, reopen. + Not worth it for a caption on a test pattern, so those are dropped. + """ + s = s.replace("'", '') + return s.replace('\\', r'\\').replace(':', r'\:').replace(',', r'\,') + + +def gst_publish_cmd(a): + enc = {'h264': 'x264enc tune=zerolatency speed-preset=ultrafast ' + 'key-int-max=%d bitrate=%d' % (a.fps * a.gop_seconds, + _kbits(a.bitrate)), + 'hevc': 'x265enc tune=zerolatency speed-preset=ultrafast ' + 'key-int-max=%d bitrate=%d' % (a.fps * a.gop_seconds, + _kbits(a.bitrate))}[a.codec] + w, h = a.size.split('x') + src = 'videotestsrc is-live=true pattern=smpte' + if a.duration: + src += ' num-buffers=%d' % (a.fps * a.duration) + fs = _font_size(a) + parts = [ + src, + 'video/x-raw,width=%s,height=%s,framerate=%d/1' % (w, h, a.fps), + 'clockoverlay time-format="%H:%M:%S" font-desc="Sans {}" ' + 'valignment=bottom halignment=center'.format(fs), + 'timeoverlay font-desc="Sans {}" valignment=top ' + 'halignment=center'.format(fs), + 'videoconvert', + enc, + 'mpegtsmux alignment=7 name=mux', + ] + if a.transport == 'udp': + parts.append('udpsink host=%s port=%d' % (a.host, a.port)) + else: + parts.append('rtspclientsink location=%s' % publish_url(a)) + # gst-launch takes each argv element as one pipeline token -- it does + # NOT split them on spaces -- so "videotestsrc is-live=true" as a + # single argument is a syntax error. shlex.split gives one word per + # argument while keeping "Sans 18" together as the quoted value it is. + return ['gst-launch-1.0', '-q'] + shlex.split(' ! '.join(parts)) + + +def _kbits(bitrate): + m = re.match(r'^(\d+)([kKmM]?)$', bitrate) + if not m: + die('bad --bitrate %r (try 2M or 2000k)' % bitrate) + n = int(m.group(1)) + return n * 1000 if m.group(2).lower() == 'm' else (n if m.group(2) + else n // 1000) + + +def cmd_publish(a): + encoder = pick_encoder(a.encoder, a.transport) + cmd = (ffmpeg_publish_cmd(a) if encoder == 'ffmpeg' + else gst_publish_cmd(a)) + + log('publishing to %s' % publish_url(a)) + log(' %s %s %s @ %dfps, %s, audio=%s, encoder=%s' + % (a.codec, a.size, a.transport, a.fps, a.bitrate, + 'on' if a.audio else 'off', encoder)) + if a.dry_run: + print(' '.join(_shquote(c) for c in cmd)) + return 0 + if a.verbose: + log(' $ %s' % ' '.join(_shquote(c) for c in cmd)) + + try: + p = _spawn(cmd) + except OSError as e: + die('cannot start %s: %s' % (cmd[0], e)) + try: + return p.wait() + except KeyboardInterrupt: + p.send_signal(signal.SIGINT) + try: + return p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + return 130 + finally: + # Covers every other way this process can end: a signal, an + # exception, or the shell going away. + _reap(p) + + +def _pdeathsig(): + """Ask the kernel to SIGTERM this child when its parent dies. + + Without it, killing the wrapper leaves ffmpeg publishing forever -- + and because one publisher holds a slot, that orphan then refuses the + *next* publisher as slot-busy. A leaked test publisher can quietly + take over a real video port, which is exactly what it did. + """ + try: + import ctypes + libc = ctypes.CDLL('libc.so.6', use_errno=True) + libc.prctl(1, signal.SIGTERM, 0, 0, 0) # PR_SET_PDEATHSIG + except Exception: + pass # best effort; the + # finally: below still + # covers a clean exit + + +def _spawn(cmd, **kw): + return subprocess.Popen(cmd, preexec_fn=_pdeathsig, **kw) + + +def _reap(p): + """Stop a publisher and make sure it is really gone.""" + if p is None or p.poll() is not None: + return + p.terminate() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + p.wait() + + +def _shquote(s): + return s if re.match(r'^[\w@%+=:,./-]+$', s) else "'%s'" % s.replace( + "'", "'\\''") + + +# ---------------------------------------------------------------- viewer + +def viewer_query(a): + """Credential for a viewer URL. A token beats a password: it is + short-lived and does not land in logs or history as a reusable + secret.""" + if a.token: + return 't=%s' % a.token + if a.viewer_pass: + return 'pw=%s' % a.viewer_pass + return '' + + +def http_view(a, sink, deadline): + path = a.path + q = viewer_query(a) + if q: + path += ('&' if '?' in path else '?') + q + req = ('GET %s HTTP/1.1\r\nHost: %s:%d\r\nUser-Agent: supportproxy-test\r\n' + 'Connection: close\r\n\r\n' % (path, a.host, a.port)) + s = socket.create_connection((a.host, a.port), timeout=a.connect_timeout) + s.sendall(req.encode()) + return _read_after_headers(s, sink, deadline, a) + + +def tcp_view(a, sink, deadline): + """Raw TCP viewer: connect, say nothing, read. Only works when the + slot has 'open TCP viewers' enabled -- there is nowhere in a raw + stream to carry a credential.""" + s = socket.create_connection((a.host, a.port), timeout=a.connect_timeout) + return _read_body(s, sink, deadline, a) + + +def ws_view(a, sink, deadline): + key = base64.b64encode(os.urandom(16)).decode() + path = a.path + q = viewer_query(a) + if q: + path += ('&' if '?' in path else '?') + q + req = ('GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\n' + 'Connection: Upgrade\r\nSec-WebSocket-Key: %s\r\n' + 'Sec-WebSocket-Version: 13\r\n\r\n' + % (path, a.host, a.port, key)) + s = socket.create_connection((a.host, a.port), timeout=a.connect_timeout) + s.sendall(req.encode()) + head, rest = _read_headers(s, a) + if '101' not in head.split('\r\n')[0]: + raise ViewError('websocket upgrade refused: %s' + % head.split('\r\n')[0]) + return _read_ws_frames(s, rest, sink, deadline, a) + + +class ViewError(Exception): + pass + + +def _read_headers(s, a): + buf = b'' + while b'\r\n\r\n' not in buf: + if len(buf) > 65536: + raise ViewError('no end of headers after 64 KiB') + chunk = s.recv(4096) + if not chunk: + raise ViewError('connection closed during headers') + buf += chunk + head, _, rest = buf.partition(b'\r\n\r\n') + return head.decode('latin-1'), rest + + +def _read_after_headers(s, sink, deadline, a): + head, rest = _read_headers(s, a) + status = head.split('\r\n')[0] + if ' 200' not in status: + raise ViewError('server said: %s\n%s' % (status, head)) + if rest: + sink(rest) + return _read_body(s, sink, deadline, a, primed=True) + + +def _read_body(s, sink, deadline, a, primed=False): + s.settimeout(1.0) + while time.time() < deadline: + try: + chunk = s.recv(65536) + except socket.timeout: + continue + if not chunk: + break + sink(chunk) + s.close() + return True + + +def _read_ws_frames(s, rest, sink, deadline, a): + """Enough of RFC 6455 to read a binary stream from the server. + Server-to-client frames are never masked.""" + buf = bytearray(rest) + s.settimeout(1.0) + while time.time() < deadline: + # Parse whatever complete frames are buffered. + while True: + frame, used = _ws_parse(buf) + if frame is None: + break + del buf[:used] + opcode, payload = frame + if opcode == 0x8: # close + s.close() + return True + if opcode in (0x1, 0x2, 0x0): + sink(payload) + try: + chunk = s.recv(65536) + except socket.timeout: + continue + if not chunk: + break + buf += chunk + s.close() + return True + + +def _ws_parse(buf): + if len(buf) < 2: + return None, 0 + b0, b1 = buf[0], buf[1] + opcode = b0 & 0x0F + masked = bool(b1 & 0x80) + ln = b1 & 0x7F + off = 2 + if ln == 126: + if len(buf) < off + 2: + return None, 0 + ln = int.from_bytes(buf[off:off + 2], 'big') + off += 2 + elif ln == 127: + if len(buf) < off + 8: + return None, 0 + ln = int.from_bytes(buf[off:off + 8], 'big') + off += 8 + if masked: + if len(buf) < off + 4: + return None, 0 + mask = buf[off:off + 4] + off += 4 + if len(buf) < off + ln: + return None, 0 + payload = bytes(buf[off:off + ln]) + if masked: + payload = bytes(c ^ mask[i % 4] for i, c in enumerate(payload)) + return (opcode, payload), off + ln + + +# ------------------------------------------------------------ TS analysis + +class TSAnalyser: + """Just enough MPEG-TS to say whether what arrived is a real stream. + + Deliberately independent of the proxy's own scanner: a bug shared + between the thing under test and the thing checking it would be + invisible. + """ + + def __init__(self): + self.buf = bytearray() + self.bytes = 0 + self.packets = 0 + self.unsynced = 0 + self.pids = {} + self.cc = {} + self.cc_errors = 0 + self.rai = 0 + self.pat_seen = 0 + self.pmt_pids = set() + self.streams = {} + self.first_byte_at = None + self.last_byte_at = None + + def feed(self, data): + now = time.time() + if self.first_byte_at is None: + self.first_byte_at = now + self.last_byte_at = now + self.bytes += len(data) + self.buf += data + # Resync to a sync byte if we are not on one. + while self.buf and self.buf[0] != 0x47: + del self.buf[0] + self.unsynced += 1 + while len(self.buf) >= TS_PACKET: + pkt = bytes(self.buf[:TS_PACKET]) + if pkt[0] != 0x47: + del self.buf[0] + self.unsynced += 1 + continue + del self.buf[:TS_PACKET] + self._packet(pkt) + + def _packet(self, pkt): + self.packets += 1 + pid = ((pkt[1] & 0x1F) << 8) | pkt[2] + self.pids[pid] = self.pids.get(pid, 0) + 1 + pusi = bool(pkt[1] & 0x40) + afc = (pkt[3] >> 4) & 0x3 + cc = pkt[3] & 0x0F + + # Continuity counter only advances on packets that carry payload. + if afc in (1, 3): + prev = self.cc.get(pid) + if prev is not None and cc != (prev + 1) % 16: + self.cc_errors += 1 + self.cc[pid] = cc + payload = 4 + if afc in (2, 3): + af_len = pkt[4] + if afc == 2: + payload = TS_PACKET + else: + payload = 5 + af_len + if af_len > 0 and len(pkt) > 5 and (pkt[5] & 0x40): + self.rai += 1 + if afc in (1, 3) and payload < TS_PACKET: + if pid == 0 and pusi: + self.pat_seen += 1 + self._parse_pat(pkt[payload:]) + elif pid in self.pmt_pids and pusi: + self._parse_pmt(pkt[payload:]) + + def _section(self, data): + if not data: + return None + ptr = data[0] + body = data[1 + ptr:] + if len(body) < 3: + return None + length = ((body[1] & 0x0F) << 8) | body[2] + if len(body) < 3 + length: + return None # spans packets; the next copy will do + return body[:3 + length] + + def _parse_pat(self, data): + sec = self._section(data) + if not sec or sec[0] != 0x00: + return + # header 8 bytes, then 4-byte entries, then 4-byte CRC + body = sec[8:-4] + for i in range(0, len(body) - 3, 4): + prog = (body[i] << 8) | body[i + 1] + pid = ((body[i + 2] & 0x1F) << 8) | body[i + 3] + if prog != 0: + self.pmt_pids.add(pid) + + def _parse_pmt(self, data): + sec = self._section(data) + if not sec or sec[0] != 0x02: + return + if len(sec) < 12: + return + info_len = ((sec[10] & 0x0F) << 8) | sec[11] + i = 12 + info_len + end = len(sec) - 4 + while i + 4 < end: + stype = sec[i] + epid = ((sec[i + 1] & 0x1F) << 8) | sec[i + 2] + es_len = ((sec[i + 3] & 0x0F) << 8) | sec[i + 4] + self.streams[epid] = stype + i += 5 + es_len + + def report(self): + span = 0.0 + if self.first_byte_at and self.last_byte_at: + span = self.last_byte_at - self.first_byte_at + return { + 'bytes': self.bytes, + 'ts_packets': self.packets, + 'seconds': round(span, 2), + 'megabits_per_sec': round(self.bytes * 8 / span / 1e6, 2) + if span > 0.5 else None, + 'discarded_unsynced_bytes': self.unsynced, + 'continuity_errors': self.cc_errors, + 'random_access_points': self.rai, + 'pat_sections': self.pat_seen, + 'pids': dict(sorted(self.pids.items())), + 'elementary_streams': { + pid: STREAM_TYPES.get(t, 'type 0x%02X' % t) + for pid, t in sorted(self.streams.items())}, + } + + def verdict(self, want_seconds): + """(ok, [problems]) -- what a human would call a working stream.""" + bad = [] + if self.packets == 0: + bad.append('no MPEG-TS packets arrived at all') + return False, bad + if self.pat_seen == 0: + bad.append('no PAT: a viewer cannot discover the video PID') + if not self.streams: + bad.append('no PMT: no elementary streams declared') + if self.rai == 0: + bad.append('no random-access point: a late viewer has no ' + 'anchor to start from') + if self.cc_errors: + bad.append('%d continuity errors (packet loss or interleaving)' + % self.cc_errors) + if self.unsynced: + bad.append('%d bytes discarded resyncing' % self.unsynced) + span = (self.last_byte_at - self.first_byte_at) if self.first_byte_at \ + else 0 + if want_seconds and span < want_seconds * 0.5: + bad.append('stream stopped after %.1fs of %ds requested' + % (span, want_seconds)) + return (not bad), bad + + +def cmd_view(a): + an = TSAnalyser() + out = open(a.save, 'wb') if a.save else None + + def sink(data): + an.feed(data) + if out: + out.write(data) + + log('viewing %s://%s:%d%s (%s) for %ds' + % (a.via, a.host, a.port, a.path if a.via != 'tcp' else '', + 'token' if a.token else ('password' if a.viewer_pass else + 'no credential'), + a.duration)) + reader = {'http': http_view, 'tcp': tcp_view, 'ws': ws_view}[a.via] + deadline = time.time() + a.duration + try: + reader(a, sink, deadline) + except ViewError as e: + if out: + out.close() + die(str(e), 1) + except (socket.timeout, ConnectionRefusedError, OSError) as e: + if out: + out.close() + die('%s://%s:%d: %s' % (a.via, a.host, a.port, e), 1) + finally: + if out: + out.close() + log('wrote %s' % a.save) + + rep = an.report() + if a.json: + print(json.dumps(rep, indent=2)) + else: + _print_report(rep) + ok, problems = an.verdict(a.duration) + for p in problems: + log(' ! %s' % p) + return 0 if ok else 1 + + +def _print_report(rep): + print('bytes %d' % rep['bytes']) + print('TS packets %d' % rep['ts_packets']) + print('duration %ss' % rep['seconds']) + if rep['megabits_per_sec'] is not None: + print('rate %s Mbit/s' % rep['megabits_per_sec']) + print('PAT sections %d' % rep['pat_sections']) + print('random access %d' % rep['random_access_points']) + print('continuity errs %d' % rep['continuity_errors']) + print('resync discards %d' % rep['discarded_unsynced_bytes']) + if rep['elementary_streams']: + print('streams') + for pid, name in rep['elementary_streams'].items(): + print(' PID %-5d %s' % (pid, name)) + else: + print('streams (none declared)') + + +# ----------------------------------------------------------------- check + +def cmd_check(a): + """Publish and view at once, then say whether it worked.""" + encoder = pick_encoder(a.encoder, a.transport) + + # The publisher has to outlive the viewer: the viewer only starts + # after --settle, so publishing for exactly --duration would cut the + # stream off early and be reported as "stream stopped". + view_seconds = a.duration + pub_args = argparse.Namespace(**vars(a)) + pub_args.duration = int(a.duration + a.settle + 2) if a.duration else 0 + + cmd = (ffmpeg_publish_cmd(pub_args) if encoder == 'ffmpeg' + else gst_publish_cmd(pub_args)) + if a.verbose: + log(' $ %s' % ' '.join(_shquote(c) for c in cmd)) + + log('publishing to %s (%s, %s)' % (publish_url(a), a.codec, encoder)) + pub = _spawn(cmd, stdout=subprocess.DEVNULL, + stderr=(None if a.verbose else subprocess.DEVNULL)) + result = {} + + def run_view(): + try: + result['rc'] = cmd_view(a) + except SystemExit as e: + result['rc'] = e.code + + try: + # Give the publisher a moment to be admitted and start a GOP, + # otherwise the viewer arrives before there is any anchor to + # join at and reports a stream that is in fact fine. + time.sleep(a.settle) + if pub.poll() is not None: + die('publisher exited immediately (rc=%d); re-run with ' + '--verbose to see why' % pub.returncode, 1) + t = threading.Thread(target=run_view) + t.start() + t.join() + finally: + _reap(pub) + + rc = result.get('rc', 1) + log('RESULT: %s' % ('PASS' if rc == 0 else 'FAIL')) + return rc + + +def cmd_caps(a): + caps = capabilities() + if a.json: + print(json.dumps(caps, indent=2)) + return 0 + for k, v in caps.items(): + print('%-22s %s' % (k, 'yes' if v else 'NO')) + print() + print('publish transports: udp (MPEG-TS), rtsp') + print(' SRT is not implemented in the proxy yet') + print('view transports: http, tcp (raw), ws') + return 0 + + +# ------------------------------------------------------------------ main + +def add_common(p): + p.add_argument('--host', default='127.0.0.1', + help='proxy host (default 127.0.0.1)') + p.add_argument('--port', type=int, required=True, + help='the entry\'s video port') + p.add_argument('-v', '--verbose', action='store_true') + + +def add_publish_opts(p): + p.add_argument('--transport', choices=['udp', 'rtsp'], default='udp', + help='udp = MPEG-TS datagrams (cannot carry a password); ' + 'rtsp = publish over RTSP (can)') + p.add_argument('--publish-pass', default='', + help='publish password, RTSP only -- it rides in the ' + 'request-line query as ?pw=') + p.add_argument('--rtsp-path', default='cam', + help='RTSP path (cosmetic; the port selects the slot)') + p.add_argument('--codec', choices=['h264', 'hevc'], default='h264', + help='h264 plays in a browser; hevc does not') + p.add_argument('--size', default='1280x720') + p.add_argument('--fps', type=int, default=25) + p.add_argument('--bitrate', default='2M') + p.add_argument('--gop-seconds', type=int, default=2, + help='keyframe interval; a late viewer waits up to this ' + 'long for an anchor (default 2)') + p.add_argument('--audio', action='store_true', + help='include an AAC tone (off by default, like the ' + 'proxy)') + p.add_argument('--duration', type=int, default=0, + help='seconds to publish, 0 = until interrupted') + p.add_argument('--encoder', choices=['auto', 'ffmpeg', 'gst'], + default='auto') + p.add_argument('--label', default='', + help='extra text to burn into the frame, e.g. which ' + 'host is sending') + p.add_argument('--font-size', type=int, default=0, + help='0 = scale to the frame height (default)') + p.add_argument('--loglevel', default='warning', + help='ffmpeg -loglevel (default warning)') + p.add_argument('--dry-run', action='store_true', + help='print the encoder command and exit') + + +def add_view_opts(p): + p.add_argument('--via', choices=['http', 'tcp', 'ws'], default='http') + p.add_argument('--path', default='/stream.ts', + help='/stream.ts and / always work; /v1.ts../v3.ts name ' + 'a slot explicitly') + p.add_argument('--viewer-pass', default='') + p.add_argument('--token', default='', + help='short-lived HMAC token from the web admin video ' + 'page; preferred over --viewer-pass') + p.add_argument('--save', default='', + help='also write the received stream to this file') + p.add_argument('--json', action='store_true') + p.add_argument('--connect-timeout', type=float, default=10.0) + + +def main(argv=None): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest='cmd', required=True) + + p = sub.add_parser('publish', help='send a test pattern to a video port') + add_common(p) + add_publish_opts(p) + p.set_defaults(func=cmd_publish) + + p = sub.add_parser('view', help='pull a stream back and report on it') + add_common(p) + add_view_opts(p) + p.add_argument('--duration', type=int, default=10, + help='seconds to watch (default 10)') + p.set_defaults(func=cmd_view) + + p = sub.add_parser('check', help='publish and view together, pass/fail') + add_common(p) + add_publish_opts(p) + add_view_opts(p) + p.add_argument('--settle', type=float, default=3.0, + help='seconds to let the publisher establish before ' + 'the viewer joins (default 3)') + p.set_defaults(func=cmd_check) + + p = sub.add_parser('caps', help='what this machine can generate') + p.add_argument('--json', action='store_true') + p.set_defaults(func=cmd_caps) + + a = ap.parse_args(argv) + # 'check' takes both option sets; its --duration comes from the view + # side and doubles as how long to publish. + if a.cmd == 'check' and not a.duration: + a.duration = 10 + return a.func(a) + + +if __name__ == '__main__': + try: + sys.exit(main()) + except KeyboardInterrupt: + sys.exit(130) 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/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/conftest.py b/tests/conftest.py index 34c45a9..4a0b7c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -84,6 +84,33 @@ def terminate(self): self.proc.kill() +def _wait_listening(ports, timeout=15.0): + """Block until every port is in LISTEN. + + Read from /proc rather than probed with a connection: connecting to + a user port latches conn1, which would consume the very thing the + test is about to set up. + """ + want = {'%04X' % p for p in ports} + deadline = time.time() + timeout + while time.time() < deadline: + listening = set() + try: + with open('/proc/net/tcp') as f: + next(f) + for line in f: + fields = line.split() + if fields[3] == '0A': # LISTEN + listening.add(fields[1].split(':')[1].upper()) + except OSError: + pass + if want <= listening: + return + time.sleep(0.05) + raise RuntimeError('SupportProxy never listened on %s' + % sorted(ports)) + + @pytest.fixture(scope="session", autouse=True) def _worker_cwd(tmp_path_factory): """Each xdist worker runs in its own tmpdir so workers don't share a @@ -165,6 +192,12 @@ def test_server(_worker_cwd): markers[m] = True print(f"DEBUG: SupportProxy loaded {m.replace('Added port ', '')}") if all(markers.values()): + # The marker is printed before the socket is bound + # (supportproxy.cpp prints "Added port" and then calls + # open_sockets), so a test that connects on the strength of + # it alone is racing. Harmless on an idle machine, and it + # starts losing when the runner is busy. + _wait_listening([port1, port2, port1_b, port2_b]) print("DEBUG: SupportProxy ready for testing!") break else: 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_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_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_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 diff --git a/tests/test_video_testtool.py b/tests/test_video_testtool.py new file mode 100644 index 0000000..8935d24 --- /dev/null +++ b/tests/test_video_testtool.py @@ -0,0 +1,362 @@ +"""scripts/test_video.py -- the test-pattern publisher and stream checker. + +The tool exists to tell a human whether a video port works, so the thing +worth guarding is that it can still say "no". A checker that always +passes is worse than no checker. The encoder command construction is +covered without invoking ffmpeg or gstreamer; the full round trip is +covered by one end-to-end case behind the same markers as the other +video tests. +""" +import argparse +import os +import shlex +import shutil +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.join(_REPO_ROOT, 'scripts')) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import test_video as tv # noqa: E402 +import keydb_lib # noqa: E402 +from test_video_child import (Proxy, _Mav, _make_workdir, # noqa: E402 + _port_bound, VPORT, PORT_ENG) + +TOOL = os.path.join(_REPO_ROOT, 'scripts', 'test_video.py') + + +def _args(**over): + base = dict(host='127.0.0.1', port=40001, transport='udp', + publish_pass='', rtsp_path='cam', codec='h264', + size='1280x720', fps=25, bitrate='2M', gop_seconds=2, + audio=False, duration=0, encoder='ffmpeg', label='', + font_size=0, loglevel='warning', dry_run=False, + verbose=False) + base.update(over) + return argparse.Namespace(**base) + + +class TestPublishUrl: + def test_udp_uses_seven_packet_datagrams(self): + """A datagram that splits a TS packet across two is rejected by + the proxy, so pkt_size is not decoration.""" + url = tv.publish_url(_args(transport='udp')) + assert url == 'udp://127.0.0.1:40001?pkt_size=1316' + assert 1316 % 188 == 0 + + def test_rtsp_carries_the_publish_password_in_the_query(self): + """The request-line query is the only place RTSP can carry a + credential without the proxy parsing the session.""" + url = tv.publish_url(_args(transport='rtsp', publish_pass='s3cret')) + assert url == 'rtsp://127.0.0.1:40001/cam?pw=s3cret' + + def test_rtsp_without_a_password_has_no_query(self): + url = tv.publish_url(_args(transport='rtsp')) + assert '?' not in url + + +# Anything that shells out to the tool needs a usable encoder; the tool +# exits 2 without one. Skip rather than fail, so the suite is honest on a +# machine that has no ffmpeg -- CI installs it (scripts/setup_ci.sh) so +# these do run there. +needs_encoder = pytest.mark.skipif( + shutil.which('ffmpeg') is None and shutil.which('gst-launch-1.0') is None, + reason='needs ffmpeg or gstreamer') + + +class TestFfmpegCommand: + """Pure command construction -- no encoder needs to exist. + + ffmpeg_publish_cmd() probes the local binary to decide whether it can + use drawtext, so without that stub these assert on a command built + for a machine with no ffmpeg, and -vf is simply absent. + """ + + @pytest.fixture(autouse=True) + def _with_drawtext(self, monkeypatch): + monkeypatch.setattr(tv, 'ffmpeg_has_filter', lambda name: True) + + def test_drawtext_colon_is_escaped_inside_quotes(self): + """Measured behaviour: the value needs BOTH the surrounding + single quotes and a backslash on the colon. Either alone still + splits the filter argument and ffmpeg refuses the graph.""" + cmd = tv.ffmpeg_publish_cmd(_args()) + vf = cmd[cmd.index('-vf') + 1] + assert "text='" in vf + assert r'%{pts\:hms}' in vf + assert r'%{pts\\:hms}' not in vf, 'double-escaped: prints a backslash' + + def test_x_is_clamped_so_wide_text_is_not_clipped_both_ends(self): + cmd = tv.ffmpeg_publish_cmd(_args(size='320x180')) + vf = cmd[cmd.index('-vf') + 1] + assert 'max(0' in vf + + def test_font_scales_with_frame_height(self): + small = tv._font_size(_args(size='320x180')) + big = tv._font_size(_args(size='1920x1080')) + assert small < big + assert small >= 12, 'must stay legible on a small frame' + + def test_explicit_font_size_wins(self): + assert tv._font_size(_args(size='320x180', font_size=40)) == 40 + + def test_label_apostrophe_is_dropped_not_left_to_break_the_graph(self): + cmd = tv.ffmpeg_publish_cmd(_args(label="tridge's laptop")) + vf = cmd[cmd.index('-vf') + 1] + assert 'tridges laptop' in vf + + def test_audio_off_by_default_matching_the_proxy(self): + assert '-an' in tv.ffmpeg_publish_cmd(_args()) + assert '-an' not in tv.ffmpeg_publish_cmd(_args(audio=True)) + + def test_hevc_selects_the_right_encoder(self): + assert 'libx265' in tv.ffmpeg_publish_cmd(_args(codec='hevc')) + + def test_keyframe_interval_follows_gop_seconds(self): + cmd = tv.ffmpeg_publish_cmd(_args(fps=30, gop_seconds=2)) + assert cmd[cmd.index('-g') + 1] == '60' + + +class TestGstCommand: + def test_each_word_is_its_own_argument(self): + """gst-launch takes every argv element as one pipeline token and + does NOT split on spaces, so 'videotestsrc is-live=true' passed + as a single argument is a syntax error.""" + argv = tv.gst_publish_cmd(_args(encoder='gst')) + assert 'videotestsrc' in argv + assert not any(a.startswith('videotestsrc ') for a in argv) + + def test_separators_are_present(self): + argv = tv.gst_publish_cmd(_args(encoder='gst')) + assert argv.count('!') >= 6 + + def test_quoted_font_stays_one_argument(self): + argv = tv.gst_publish_cmd(_args(encoder='gst', font_size=18)) + assert 'font-desc=Sans 18' in argv + + def test_alignment_7_for_udp(self): + """mpegtsmux must emit 7-packet groups to match the datagram.""" + argv = tv.gst_publish_cmd(_args(encoder='gst')) + assert 'alignment=7' in argv + + def test_duration_bounds_the_source(self): + argv = tv.gst_publish_cmd(_args(encoder='gst', duration=4, fps=25)) + assert 'num-buffers=100' in argv + + +class TestKbits: + @pytest.mark.parametrize('spec,want', [ + ('2M', 2000), ('2000k', 2000), ('500k', 500), ('2000000', 2000)]) + def test_conversion(self, spec, want): + assert tv._kbits(spec) == want + + +class TestAnalyser: + """The analyser is what turns "bytes arrived" into "a stream + arrived", so its refusals matter more than its acceptances.""" + + def test_empty_input_is_not_a_stream(self): + an = tv.TSAnalyser() + ok, problems = an.verdict(0) + assert not ok + assert 'no MPEG-TS packets' in problems[0] + + def test_garbage_is_not_a_stream(self): + an = tv.TSAnalyser() + an.feed(b'\xde\xad\xbe\xef' * 1000) + ok, _ = an.verdict(0) + assert not ok + + def test_real_stream_is_accepted(self): + import tsgen + g = tsgen.TSGen() + an = tv.TSAnalyser() + for dgram in g.datagrams(g.stream(400)): + an.feed(dgram) + ok, problems = an.verdict(0) + assert ok, problems + rep = an.report() + assert rep['pat_sections'] > 0 + assert rep['random_access_points'] > 0 + assert rep['continuity_errors'] == 0 + + def test_resyncs_after_leading_junk(self): + """A viewer that joins mid-packet must not be reported as a + broken stream -- it should resync and say how much it dropped.""" + import tsgen + g = tsgen.TSGen() + an = tv.TSAnalyser() + an.feed(b'\x00' * 37) + for dgram in g.datagrams(g.stream(400)): + an.feed(dgram) + assert an.packets > 0 + assert an.unsynced == 37 + + def test_missing_pat_is_reported(self): + an = tv.TSAnalyser() + # 188-byte packets on a non-zero PID: syntactically fine, but a + # viewer could never find the video. + pkt = bytes([0x47, 0x01, 0x00, 0x10]) + b'\xff' * 184 + an.feed(pkt * 50) + ok, problems = an.verdict(0) + assert not ok + assert any('PAT' in p for p in problems) + + +class TestCli: + def test_caps_runs(self): + r = subprocess.run([sys.executable, TOOL, 'caps', '--json'], + capture_output=True, text=True, timeout=60) + assert r.returncode == 0 + import json + assert 'ffmpeg' in json.loads(r.stdout) + + @needs_encoder + def test_dry_run_emits_a_runnable_command(self): + r = subprocess.run([sys.executable, TOOL, 'publish', '--port', + '40001', '--dry-run'], + capture_output=True, text=True, timeout=60) + assert r.returncode == 0 + argv = shlex.split(r.stdout.strip()) + assert argv[0] in ('ffmpeg', 'gst-launch-1.0') + + def test_port_is_required(self): + r = subprocess.run([sys.executable, TOOL, 'publish'], + capture_output=True, text=True, timeout=60) + assert r.returncode != 0 + + +@pytest.mark.slow +@needs_encoder +class TestEndToEnd: + """One real round trip: test pattern -> proxy -> viewer -> verdict.""" + + def _proxy(self, tmp_path, **kw): + wd = _make_workdir(tmp_path, **kw) + return wd, Proxy(wd) + + def _wait_bound(self): + for _ in range(100): + if _port_bound(VPORT): + return True + time.sleep(0.1) + return False + + def _check(self, *extra): + return subprocess.run( + [sys.executable, TOOL, 'check', '--host', '127.0.0.1', + '--port', str(VPORT), '--size', '320x180', '--duration', '4', + '--settle', '3'] + list(extra), + capture_output=True, text=True, timeout=120) + + def test_publish_and_view_round_trip(self, tmp_path): + if not os.path.exists(os.path.join(_REPO_ROOT, 'supportproxy')): + pytest.skip('supportproxy not built') + wd, px = self._proxy(tmp_path) + try: + assert self._wait_bound() + _Mav() # latch conn1 from 127.0.0.1 + time.sleep(1.5) + r = self._check() + assert r.returncode == 0, r.stdout + r.stderr + assert 'H.264' in r.stdout + finally: + px.proc.terminate() + + def _with_viewer_pass(self, tmp_path, pw): + wd, px = self._proxy(tmp_path) + db = keydb_lib.open_db(str(wd / 'keys.tdb')) + db.transaction_start() + keydb_lib.set_video_viewer_pass(db, PORT_ENG, pw) + db.transaction_prepare_commit() + db.transaction_commit() + db.close() + return px + + def test_the_wrong_viewer_password_fails(self, tmp_path): + """The guard that matters: a checker that cannot fail is + worthless. Each case gets its own proxy -- a publisher holds its + slot for 10s after going quiet, so a second run against the same + proxy would be refused as slot-busy and 'fail' for the wrong + reason.""" + if not os.path.exists(os.path.join(_REPO_ROOT, 'supportproxy')): + pytest.skip('supportproxy not built') + px = self._with_viewer_pass(tmp_path, 'hunter2') + try: + assert self._wait_bound() + _Mav() + time.sleep(1.5) + assert self._check('--viewer-pass', 'wrong').returncode != 0 + finally: + px.proc.terminate() + + def test_the_right_viewer_password_passes(self, tmp_path): + if not os.path.exists(os.path.join(_REPO_ROOT, 'supportproxy')): + pytest.skip('supportproxy not built') + px = self._with_viewer_pass(tmp_path, 'hunter2') + try: + assert self._wait_bound() + _Mav() + time.sleep(1.5) + r = self._check('--viewer-pass', 'hunter2') + assert r.returncode == 0, r.stdout + r.stderr + finally: + px.proc.terminate() + + +class TestNoOrphanPublisher: + """Killing the wrapper must take ffmpeg with it. + + An orphaned publisher keeps sending, and because one publisher holds + a slot, it then refuses the *next* publisher as slot-busy. A leaked + test publisher quietly takes over a real video port -- which is + exactly what happened on the live server during this work. + """ + + def test_ffmpeg_dies_with_its_parent(self, tmp_path): + import shutil + if shutil.which('ffmpeg') is None: + pytest.skip('ffmpeg not installed') + # Publish to a port nothing is listening on: the publisher still + # runs, which is all this needs. + p = subprocess.Popen( + [sys.executable, TOOL, 'publish', '--host', '127.0.0.1', + '--port', '9', '--size', '128x72'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + child = None + for _ in range(60): + time.sleep(0.2) + out = subprocess.run(['pgrep', '-P', str(p.pid), '-x', 'ffmpeg'], + capture_output=True, text=True).stdout + if out.strip(): + child = int(out.split()[0]) + break + assert child, 'ffmpeg never started' + + p.kill() # the worst case: no chance to clean up + p.wait() + for _ in range(50): + time.sleep(0.2) + if subprocess.run(['kill', '-0', str(child)], + capture_output=True).returncode != 0: + return # gone, as it should be + raise AssertionError('ffmpeg %d outlived its parent' % child) + finally: + if p.poll() is None: + p.kill() + p.wait() + + def test_the_helper_is_wired_in(self): + """Both spawn sites must go through it, or one path leaks.""" + src = open(TOOL).read() + assert 'PR_SET_PDEATHSIG' in src or 'prctl' in src + assert src.count('_spawn(') >= 3 # def + publish + check + assert 'subprocess.Popen(cmd)' not in src, \ + 'a raw Popen bypasses the death-signal helper' 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/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/tests/test_ws_handshake_ordering.py b/tests/test_ws_handshake_ordering.py index b5c4bc1..473ef66 100644 --- a/tests/test_ws_handshake_ordering.py +++ b/tests/test_ws_handshake_ordering.py @@ -82,9 +82,40 @@ def _drain(): proc.kill() proc.wait(timeout=2) raise RuntimeError('proxy did not load test port pair') + _wait_accepting(PORT_USER) return proc +def _wait_accepting(port, timeout=10.0): + """Block until `port` is in LISTEN. + + The readiness line only says the parent has the pair in its table; + the listening socket is not necessarily up yet. Connecting on the + strength of the log line alone is a race that stays hidden on an + idle machine and starts losing when the runner is busy -- which is + what it did once CI grew a video suite to run alongside. + + Read from /proc rather than probed with a connection: a connection + to the user port latches conn1, so probing would consume the very + thing the test is about to set up. + """ + want = '%04X' % port + deadline = time.time() + timeout + while time.time() < deadline: + try: + with open('/proc/net/tcp') as f: + next(f) + for line in f: + fields = line.split() + if (fields[1].split(':')[1].upper() == want + and fields[3] == '0A'): # LISTEN + return + except OSError: + pass + time.sleep(0.05) + raise RuntimeError('proxy never listened on port %d' % port) + + def _terminate(proc): proc.send_signal(signal.SIGTERM) try: 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/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..0f5a18a --- /dev/null +++ b/tests/webadmin/test_log_video.py @@ -0,0 +1,393 @@ +"""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 _our_ffmpeg_count(): + """ffmpeg processes this test process started. + + Counting every ffmpeg on the machine made this fail whenever a + sibling xdist worker happened to start one between the two samples + -- a leak reported against a test that leaked nothing. The remux + spawns its ffmpeg as our direct child, so that is what to count. + """ + mine = 0 + us = os.getpid() + for name in os.listdir('/proc'): + if not name.isdigit(): + continue + pid = int(name) + try: + with open('/proc/%d/comm' % pid) as f: + if f.read().strip() != 'ffmpeg': + continue + with open('/proc/%d/status' % pid) as f: + for line in f: + if line.startswith('PPid:'): + if int(line.split()[1]) == us: + mine += 1 + break + except (OSError, ValueError): + continue + return mine + + +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() + _seed_real_ts(app, ALICE_PORT2) + login_as(client, BOB_PORT1, BOB_PASS) + before = _our_ffmpeg_count() + r = client.get('/admin/logs/%d/%s/%s/play.mp4' + % (ALICE_PORT2, DATE, VIDEO)) + r.get_data() + r.close() + time.sleep(0.5) + after = _our_ffmpeg_count() + assert after <= 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