Compare commits

...
6 Commits
Author SHA1 Message Date
doloro a9bd890d16 cargo clippy fix 2026-09-17 18:57:35 +01:00
doloro 21a20b898c added custom url label and is protected fields to catalog listing 2026-09-17 18:57:00 +01:00
doloro 6ac7e60a34 Bump : 0.6.0 : Data channel pipeline, currently used for sending viewers data 2026-09-10 13:59:09 +01:00
doloro 94daa39d87 nix flake update 2026-09-10 13:58:09 +01:00
doloro c4a7e622b7 fmt all 2026-08-29 15:15:28 +01:00
doloro 98dbb3d36d Cleanup: over-engineering pass
- merge /api/uptime + /api/version into /api/health and /api/stats
- collapse RTMP per-codec dispatch into a single CodecParser path
- dedupe STUN attribute walking (ufrag_pair / parse_xor_mapped_address)
- dedupe StreamCodec <-> str0m Codec mapping via StreamCodec::from_str0m
- dedupe bearer token extraction in WHIP handlers
- replace regex charset validation with stdlib checks
- flatten one-field wrappers (WebRtcProxyConfig, HttpServerConfig, ServerInfo)
- remove SessionCookie extractor (only fed debug logs)
- delete dead code: entity ActiveModel helpers, get_stream_session,
  WebrtcProxy::local_addr, _connected flag, commented-out blocks
- drop unused deps: futures, rand, regex, serde_json
2026-08-21 11:39:02 +01:00
19 changed files with 306 additions and 432 deletions
Generated
+3 -20
View File
@@ -1151,7 +1151,6 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor",
"futures-io", "futures-io",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
@@ -1202,17 +1201,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.32" version = "0.3.32"
@@ -1231,10 +1219,8 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-macro",
"futures-sink", "futures-sink",
"futures-task", "futures-task",
"memchr", "memchr",
@@ -2870,9 +2856,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.150" version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr", "memchr",
@@ -2906,7 +2892,7 @@ dependencies = [
[[package]] [[package]]
name = "server" name = "server"
version = "0.5.4" version = "0.6.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"async-broadcast", "async-broadcast",
@@ -2916,11 +2902,8 @@ dependencies = [
"chrono", "chrono",
"dashmap", "dashmap",
"entity", "entity",
"futures",
"migration", "migration",
"opus", "opus",
"rand 0.10.2",
"regex",
"rml_rtmp", "rml_rtmp",
"rubato", "rubato",
"sea-orm", "sea-orm",
-24
View File
@@ -91,27 +91,3 @@ impl Entity {
.await .await
} }
} }
impl ActiveModel {
pub async fn change_label_value(
mut self,
db: &DatabaseConnection,
value: String,
) -> Result<Model, DbErr> {
self.label = Set(value);
self.update(db).await
}
pub async fn password(
mut self,
db: &DatabaseConnection,
value: String,
) -> Result<Model, DbErr> {
self.password = if value.is_empty() {
Set(None)
} else {
Set(Some(value))
};
self.update(db).await
}
}
+4 -15
View File
@@ -50,32 +50,21 @@ impl Model {
.insert(db) .insert(db)
.await .await
} }
pub async fn get_stream_session(
db: &DatabaseConnection,
stream_session_id: i32,
) -> Result<Model, DbErr> {
Entity::find_by_id(stream_session_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound(format!(
"stream_session {stream_session_id}"
)))
}
pub async fn get_all_active_sessions(db: &DatabaseConnection) -> Result<Vec<Model>, DbErr> { pub async fn get_all_active_sessions(db: &DatabaseConnection) -> Result<Vec<Model>, DbErr> {
Ok(Entity::find() Entity::find()
.filter(Column::EndedAt.is_null()) .filter(Column::EndedAt.is_null())
.all(db) .all(db)
.await?) .await
} }
pub async fn get_active_by_stream_key_id( pub async fn get_active_by_stream_key_id(
db: &DatabaseConnection, db: &DatabaseConnection,
stream_key_id: i32, stream_key_id: i32,
) -> Result<Option<Model>, DbErr> { ) -> Result<Option<Model>, DbErr> {
Ok(Entity::find() Entity::find()
.filter(Column::StreamKeyId.eq(stream_key_id)) .filter(Column::StreamKeyId.eq(stream_key_id))
.filter(Column::EndedAt.is_null()) .filter(Column::EndedAt.is_null())
.one(db) .one(db)
.await?) .await
} }
pub async fn clean_unended_streams(db: &DatabaseConnection) -> Result<u64, DbErr> { pub async fn clean_unended_streams(db: &DatabaseConnection) -> Result<u64, DbErr> {
+4 -4
View File
@@ -58,18 +58,18 @@ impl Entity {
if let Some(x) = sessions { if let Some(x) = sessions {
Entity::find_by_id(x.id_user).one(db).await Entity::find_by_id(x.id_user).one(db).await
} else { } else {
return Ok(None); Ok(None)
} }
} }
pub async fn find_by_username( pub async fn find_by_username(
db: &DatabaseConnection, db: &DatabaseConnection,
username: String, username: String,
) -> Result<Option<Model>, DbErr> { ) -> Result<Option<Model>, DbErr> {
let user = Entity::find()
Entity::find()
.filter(Column::Username.eq(username)) .filter(Column::Username.eq(username))
.one(db) .one(db)
.await; .await
user
} }
} }
+2 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "server" name = "server"
version = "0.5.4" version = "0.6.0"
edition = "2024" edition = "2024"
[target.x86_64-unknown-linux-gnu] [target.x86_64-unknown-linux-gnu]
@@ -15,20 +15,17 @@ path = "src/main.rs"
async-broadcast = "0.7.2" async-broadcast = "0.7.2"
bytes = "1" bytes = "1"
dashmap = "6.2.1" dashmap = "6.2.1"
rand = "0.10.1"
rml_rtmp = "0.8.0" rml_rtmp = "0.8.0"
str0m = "0.20.0" str0m = "0.20.0"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros"] }
serde = { version = "1.0.228", features = ["serde_derive"] } serde = { version = "1.0.228", features = ["serde_derive"] }
serde_json = "1.0.150"
sea-orm = { version = "1", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] } sea-orm = { version = "1", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros" ] }
entity = {path = "../entity"} entity = {path = "../entity"}
migration = {path = "../migration"} migration = {path = "../migration"}
argon2 = "0.5.3" argon2 = "0.5.3"
uuid = { version = "1.23.3", features = ["v4"] } uuid = { version = "1.23.3", features = ["v4"] }
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors"] }
futures = "0.3.32"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
symphonia = { version = "0.5", features = ["aac"] } symphonia = { version = "0.5", features = ["aac"] }
@@ -36,7 +33,7 @@ opus = "0.3.1"
rubato = "3.0.0" rubato = "3.0.0"
chrono = "0.4.45" chrono = "0.4.45"
time = "0.3" time = "0.3"
regex = "1"
thiserror = "2.0.18" thiserror = "2.0.18"
sysinfo = "0.36" sysinfo = "0.36"
axum-extra = { version = "0.12.6", features = ["cookie"] } axum-extra = { version = "0.12.6", features = ["cookie"] }
serde_json = "1.0.151"
+6 -26
View File
@@ -1,10 +1,10 @@
use std::{error::Error, fmt::Display}; use std::error::Error;
use bytes::Bytes; use bytes::Bytes;
use rubato::{audioadapter_buffers::direct::InterleavedSlice, Fft, Resampler}; use rubato::{Fft, Resampler, audioadapter_buffers::direct::InterleavedSlice};
use symphonia::core::{ use symphonia::core::{
audio::SampleBuffer, audio::SampleBuffer,
codecs::{CodecParameters, Decoder, DecoderOptions, CODEC_TYPE_AAC}, codecs::{CODEC_TYPE_AAC, CodecParameters, Decoder, DecoderOptions},
formats::Packet, formats::Packet,
}; };
@@ -44,7 +44,7 @@ impl AudioProcesser {
pub fn encode(&mut self, frame: AudioFrame) -> Vec<OpusAudioFrame> { pub fn encode(&mut self, frame: AudioFrame) -> Vec<OpusAudioFrame> {
let mut samples: Vec<f32> = frame let mut samples: Vec<f32> = frame
.data .data
.chunks_exact(4) .as_chunks::<4>().0.iter()
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])) .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect(); .collect();
@@ -95,23 +95,6 @@ pub struct AACParser {
decoder: Option<Box<dyn Decoder>>, decoder: Option<Box<dyn Decoder>>,
} }
#[derive(Debug)]
enum AudioParseError {
InvalidCodec,
NoConfigPacket,
}
impl Display for AudioParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCodec => write!(f, "Not the right codec provided"),
Self::NoConfigPacket => write!(f, "No config packet cached"),
}
}
}
impl Error for AudioParseError {}
// Byte 0: upper nibble = sound format (10 = AAC) // Byte 0: upper nibble = sound format (10 = AAC)
// Byte 1: 0 = AudioSpecificConfig, 1 = raw AAC frame // Byte 1: 0 = AudioSpecificConfig, 1 = raw AAC frame
impl AACParser { impl AACParser {
@@ -129,7 +112,7 @@ impl AACParser {
} }
if (bytes[0] >> 4) != 10 { if (bytes[0] >> 4) != 10 {
return Err(Box::new(AudioParseError::InvalidCodec)); return Err("not the right codec provided".into());
} }
if bytes[1] == 0 { if bytes[1] == 0 {
@@ -142,10 +125,7 @@ impl AACParser {
return Ok(None); return Ok(None);
} }
let decoder = self let decoder = self.decoder.as_mut().ok_or("no config packet cached")?;
.decoder
.as_mut()
.ok_or(AudioParseError::NoConfigPacket)?;
let packet = Packet::new_from_boxed_slice( let packet = Packet::new_from_boxed_slice(
0, 0,
+28 -13
View File
@@ -50,7 +50,9 @@ impl Av1CodecParser {
let has_size = (header >> 1) & 1 != 0; let has_size = (header >> 1) & 1 != 0;
i += 1; i += 1;
if has_extension { if has_extension {
if i >= data.len() { return false; } if i >= data.len() {
return false;
}
i += 1; i += 1;
} }
if obu_type == 1 { if obu_type == 1 {
@@ -61,13 +63,19 @@ impl Av1CodecParser {
let mut size: usize = 0; let mut size: usize = 0;
let mut shift = 0; let mut shift = 0;
loop { loop {
if i >= data.len() { return false; } if i >= data.len() {
return false;
}
let b = data[i] as usize; let b = data[i] as usize;
i += 1; i += 1;
size |= (b & 0x7F) << shift; size |= (b & 0x7F) << shift;
shift += 7; shift += 7;
if b & 0x80 == 0 { break; } if b & 0x80 == 0 {
if shift > 32 { return false; } break;
}
if shift > 32 {
return false;
}
} }
i += size; i += size;
} else { } else {
@@ -78,7 +86,11 @@ impl Av1CodecParser {
false false
} }
fn obus_for_frame(&mut self, payload: &[u8], rtmp_is_keyframe: bool) -> Option<(Vec<u8>, bool)> { fn obus_for_frame(
&mut self,
payload: &[u8],
rtmp_is_keyframe: bool,
) -> Option<(Vec<u8>, bool)> {
if payload.is_empty() { if payload.is_empty() {
return None; return None;
} }
@@ -95,13 +107,12 @@ impl Av1CodecParser {
self.first_coded_frame = false; self.first_coded_frame = false;
if is_keyframe if is_keyframe && let Some(config) = &self.config_obus {
&& let Some(config) = &self.config_obus { let mut out = Vec::with_capacity(config.len() + payload.len());
let mut out = Vec::with_capacity(config.len() + payload.len()); out.extend_from_slice(config);
out.extend_from_slice(config); out.extend_from_slice(payload);
out.extend_from_slice(payload); return Some((out, true));
return Some((out, true)); }
}
Some((payload.to_vec(), is_keyframe)) Some((payload.to_vec(), is_keyframe))
} }
@@ -129,7 +140,11 @@ impl CodecParser for Av1CodecParser {
// FourCC — enhanced RTMP only defines CTS for hvc1 CodedFrames. // FourCC — enhanced RTMP only defines CTS for hvc1 CodedFrames.
let payload = data.get(5..)?; let payload = data.get(5..)?;
let (obus, is_keyframe) = self.obus_for_frame(payload, rtmp_is_keyframe)?; let (obus, is_keyframe) = self.obus_for_frame(payload, rtmp_is_keyframe)?;
Some(VideoFrame { data: Bytes::from(obus), is_keyframe, timestamp_ms }) Some(VideoFrame {
data: Bytes::from(obus),
is_keyframe,
timestamp_ms,
})
} }
_ => None, _ => None,
} }
+21 -10
View File
@@ -53,12 +53,20 @@ impl H264CodecParser {
} }
let pts_ms = Self::pts_ms(timestamp_ms, &bytes[5..8]); let pts_ms = Self::pts_ms(timestamp_ms, &bytes[5..8]);
let data = self.avcc_to_annexb(bytes.get(8..)?, is_keyframe)?; let data = self.avcc_to_annexb(bytes.get(8..)?, is_keyframe)?;
Some(VideoFrame { data: Bytes::from(data), is_keyframe, timestamp_ms: pts_ms }) Some(VideoFrame {
data: Bytes::from(data),
is_keyframe,
timestamp_ms: pts_ms,
})
} }
3 => { 3 => {
// CodedFramesX: no CTS field, bytes 5+ = AVCC NALUs // CodedFramesX: no CTS field, bytes 5+ = AVCC NALUs
let data = self.avcc_to_annexb(bytes.get(5..)?, is_keyframe)?; let data = self.avcc_to_annexb(bytes.get(5..)?, is_keyframe)?;
Some(VideoFrame { data: Bytes::from(data), is_keyframe, timestamp_ms }) Some(VideoFrame {
data: Bytes::from(data),
is_keyframe,
timestamp_ms,
})
} }
_ => None, _ => None,
} }
@@ -77,7 +85,11 @@ impl H264CodecParser {
// PTS = DTS + CTS — see note on CodedFrames above. // PTS = DTS + CTS — see note on CodedFrames above.
let pts_ms = Self::pts_ms(timestamp_ms, &bytes[2..5]); let pts_ms = Self::pts_ms(timestamp_ms, &bytes[2..5]);
let data = self.avcc_to_annexb(&bytes[5..], is_keyframe)?; let data = self.avcc_to_annexb(&bytes[5..], is_keyframe)?;
Some(VideoFrame { data: Bytes::from(data), is_keyframe, timestamp_ms: pts_ms }) Some(VideoFrame {
data: Bytes::from(data),
is_keyframe,
timestamp_ms: pts_ms,
})
} }
_ => None, _ => None,
} }
@@ -148,13 +160,12 @@ impl H264CodecParser {
// Prepend SPS+PPS before every keyframe so str0m's packetizer // Prepend SPS+PPS before every keyframe so str0m's packetizer
// can bundle them into a STAP-A alongside the IDR NALU. // can bundle them into a STAP-A alongside the IDR NALU.
if is_keyframe if is_keyframe && let (Some(sps), Some(pps)) = (&self.sps, &self.pps) {
&& let (Some(sps), Some(pps)) = (&self.sps, &self.pps) { out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(sps);
out.extend_from_slice(sps); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(pps);
out.extend_from_slice(pps); }
}
// Convert each length-prefixed NALU to an Annex B start-code NALU. // Convert each length-prefixed NALU to an Annex B start-code NALU.
let mut i = 0; let mut i = 0;
+19 -11
View File
@@ -74,15 +74,15 @@ impl H265CodecParser {
fn hvcc_to_annexb(&self, payload: &[u8], is_keyframe: bool) -> Option<Vec<u8>> { fn hvcc_to_annexb(&self, payload: &[u8], is_keyframe: bool) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(payload.len()); let mut out = Vec::with_capacity(payload.len());
if is_keyframe if is_keyframe && let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps)
&& let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps) { {
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(vps); out.extend_from_slice(vps);
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(sps); out.extend_from_slice(sps);
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(pps); out.extend_from_slice(pps);
} }
let mut i = 0; let mut i = 0;
while i + 4 <= payload.len() { while i + 4 <= payload.len() {
@@ -128,12 +128,20 @@ impl CodecParser for H265CodecParser {
let cts = i32::from_be_bytes([0, data[5], data[6], data[7]]) << 8 >> 8; let cts = i32::from_be_bytes([0, data[5], data[6], data[7]]) << 8 >> 8;
let pts_ms = (timestamp_ms as i64 + cts as i64).max(0) as u32; let pts_ms = (timestamp_ms as i64 + cts as i64).max(0) as u32;
let annexb = self.hvcc_to_annexb(data.get(8..)?, is_keyframe)?; let annexb = self.hvcc_to_annexb(data.get(8..)?, is_keyframe)?;
Some(VideoFrame { data: Bytes::from(annexb), is_keyframe, timestamp_ms: pts_ms }) Some(VideoFrame {
data: Bytes::from(annexb),
is_keyframe,
timestamp_ms: pts_ms,
})
} }
3 => { 3 => {
// CodedFramesX: no CTS, bytes 5+ = HVCC // CodedFramesX: no CTS, bytes 5+ = HVCC
let annexb = self.hvcc_to_annexb(data.get(5..)?, is_keyframe)?; let annexb = self.hvcc_to_annexb(data.get(5..)?, is_keyframe)?;
Some(VideoFrame { data: Bytes::from(annexb), is_keyframe, timestamp_ms }) Some(VideoFrame {
data: Bytes::from(annexb),
is_keyframe,
timestamp_ms,
})
} }
_ => None, _ => None,
} }
+30 -99
View File
@@ -1,28 +1,22 @@
use std::{ use std::{
net::SocketAddr, net::SocketAddr,
sync::{ sync::{
Arc, LazyLock, Arc,
atomic::{AtomicI32, Ordering}, atomic::{AtomicI32, Ordering},
}, },
time::Duration, time::{Duration, Instant},
}; };
use axum_extra::extract::{CookieJar, cookie::Cookie}; use axum_extra::extract::{CookieJar, cookie::Cookie};
use regex::Regex;
// The Rust `regex` crate is guaranteed linear-time and therefore does NOT /// Allowed charset for labels, passwords and custom IDs: letters, numbers,
// support lookaround, so the JS source pattern /// dashes and apostrophes — no spaces. Mirrors the frontend
// /^(?=.*[A-Za-z])[A-Za-z0-9_-]{1,67}$/ /// `/^[A-Za-z0-9'-]{0,67}$/` used by the keys-page popups.
// cannot be ported verbatim. The `(?=.*[A-Za-z])` lookahead only means fn valid_charset(s: &str) -> bool {
// "must contain at least one letter" — we drop it from the pattern and s.len() <= 67
// enforce that condition with a separate `.chars().any(..)` check below. && s.chars()
static KEY_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9'-]{1,67}$").unwrap()); .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '\'')
}
// Allowed charset for stream-key passwords and custom IDs: letters, numbers,
// dashes and apostrophes only — no spaces. Mirrors the frontend
// `/^[A-Za-z0-9'-]{0,67}$/` used by the keys-page popups; empty clears the value.
static PASSWORD_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z0-9'-]{0,67}$").unwrap());
use axum::{ use axum::{
Json, Router, Json, Router,
@@ -58,23 +52,15 @@ use crate::{
const MAX_USERNAME_LEN: usize = 32; const MAX_USERNAME_LEN: usize = 32;
const MAX_LABEL_LEN: usize = 64; const MAX_LABEL_LEN: usize = 64;
pub struct HttpServerConfig {
pub signup_code: String,
}
pub struct ServerInfo {
pub version: &'static str,
pub start_time: std::time::Instant,
}
pub struct HttpServer { pub struct HttpServer {
pub offer_tx: Sender<(i32, i32, String)>, pub offer_tx: Sender<(i32, i32, String)>,
pub accept_rx: async_broadcast::InactiveReceiver<(i32, Result<String, String>)>, pub accept_rx: async_broadcast::InactiveReceiver<(i32, Result<String, String>)>,
pub appstate: Arc<Mutex<AppState>>, pub appstate: Arc<Mutex<AppState>>,
pub request_count: AtomicI32, pub request_count: AtomicI32,
pub db: DatabaseConnection, pub db: DatabaseConnection,
pub config: Arc<HttpServerConfig>, pub signup_code: String,
pub info: Arc<ServerInfo>, pub version: &'static str,
pub start_time: Instant,
} }
impl HttpServer { impl HttpServer {
@@ -112,8 +98,6 @@ impl HttpServer {
.get(get_all_stream_keys) .get(get_all_stream_keys)
.patch(edit_stream_key), .patch(edit_stream_key),
) )
// .route("/api/admin/server_stats", get(todo!()))
// .route("/api/whip", post(handle_whip_injest))
.route("/api/login", post(login_handler)) .route("/api/login", post(login_handler))
.route("/api/stream/{slug}", post(stream_handler)) .route("/api/stream/{slug}", post(stream_handler))
.route("/api/whip", post(handle_whip_injest)) .route("/api/whip", post(handle_whip_injest))
@@ -121,14 +105,8 @@ impl HttpServer {
"/api/whip/{slug}", "/api/whip/{slug}",
delete(handle_whip_injest_delete).patch(handle_whip_injest_patch), delete(handle_whip_injest_delete).patch(handle_whip_injest_patch),
) )
// .route(
// "/api/whip/{slug}",
// delete(handle_whip_injest_delete).patch(handle_whip_injest_patch),
// )
.route("/api/meow", get(meow_handler)) .route("/api/meow", get(meow_handler))
.route("/api/health", get(health_handler)) .route("/api/health", get(health_handler))
.route("/api/uptime", get(uptime_handler))
.route("/api/version", get(version_handler))
.route("/api/stats", get(stats_handler)) .route("/api/stats", get(stats_handler))
.layer(cors) .layer(cors)
.with_state(state); .with_state(state);
@@ -147,9 +125,11 @@ impl HttpServer {
#[derive(Serialize)] #[derive(Serialize)]
struct StreamListing { struct StreamListing {
label: String, label: String,
custom_url_label: String,
id: i32, id: i32,
user: String, user: String,
started_at: DateTime<Utc>, //UNIX TIMESTAMP started_at: DateTime<Utc>,
is_password_protected: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -172,28 +152,24 @@ async fn edit_stream_key(
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
// Trim surrounding whitespace so labels aren't stored with leading/trailing spaces. // Trim surrounding whitespace so labels aren't stored with leading/trailing spaces.
let new_label = payload.new.as_deref().map(str::trim); let new_label = payload.new.as_deref().map(str::trim);
// Length (1..=67) and allowed charset ([A-Za-z0-9'-], no spaces). // Length (1..=67), allowed charset, and at least one letter.
if let Some(label) = new_label { if let Some(label) = new_label {
if !KEY_RE.is_match(label) { if label.is_empty() || !valid_charset(label) {
return Err(HttpError::BadRequest("invalid label".into())); return Err(HttpError::BadRequest("invalid label".into()));
} }
// Replaces the JS lookahead: label must contain at least one letter.
if !label.chars().any(|c| c.is_ascii_alphabetic()) { if !label.chars().any(|c| c.is_ascii_alphabetic()) {
return Err(HttpError::BadRequest("label must contain a letter".into())); return Err(HttpError::BadRequest("label must contain a letter".into()));
} }
} }
// Custom IDs and passwords share a charset: letters, numbers, dashes, // Empty values are allowed (they clear the field).
// apostrophes — no spaces. Empty values are allowed (they clear the field). if let Some(custom_id) = payload.custom_id.as_deref()
if let Some(custom_id) = payload.custom_id.as_deref() { && !custom_id.is_empty() && !valid_charset(custom_id) {
if !custom_id.is_empty() && !PASSWORD_RE.is_match(custom_id) {
return Err(HttpError::BadRequest("invalid custom id".into())); return Err(HttpError::BadRequest("invalid custom id".into()));
} }
} if let Some(pwd) = payload.password.as_deref()
if let Some(pwd) = payload.password.as_deref() { && !pwd.is_empty() && !valid_charset(pwd) {
if !pwd.is_empty() && !PASSWORD_RE.is_match(pwd) {
return Err(HttpError::BadRequest("invalid password".into())); return Err(HttpError::BadRequest("invalid password".into()));
} }
}
let stream_key = stream_key::Entity::find_by_id(payload.id) let stream_key = stream_key::Entity::find_by_id(payload.id)
.one(&state.db) .one(&state.db)
@@ -276,8 +252,10 @@ async fn catalog_handler(
.map(|x| StreamListing { .map(|x| StreamListing {
id: x.stream_key_id, id: x.stream_key_id,
label: x.stream_key_label.clone(), label: x.stream_key_label.clone(),
custom_url_label: x.custom_id.clone().unwrap_or_default(),
user: x.stream_key_user.clone(), user: x.stream_key_user.clone(),
started_at: x.started_at, started_at: x.started_at,
is_password_protected: x.password.is_some(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
Ok(Json(catalog)) Ok(Json(catalog))
@@ -288,22 +266,6 @@ struct CreateStreamKeyBody {
label: String, label: String,
} }
struct SessionCookie(Option<String>);
impl<S> FromRequestParts<S> for SessionCookie
where
S: Send + Sync,
{
type Rejection = HttpError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let jar = CookieJar::from_request_parts(parts, _state).await.unwrap();
Ok(SessionCookie(
jar.get("session").map(|c| c.value().to_string()),
))
}
}
struct AuthUser(entity::users::Model); struct AuthUser(entity::users::Model);
impl FromRequestParts<Arc<HttpServer>> for AuthUser { impl FromRequestParts<Arc<HttpServer>> for AuthUser {
@@ -409,13 +371,10 @@ fn cookie_for_token(token: &str, dev: bool) -> Cookie<'static> {
#[axum::debug_handler] #[axum::debug_handler]
async fn login_handler( async fn login_handler(
session: SessionCookie,
State(state): State<Arc<HttpServer>>, State(state): State<Arc<HttpServer>>,
DevFlag(dev): DevFlag, DevFlag(dev): DevFlag,
Json(payload): Json<LoginForm>, Json(payload): Json<LoginForm>,
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
tracing::debug!(session = ?session.0, "login: existing session cookie");
let user = users::Entity::find_by_username(&state.db, payload.username.clone()) let user = users::Entity::find_by_username(&state.db, payload.username.clone())
.await? .await?
.ok_or_else(|| { .ok_or_else(|| {
@@ -447,13 +406,11 @@ struct CreateUserForm {
} }
async fn create_user_handler( async fn create_user_handler(
session: SessionCookie,
State(state): State<Arc<HttpServer>>, State(state): State<Arc<HttpServer>>,
DevFlag(dev): DevFlag, DevFlag(dev): DevFlag,
Json(payload): Json<CreateUserForm>, Json(payload): Json<CreateUserForm>,
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
tracing::debug!(session = ?session.0, "create_user: existing session cookie"); if state.signup_code.is_empty() || payload.ref_token != state.signup_code {
if state.config.signup_code.is_empty() || payload.ref_token != state.config.signup_code {
warn!(username = %payload.username, "signup rejected: invalid signup code"); warn!(username = %payload.username, "signup rejected: invalid signup code");
return Err(HttpError::Unauthorized); return Err(HttpError::Unauthorized);
} }
@@ -596,43 +553,17 @@ struct HealthResponse {
async fn health_handler( async fn health_handler(
State(state): State<Arc<HttpServer>>, State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
let uptime = state.info.start_time.elapsed().as_secs(); let uptime = state.start_time.elapsed().as_secs();
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(HealthResponse { Json(HealthResponse {
status: "ok", status: "ok",
uptime_seconds: uptime, uptime_seconds: uptime,
version: state.info.version, version: state.version,
}), }),
)) ))
} }
#[derive(Serialize)]
struct UptimeResponse {
uptime_seconds: u64,
}
async fn uptime_handler(
State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> {
let uptime = state.info.start_time.elapsed().as_secs();
Ok((
StatusCode::OK,
Json(UptimeResponse {
uptime_seconds: uptime,
}),
))
}
async fn version_handler(
State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> {
Ok((
StatusCode::OK,
Json(serde_json::json!({ "version": state.info.version })),
))
}
#[derive(Serialize)] #[derive(Serialize)]
struct StatsResponse { struct StatsResponse {
version: &'static str, version: &'static str,
@@ -645,7 +576,7 @@ struct StatsResponse {
async fn stats_handler( async fn stats_handler(
State(state): State<Arc<HttpServer>>, State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
let uptime = state.info.start_time.elapsed().as_secs(); let uptime = state.start_time.elapsed().as_secs();
let mut system = System::new(); let mut system = System::new();
system.refresh_cpu_all(); system.refresh_cpu_all();
let cpu_usage = system.global_cpu_usage(); let cpu_usage = system.global_cpu_usage();
@@ -657,7 +588,7 @@ async fn stats_handler(
Ok(( Ok((
StatusCode::OK, StatusCode::OK,
Json(StatsResponse { Json(StatsResponse {
version: state.info.version, version: state.version,
uptime_seconds: uptime, uptime_seconds: uptime,
cpu_usage_percent: cpu_usage, cpu_usage_percent: cpu_usage,
active_streams, active_streams,
+1 -1
View File
@@ -42,7 +42,7 @@ impl HttpError {
Self::BadRequest(_) => StatusCode::BAD_REQUEST, Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Unprocessable(_) => StatusCode::UNPROCESSABLE_ENTITY, Self::Unprocessable(_) => StatusCode::UNPROCESSABLE_ENTITY,
Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE, Self::NotAcceptable(_) => StatusCode::NOT_ACCEPTABLE,
Self::WhepCodecError(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::WhepCodecError(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
} }
} }
} }
+24 -22
View File
@@ -16,10 +16,7 @@ use tracing_subscriber::EnvFilter;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
audio::OpusAudioFrame, audio::OpusAudioFrame, codec::VideoFrame, http::HttpServer, webrtc_proxy::WebrtcProxy,
codec::VideoFrame,
http::{HttpServer, HttpServerConfig, ServerInfo},
webrtc_proxy::{WebRtcProxyConfig, WebrtcProxy},
}; };
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -30,11 +27,11 @@ mod hash;
mod http; mod http;
mod http_error; mod http_error;
mod rtmp; mod rtmp;
mod stream_session_2;
mod webrtc; mod webrtc;
mod webrtc_ingest; mod webrtc_ingest;
mod webrtc_proxy; mod webrtc_proxy;
// #[derive(Debug)]
pub struct AppState { pub struct AppState {
pub stream_sessions: Arc<DashMap<i32, StreamSession>>, pub stream_sessions: Arc<DashMap<i32, StreamSession>>,
pub webrtc_proxy: WebrtcProxy, pub webrtc_proxy: WebrtcProxy,
@@ -47,6 +44,19 @@ pub enum StreamCodec {
AV1, AV1,
} }
impl StreamCodec {
/// Map str0m's codec enum to ours; None for non-video codecs.
pub fn from_str0m(c: str0m::format::Codec) -> Option<Self> {
use str0m::format::Codec;
match c {
Codec::H264 => Some(Self::H264),
Codec::H265 => Some(Self::H265),
Codec::Av1 => Some(Self::AV1),
_ => None,
}
}
}
pub struct StreamSession { pub struct StreamSession {
pub stream_key_id: i32, pub stream_key_id: i32,
pub stream_key_label: String, pub stream_key_label: String,
@@ -92,12 +102,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
stream_session::Model::clean_unended_streams(&db) stream_session::Model::clean_unended_streams(&db)
.await .await
.unwrap(); .unwrap();
let proxyconfig = WebRtcProxyConfig { let proxyconfig = env::var("RTC_PORT")
proxy_port: env::var("RTC_PORT") .unwrap_or("6969".into())
.unwrap_or("6969".into()) .parse()
.parse() .expect("RTC_PORT needs to be a number (i32)");
.expect("RTC_PORT needs to be a number (i32)"),
};
let proxy = webrtc_proxy::WebrtcProxy::new(proxyconfig).await.unwrap(); let proxy = webrtc_proxy::WebrtcProxy::new(proxyconfig).await.unwrap();
let appstate = Arc::new(Mutex::new(AppState { let appstate = Arc::new(Mutex::new(AppState {
@@ -120,17 +128,12 @@ async fn main() -> Result<(), Box<dyn Error>> {
appstate: appstate.clone(), appstate: appstate.clone(),
request_count: std::sync::atomic::AtomicI32::new(0), request_count: std::sync::atomic::AtomicI32::new(0),
db: db.clone(), db: db.clone(),
config: HttpServerConfig { signup_code: env::var("SIGNUP_CODE").unwrap_or_else(|_| {
signup_code: env::var("SIGNUP_CODE").unwrap_or_else(|_| { warn!("SIGNUP_CODE not set; signup will be disabled");
warn!("SIGNUP_CODE not set; signup will be disabled"); String::new()
String::new()
}),
}
.into(),
info: Arc::new(ServerInfo {
version: SERVER_VERSION,
start_time: std::time::Instant::now(),
}), }),
version: SERVER_VERSION,
start_time: std::time::Instant::now(),
}; };
let app = appstate.lock().await; let app = appstate.lock().await;
@@ -164,7 +167,6 @@ async fn main() -> Result<(), Box<dyn Error>> {
res = workers.join_next() => { res = workers.join_next() => {
if let Some(Err(e)) = res { if let Some(Err(e)) = res {
tracing::error!("worker panicked: {:?}", e); tracing::error!("worker panicked: {:?}", e);
// fs::File::
} else { } else {
tracing::error!("a worker exited unexpectedly"); tracing::error!("a worker exited unexpectedly");
} }
+8 -51
View File
@@ -14,7 +14,7 @@ use tokio::{
net::{TcpListener, TcpStream}, net::{TcpListener, TcpStream},
time::timeout, time::timeout,
}; };
use tracing::{debug, info, trace, warn}; use tracing::{debug, info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -134,7 +134,6 @@ impl Rtmp {
// ponytail: fixed 512; per-stream dynamic sizing if memory ever matters. // ponytail: fixed 512; per-stream dynamic sizing if memory ever matters.
let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(512); let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(512);
let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(512); let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(512);
// video_rx.cycle
video_tx.set_overflow(true); video_tx.set_overflow(true);
audio_tx.set_overflow(true); audio_tx.set_overflow(true);
@@ -369,9 +368,6 @@ impl Rtmp {
} }
current_stream_key_id = None; current_stream_key_id = None;
} }
// TODO: We can totally replace the broadcast with a
// circular_buff
// Arc<Vec<ArcSwap<Frame>>>
ServerSessionEvent::VideoDataReceived { ServerSessionEvent::VideoDataReceived {
data, timestamp, .. data, timestamp, ..
} => match Self::parse_video_codec(&data) { } => match Self::parse_video_codec(&data) {
@@ -385,52 +381,13 @@ impl Rtmp {
} }
codec_stamped = true; codec_stamped = true;
} }
match codec { let p = parser.get_or_insert_with(|| match codec {
StreamCodec::H264 => { StreamCodec::H264 => Box::new(H264CodecParser::new()),
let p = parser.get_or_insert_with(|| { StreamCodec::H265 => Box::new(H265CodecParser::new()),
Box::new(H264CodecParser::new()) StreamCodec::AV1 => Box::new(Av1CodecParser::new()),
}); });
if let Some(frame) = p.parse(&data, timestamp.value) if let Some(frame) = p.parse(&data, timestamp.value) {
{ video_tx.broadcast(Arc::new(frame)).await.ok();
video_tx.broadcast(Arc::new(frame)).await.ok();
}
}
StreamCodec::H265 => {
let p = parser.get_or_insert_with(|| {
Box::new(H265CodecParser::new())
});
if let Some(frame) = p.parse(&data, timestamp.value)
{
video_tx.broadcast(Arc::new(frame)).await.ok();
}
}
StreamCodec::AV1 => {
let p = parser.get_or_insert_with(|| {
Box::new(Av1CodecParser::new())
});
let pkt_type = data[0] & 0x0F;
match p.parse(&data, timestamp.value) {
Some(frame) => {
trace!(
pkt_type,
is_keyframe = frame.is_keyframe,
ts = frame.timestamp_ms,
bytes = frame.data.len(),
"AV1 frame → broadcast"
);
video_tx
.broadcast(Arc::new(frame))
.await
.ok();
}
None => {
trace!(
pkt_type,
"AV1 packet produced no frame (seq header or unknown type)"
);
}
}
}
} }
} }
Err(_err) => { Err(_err) => {
+18
View File
@@ -0,0 +1,18 @@
use serde::Serialize;
use crate::StreamSession;
#[derive(Serialize)]
pub struct StreamUpdateData {
viewers: u32,
}
impl StreamSession {
pub fn stream_update_data(&self) -> StreamUpdateData {
StreamUpdateData {
viewers: self
.active_clients
.load(std::sync::atomic::Ordering::Relaxed),
}
}
}
+49 -4
View File
@@ -2,13 +2,18 @@ use bytes::Bytes;
use dashmap::DashMap; use dashmap::DashMap;
use entity::stream_key; use entity::stream_key;
use sea_orm::{DatabaseConnection, EntityTrait}; use sea_orm::{DatabaseConnection, EntityTrait};
use std::{net::SocketAddr, sync::Arc, time::Instant}; use std::{
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use tokio::{net::UdpSocket, sync::mpsc::Receiver}; use tokio::{net::UdpSocket, sync::mpsc::Receiver};
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use str0m::{ use str0m::{
Candidate, Event, Input, Output, Rtc, Candidate, Event, Input, Output, Rtc,
change::SdpOffer, change::SdpOffer,
channel::ChannelId,
media::{Frequency, MediaKind, MediaTime, Mid, Pt}, media::{Frequency, MediaKind, MediaTime, Mid, Pt},
net::{Protocol, Receive}, net::{Protocol, Receive},
}; };
@@ -58,9 +63,6 @@ impl Webrtc {
continue; continue;
} }
// let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
// let local_addr = socket.local_addr().unwrap();
let local_addr = self.proxy.public_addr(); let local_addr = self.proxy.public_addr();
let session = self.sessions_ref.get(&stream_id); let session = self.sessions_ref.get(&stream_id);
@@ -239,10 +241,34 @@ impl Webrtc {
let mut video_pt = None; let mut video_pt = None;
let mut audio_mid: Option<Mid> = None; let mut audio_mid: Option<Mid> = None;
let mut audio_pt = None; let mut audio_pt = None;
let mut channel_id = None;
let mut connected = false; let mut connected = false;
let mut video_stream: Option<async_broadcast::Receiver<Arc<VideoFrame>>> = None; let mut video_stream: Option<async_broadcast::Receiver<Arc<VideoFrame>>> = None;
let mut audio_stream: Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>> = None; let mut audio_stream: Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>> = None;
let mut saw_keyframe = false; let mut saw_keyframe = false;
// Update client with stream info through webrtc data channel ()
let mut tick = tokio::time::interval(Duration::from_millis(2000));
if let Some(ses) = sessions_ref.get(&stream_id) {
ses.active_clients
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
struct ActiveClientGuard {
sessions: Arc<DashMap<i32, StreamSession>>,
id: i32,
}
impl Drop for ActiveClientGuard {
fn drop(&mut self) {
if let Some(ses) = self.sessions.get(&self.id) {
ses.active_clients
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
}
let _active_client_guard = ActiveClientGuard {
sessions: sessions_ref.clone(),
id: stream_id,
};
loop { loop {
let deadline = loop { let deadline = loop {
@@ -255,6 +281,9 @@ impl Webrtc {
} }
} }
Ok(Output::Event(e)) => match e { Ok(Output::Event(e)) => match e {
Event::ChannelOpen(channelId, _name) => {
channel_id = Some(channelId);
}
Event::MediaAdded(ma) => { Event::MediaAdded(ma) => {
info!(stream_id, kind = ?ma.kind, mid = ?ma.mid, "MediaAdded"); info!(stream_id, kind = ?ma.kind, mid = ?ma.mid, "MediaAdded");
if ma.kind == MediaKind::Video { if ma.kind == MediaKind::Video {
@@ -434,6 +463,14 @@ impl Webrtc {
Err(async_broadcast::RecvError::Overflowed(_)) => {} Err(async_broadcast::RecvError::Overflowed(_)) => {}
} }
} }
_interval = tick.tick() => {
if let Some(id) = channel_id
&& let Some(session) = sessions_ref.get(&stream_id)
&& let Ok(json) = serde_json::to_vec(&session.stream_update_data())
&& let Some(mut ch) = rtc.channel(id) {
let _ = ch.write(false, &json);
}
}
} }
} }
} }
@@ -500,4 +537,12 @@ impl Webrtc {
warn!("RTP write error: {:?}", e); warn!("RTP write error: {:?}", e);
} }
} }
fn write_channel_data(rtc: &mut Rtc, channel_id: ChannelId, data: &[u8]) {
if let Some(mut channel) = rtc.channel(channel_id)
&& let Err(e) = channel.write(false, data)
{
warn!("Channel write error: {:?}", e)
}
}
} }
+31 -57
View File
@@ -33,18 +33,21 @@ use crate::{
/// Kill a WHIP publish if it delivers no media (video or audio) this long. /// Kill a WHIP publish if it delivers no media (video or audio) this long.
const NO_MEDIA_TIMEOUT: Duration = Duration::from_secs(30); const NO_MEDIA_TIMEOUT: Duration = Duration::from_secs(30);
fn bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string())
}
pub async fn handle_whip_injest_delete( pub async fn handle_whip_injest_delete(
State(state): State<Arc<HttpServer>>, State(state): State<Arc<HttpServer>>,
ConnectInfo(remote): ConnectInfo<SocketAddr>, ConnectInfo(remote): ConnectInfo<SocketAddr>,
Path(_slug): Path<String>, Path(_slug): Path<String>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<impl IntoResponse, HttpError> { ) -> Result<impl IntoResponse, HttpError> {
let token = headers let Some(token) = bearer_token(&headers) else {
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let Some(token) = token else {
warn!("Whip: missing bearer token from {}", remote); warn!("Whip: missing bearer token from {}", remote);
return Err(HttpError::Unauthorized); return Err(HttpError::Unauthorized);
}; };
@@ -68,7 +71,13 @@ pub async fn handle_whip_injest_delete(
state.appstate.lock().await.stream_sessions.remove(&key.id); state.appstate.lock().await.stream_sessions.remove(&key.id);
// Drop the trickle channel too, so PATCHes to a deleted session 404 and // Drop the trickle channel too, so PATCHes to a deleted session 404 and
// the lingering detach task's own cleanup can't reach a newer session. // the lingering detach task's own cleanup can't reach a newer session.
state.appstate.lock().await.webrtc_proxy.trickle_tx.remove(&key.id); state
.appstate
.lock()
.await
.webrtc_proxy
.trickle_tx
.remove(&key.id);
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
@@ -120,12 +129,7 @@ pub async fn handle_whip_injest(
}; };
let public_addr = state.appstate.lock().await.webrtc_proxy.public_addr(); let public_addr = state.appstate.lock().await.webrtc_proxy.public_addr();
let token = headers let Some(token) = bearer_token(&headers) else {
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let Some(token) = token else {
warn!("Whip: missing bearer token from {}", remote); warn!("Whip: missing bearer token from {}", remote);
return err(StatusCode::UNAUTHORIZED, "missing bearer token"); return err(StatusCode::UNAUTHORIZED, "missing bearer token");
}; };
@@ -187,17 +191,13 @@ pub async fn handle_whip_injest(
cc.clear(); cc.clear();
cc.enable_opus(true); cc.enable_opus(true);
match &stream_codec { match &stream_codec {
Some(crate::StreamCodec::H264) => { Some(codec) => {
info!("Whip: enabling H.264 codec"); info!("Whip: enabling {codec:?} codec");
cc.enable_h264(true); match codec {
} crate::StreamCodec::H264 => cc.enable_h264(true),
Some(crate::StreamCodec::H265) => { crate::StreamCodec::H265 => cc.enable_h265(true),
info!("Whip: enabling H.265 codec"); crate::StreamCodec::AV1 => cc.enable_av1(true),
cc.enable_h265(true); }
}
Some(crate::StreamCodec::AV1) => {
info!("Whip: enabling AV1 codec");
cc.enable_av1(true);
} }
None => { None => {
warn!("Whip: no video codec detected in offer, enabling H.264 as fallback"); warn!("Whip: no video codec detected in offer, enabling H.264 as fallback");
@@ -366,12 +366,6 @@ pub async fn handle_whip_injest(
.status(StatusCode::CREATED) .status(StatusCode::CREATED)
.header(header::CONTENT_TYPE, "application/sdp") .header(header::CONTENT_TYPE, "application/sdp")
.header(header::LOCATION, &location) .header(header::LOCATION, &location)
// // Provide a STUN server via Link header so OBS can gather
// // ICE candidates even without explicit STUN configuration.
// .header(
// header::LINK,
// "<stun:stun.l.google.com:19302>; rel=\"ice-server\"",
// )
.body(axum::body::Body::from(answer_sdp)) .body(axum::body::Body::from(answer_sdp))
.unwrap() .unwrap()
} }
@@ -422,7 +416,6 @@ async fn detach_inject_rtc(
let mut audio_mid: Option<Mid> = None; let mut audio_mid: Option<Mid> = None;
let mut video_tx: Option<async_broadcast::Sender<Arc<VideoFrame>>> = None; let mut video_tx: Option<async_broadcast::Sender<Arc<VideoFrame>>> = None;
let mut audio_tx: Option<async_broadcast::Sender<Arc<OpusAudioFrame>>> = None; let mut audio_tx: Option<async_broadcast::Sender<Arc<OpusAudioFrame>>> = None;
let mut _connected = false;
let mut disconnect_timer: Option<Instant> = None; let mut disconnect_timer: Option<Instant> = None;
loop { loop {
@@ -525,7 +518,6 @@ async fn detach_inject_rtc(
video_tx = Some(session.frame_channel.clone()); video_tx = Some(session.frame_channel.clone());
audio_tx = Some(session.audio_channel.clone()); audio_tx = Some(session.audio_channel.clone());
} }
_connected = true;
} }
_ => {} _ => {}
}, },
@@ -610,22 +602,14 @@ async fn detach_inject_rtc(
pub fn extract_negotiated_codec_info( pub fn extract_negotiated_codec_info(
answer: &str0m::change::SdpAnswer, answer: &str0m::change::SdpAnswer,
) -> (Option<crate::StreamCodec>, Option<u8>, Option<u32>) { ) -> (Option<crate::StreamCodec>, Option<u8>, Option<u32>) {
use str0m::format::Codec;
for line in answer.media_lines.iter() { for line in answer.media_lines.iter() {
// Iterate rtp_params on every m-line; video check via
// codec.is_video() avoids needing str0m's private MediaType.
for p in line.rtp_params() { for p in line.rtp_params() {
if p.spec().codec.is_video() { if p.spec().codec.is_video() {
let pt = Some(*p.pt()); return (
let profile = p.spec().format.profile_level_id; crate::StreamCodec::from_str0m(p.spec().codec),
let codec = match p.spec().codec { Some(*p.pt()),
Codec::H264 => Some(crate::StreamCodec::H264), p.spec().format.profile_level_id,
Codec::H265 => Some(crate::StreamCodec::H265), );
Codec::Av1 => Some(crate::StreamCodec::AV1),
_ => None,
};
return (codec, pt, profile);
} }
} }
} }
@@ -634,24 +618,14 @@ pub fn extract_negotiated_codec_info(
/// Extract the video codec from an SDP offer's media lines. /// Extract the video codec from an SDP offer's media lines.
/// ///
/// Walks every m= line's `rtp_params()`, maps str0m's `Codec` enum /// Walks every m= line's `rtp_params()`; returns the first video codec found.
/// to our `StreamCodec`. Returns the first video codec found.
pub fn video_codec_from_sdp_offer( pub fn video_codec_from_sdp_offer(
sdp_offer: &str0m::change::SdpOffer, sdp_offer: &str0m::change::SdpOffer,
) -> Option<crate::StreamCodec> { ) -> Option<crate::StreamCodec> {
use str0m::format::Codec;
// SdpOffer derefs to Sdp, which has pub media_lines.
// MediaLine is pub(crate) in str0m, but we can call its pub methods.
for line in sdp_offer.media_lines.iter() { for line in sdp_offer.media_lines.iter() {
for p in line.rtp_params() { for p in line.rtp_params() {
if p.spec().codec.is_video() { if p.spec().codec.is_video() {
return match p.spec().codec { return crate::StreamCodec::from_str0m(p.spec().codec);
Codec::H264 => Some(crate::StreamCodec::H264),
Codec::H265 => Some(crate::StreamCodec::H265),
Codec::Av1 => Some(crate::StreamCodec::AV1),
_ => None,
};
} }
} }
} }
+48 -60
View File
@@ -14,10 +14,6 @@ use tokio::{
use tracing::{debug, error, info, trace, warn}; use tracing::{debug, error, info, trace, warn};
use uuid::Uuid; use uuid::Uuid;
pub struct WebRtcProxyConfig {
pub proxy_port: i32,
}
#[derive(Clone)] #[derive(Clone)]
pub struct WebrtcProxy { pub struct WebrtcProxy {
clients_ufrag: Arc<DashMap<String, tokio::sync::mpsc::Sender<(Bytes, SocketAddr)>>>, clients_ufrag: Arc<DashMap<String, tokio::sync::mpsc::Sender<(Bytes, SocketAddr)>>>,
@@ -34,14 +30,14 @@ pub struct WebrtcProxy {
const STUN_MAGIC: u32 = 0x2112A442; const STUN_MAGIC: u32 = 0x2112A442;
impl WebrtcProxy { impl WebrtcProxy {
pub async fn new(config: WebRtcProxyConfig) -> Result<Self, Box<dyn Error>> { pub async fn new(proxy_port: i32) -> Result<Self, Box<dyn Error>> {
let sock = UdpSocket::bind(format!("0.0.0.0:{}", config.proxy_port)).await?; let sock = UdpSocket::bind(format!("0.0.0.0:{}", proxy_port)).await?;
let port = sock.local_addr()?.port(); let port = sock.local_addr()?.port();
let public_ip = match env::var("PUBLIC_DOMAIN") let public_ip = match env::var("PUBLIC_DOMAIN")
.ok() .ok()
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
{ {
Some(domain) => { Some(domain) => {
let ip = resolve_domain(&domain).await?; let ip = resolve_domain(&domain).await?;
info!(%domain, %ip, "resolved PUBLIC_DOMAIN for WebRTC candidates"); info!(%domain, %ip, "resolved PUBLIC_DOMAIN for WebRTC candidates");
@@ -55,12 +51,14 @@ impl WebrtcProxy {
// IP_UNICAST_IF), which makes checks to 127.0.0.1 vanish. // IP_UNICAST_IF), which makes checks to 127.0.0.1 vanish.
match default_iface_ipv4() { match default_iface_ipv4() {
Some(ip) => { Some(ip) => {
info!(%ip, "using default interface IP for WebRTC candidates (debug build)"); info!(%ip, "using default interface IP for WebRTC candidates (debug build)");
ip ip
} }
None => { None => {
warn!("no non-loopback IPv4 interface found, falling back to 127.0.0.1"); warn!(
IpAddr::from([127, 0, 0, 1]) "no non-loopback IPv4 interface found, falling back to 127.0.0.1"
);
IpAddr::from([127, 0, 0, 1])
} }
} }
} else { } else {
@@ -165,10 +163,6 @@ impl WebrtcProxy {
self.clients_ufrag.insert(ufrag, tx); self.clients_ufrag.insert(ufrag, tx);
(self.socket.clone(), rx) (self.socket.clone(), rx)
} }
pub fn local_addr(&self) -> SocketAddr {
self.socket.local_addr().unwrap()
}
pub fn public_addr(&self) -> SocketAddr { pub fn public_addr(&self) -> SocketAddr {
self.public_addr self.public_addr
} }
@@ -180,33 +174,37 @@ impl WebrtcProxy {
if magic != STUN_MAGIC { if magic != STUN_MAGIC {
return None; return None;
} }
// attribies start at 20 let value = stun_attributes(b).find(|(t, _)| *t == 0x0006)?.1;
let mut pos = 20usize; let value = std::str::from_utf8(value).ok()?;
while (pos + 4) <= b.len() { let mut parts = value.split(':');
let attr_type: u16 = u16::from_be_bytes(b[pos..pos + 2].try_into().ok()?); Some((
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?); parts.next()?.to_string(),
pos += 4; parts.next().map(|s| s.to_string()),
if attr_type == 0x0006 { ))
let value =
std::str::from_utf8(b[pos..pos + (attr_len as usize)].try_into().ok()?).ok()?;
let mut parts = value.split(':');
let first = parts.next()?.to_string();
let second = parts.next().map(|s| s.to_string());
return Some((first, second));
}
pos += (attr_len as usize + 3) & !3;
}
None
} }
} }
/// Iterate `(attr_type, attr_value)` pairs over a STUN message's attributes
/// (RFC 5389 §15: header is 20 bytes, each attribute padded to a 4-byte boundary).
fn stun_attributes(data: &[u8]) -> impl Iterator<Item = (u16, &[u8])> {
let mut pos = 20usize;
std::iter::from_fn(move || {
let attr_type = u16::from_be_bytes(data.get(pos..pos + 2)?.try_into().ok()?);
let attr_len = u16::from_be_bytes(data.get(pos + 2..pos + 4)?.try_into().ok()?) as usize;
pos += 4;
let value = data.get(pos..pos + attr_len)?;
pos += (attr_len + 3) & !3;
Some((attr_type, value))
})
}
/// IP of the interface holding the default route, via a UDP connect() trick: /// IP of the interface holding the default route, via a UDP connect() trick:
/// connect() only does a route lookup (no packets sent), so the kernel binds /// connect() only does a route lookup (no packets sent), so the kernel binds
/// the source address the OS would use for outbound traffic. /// the source address the OS would use for outbound traffic.
fn default_iface_ipv4() -> Option<IpAddr> { fn default_iface_ipv4() -> Option<IpAddr> {
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?; let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:9").ok()?; sock.connect("8.8.8.8:9").ok()?;
sock.local_addr().ok().map(|a| a.ip()) sock.local_addr().ok().map(|a| a.ip())
} }
async fn resolve_domain(domain: &str) -> Result<std::net::IpAddr, Box<dyn Error>> { async fn resolve_domain(domain: &str) -> Result<std::net::IpAddr, Box<dyn Error>> {
@@ -251,27 +249,17 @@ fn parse_xor_mapped_address(data: &[u8]) -> Option<std::net::IpAddr> {
return None; return None;
} }
let mut pos = 20usize; let value = stun_attributes(data).find(|(t, _)| *t == 0x0020)?.1;
while pos + 4 <= data.len() { if value.len() < 8 {
let attr_type = u16::from_be_bytes(data[pos..pos + 2].try_into().ok()?); return None;
let attr_len = u16::from_be_bytes(data[pos + 2..pos + 4].try_into().ok()?) as usize; }
pos += 4; // byte 0: reserved, byte 1: family (0x01=IPv4, 0x02=IPv6)
if pos + attr_len > data.len() { if value[1] == 0x01 {
break; let x_addr = u32::from_be_bytes(value[4..8].try_into().ok()?);
} Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(
if attr_type == 0x0020 && attr_len >= 8 { x_addr ^ magic,
// byte 0: reserved, byte 1: family (0x01=IPv4, 0x02=IPv6) )))
let family = data[pos + 1]; } else {
let x_port = u16::from_be_bytes(data[pos + 2..pos + 4].try_into().ok()?); None
let _ = x_port ^ (STUN_MAGIC >> 16) as u16; // port (unused here)
if family == 0x01 {
let x_addr = u32::from_be_bytes(data[pos + 4..pos + 8].try_into().ok()?);
let addr = x_addr ^ STUN_MAGIC;
return Some(std::net::IpAddr::V4(std::net::Ipv4Addr::from(addr)));
}
}
pos += (attr_len + 3) & !3;
} }
None
} }
+1 -1
View File
@@ -3,7 +3,7 @@
//! whether the string-fixups in webrtc_ingest.rs actually match. //! whether the string-fixups in webrtc_ingest.rs actually match.
use std::{net::SocketAddr, time::Instant}; use std::{net::SocketAddr, time::Instant};
use str0m::{change::SdpOffer, net::Protocol, Candidate, Rtc}; use str0m::{Candidate, Rtc, change::SdpOffer, net::Protocol};
fn obs_like_offer() -> String { fn obs_like_offer() -> String {
let mut s = String::new(); let mut s = String::new();
Generated
+9 -9
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1785284101, "lastModified": 1788465171,
"narHash": "sha256-ghcXEpYEM4a7pbEkoqbn8c0ptJJqgGzuFiG3T6W5g4I=", "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b", "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -35,11 +35,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1785301185, "lastModified": 1789012029,
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=", "narHash": "sha256-1CBkBf+Nhggykzlx0jvXrj5rk20btl+tK8Fa8Ml/BL4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529", "rev": "d5dfd8e6716dde34398bc14bc87c10dece9c8c68",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -64,11 +64,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1786160316, "lastModified": 1789024335,
"narHash": "sha256-oLoc3ZLg1LX/S5Jb3v6MrF415AzDyC4vgeWy9UcYTQk=", "narHash": "sha256-kCy/MVLRIr95DJ4vspVzWj+kO/x+JuwSHmYZCvShg8w=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "4e1c940c96560ceab7c547f89642231371a66646", "rev": "577bb1e1fc5af0713169176c5c76622c21fa3ec0",
"type": "github" "type": "github"
}, },
"original": { "original": {