diff --git a/AGENTS.md b/AGENTS.md index 164c216..e0d0f5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Non-obvious notes for this environment: - **Toolchain:** `Cargo.toml` sets `edition = "2024"`, `rust-version = "1.95"`. The VM snapshot ships stable Rust 1.97 as the default `rustup` toolchain; the older system Rust (1.83) will not compile this crate. -- **Dependency source:** the server depends on `librtmp2 = "0.3.1"` from **crates.io**, not the sibling `../librtmp2` checkout. Editing the local `librtmp2` repo has no effect here unless you add a `[patch]`/path override. +- **Dependency source:** `librtmp2 = { version = "0.4.0", features = ["tls"] }` resolves from crates.io. It does **not** use the sibling `../librtmp2` checkout unless you add a `[patch]` override locally. - **SQLite is bundled** via `rusqlite` `bundled` feature — no system SQLite needed. - **Running (dev):** `LRTMP2_DB=./server.db ./target/debug/librtmp2-server -v`. RTMP listens on `:1935`, HTTP API on `:8080`. Runtime files (`server.db*`) are untracked and must not be committed. - **API token:** generated once on first start and stored in SQLite (printed to stderr). To use a known token instead, export a real `LRTMP2_API_TOKEN` **process env var before first startup** — the `.env` loader deliberately ignores `LRTMP2_API_TOKEN` in the file. The token is only re-read from env while seeding a fresh DB, so delete `server.db*` to re-seed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9794a20..4e9bf8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ begin at `1.0.0`. ## [Unreleased] +## [0.1.5] — 2026-07-15 + +### Changed +- Bump the `librtmp2` dependency to the crates.io release **0.4.0** (E-RTMP v2 connect negotiation, + multitrack relay, Enhanced-RTMP init-cache/onMetaData replay, legacy pause/seek). +- Update README protocol notes to match inherited `librtmp2` 0.4.0 behaviour. +- Update the RTMP HTTP E2E test `Frame` initializer for the expanded 0.4.0 + `librtmp2::types::Frame` shape, including the optional multitrack `track_id`. + ## [0.1.4] — 2026-07-13 ### Fixed @@ -186,7 +195,8 @@ plaintext RTMP and RTMPS. ### Planned - REST API enhancements for server management -[Unreleased]: https://github.com/OpenRTMP/librtmp2-server/compare/v0.1.4...HEAD +[Unreleased]: https://github.com/OpenRTMP/librtmp2-server/compare/v0.1.5...HEAD +[0.1.5]: https://github.com/OpenRTMP/librtmp2-server/compare/v0.1.4...v0.1.5 [0.1.4]: https://github.com/OpenRTMP/librtmp2-server/compare/v0.1.3...v0.1.4 [0.1.3]: https://github.com/OpenRTMP/librtmp2-server/compare/v0.1.2...v0.1.3 [0.1.2]: https://github.com/OpenRTMP/librtmp2-server/compare/v0.1.1...v0.1.2 diff --git a/Cargo.lock b/Cargo.lock index dc7c851..1a6b1b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -867,9 +867,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "librtmp2" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b69e88de9dfd598389e513f421e1fe9cd8d1a0d980a810acbed84297f32cfb" +checksum = "1cedd2ff740b4512de3108f3e4b7c797163854f2ee914c8ff16e67dd450f182b" dependencies = [ "cc", "libc", @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "librtmp2-server" -version = "0.1.4" +version = "0.1.5" dependencies = [ "axum", "chrono", @@ -1609,9 +1609,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index ddd3b6c..b9021e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "librtmp2-server" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version = "1.95" license = "MIT" @@ -15,7 +15,7 @@ name = "librtmp2-server" path = "src/main.rs" [dependencies] -librtmp2 = { version = "0.3.1", features = ["tls"] } +librtmp2 = { version = "0.4.0", features = ["tls"] } axum = "0.8" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync"] } rusqlite = { version = "0.40", features = ["bundled"] } diff --git a/README.md b/README.md index d3ec429..d01b3ba 100644 --- a/README.md +++ b/README.md @@ -12,45 +12,45 @@ Focused on RTMP/E-RTMP only. SQLite-backed. JSON stats. Nginx-compatible XML. ## Project Status -`librtmp2-server` is currently **Alpha** software. +`librtmp2-server` is **alpha** software: the HTTP/API/DB layer is usable, but RTMP behaviour comes entirely from the embedded [`librtmp2`](https://github.com/OpenRTMP/librtmp2) crate — see that repo's [implementation status](https://github.com/OpenRTMP/librtmp2#implementation-status) for protocol details. -Implemented in this repository: +### Implemented in this repository -- **Integrated RTMP listener** — listens on the configured `RTMP_BIND` address through the Rust `librtmp2` server implementation -- **RTMPS listener** — when `TLS_ENABLED=true`, a second listener on `RTMPS_BIND` accepts RTMPS *alongside* the plaintext RTMP listener -- **OBS / FFmpeg publishing path** — publish requests are routed through the DB-backed stream-key validation layer -- **Play / publish authentication** — separate `publish_key` and `play_key` validation -- **SQLite persistence** — streams, publishers, players, and stats are stored in a database -- **Live publisher/player tracking** — connection state is mirrored into the database -- **JSON stats** — `/stats?key=***` clean modern JSON -- **Nginx-RTMP XML** — `/stats-nginx?key=***` for existing tools -- **REST API** — Stream CRUD, Bearer token auth -- **Docker-ready** — Lightweight Alpine container +- **RTMP listener** on `RTMP_BIND` via `librtmp2` +- **RTMPS listener** on `RTMPS_BIND` when `TLS_ENABLED=true` (alongside plaintext RTMP) +- **OBS / FFmpeg publish path** with DB-backed `publish_key` validation +- **Play authentication** via `play_key` +- **Publisher → player relay** (same `(app, stream)` route) +- **SQLite persistence** — streams, publishers, players, stats +- **JSON stats** — `/stats?key=***` +- **Nginx-RTMP XML** — `/stats-nginx?key=***` +- **REST API** — stream CRUD, Bearer token auth +- **Docker** — Alpine-based images on GHCR -Still under active development: +### Protocol behaviour inherited from librtmp2 (not reimplemented here) -- RTMPS/TLS production readiness -- Protocol completeness and compatibility hardening -- Performance optimization -- Production hardening -- Additional E-RTMP functionality +- Live H.264/AAC and Enhanced-RTMP **passthrough** ingest (HEVC/AV1/Opus, multitrack when publishers send it) +- Late player join gets cached codec sequence headers (legacy + Enhanced-RTMP) and last keyframe; `onMetaData` is replayed to late joiners +- Legacy RTMP commands (`pause`, `seek`, `receiveAudio`/`receiveVideo`, `closeStream`) are handled in the protocol layer +- E-RTMP v2 connect capability negotiation and multitrack relay live in `librtmp2`; this server does not expose per-track IDs in the HTTP API yet +- No nginx-rtmp feature parity (HLS, exec, push relay, recording) -The server is not intended to be presented as a drop-in production replacement for `nginx-rtmp` yet. Test your OBS/FFmpeg workflow before using it for critical streams. +Test your OBS/FFmpeg workflow before using this for critical streams. It is not a drop-in replacement for `nginx-rtmp`. --- ## Features -- **Integrated RTMP listener** — built on the Rust `librtmp2` implementation -- **RTMP + RTMPS at the same time** — RTMPS is an additional listener, not a mode switch; plaintext RTMP keeps working when TLS is enabled -- **SQLite persistence** — Streams, publishers, players, stats all in a DB +Everything below is implemented **in this repo**. Wire-protocol limits are defined by `librtmp2` (see link above). + +- **Integrated RTMP listener** — built on the `librtmp2` crate +- **RTMP + RTMPS at the same time** — RTMPS is an additional listener, not a mode switch +- **SQLite persistence** — streams, publishers, players, stats - **Unique keys per stream** — `publish_key`, `play_key`, `stats_key` -- **Privacy by design** — No one can see streams/stats without the exact key -- **JSON stats** — `/stats?key=***` clean modern JSON -- **Nginx-RTMP XML** — `/stats-nginx?key=***` for existing tools -- **REST API** — Stream CRUD, Bearer token auth -- **Docker-ready** — Lightweight Alpine container -- **Alpha quality** — Interfaces and implementation details may still change +- **Privacy by design** — no public stream list without keys +- **JSON + Nginx-compatible XML stats** +- **REST API** — stream CRUD, Bearer token auth +- **Docker-ready** — lightweight Alpine container --- @@ -69,16 +69,15 @@ connections on both, and forwards connect/publish/play/close events into the DB-backed [`RtmpEventHandler`](src/rtmp_bridge.rs) bridge while also updating codec/stats data. -Both `librtmp2-server` and `librtmp2` are under active development and should be -considered Alpha software. +Both `librtmp2-server` and `librtmp2` are alpha; API and protocol details may still change. ```text OBS / FFmpeg / App │ ▼ librtmp2-server (Rust) - ├── RTMP Listener (port 1935) ← integrated via librtmp2 (Alpha), always on - ├── RTMPS Listener (port 1936) ← alongside RTMP, only when TLS_ENABLED=true + ├── RTMP Listener (port 1935) ← librtmp2, always on + ├── RTMPS Listener (port 1936) ← librtmp2, when TLS_ENABLED=true ├── SQLite (streams, publishers, players, stats) ├── HTTP API (port 8080, axum) │ ├── /api/v1/streams CRUD @@ -88,12 +87,9 @@ OBS / FFmpeg / App │ ▼ librtmp2 (Rust) - ├── Handshake - ├── Chunking - ├── AMF - ├── RTMP Commands - ├── E-RTMP v1 - └── E-RTMP v2 + ├── Live publish/play relay + ├── Legacy RTMP session core + └── E-RTMP passthrough + parser modules (see librtmp2 README) ``` --- @@ -107,6 +103,12 @@ OBS / FFmpeg / App ### Compile +`librtmp2-server` uses the published `librtmp2` 0.4.0 release from crates.io: + +```toml +librtmp2 = { version = "0.4.0", features = ["tls"] } +``` + ```bash git clone https://github.com/OpenRTMP/librtmp2-server.git cd librtmp2-server diff --git a/src/db.rs b/src/db.rs index ff5abf2..ea9dd9c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -452,6 +452,9 @@ impl Db { crate::log_error!("stream_find_by: rejected disallowed column '{column}'"); return DbLookup::Failed; } + if !crate::keygen::is_valid_access_key(key) { + return DbLookup::Missing; + } let conn = self.conn.lock(); map_optional(conn.query_row( &format!( @@ -472,6 +475,9 @@ impl Db { /// belongs to a disabled/pending-delete stream, so the RTMP auth-failure /// rate limiter only counts the former as a credential mismatch. pub fn stream_find_by_publish_key_any(&self, key: &str) -> DbLookup { + if !crate::keygen::is_valid_access_key(key) { + return DbLookup::Missing; + } let conn = self.conn.lock(); map_optional(conn.query_row( &format!( @@ -655,6 +661,9 @@ impl Db { } pub fn viewer_find_by_play_key(&self, key: &str) -> DbLookup { + if !crate::keygen::is_valid_access_key(key) { + return DbLookup::Missing; + } let conn = self.conn.lock(); map_optional(conn.query_row( &format!( @@ -1125,9 +1134,9 @@ mod tests { id: id.to_string(), name: format!("{id} name"), app: "live".to_string(), - publish_key: pub_key.to_string(), - play_key: play_key.to_string(), - stats_key: stats_key.to_string(), + publish_key: crate::keygen::test_pad_access_key(pub_key), + play_key: crate::keygen::test_pad_access_key(play_key), + stats_key: crate::keygen::test_pad_access_key(stats_key), enabled: true, created_at: now_ts(), } @@ -1177,18 +1186,18 @@ mod tests { assert_eq!(got.name, "stream1 name"); assert_eq!( - match db.stream_find_by_publish_key("pub_key_123") { + match db.stream_find_by_publish_key(&s.publish_key) { DbLookup::Ok(s) => s.id, _ => panic!("publish key lookup failed"), }, "stream1" ); assert!(matches!( - db.viewer_find_by_play_key("pl_key_456"), + db.viewer_find_by_play_key(&s.play_key), DbLookup::Ok(_) )); assert!(matches!( - db.stream_find_by_stats_key("st_key_789"), + db.stream_find_by_stats_key(&s.stats_key), DbLookup::Ok(_) )); assert!(matches!( @@ -1202,13 +1211,8 @@ mod tests { #[test] fn publishers_players_and_stats() { let db = Db::open(":memory:").unwrap(); - db.stream_add(&sample_stream( - "stream1", - "pub_key_123", - "pl_key_456", - "st_key_789", - )) - .unwrap(); + let s = sample_stream("stream1", "pub_key_123", "pl_key_456", "st_key_789"); + db.stream_add(&s).unwrap(); let mut p = Publisher { id: "pub1".to_string(), @@ -1231,7 +1235,7 @@ mod tests { assert!(db.publisher_try_acquire(&p)); assert_eq!(db.publisher_list(Some("stream1")).len(), 1); - let DbLookup::Ok(viewer) = db.viewer_find_by_play_key("pl_key_456") else { + let DbLookup::Ok(viewer) = db.viewer_find_by_play_key(&s.play_key) else { panic!("viewer not found"); }; let player = Player { @@ -1380,7 +1384,10 @@ mod tests { }); // Simulate on_close for pub1: find by publish_key -> stream_id -> list. - let DbLookup::Ok(found) = db.stream_find_by_publish_key("pub_key_1") else { + let DbLookup::Ok(stream1) = db.stream_get("stream1") else { + panic!("stream not found"); + }; + let DbLookup::Ok(found) = db.stream_find_by_publish_key(&stream1.publish_key) else { panic!("publish key not found"); }; let mut pubs = db.publisher_list(Some(&found.id)); @@ -1401,14 +1408,9 @@ mod tests { #[test] fn player_try_acquire_enforces_per_key_connection_cap() { let db = Db::open(":memory:").unwrap(); - db.stream_add(&sample_stream( - "stream1", - "pub_key_123", - "pl_key_456", - "st_key_789", - )) - .unwrap(); - let DbLookup::Ok(viewer) = db.viewer_find_by_play_key("pl_key_456") else { + let s = sample_stream("stream1", "pub_key_123", "pl_key_456", "st_key_789"); + db.stream_add(&s).unwrap(); + let DbLookup::Ok(viewer) = db.viewer_find_by_play_key(&s.play_key) else { panic!("viewer not found"); }; @@ -1434,4 +1436,34 @@ mod tests { }; assert!(!db.player_try_acquire(&overflow)); } + + #[test] + fn legacy_short_access_keys_are_rejected_at_lookup() { + let db = Db::open(":memory:").unwrap(); + let s = Stream { + id: "legacy".to_string(), + name: "Legacy".to_string(), + app: "live".to_string(), + publish_key: "a".to_string(), + play_key: "b".to_string(), + stats_key: "c".to_string(), + enabled: true, + created_at: now_ts(), + }; + assert!(db.stream_add(&s).is_ok()); + + assert!(matches!( + db.stream_find_by_publish_key("a"), + DbLookup::Missing + )); + assert!(matches!( + db.stream_find_by_publish_key_any("a"), + DbLookup::Missing + )); + assert!(matches!(db.viewer_find_by_play_key("b"), DbLookup::Missing)); + assert!(matches!( + db.stream_find_by_stats_key("c"), + DbLookup::Missing + )); + } } diff --git a/src/http.rs b/src/http.rs index 1a59b8b..abcffcd 100644 --- a/src/http.rs +++ b/src/http.rs @@ -206,21 +206,9 @@ fn is_valid_stream_key_part(value: &str) -> bool { && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) } -/// Minimum length for operator-supplied publish/play/stats keys. Shorter custom -/// keys are trivially brute-forced over the unrate-limited RTMP auth path. -const MIN_ACCESS_KEY_LEN: usize = 32; - /// Publish/play/stats keys: safe ASCII, no slashes, minimum entropy via length. fn is_valid_access_key(value: &str) -> bool { - if value.len() < MIN_ACCESS_KEY_LEN || value.len() > 63 { - return false; - } - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return false; - }; - first.is_ascii_alphanumeric() - && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + crate::keygen::is_valid_access_key(value) } fn trim_optional_string(value: Option) -> Option { @@ -249,7 +237,7 @@ fn resolve_or_generate_access_key( } } -const ACCESS_KEY_VALIDATION_MSG: &str = "Key must be 32-63 characters, start with a letter or number, and use only letters, numbers, dots, underscores, or hyphens"; +const ACCESS_KEY_VALIDATION_MSG: &str = crate::keygen::ACCESS_KEY_VALIDATION_MSG; fn access_keys_must_be_unique(keys: &[&str]) -> bool { let mut seen = HashSet::with_capacity(keys.len()); diff --git a/src/keygen.rs b/src/keygen.rs index ef87a32..e871cd9 100644 --- a/src/keygen.rs +++ b/src/keygen.rs @@ -18,6 +18,37 @@ pub const PREFIX_STATS_KEY: &str = "sts_"; /// Prefix for configured viewer-slot row ids (panel-managed play access). pub const PREFIX_VIEWER_ID: &str = "vi_"; +/// Minimum length for publish/play/stats keys used at runtime. Shorter keys stored +/// in legacy databases are rejected on RTMP and public stats paths. +pub const MIN_ACCESS_KEY_LEN: usize = 32; + +/// Publish/play/stats keys: safe ASCII, no slashes, minimum entropy via length. +pub fn is_valid_access_key(value: &str) -> bool { + if value.len() < MIN_ACCESS_KEY_LEN || value.len() > 63 { + return false; + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphanumeric() + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +pub const ACCESS_KEY_VALIDATION_MSG: &str = "Key must be 32-63 characters, start with a letter or number, and use only letters, numbers, dots, underscores, or hyphens"; + +#[cfg(test)] +pub fn test_pad_access_key(value: &str) -> String { + if is_valid_access_key(value) { + return value.to_string(); + } + let mut padded = value.to_string(); + while padded.len() < MIN_ACCESS_KEY_LEN { + padded.push('x'); + } + padded.chars().take(63).collect() +} + fn keygen_with_entropy(prefix: &str, entropy_bytes: usize) -> Result { let mut rnd = vec![0u8; entropy_bytes]; SysRng @@ -65,4 +96,11 @@ mod tests { assert_eq!(token.len(), API_TOKEN_ENTROPY_BYTES * 2); assert!(token.chars().all(|c| c.is_ascii_hexdigit())); } + + #[test] + fn access_key_validation_enforces_minimum_length() { + assert!(is_valid_access_key("live.main_1_with_sufficient_length_ok")); + assert!(!is_valid_access_key("too_short")); + assert!(!is_valid_access_key("a")); + } } diff --git a/src/rtmp_bridge.rs b/src/rtmp_bridge.rs index 76a7925..c4c409b 100644 --- a/src/rtmp_bridge.rs +++ b/src/rtmp_bridge.rs @@ -894,17 +894,23 @@ mod tests { id: id.to_string(), name: format!("{id} name"), app: "live".to_string(), - publish_key: pub_key.to_string(), - play_key: play_key.to_string(), - stats_key: format!("st_{id}"), + publish_key: crate::keygen::test_pad_access_key(pub_key), + play_key: crate::keygen::test_pad_access_key(play_key), + stats_key: crate::keygen::test_pad_access_key(&format!("stats_{id}")), enabled: true, created_at: crate::db::now_ts(), } } - fn add_stream_with_player(db: &Db, id: &str, pub_key: &str, play_key: &str) { - db.stream_add(&sample_stream(id, pub_key, play_key)) - .unwrap(); + fn add_stream_with_player( + db: &Db, + id: &str, + pub_key: &str, + play_key: &str, + ) -> crate::db::Stream { + let s = sample_stream(id, pub_key, play_key); + db.stream_add(&s).unwrap(); + s } #[test] @@ -1057,23 +1063,23 @@ mod tests { #[test] fn publish_and_play_reject_stream_marked_for_deletion() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let deleted = Arc::new(Mutex::new(HashSet::new())); deleted.lock().insert("s1".to_string()); let bridge = DbRtmpBridge::new(db, deleted); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k").is_err()); + assert!(bridge.authorize_publish(1, "live", &s.publish_key).is_err()); bridge.on_close(1); bridge.on_connect(2, "127.0.0.1:1000"); - assert!(bridge.authorize_play(2, "live", "pl_k").is_err()); + assert!(bridge.authorize_play(2, "live", &s.play_key).is_err()); } #[test] fn publish_with_valid_key_for_disabled_stream_does_not_burn_auth_budget() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); assert!(db.stream_disable("s1").unwrap()); let bridge = test_bridge(db); let ip = "203.0.113.8:1935"; @@ -1084,7 +1090,11 @@ mod tests { // a brute-force guess would. for conn in 0..(RTMP_AUTH_MAX_FAILURES as u64 + 2) { bridge.on_connect(conn, ip); - assert!(bridge.authorize_publish(conn, "live", "pub_k").is_err()); + assert!( + bridge + .authorize_publish(conn, "live", &s.publish_key) + .is_err() + ); bridge.on_close(conn); } @@ -1102,33 +1112,37 @@ mod tests { #[test] fn publish_rejects_key_for_wrong_app() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "other", "pub_k").is_err()); + assert!( + bridge + .authorize_publish(1, "other", &s.publish_key) + .is_err() + ); assert_eq!(db.publisher_list(Some("s1")).len(), 0); } #[test] fn play_rejects_key_for_wrong_app() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_play(1, "other", "pl_k").is_err()); + assert!(bridge.authorize_play(1, "other", &s.play_key).is_err()); assert_eq!(db.player_list(Some("s1")).len(), 0); } #[test] fn publish_then_close_deactivates_publisher() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s.publish_key).is_ok()); assert_eq!(db.publisher_list(Some("s1")).len(), 1); bridge.on_close(1); @@ -1138,14 +1152,14 @@ mod tests { #[test] fn close_only_affects_its_own_connection() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); - add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); + let s1 = add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); + let s2 = add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); bridge.on_connect(2, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k1").is_ok()); - assert!(bridge.authorize_publish(2, "live", "pub_k2").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s1.publish_key).is_ok()); + assert!(bridge.authorize_publish(2, "live", &s2.publish_key).is_ok()); bridge.on_close(1); assert_eq!(db.publisher_list(Some("s1")).len(), 0); @@ -1155,24 +1169,24 @@ mod tests { #[test] fn authorize_publish_rejects_second_publisher() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s.publish_key).is_ok()); bridge.on_connect(2, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(2, "live", "pub_k").is_err()); + assert!(bridge.authorize_publish(2, "live", &s.publish_key).is_err()); } #[test] fn on_connect_preserves_prior_authorize_publish_state() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); // publish callback can run during poll() before the poll-loop on_connect. - assert!(bridge.authorize_publish(1, "live", "pub_k").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s.publish_key).is_ok()); bridge.on_connect(1, "127.0.0.1:1000"); assert!(bridge.has_publisher(1)); @@ -1192,13 +1206,13 @@ mod tests { #[test] fn authorize_publish_switching_streams_deactivates_prior_publisher() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); - add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); + let s1 = add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); + let s2 = add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k1").is_ok()); - assert!(bridge.authorize_publish(1, "live", "pub_k2").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s1.publish_key).is_ok()); + assert!(bridge.authorize_publish(1, "live", &s2.publish_key).is_ok()); assert_eq!(db.publisher_list(Some("s1")).len(), 0); assert_eq!(db.publisher_list(Some("s2")).len(), 1); @@ -1207,16 +1221,20 @@ mod tests { #[test] fn authorize_publish_failed_switch_keeps_prior_publisher_active() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); - add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); + let s1 = add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); + let s2 = add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); bridge.on_connect(2, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k1").is_ok()); - assert!(bridge.authorize_publish(2, "live", "pub_k2").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s1.publish_key).is_ok()); + assert!(bridge.authorize_publish(2, "live", &s2.publish_key).is_ok()); - assert!(bridge.authorize_publish(1, "live", "pub_k2").is_err()); + assert!( + bridge + .authorize_publish(1, "live", &s2.publish_key) + .is_err() + ); assert!(bridge.has_publisher(1)); assert_eq!(db.publisher_list(Some("s1")).len(), 1); @@ -1226,13 +1244,13 @@ mod tests { #[test] fn on_play_switching_streams_deactivates_prior_player() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); - add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); + let s1 = add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); + let s2 = add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_play(1, "live", "pl_k1").is_ok()); - assert!(bridge.authorize_play(1, "live", "pl_k2").is_ok()); + assert!(bridge.authorize_play(1, "live", &s1.play_key).is_ok()); + assert!(bridge.authorize_play(1, "live", &s2.play_key).is_ok()); assert_eq!(db.player_list(Some("s1")).len(), 0); assert_eq!(db.player_list(Some("s2")).len(), 1); @@ -1244,17 +1262,17 @@ mod tests { #[test] fn player_replacement_stats_reset_is_not_consumed_by_publisher_stats() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); - add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); + let s1 = add_stream_with_player(&db, "s1", "pub_k1", "pl_k1"); + let s2 = add_stream_with_player(&db, "s2", "pub_k2", "pl_k2"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_publish(1, "live", "pub_k1").is_ok()); - assert!(bridge.authorize_play(1, "live", "pl_k1").is_ok()); + assert!(bridge.authorize_publish(1, "live", &s1.publish_key).is_ok()); + assert!(bridge.authorize_play(1, "live", &s1.play_key).is_ok()); bridge.update_publisher_stats(1, 1_000, "avc1", "mp4a", PublisherStreamMetadata::default()); bridge.update_player_stats(1, 2_000); - assert!(bridge.authorize_play(1, "live", "pl_k2").is_ok()); + assert!(bridge.authorize_play(1, "live", &s2.play_key).is_ok()); bridge.update_publisher_stats(1, 1_500, "avc1", "mp4a", PublisherStreamMetadata::default()); { @@ -1273,11 +1291,11 @@ mod tests { #[test] fn update_player_stats_persists_bytes_out() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); bridge.on_connect(1, "127.0.0.1:1000"); - assert!(bridge.authorize_play(1, "live", "pl_k").is_ok()); + assert!(bridge.authorize_play(1, "live", &s.play_key).is_ok()); bridge.update_player_stats(1, 4096); let players = db.player_list(Some("s1")); @@ -1288,19 +1306,19 @@ mod tests { #[test] fn play_rejects_when_connection_cap_reached() { let db = Arc::new(Db::open(":memory:").unwrap()); - add_stream_with_player(&db, "s1", "pub_k", "pl_k"); + let s = add_stream_with_player(&db, "s1", "pub_k", "pl_k"); let bridge = test_bridge(Arc::clone(&db)); - let DbLookup::Ok(viewer) = db.viewer_find_by_play_key("pl_k") else { + let DbLookup::Ok(viewer) = db.viewer_find_by_play_key(&s.play_key) else { panic!("viewer not found"); }; for conn in 1..=crate::db::MAX_CONNECTIONS_PER_PLAY_KEY as u64 { bridge.on_connect(conn, "127.0.0.1:1000"); - assert!(bridge.authorize_play(conn, "live", "pl_k").is_ok()); + assert!(bridge.authorize_play(conn, "live", &s.play_key).is_ok()); } bridge.on_connect(99, "127.0.0.1:1000"); - assert!(bridge.authorize_play(99, "live", "pl_k").is_err()); + assert!(bridge.authorize_play(99, "live", &s.play_key).is_err()); assert_eq!( db.player_list(Some(&viewer.stream_id)) .iter() diff --git a/tests/rtmp_http_e2e.rs b/tests/rtmp_http_e2e.rs index 41b31f3..6c5c67c 100644 --- a/tests/rtmp_http_e2e.rs +++ b/tests/rtmp_http_e2e.rs @@ -100,18 +100,11 @@ fn http_create_stream_then_rtmp_publish_and_play() { let frame = Frame { frame_type: FrameType::Video, timestamp: 0, - composition_time: 0, size: data.len() as u32, data: data.as_ptr(), - audio_codec: Default::default(), - audio_sample_rate: 0, - audio_channels: 0, - audio_bit_depth: 0, - audio_fourcc: Default::default(), video_codec: VideoCodec::H264, - video_fourcc: Default::default(), video_frame_type: 1, - is_metadata: 0, + ..Default::default() }; publisher.send_frame(&frame)?;