You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-core/src/provider/live_proxy.rs

525 lines
20 KiB
Rust

//! `LiveProxy` `MediaProvider`
//!
//! Provides playback media resources for live streams sourced from external URLs.
//! The external source URL is stored in `source_config`, while the internal
//! room/media binding comes from the runtime provider context. Playback output
//! points to SyncTV live delivery resources.
//!
//! The `PullStreamManager` handles the actual pulling from the external source.
use super::{
playback_transport::PlaybackTransportAction, MediaProvider, PlaybackResult, ProviderContext,
ProviderError, SourceConfig,
};
use crate::models::media::{
LiveProxyPlaybackMetadata, PlaybackLiveProxyMedia, PlaybackMediaProvider, PlaybackMetadata,
PlaybackRtmpMedia,
};
use crate::models::{MediaId, RoomId};
use async_trait::async_trait;
use base64::Engine as _;
use serde_json::Value;
use std::time::Duration;
use synctv_common::ssrf::SsrfTargetError;
/// `LiveProxy` `MediaProvider`
///
/// Generates playback media resources for live streams from external sources.
/// The external protocol and URL are stored in `source_config.source` and validated on creation.
/// Playback output references SyncTV live delivery resources. Internal
/// room/media identity is injected at playback time through `ProviderContext`.
pub struct LiveProxyProvider {
ssrf_guard: synctv_common::ssrf::SsrfGuard,
}
impl Default for LiveProxyProvider {
fn default() -> Self {
Self::new()
}
}
impl LiveProxyProvider {
pub const NAME: &'static str = "live_proxy";
pub fn new() -> Self {
Self::new_with_ssrf_guard(synctv_common::ssrf::SsrfGuard::strict_policy())
}
#[must_use]
pub const fn new_with_ssrf_guard(ssrf_guard: synctv_common::ssrf::SsrfGuard) -> Self {
Self { ssrf_guard }
}
pub async fn validate_prepared_config(
&self,
config: &crate::models::LiveProxyMediaSourceConfig,
) -> Result<(), ProviderError> {
Self::validate_external_source(&config.source, &self.ssrf_guard).await
}
async fn validate_live_source_url(
url: &str,
guard: &synctv_common::ssrf::SsrfGuard,
) -> Result<(), ProviderError> {
// Validate the external live-source URL format.
// Use URL path parsing to avoid false positives from `.flv` appearing
// outside the upstream URL path.
let parsed_url = url::Url::parse(url).map_err(|error| {
ProviderError::InvalidConfig(format!("Invalid LiveProxy source URL '{url}': {error}"))
})?;
let is_rtmp = parsed_url.scheme().eq_ignore_ascii_case("rtmp");
let is_rtsp = parsed_url.scheme().eq_ignore_ascii_case("rtsp");
let is_flv =
matches!(parsed_url.scheme(), "http" | "https") && parsed_url.path().ends_with(".flv");
if !is_rtmp && !is_rtsp && !is_flv {
return Err(ProviderError::InvalidConfig(format!(
"Unsupported source URL format: {url}. Expected rtmp://, rtsp://, or *.flv"
)));
}
Self::reject_synctv_publish_url(&parsed_url)?;
let host = parsed_url.host_str().ok_or_else(|| {
ProviderError::InvalidConfig("LiveProxy source URL is missing a host".to_string())
})?;
let default_port = if is_rtmp {
1935
} else if is_rtsp {
554
} else if parsed_url.scheme() == "https" {
443
} else {
80
};
let port = parsed_url.port().unwrap_or(default_port);
guard
.validate_url_target_with_default_port(host, port, default_port)
.map_err(|error| match error {
SsrfTargetError::BlockedHost(host) => ProviderError::InvalidConfig(format!(
"LiveProxy source host '{host}' is blocked by SSRF policy"
)),
SsrfTargetError::BlockedIp(ip) => ProviderError::InvalidConfig(format!(
"LiveProxy source IP '{ip}' is blocked by SSRF policy"
)),
SsrfTargetError::BlockedPort { port } => ProviderError::InvalidConfig(format!(
"LiveProxy source port '{port}' is blocked by SSRF policy"
)),
})?;
if host.parse::<std::net::IpAddr>().is_err()
&& (is_rtmp || is_rtsp)
&& guard.dns_resolver().is_some()
{
let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host, port))
.await
.map_err(|error| {
ProviderError::InvalidConfig(format!(
"LiveProxy streaming source host '{host}' could not be resolved: {error}"
))
})?
.collect();
if addrs.is_empty() {
return Err(ProviderError::InvalidConfig(format!(
"LiveProxy streaming source host '{host}' did not resolve to any addresses"
)));
}
if let Some(blocked_addr) = addrs
.iter()
.find(|addr| guard.is_ip_blocked_for_host(host, &addr.ip()))
{
return Err(ProviderError::InvalidConfig(format!(
"LiveProxy streaming source host '{host}' resolved to blocked IP '{}'",
blocked_addr.ip()
)));
}
}
Ok(())
}
async fn validate_external_source(
source: &crate::models::ExternalLiveSourceConfig,
guard: &synctv_common::ssrf::SsrfGuard,
) -> Result<(), ProviderError> {
if let crate::models::ExternalLiveSourceConfig::Rtsp {
video_track: crate::models::RtspTrackSelection::Disabled,
audio_track: crate::models::RtspTrackSelection::Disabled,
..
} = source
{
return Err(ProviderError::InvalidConfig(
"RTSP source must enable a video or audio track".to_string(),
));
}
let url = source.url();
let parsed = url::Url::parse(url).map_err(|error| {
ProviderError::InvalidConfig(format!("Invalid LiveProxy source URL '{url}': {error}"))
})?;
let protocol_matches = match source {
crate::models::ExternalLiveSourceConfig::Rtmp { .. } => parsed.scheme() == "rtmp",
crate::models::ExternalLiveSourceConfig::Rtsp { .. } => parsed.scheme() == "rtsp",
crate::models::ExternalLiveSourceConfig::HttpFlv { .. } => {
matches!(parsed.scheme(), "http" | "https") && parsed.path().ends_with(".flv")
}
};
if !protocol_matches {
return Err(ProviderError::InvalidConfig(
"External live source protocol and URL disagree".to_string(),
));
}
Self::validate_live_source_url(url, guard).await
}
fn reject_synctv_publish_url(parsed_url: &url::Url) -> Result<(), ProviderError> {
if !parsed_url.scheme().eq_ignore_ascii_case("rtmp") {
return Ok(());
}
let Some(token) = parsed_url
.query_pairs()
.find_map(|(key, value)| (key == "token").then_some(value.into_owned()))
else {
return Ok(());
};
if !Self::looks_like_synctv_publish_key(&token) {
return Ok(());
}
Err(ProviderError::InvalidConfig(
"LiveProxy source URL points at a SyncTV RTMP publish endpoint. Use the original upstream RTMP/HTTP-FLV source URL, or use the RTMP provider for SyncTV-managed live media.".to_string(),
))
}
fn looks_like_synctv_publish_key(token: &str) -> bool {
let mut parts = token.split('.');
let (Some(_header), Some(payload), Some(_signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
let Ok(payload) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
return false;
};
let Ok(payload) = serde_json::from_slice::<Value>(&payload) else {
return false;
};
payload
.get("perm_manage_live_streams")
.and_then(Value::as_bool)
.unwrap_or(false)
&& payload.get("room_id").is_some()
&& payload.get("media_id").is_some()
}
fn resolve_live_binding<'a>(
ctx: &'a ProviderContext<'a>,
) -> Result<(&'a RoomId, &'a MediaId), ProviderError> {
let room_id = ctx.room_id().ok_or_else(|| {
ProviderError::InvalidConfig(
"Missing room_id in provider context for live proxy playback".to_string(),
)
})?;
let media_id = ctx.media_id().ok_or_else(|| {
ProviderError::InvalidConfig(
"Missing media_id in provider context for live proxy playback".to_string(),
)
})?;
Ok((room_id, media_id))
}
}
fn mark_live_proxy_playback_resources(
result: &mut PlaybackResult,
version: &str,
expires_at: i64,
client_profile: Option<&super::PlaybackClientProfile>,
) {
// Live proxy playback is owned by SyncTV, so generated modes point at
// provider-playback transport actions directly.
let default_mode = result.default_mode.clone();
let original_modes = std::mem::take(&mut result.playback_infos);
for (mode_name, info) in original_modes {
let Some(mut info) =
super::build_proxy_playback_info_for_client(&mode_name, &info, client_profile)
else {
continue;
};
let is_hls = super::playback_info_is_hls(&mode_name, &info);
let media_count = info.medias.len();
for media in &mut info.medias {
let (room_id, media_id) = match &media.provider {
PlaybackMediaProvider::Rtmp(
PlaybackRtmpMedia::HlsMaster {
room_id, media_id, ..
}
| PlaybackRtmpMedia::FlvStream {
room_id, media_id, ..
},
) => (*room_id, *media_id),
_ => continue,
};
media.provider = if is_hls {
PlaybackMediaProvider::LiveProxy(PlaybackLiveProxyMedia::HlsMaster {
version: version.to_string(),
expires_at,
room_id,
media_id,
})
} else if mode_name == "flv" || (mode_name == default_mode && media_count == 1) {
PlaybackMediaProvider::LiveProxy(PlaybackLiveProxyMedia::FlvStream {
version: version.to_string(),
expires_at,
room_id,
media_id,
})
} else {
continue;
};
}
result.playback_infos.insert(mode_name, info);
}
super::select_generated_playback_default(result, &default_mode, true);
}
fn default_live_proxy_mode(profile: Option<&super::PlaybackClientProfile>) -> &'static str {
if profile.is_some_and(|profile| {
profile
.supported_live_transports
.contains(&super::PlaybackLiveTransport::Flv)
}) {
"flv"
} else {
"hls"
}
}
#[async_trait]
impl MediaProvider for LiveProxyProvider {
fn name(&self) -> &'static str {
Self::NAME
}
async fn generate_playback(
&self,
ctx: &ProviderContext<'_>,
source_config: &crate::models::MediaSourceConfig,
) -> Result<PlaybackResult, ProviderError> {
let crate::models::MediaSourceConfig::LiveProxy(source_config) = source_config else {
return Err(ProviderError::InvalidConfig(
"LiveProxy requires LiveProxy media source_config".to_string(),
));
};
let (room_id, media_id) = Self::resolve_live_binding(ctx)?;
let source_url = source_config.source.url().to_string();
Self::validate_external_source(&source_config.source, &self.ssrf_guard).await?;
let mut result = super::build_live_playback(*media_id, *room_id);
result.default_mode = default_live_proxy_mode(ctx.playback_client_profile()).to_string();
let parsed_source_url = url::Url::parse(&source_url).map_err(|error| {
ProviderError::InvalidConfig(format!(
"Invalid LiveProxy source URL '{source_url}': {error}"
))
})?;
let redacted_host = parsed_source_url
.host_str()
.ok_or_else(|| {
ProviderError::InvalidConfig("LiveProxy source URL is missing a host".to_string())
})?
.to_string();
result.metadata = Some(PlaybackMetadata::LiveProxy(LiveProxyPlaybackMetadata {
media_id: *media_id,
room_id: *room_id,
source_host: Some(redacted_host),
}));
let cache_key = format!("playback:{room_id}:{media_id}");
let cache_ttl = Duration::from_mins(5);
let client_profile = ctx.playback_client_profile();
let result = super::cache_versioned_playback_and_build_response(
result,
Self::NAME,
&cache_key,
cache_ttl,
ctx,
|result, version, expires_at| {
mark_live_proxy_playback_resources(result, version, expires_at, client_profile);
},
)
.await?;
super::require_compatible_playback_route(
result,
crate::models::PlaybackProxyMode::Only,
client_profile,
)
}
async fn validate_source_config(
&self,
_ctx: &ProviderContext<'_>,
source_config: SourceConfig<'_>,
) -> Result<(), ProviderError> {
let SourceConfig::Media(crate::models::MediaSourceConfig::LiveProxy(source_config)) =
source_config
else {
return Err(ProviderError::InvalidConfig(
"LiveProxy requires LiveProxy media source_config".to_string(),
));
};
Self::validate_external_source(&source_config.source, &self.ssrf_guard).await
}
}
impl LiveProxyProvider {
pub async fn get_flv_stream(
&self,
store: Option<&std::sync::Arc<dyn super::store::ProviderStore>>,
version: &str,
request_context: Option<&super::ExecutionControl>,
access: super::playback_transport::LiveFlvAccess,
) -> Result<PlaybackTransportAction, ProviderError> {
let versioned =
super::playback_transport::lookup_versioned(store, version, request_context).await?;
super::live_helpers::build_flv_action(Self::NAME, &versioned, access)
}
pub async fn get_hls_master(
&self,
store: Option<&std::sync::Arc<dyn super::store::ProviderStore>>,
version: &str,
request_context: Option<&super::ExecutionControl>,
) -> Result<PlaybackTransportAction, ProviderError> {
let versioned =
super::playback_transport::lookup_versioned(store, version, request_context).await?;
super::live_helpers::build_hls_master_action(Self::NAME, &versioned)
}
pub async fn get_hls_playlist(
&self,
store: Option<&std::sync::Arc<dyn super::store::ProviderStore>>,
version: &str,
generation_id: &str,
request_context: Option<&super::ExecutionControl>,
) -> Result<PlaybackTransportAction, ProviderError> {
let versioned =
super::playback_transport::lookup_versioned(store, version, request_context).await?;
super::live_helpers::build_hls_playlist_action(Self::NAME, &versioned, generation_id)
}
pub async fn get_hls_segment(
&self,
store: Option<&std::sync::Arc<dyn super::store::ProviderStore>>,
version: &str,
generation_id: &str,
segment_name: &str,
request_context: Option<&super::ExecutionControl>,
) -> Result<PlaybackTransportAction, ProviderError> {
let versioned =
super::playback_transport::lookup_versioned(store, version, request_context).await?;
super::live_helpers::build_hls_segment_action(
Self::NAME,
&versioned,
generation_id,
segment_name,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_proxy_uses_the_low_latency_transport_advertised_by_the_client() {
let hls_profile = super::super::PlaybackClientProfile::default();
assert_eq!(default_live_proxy_mode(Some(&hls_profile)), "hls");
let native_profile = super::super::PlaybackClientProfile {
supported_live_transports: vec![
super::super::PlaybackLiveTransport::Hls,
super::super::PlaybackLiveTransport::Flv,
],
..Default::default()
};
assert_eq!(default_live_proxy_mode(Some(&native_profile)), "flv");
assert_eq!(default_live_proxy_mode(None), "hls");
}
#[tokio::test]
async fn validate_live_source_url_allows_custom_port_for_allowed_host() {
let guard = synctv_common::ssrf::SsrfGuard::builder()
.extra_allowed_host("media.internal".to_string())
.build();
LiveProxyProvider::validate_live_source_url("http://media.internal:18000/live.flv", &guard)
.await
.expect("allowed host custom port should pass LiveProxy validation");
}
#[tokio::test]
async fn validate_live_source_url_blocks_custom_port_for_regular_host() {
let guard = synctv_common::ssrf::SsrfGuard::strict_policy();
let error = LiveProxyProvider::validate_live_source_url(
"http://public.example:18000/live.flv",
&guard,
)
.await
.expect_err("regular host custom port should fail LiveProxy validation");
assert!(
matches!(error, ProviderError::InvalidConfig(ref message) if message.contains("port '18000'")),
"unexpected error: {error}"
);
}
#[tokio::test]
async fn validates_explicit_rtsp_source_protocol() {
let guard = synctv_common::ssrf::SsrfGuard::disabled();
let source = crate::models::ExternalLiveSourceConfig::Rtsp {
url: "rtsp://camera.example/live".to_string(),
transport: crate::models::RtspTransport::Udp,
video_track: crate::models::RtspTrackSelection::Index(2),
audio_track: crate::models::RtspTrackSelection::Disabled,
};
LiveProxyProvider::validate_external_source(&source, &guard)
.await
.expect("explicit RTSP source should validate");
let mismatched = crate::models::ExternalLiveSourceConfig::Rtsp {
url: "rtmp://camera.example/live".to_string(),
transport: crate::models::RtspTransport::Tcp,
video_track: crate::models::RtspTrackSelection::FirstCompatible,
audio_track: crate::models::RtspTrackSelection::FirstCompatible,
};
let error = LiveProxyProvider::validate_external_source(&mismatched, &guard)
.await
.expect_err("protocol mismatch should fail validation");
assert!(error.to_string().contains("protocol and URL disagree"));
}
#[tokio::test]
async fn rejects_rtsp_source_with_both_tracks_disabled() {
let guard = synctv_common::ssrf::SsrfGuard::disabled();
let source = crate::models::ExternalLiveSourceConfig::Rtsp {
url: "rtsp://camera.example/live".to_string(),
transport: crate::models::RtspTransport::Tcp,
video_track: crate::models::RtspTrackSelection::Disabled,
audio_track: crate::models::RtspTrackSelection::Disabled,
};
let error = LiveProxyProvider::validate_external_source(&source, &guard)
.await
.expect_err("RTSP source with no enabled media must fail validation");
assert!(error
.to_string()
.contains("must enable a video or audio track"));
}
}