Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
df5bf80
docs: align README with code-accurate implementation status
AlexanderWagnerDev Jul 14, 2026
3e57d44
chore: bump librtmp2 to 0.4.0 (0.1.5)
AlexanderWagnerDev Jul 14, 2026
ab85c76
chore: track librtmp2 0.4.0 via git branch until crates.io
AlexanderWagnerDev Jul 14, 2026
042c91d
chore: refresh librtmp2 lock to latest 0.4.0 branch (617d3d2)
AlexanderWagnerDev Jul 14, 2026
021226f
fix: pin librtmp2 to validated PR head
AlexanderWagnerDev Jul 14, 2026
abd57d7
Update Cargo.lock
github-actions[bot] Jul 14, 2026
cb703a8
chore: stage librtmp2 dependency refresh
AlexanderWagnerDev Jul 14, 2026
5d9081c
ci: validate librtmp2 dependency refresh
AlexanderWagnerDev Jul 14, 2026
9ad2b55
fix: pin server to validated librtmp2 revision
github-actions[bot] Jul 14, 2026
ffc5621
docs: clarify validated librtmp2 revision
AlexanderWagnerDev Jul 14, 2026
a76a527
docs: update 0.1.5 changelog date
AlexanderWagnerDev Jul 15, 2026
ab216d3
chore: add one-shot dependency refresh for PR 82
AlexanderWagnerDev Jul 15, 2026
bb6180d
Update Cargo.lock
github-actions[bot] Jul 15, 2026
9c8e69b
fix: simplify PR 82 dependency refresh
AlexanderWagnerDev Jul 15, 2026
43b84cd
fix: refresh librtmp2 dependency pin
github-actions[bot] Jul 15, 2026
6914405
chore: clarify reproducible librtmp2 pin
AlexanderWagnerDev Jul 15, 2026
7ce4f51
fix: reject legacy short access keys
AlexanderWagnerDev Jul 15, 2026
0e025b2
chore: apply crates.io librtmp2 0.4.0 migration
AlexanderWagnerDev Jul 15, 2026
d23067a
chore: use crates.io librtmp2 0.4.0
github-actions[bot] Jul 15, 2026
870e3be
docs: clarify crates.io dependency source
AlexanderWagnerDev Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "librtmp2-server"
version = "0.1.4"
version = "0.1.5"
edition = "2024"
rust-version = "1.95"
license = "MIT"
Expand All @@ -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"] }
Expand Down
78 changes: 40 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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
Expand All @@ -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)
```

---
Expand All @@ -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
Expand Down
78 changes: 55 additions & 23 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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<Stream> {
if !crate::keygen::is_valid_access_key(key) {
return DbLookup::Missing;
}
let conn = self.conn.lock();
map_optional(conn.query_row(
&format!(
Expand Down Expand Up @@ -655,6 +661,9 @@ impl Db {
}

pub fn viewer_find_by_play_key(&self, key: &str) -> DbLookup<StreamViewer> {
if !crate::keygen::is_valid_access_key(key) {
return DbLookup::Missing;
}
let conn = self.conn.lock();
map_optional(conn.query_row(
&format!(
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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!(
Expand All @@ -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(),
Expand All @@ -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 {
Expand Down Expand Up @@ -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));
Expand All @@ -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");
};

Expand All @@ -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
));
}
}
16 changes: 2 additions & 14 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Option<String> {
Expand Down Expand Up @@ -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());
Expand Down
Loading