mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-19 14:22:19 +02:00
Network access restriction for api user (#728)
* Network access restriction for api user * Fixed strm provider:// url bug
This commit is contained in:
@@ -465,7 +465,7 @@ use crate::api::panel_api::{can_provision_on_exhausted, create_panel_api_provisi
|
||||
use crate::utils::LRUResourceCache;
|
||||
pub use internal_server_error;
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{AdmissionStrategy, ConnectFailureReason, FailureStage};
|
||||
use shared::model::{AdmissionStrategy, ConnectFailureReason, FailureStage, GeoIpUnavailablePolicy};
|
||||
use shared::utils::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs};
|
||||
pub use try_option_bad_request;
|
||||
pub use try_option_forbidden;
|
||||
@@ -551,7 +551,7 @@ pub async fn serve_file(file_path: &Path, mime_type: String, cache_control: Opti
|
||||
pub fn get_user_target_by_username(
|
||||
username: &str,
|
||||
app_state: &Arc<AppState>,
|
||||
) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
|
||||
) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
|
||||
if !username.is_empty() {
|
||||
return app_state.app_config.get_target_for_username(username);
|
||||
}
|
||||
@@ -563,7 +563,7 @@ pub fn get_user_target_by_credentials<'a>(
|
||||
password: &str,
|
||||
api_req: &'a UserApiRequest,
|
||||
app_state: &'a AppState,
|
||||
) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
|
||||
) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
|
||||
if !username.is_empty() && !password.is_empty() {
|
||||
app_state.app_config.get_target_for_user(username, password)
|
||||
} else {
|
||||
@@ -579,12 +579,124 @@ pub fn get_user_target_by_credentials<'a>(
|
||||
pub fn get_user_target<'a>(
|
||||
api_req: &'a UserApiRequest,
|
||||
app_state: &'a AppState,
|
||||
) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
|
||||
) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
|
||||
let username = api_req.username.as_str().trim();
|
||||
let password = api_req.password.as_str().trim();
|
||||
get_user_target_by_credentials(username, password, api_req, app_state)
|
||||
}
|
||||
|
||||
/// Result of a policy-aware network access evaluation.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum NetworkAccessDecision {
|
||||
/// Request is allowed (matched CIDR or country with `GeoIP` available).
|
||||
Allowed,
|
||||
/// Request is allowed because `GeoIP` is unavailable and policy is Allow.
|
||||
AllowedGeoIpUnavailable,
|
||||
/// Request is denied with a typed reason.
|
||||
Denied(NetworkAccessDenyReason),
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum NetworkAccessDenyReason {
|
||||
NoCidrMatch,
|
||||
NoCountryMatch,
|
||||
GeoIpUnavailable,
|
||||
CountryUnknown,
|
||||
MalformedClientIp,
|
||||
}
|
||||
|
||||
impl NetworkAccessDenyReason {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::NoCidrMatch => "no_cidr_match",
|
||||
Self::NoCountryMatch => "no_country_match",
|
||||
Self::GeoIpUnavailable => "geoip_unavailable",
|
||||
Self::CountryUnknown => "country_unknown",
|
||||
Self::MalformedClientIp => "malformed_client_ip",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs a network access denial with structured context for operator debugging.
|
||||
/// Do NOT log passwords or secrets.
|
||||
#[allow(clippy::uninlined_format_args)]
|
||||
pub fn log_network_access_denied(username: &str, client_ip: &str, reason: &str) {
|
||||
let sanitized_username = sanitize_sensitive_info(username);
|
||||
let sanitized_client_ip = sanitize_sensitive_info(client_ip);
|
||||
warn!(
|
||||
target: "network_access",
|
||||
"Network access denied: user=\"{}\" client_ip=\"{}\" reason={}",
|
||||
sanitized_username,
|
||||
sanitized_client_ip,
|
||||
reason
|
||||
);
|
||||
}
|
||||
|
||||
/// Logs a network access allowed-without-GeoIP event for explicit-risk observability.
|
||||
#[allow(clippy::uninlined_format_args)]
|
||||
pub fn log_network_access_allowed_geoip_unavailable(username: &str, client_ip: &str) {
|
||||
warn!(
|
||||
target: "network_access",
|
||||
"Network access allowed because GeoIP is unavailable and reverse_proxy.geoip.unavailable_policy=allow; user=\"{}\" client_ip=\"{}\"",
|
||||
sanitize_sensitive_info(username),
|
||||
sanitize_sensitive_info(client_ip)
|
||||
);
|
||||
}
|
||||
|
||||
/// Evaluates network access with the configured GeoIP-unavailable policy.
|
||||
/// Returns a structured decision for logging and HTTP response mapping.
|
||||
pub fn evaluate_network_access(
|
||||
user: &ProxyUserCredentials,
|
||||
client_ip: &str,
|
||||
geoip: &Arc<ArcSwapOption<crate::utils::GeoIp>>,
|
||||
geoip_unavailable_policy: GeoIpUnavailablePolicy,
|
||||
) -> NetworkAccessDecision {
|
||||
let Some(access) = user.network_access.as_ref() else {
|
||||
return NetworkAccessDecision::Allowed;
|
||||
};
|
||||
if access.is_empty() {
|
||||
return NetworkAccessDecision::Allowed;
|
||||
}
|
||||
|
||||
let Ok(parsed_ip) = client_ip.parse::<std::net::IpAddr>() else {
|
||||
return NetworkAccessDecision::Denied(NetworkAccessDenyReason::MalformedClientIp);
|
||||
};
|
||||
|
||||
// CIDR check
|
||||
for net in &access.allowed_networks {
|
||||
if net.contains(&parsed_ip) {
|
||||
return NetworkAccessDecision::Allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// No CIDR match — check country rules
|
||||
if access.allowed_countries.is_empty() {
|
||||
return NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch);
|
||||
}
|
||||
|
||||
// Country rules exist — check if GeoIP is available
|
||||
let geoip_guard = geoip.load();
|
||||
let Some(geoip_db) = geoip_guard.as_ref() else {
|
||||
// GeoIP unavailable — apply policy
|
||||
return match geoip_unavailable_policy {
|
||||
GeoIpUnavailablePolicy::Allow => NetworkAccessDecision::AllowedGeoIpUnavailable,
|
||||
GeoIpUnavailablePolicy::Deny => NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable),
|
||||
};
|
||||
};
|
||||
|
||||
// GeoIP is loaded — do country lookup
|
||||
match geoip_db.lookup(client_ip) {
|
||||
Some(country) => {
|
||||
if access.allowed_countries.iter().any(|c| c == &country) {
|
||||
NetworkAccessDecision::Allowed
|
||||
} else {
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch)
|
||||
}
|
||||
}
|
||||
None => NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StreamOptions {
|
||||
pub stream_retry: bool,
|
||||
pub buffer_enabled: bool,
|
||||
@@ -3257,6 +3369,7 @@ pub fn create_api_proxy_user(app_state: &Arc<AppState>) -> ProxyUserCredentials
|
||||
soft_connections: 0,
|
||||
soft_priority: 0,
|
||||
t_is_api_user: true,
|
||||
network_access: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3285,8 +3398,8 @@ mod tests {
|
||||
},
|
||||
auth::Fingerprint,
|
||||
model::{
|
||||
AppConfig, Config, ConfigInput, ConfigInputAlias, ConfigTarget, MediaToolCapabilities, ProcessTargets,
|
||||
ProxyUserCredentials, SourcesConfig,
|
||||
AppConfig, Config, ConfigInput, ConfigInputAlias, ConfigTarget,
|
||||
MediaToolCapabilities, NetworkAccess, ProcessTargets, ProxyUserCredentials, SourcesConfig,
|
||||
},
|
||||
utils::{FileLockManager, GeoIp},
|
||||
};
|
||||
@@ -3298,7 +3411,7 @@ mod tests {
|
||||
foundation::Filter,
|
||||
model::{
|
||||
ClusterFlags, ConfigPaths, ConfigTargetOptions, InputFetchMethod, InputType, PlaylistItemType,
|
||||
ProcessingOrder, StreamChannel, XtreamCluster,
|
||||
ProcessingOrder, ProxyType, StreamChannel, XtreamCluster,
|
||||
},
|
||||
utils::{default_catchup_session_ttl_secs, default_hls_session_ttl_secs, Internable},
|
||||
};
|
||||
@@ -7290,4 +7403,345 @@ mod tests {
|
||||
assert_eq!(extension, "");
|
||||
assert_eq!(query_path, "1");
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// evaluate_network_access tests
|
||||
// =========================================================================================
|
||||
|
||||
/// Helper to create a test user with specific network access
|
||||
fn user_with_network_access(network_access: Option<NetworkAccess>) -> ProxyUserCredentials {
|
||||
ProxyUserCredentials {
|
||||
username: "test".to_string(),
|
||||
password: "test".to_string(),
|
||||
token: None,
|
||||
proxy: ProxyType::default(),
|
||||
server: None,
|
||||
epg_timeshift: None,
|
||||
epg_request_timeshift: None,
|
||||
created_at: None,
|
||||
exp_date: None,
|
||||
max_connections: 0,
|
||||
status: None,
|
||||
output_clusters: ClusterFlags::all(),
|
||||
ui_enabled: true,
|
||||
comment: None,
|
||||
priority: 0,
|
||||
soft_connections: 0,
|
||||
soft_priority: 0,
|
||||
t_is_api_user: false,
|
||||
network_access,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_config_allows_all() {
|
||||
let user = user_with_network_access(None);
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_config_allows_all() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cidr_match_allows() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(evaluate_network_access(&user, "192.168.1.42", &geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cidr_miss_denies() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn country_match_allows() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("DE")))));
|
||||
assert_eq!(evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn country_miss_denies() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("US")))));
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_geoip_denies_on_country_restriction() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_vs_ipv6_denies_gracefully() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["2001:db8::/32".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_vs_ipv4_denies_gracefully() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "2001:db8::1", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_cidr_or_country_match_allows() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["US".to_string()],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("DE")))));
|
||||
assert_eq!(evaluate_network_access(&user, "192.168.1.42", &mock_geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_ip_cidr() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["192.168.1.1/32".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "192.168.1.2", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// network denied reason tests
|
||||
// =========================================================================================
|
||||
|
||||
#[test]
|
||||
fn network_denied_reason_cidr_no_match() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_denied_reason_country_no_match() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("US")))));
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_denied_reason_geoip_unavailable() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_denied_reason_country_unknown_when_geoip_loaded_but_unknown_ip() {
|
||||
// GeoIP is loaded (not None), but lookup returns None for this IP (private/unknown).
|
||||
// We need a GeoIP that only covers a private range, so public IPs get None.
|
||||
// Use a CIDR-only restriction (no country rules) so we can verify
|
||||
// that when countries ARE checked, lookup None gives "country_unknown".
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()], // miss CIDR first
|
||||
}));
|
||||
// Use the real GeoIp::new() which only seeds private ranges.
|
||||
// For 8.8.8.8 (public), lookup returns None.
|
||||
let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new()))));
|
||||
// CIDR miss -> country check -> geoip loaded but lookup returns None
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_denied_reason_none_when_allowed() {
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("DE")))));
|
||||
assert_eq!(evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_denied_reason_none_when_no_config() {
|
||||
let user = user_with_network_access(None);
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
assert_eq!(evaluate_network_access(&user, "192.168.1.1", &geoip, GeoIpUnavailablePolicy::Deny), NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// GeoIP unavailable policy tests
|
||||
// =========================================================================================
|
||||
|
||||
#[test]
|
||||
fn geoip_unavailable_default_deny_denies() {
|
||||
// Country rule exists but GeoIP is unavailable — default policy is Deny
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny);
|
||||
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geoip_unavailable_explicit_allow_allows() {
|
||||
// Country rule exists, GeoIP unavailable, but policy is Allow — allows
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow);
|
||||
assert_eq!(decision, NetworkAccessDecision::AllowedGeoIpUnavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geoip_unavailable_cidr_only_still_denies() {
|
||||
// CIDR only rules, no match — should deny even with Allow policy
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec![],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Allow);
|
||||
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCidrMatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geoip_unavailable_cidr_match_allows() {
|
||||
// CIDR match always allows, regardless of policy
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec!["10.0.0.0/8".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
let decision = evaluate_network_access(&user, "10.0.0.1", &geoip, GeoIpUnavailablePolicy::Deny);
|
||||
assert_eq!(decision, NetworkAccessDecision::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geoip_loaded_country_mismatch_still_denies() {
|
||||
// Loaded GeoIP but country doesn't match — should deny under Allow policy
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let mock_geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::test_new("US")))));
|
||||
let decision = evaluate_network_access(&user, "8.8.8.8", &mock_geoip, GeoIpUnavailablePolicy::Allow);
|
||||
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::NoCountryMatch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geoip_loaded_unknown_country_still_denies() {
|
||||
// Loaded GeoIP but lookup returns None — should deny under Allow policy
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec!["192.168.1.0/24".parse().unwrap()],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::from(Some(Arc::new(GeoIp::new()))));
|
||||
let decision = evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow);
|
||||
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::CountryUnknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ip_denies_even_when_geoip_unavailable_policy_is_allow() {
|
||||
let user = user_with_network_access(Some(NetworkAccess::from(&shared::model::NetworkAccessDto {
|
||||
allowed_countries: Some(vec!["DE".to_string()]),
|
||||
allowed_networks: None,
|
||||
})));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
|
||||
let decision = evaluate_network_access(&user, "not-an-ip", &geoip, GeoIpUnavailablePolicy::Allow);
|
||||
|
||||
assert_eq!(decision, NetworkAccessDecision::Denied(NetworkAccessDenyReason::MalformedClientIp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_network_access_respects_allow_policy() {
|
||||
// verify evaluate_network_access returns AllowedGeoIpUnavailable with Allow policy
|
||||
let user = user_with_network_access(Some(NetworkAccess {
|
||||
allowed_countries: vec!["DE".to_string()],
|
||||
allowed_networks: vec![],
|
||||
}));
|
||||
let geoip = Arc::new(ArcSwapOption::<GeoIp>::default());
|
||||
// Default deny policy should return Denied
|
||||
assert_eq!(
|
||||
evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Deny),
|
||||
NetworkAccessDecision::Denied(NetworkAccessDenyReason::GeoIpUnavailable)
|
||||
);
|
||||
// Allow policy should return AllowedGeoIpUnavailable
|
||||
assert_eq!(evaluate_network_access(&user, "8.8.8.8", &geoip, GeoIpUnavailablePolicy::Allow), NetworkAccessDecision::AllowedGeoIpUnavailable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
};
|
||||
use axum::response::IntoResponse;
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
use crate::auth::resolve_api_user_context;
|
||||
|
||||
async fn cvs_api(
|
||||
fingerprint: Fingerprint,
|
||||
@@ -16,12 +17,12 @@ async fn cvs_api(
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
|
||||
let Some((user, _target)) = app_state.app_config.get_target_for_user(&username, &password) else {
|
||||
let Some((user, target)) = app_state.app_config.get_target_for_user(&username, &password) else {
|
||||
return app_state.app_config.get_auth_error_status().into_response();
|
||||
};
|
||||
|
||||
if user.permission_denied(&app_state) {
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
if let Err(e) = resolve_api_user_context(user.clone(), target.clone(), fingerprint.clone(), &app_state) {
|
||||
return e.into_player_response(app_state.app_config.get_auth_error_status());
|
||||
}
|
||||
|
||||
create_custom_video_stream_response(&app_state, &fingerprint.addr, custom_video_type).into_response()
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::{
|
||||
api_utils::{internal_server_error, try_unwrap_body},
|
||||
model::HdHomerunAppState,
|
||||
},
|
||||
auth::AuthBasic,
|
||||
auth::{try_check_network_access_only, AuthBasic, Fingerprint},
|
||||
model::{AppConfig, ConfigInputFlags, ConfigTarget, ProxyUserCredentials},
|
||||
processing::parser::xtream::get_xtream_url,
|
||||
repository::{iter_raw_m3u_target_playlist, M3uPlaylistIterator, XtreamPlaylistIterator},
|
||||
@@ -243,13 +243,19 @@ async fn discover_json(
|
||||
}
|
||||
|
||||
async fn lineup_status(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<HdHomerunAppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let cfg = Arc::clone(&app_state.app_state.app_config);
|
||||
let network_access_denied =
|
||||
cfg.get_target_for_username(&app_state.device.t_username).is_none_or(|(credentials, _)| {
|
||||
try_check_network_access_only(&credentials, &fingerprint, &app_state.app_state).is_err()
|
||||
});
|
||||
let current_state = app_state.hd_scan_state.load(std::sync::atomic::Ordering::Acquire);
|
||||
if current_state < 0 {
|
||||
if network_access_denied || current_state < 0 {
|
||||
axum::Json(json!({
|
||||
"ScanInProgress": 0,
|
||||
"ScanPossible": 1,
|
||||
"ScanPossible": i32::from(!network_access_denied),
|
||||
"Source": "Cable",
|
||||
"SourceList": ["Cable"],
|
||||
}))
|
||||
@@ -259,47 +265,48 @@ async fn lineup_status(
|
||||
let final_state = if new_state > 100 { 100 } else { new_state };
|
||||
|
||||
let cfg = Arc::clone(&app_state.app_state.app_config);
|
||||
let num_of_channels = if let Some((user, target)) = cfg.get_target_for_username(&app_state.device.t_username) {
|
||||
if target.has_output(TargetType::M3u) {
|
||||
let credentials = Arc::new(user);
|
||||
if let Some(iter) = iter_raw_m3u_target_playlist(&cfg, &target, None).await {
|
||||
iter.filter_map(move |res| {
|
||||
let credentials = Arc::clone(&credentials);
|
||||
async move {
|
||||
let item = res.ok()?;
|
||||
credentials.allows_item_type(item.item_type).then_some(item)
|
||||
let num_of_channels =
|
||||
if let Some((credentials, target)) = cfg.get_target_for_username(&app_state.device.t_username) {
|
||||
if target.has_output(TargetType::M3u) {
|
||||
if let Some(iter) = iter_raw_m3u_target_playlist(&cfg, &target, None).await {
|
||||
let cred = Arc::clone(&credentials);
|
||||
iter.filter_map(move |res| {
|
||||
let cred = Arc::clone(&cred);
|
||||
async move {
|
||||
let item = res.ok()?;
|
||||
cred.allows_item_type(item.item_type).then_some(item)
|
||||
}
|
||||
})
|
||||
.count()
|
||||
.await
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else if target.has_output(TargetType::Xtream) {
|
||||
let cred = Arc::clone(&credentials);
|
||||
let live = if cred.allows_cluster(XtreamCluster::Live) {
|
||||
match XtreamPlaylistIterator::new(XtreamCluster::Live, &cfg, &target, None, &cred).await {
|
||||
Ok(stream) => stream.count().await,
|
||||
Err(_) => 0,
|
||||
}
|
||||
})
|
||||
.count()
|
||||
.await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let vod = if cred.allows_cluster(XtreamCluster::Video) {
|
||||
match XtreamPlaylistIterator::new(XtreamCluster::Video, &cfg, &target, None, &cred).await {
|
||||
Ok(stream) => stream.count().await,
|
||||
Err(_) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
live + vod
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else if target.has_output(TargetType::Xtream) {
|
||||
let credentials = Arc::new(user);
|
||||
let live = if credentials.allows_cluster(XtreamCluster::Live) {
|
||||
match XtreamPlaylistIterator::new(XtreamCluster::Live, &cfg, &target, None, &credentials).await {
|
||||
Ok(stream) => stream.count().await,
|
||||
Err(_) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let vod = if credentials.allows_cluster(XtreamCluster::Video) {
|
||||
match XtreamPlaylistIterator::new(XtreamCluster::Video, &cfg, &target, None, &credentials).await {
|
||||
Ok(stream) => stream.count().await,
|
||||
Err(_) => 0,
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
live + vod
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
};
|
||||
|
||||
if final_state >= 100 {
|
||||
app_state.hd_scan_state.store(-1, std::sync::atomic::Ordering::Release);
|
||||
@@ -322,9 +329,17 @@ struct LineupPostQuery {
|
||||
}
|
||||
|
||||
async fn lineup_post(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<HdHomerunAppState>>,
|
||||
axum::extract::Query(query): axum::extract::Query<LineupPostQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let cfg = Arc::clone(&app_state.app_state.app_config);
|
||||
let allowed = cfg.get_target_for_username(&app_state.device.t_username).is_some_and(|(credentials, _)| {
|
||||
try_check_network_access_only(&credentials, &fingerprint, &app_state.app_state).is_ok()
|
||||
});
|
||||
if !allowed {
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
match query.scan.as_str() {
|
||||
"start" => {
|
||||
app_state.hd_scan_state.store(0, std::sync::atomic::Ordering::Release);
|
||||
@@ -429,6 +444,7 @@ async fn lineup(
|
||||
}
|
||||
|
||||
async fn auth_lineup_json(
|
||||
fingerprint: Fingerprint,
|
||||
AuthBasic((username, password)): AuthBasic,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<HdHomerunAppState>>,
|
||||
) -> impl IntoResponse {
|
||||
@@ -437,18 +453,25 @@ async fn auth_lineup_json(
|
||||
if !username.eq(&credentials.username) || !password.eq(&credentials.password) {
|
||||
return axum::http::StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
let user_credentials = Arc::new(credentials);
|
||||
if let Err(e) = try_check_network_access_only(&credentials, &fingerprint, &app_state.app_state) {
|
||||
return e.into_player_response(app_state.app_state.app_config.get_auth_error_status());
|
||||
}
|
||||
let user_credentials = Arc::clone(&credentials);
|
||||
return lineup(&app_state, &cfg, &user_credentials, &target).await.into_response();
|
||||
}
|
||||
axum::http::StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
|
||||
async fn lineup_json(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<HdHomerunAppState>>,
|
||||
) -> impl IntoResponse {
|
||||
let cfg = Arc::clone(&app_state.app_state.app_config);
|
||||
if let Some((credentials, target)) = cfg.get_target_for_username(&app_state.device.t_username) {
|
||||
let user_credentials = Arc::new(credentials);
|
||||
if let Err(e) = try_check_network_access_only(&credentials, &fingerprint, &app_state.app_state) {
|
||||
return e.into_player_response(app_state.app_state.app_config.get_auth_error_status());
|
||||
}
|
||||
let user_credentials = Arc::clone(&credentials);
|
||||
return lineup(&app_state, &cfg, &user_credentials, &target).await.into_response();
|
||||
}
|
||||
axum::http::StatusCode::NOT_FOUND.into_response()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::{
|
||||
api::{
|
||||
api_utils::{
|
||||
connection_priority_for_kind, create_session_fingerprint, force_provider_stream_response, get_headers_from_request,
|
||||
get_hls_session_ttl_secs,
|
||||
admission_failure_response, get_stream_alternative_url, is_seek_request, local_stream_response, try_option_bad_request,
|
||||
try_unwrap_body, HeaderFilter,
|
||||
admission_failure_response, connection_priority_for_kind,
|
||||
create_session_fingerprint, force_provider_stream_response, get_headers_from_request,
|
||||
get_hls_session_ttl_secs, get_stream_alternative_url, is_seek_request, local_stream_response,
|
||||
try_option_bad_request, try_unwrap_body,
|
||||
HeaderFilter,
|
||||
},
|
||||
model::{
|
||||
AppState, CustomVideoStreamType, ProviderAllocation, UserSession,
|
||||
@@ -26,6 +27,7 @@ use shared::{
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
use shared::model::ConnectFailureReason;
|
||||
use crate::auth::check_network_access_only;
|
||||
|
||||
const PLAYLIST_TEMPLATE: &str = r"#EXTM3U
|
||||
#EXT-X-VERSION:3
|
||||
@@ -377,11 +379,13 @@ async fn hls_api_stream(
|
||||
axum::extract::Path(params): axum::extract::Path<HlsApiPathParams>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl IntoResponse + Send {
|
||||
let (user, target) = try_option_bad_request!(
|
||||
app_state.app_config.get_target_for_user(¶ms.username, ¶ms.password),
|
||||
false,
|
||||
format!("Could not find any user for hls stream {}", params.username)
|
||||
);
|
||||
let Some((user, target)) = app_state.app_config.get_target_for_user(¶ms.username, ¶ms.password) else {
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
// Network access check only - permission check is done later with full stream info
|
||||
if let Err(e) = check_network_access_only(&user, &fingerprint, &app_state) {
|
||||
return e.into_player_response(app_state.app_config.get_auth_error_status());
|
||||
}
|
||||
let target_name = &target.name;
|
||||
let virtual_id = params.stream_id;
|
||||
let input = try_option_bad_request!(
|
||||
@@ -391,19 +395,12 @@ async fn hls_api_stream(
|
||||
);
|
||||
|
||||
if user.permission_denied(&app_state) {
|
||||
let denied_channel = resolve_stream_channel(
|
||||
&app_state,
|
||||
&target,
|
||||
&input,
|
||||
virtual_id,
|
||||
&Arc::from(String::new()),
|
||||
)
|
||||
.await;
|
||||
let stream_channel = resolve_stream_channel(&app_state, &target, &input, virtual_id, "").await;
|
||||
return admission_failure_response(
|
||||
&app_state,
|
||||
&fingerprint,
|
||||
&user,
|
||||
denied_channel,
|
||||
stream_channel,
|
||||
input.name.clone(),
|
||||
&req_headers,
|
||||
ConnectFailureReason::UserAccountExpired,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::{
|
||||
api::{
|
||||
api_utils::{
|
||||
admission_failure_response, create_catchup_session_key, create_session_fingerprint,
|
||||
force_provider_stream_response, get_session_reservation_ttl_secs, get_user_target,
|
||||
get_user_target_by_credentials, is_seek_request, is_session_based_playback, is_stream_share_enabled,
|
||||
local_stream_response, redirect, redirect_response, resource_response, separate_number_and_remainder,
|
||||
should_allow_exhausted_shared_reconnect, stream_response, try_option_bad_request, try_option_forbidden,
|
||||
try_result_bad_request, try_result_not_found, try_unwrap_body, RedirectParams,
|
||||
admission_failure_response, create_catchup_session_key,
|
||||
create_session_fingerprint, force_provider_stream_response, get_session_reservation_ttl_secs,
|
||||
get_user_target, get_user_target_by_credentials, is_seek_request, is_session_based_playback,
|
||||
is_stream_share_enabled, local_stream_response,
|
||||
redirect, redirect_response, resource_response,
|
||||
separate_number_and_remainder, should_allow_exhausted_shared_reconnect, stream_response,
|
||||
try_option_bad_request, try_result_bad_request, try_result_not_found,
|
||||
try_unwrap_body, RedirectParams,
|
||||
},
|
||||
endpoints::{
|
||||
hls_api::handle_hls_stream_request,
|
||||
@@ -15,6 +17,7 @@ use crate::{
|
||||
model::{AppState, UserApiRequest, UserApiRequestQueryOrBody},
|
||||
},
|
||||
auth::Fingerprint,
|
||||
model::{ConfigTarget, ProxyUserCredentials},
|
||||
repository::{m3u_get_item_for_stream_id, m3u_load_rewrite_playlist, storage_const},
|
||||
utils::debug_if_enabled,
|
||||
};
|
||||
@@ -28,20 +31,18 @@ use shared::{
|
||||
utils::{concat_path, extract_extension_from_url, sanitize_sensitive_info},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use crate::auth::{check_network_access_only, resolve_api_user_context, ApiUserAuthError};
|
||||
|
||||
async fn m3u_api(api_req: &UserApiRequest, app_state: &AppState) -> impl IntoResponse + Send {
|
||||
api_req.log_sanitized("m3u_api");
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target(api_req, app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for m3u api {}", api_req.username)
|
||||
);
|
||||
async fn m3u_api(
|
||||
user: Arc<ProxyUserCredentials>,
|
||||
target: Arc<ConfigTarget>,
|
||||
app_state: &AppState,
|
||||
content_type: &str,
|
||||
) -> impl IntoResponse + Send {
|
||||
let _guard = app_state.app_config.file_locks.write_lock_str(&user.username).await;
|
||||
|
||||
match m3u_load_rewrite_playlist(&app_state.app_config, &target, &user).await {
|
||||
Ok(m3u_iter) => {
|
||||
// Convert the stream into a stream of `Bytes`
|
||||
let content_stream = m3u_iter.map(|mut line| {
|
||||
line.push('\n');
|
||||
Ok::<Bytes, String>(Bytes::from(line))
|
||||
@@ -50,8 +51,8 @@ async fn m3u_api(api_req: &UserApiRequest, app_state: &AppState) -> impl IntoRes
|
||||
let mut builder = axum::response::Response::builder()
|
||||
.status(axum::http::StatusCode::OK)
|
||||
.header(axum::http::header::CONTENT_TYPE, mime::TEXT_PLAIN_UTF_8.to_string());
|
||||
if api_req.content_type == "m3u_plus" {
|
||||
builder = builder.header("Content-Disposition", "attachment; filename=\"playlist.m3u\"");
|
||||
if content_type == "m3u_plus" {
|
||||
builder = builder.header(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"playlist.m3u\"");
|
||||
}
|
||||
try_unwrap_body!(builder.body(axum::body::Body::from_stream(content_stream)))
|
||||
}
|
||||
@@ -62,37 +63,66 @@ async fn m3u_api(api_req: &UserApiRequest, app_state: &AppState) -> impl IntoRes
|
||||
}
|
||||
}
|
||||
|
||||
fn m3u_api_with_auth(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &Arc<AppState>,
|
||||
api_req: &UserApiRequest,
|
||||
) -> Result<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>), ApiUserAuthError> {
|
||||
let (user, target) = get_user_target(api_req, app_state)
|
||||
.ok_or(ApiUserAuthError::AuthFailed)?;
|
||||
check_network_access_only(&user, fingerprint, app_state)?;
|
||||
Ok((user, target))
|
||||
}
|
||||
|
||||
/// Network-only auth for stream endpoints. Permission check is done later by the stream
|
||||
/// handler with full stream info for `admission_failure_response`.
|
||||
fn m3u_api_stream_network_auth(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &Arc<AppState>,
|
||||
api_req: &UserApiRequest,
|
||||
stream_req: &ApiStreamRequest<'_>,
|
||||
) -> Result<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>), ApiUserAuthError> {
|
||||
let (user, target) = get_user_target_by_credentials(stream_req.username, stream_req.password, api_req, app_state)
|
||||
.ok_or(ApiUserAuthError::AuthFailed)?;
|
||||
check_network_access_only(&user, fingerprint, app_state)?;
|
||||
Ok((user, target))
|
||||
}
|
||||
|
||||
async fn m3u_api_get(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::Query(api_req): axum::extract::Query<UserApiRequest>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl IntoResponse + Send {
|
||||
m3u_api(&api_req, &app_state).await
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = match m3u_api_with_auth(&fingerprint, &app_state, &api_req) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => return e.into_player_response(auth_status),
|
||||
};
|
||||
m3u_api(user, target, &app_state, &api_req.content_type).await.into_response()
|
||||
}
|
||||
|
||||
async fn m3u_api_post(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
|
||||
) -> impl IntoResponse + Send {
|
||||
m3u_api(&api_req, &app_state).await.into_response()
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = match m3u_api_with_auth(&fingerprint, &app_state, &api_req) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => return e.into_player_response(auth_status),
|
||||
};
|
||||
m3u_api(user, target, &app_state, &api_req.content_type).await.into_response()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn m3u_api_stream(
|
||||
user: Arc<ProxyUserCredentials>,
|
||||
target: Arc<ConfigTarget>,
|
||||
fingerprint: &Fingerprint,
|
||||
req_headers: &axum::http::HeaderMap,
|
||||
app_state: &Arc<AppState>,
|
||||
api_req: &UserApiRequest,
|
||||
stream_req: ApiStreamRequest<'_>,
|
||||
// _addr: &std::net::SocketAddr,
|
||||
) -> impl IntoResponse + Send {
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target_by_credentials(stream_req.username, stream_req.password, api_req, app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for m3u stream {}", stream_req.username)
|
||||
);
|
||||
|
||||
let _guard = app_state.app_config.file_locks.write_lock_str(&user.username).await;
|
||||
|
||||
let target_name = &target.name;
|
||||
@@ -347,7 +377,21 @@ async fn m3u_api_stream(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn m3u_api_resource_auth(
|
||||
fingerprint: &Fingerprint,
|
||||
app_state: &Arc<AppState>,
|
||||
api_req: &UserApiRequest,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>), ApiUserAuthError> {
|
||||
let (user, target) = get_user_target_by_credentials(username, password, api_req, app_state)
|
||||
.ok_or(ApiUserAuthError::AuthFailed)?;
|
||||
resolve_api_user_context(user.clone(), target.clone(), fingerprint.clone(), app_state)?;
|
||||
Ok((user, target))
|
||||
}
|
||||
|
||||
async fn m3u_api_resource(
|
||||
fingerprint: Fingerprint,
|
||||
req_headers: axum::http::HeaderMap,
|
||||
axum::extract::Query(api_req): axum::extract::Query<UserApiRequest>,
|
||||
axum::extract::Path((username, password, stream_id, resource)): axum::extract::Path<(
|
||||
@@ -362,15 +406,10 @@ async fn m3u_api_resource(
|
||||
return axum::http::StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target_by_credentials(&username, &password, &api_req, &app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for m3u resource {username}")
|
||||
);
|
||||
if user.permission_denied(&app_state) {
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let (user, target) = match m3u_api_resource_auth(&fingerprint, &app_state, &api_req, &username, &password) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => return e.into_player_response(auth_status),
|
||||
};
|
||||
|
||||
let target_name = &target.name;
|
||||
if !target.has_output(TargetType::M3u) {
|
||||
@@ -411,17 +450,24 @@ macro_rules! create_m3u_api_stream {
|
||||
axum::extract::Query(api_req): axum::extract::Query<UserApiRequest>,
|
||||
axum::extract::Path((username, password, stream_id)): axum::extract::Path<(String, String, String)>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
// axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
|
||||
) -> impl IntoResponse + Send {
|
||||
m3u_api_stream(
|
||||
&fingerprint,
|
||||
&req_headers,
|
||||
&app_state,
|
||||
&api_req,
|
||||
ApiStreamRequest::from($context, &username, &password, &stream_id, ""),
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
let stream_req = ApiStreamRequest::from($context, &username, &password, &stream_id, "");
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
match m3u_api_stream_network_auth(&fingerprint, &app_state, &api_req, &stream_req) {
|
||||
Ok((user, target)) => {
|
||||
m3u_api_stream(
|
||||
user,
|
||||
target,
|
||||
&fingerprint,
|
||||
&req_headers,
|
||||
&app_state,
|
||||
stream_req,
|
||||
)
|
||||
.await
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => e.into_player_response(auth_status),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::{
|
||||
api::{
|
||||
api_utils::{get_user_target_by_username, get_username_from_auth_header, try_unwrap_body},
|
||||
api_utils::{
|
||||
get_user_target_by_username, get_username_from_auth_header, try_unwrap_body,
|
||||
},
|
||||
model::AppState,
|
||||
},
|
||||
auth::{validator_api_user, AuthBearer},
|
||||
|
||||
@@ -23,7 +23,9 @@ async fn save_config_api_proxy_user(
|
||||
};
|
||||
let _lock = app_state.app_config.file_locks.write_lock(Path::new(&api_proxy_file_path)).await;
|
||||
|
||||
credential.prepare();
|
||||
if let Err(err) = credential.prepare() {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
if let Err(err) = credential.validate() {
|
||||
return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response();
|
||||
}
|
||||
@@ -97,11 +99,11 @@ async fn save_config_api_proxy_user(
|
||||
|
||||
if user_target_idx == target_idx {
|
||||
// Update
|
||||
api_proxy.user[user_target_idx].credentials[user_idx] = ProxyUserCredentials::from(&credential);
|
||||
api_proxy.user[user_target_idx].credentials[user_idx] = Arc::new(ProxyUserCredentials::from(&credential));
|
||||
} else {
|
||||
// Move: remove from old target and insert into new target
|
||||
api_proxy.user[user_target_idx].credentials.remove(user_idx);
|
||||
api_proxy.user[target_idx].credentials.push(ProxyUserCredentials::from(&credential));
|
||||
api_proxy.user[target_idx].credentials.push(Arc::new(ProxyUserCredentials::from(&credential)));
|
||||
remove_empty_target = api_proxy.user[user_target_idx].credentials.is_empty();
|
||||
}
|
||||
|
||||
@@ -110,7 +112,7 @@ async fn save_config_api_proxy_user(
|
||||
}
|
||||
} else {
|
||||
// new user
|
||||
api_proxy.user[target_idx].credentials.push(ProxyUserCredentials::from(&credential));
|
||||
api_proxy.user[target_idx].credentials.push(Arc::new(ProxyUserCredentials::from(&credential)));
|
||||
}
|
||||
|
||||
let new_api_proxy = Arc::new(api_proxy);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use crate::{
|
||||
api::{
|
||||
api_utils::{
|
||||
create_api_proxy_user, empty_json_response_as_array, get_user_target, get_user_target_by_credentials,
|
||||
internal_server_error, resource_response, stream_json_or_bin_response_stream,
|
||||
try_option_forbidden, try_unwrap_body,
|
||||
create_api_proxy_user, empty_json_response_as_array, get_user_target,
|
||||
get_user_target_by_credentials, internal_server_error,
|
||||
resource_response,
|
||||
stream_json_or_bin_response_stream, try_unwrap_body,
|
||||
},
|
||||
model::{AppState, UserApiRequestQueryOrBody, UserApiRequest},
|
||||
model::{AppState, UserApiRequest, UserApiRequestQueryOrBody},
|
||||
},
|
||||
auth::Fingerprint,
|
||||
model::{Config, ConfigTarget, ProxyUserCredentials, TargetOutput, EPG_ATTRIB_ID, EPG_TAG_CHANNEL},
|
||||
repository::{
|
||||
get_target_storage_path, m3u_get_epg_file_path_for_target, storage_const, xtream_get_epg_file_path_for_target,
|
||||
xtream_get_storage_path, BPlusTreeQuery, epg_query_channels, LockedReceiverStream, XML_PREAMBLE,
|
||||
epg_query_channels, get_target_storage_path, m3u_get_epg_file_path_for_target, storage_const,
|
||||
xtream_get_epg_file_path_for_target, xtream_get_storage_path, BPlusTreeQuery, LockedReceiverStream, XML_PREAMBLE,
|
||||
},
|
||||
utils,
|
||||
utils::{
|
||||
@@ -20,7 +22,7 @@ use crate::{
|
||||
};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, TimeZone};
|
||||
use log::{debug, error, trace};
|
||||
use log::{error, trace};
|
||||
use quick_xml::events::{BytesEnd, BytesStart, BytesText, Event};
|
||||
use shared::{
|
||||
concat_string,
|
||||
@@ -38,6 +40,7 @@ use std::{
|
||||
use tokio::{io::AsyncWriteExt, sync::mpsc, task};
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use crate::auth::resolve_api_user_context;
|
||||
use crate::model::ApiProxyServerInfo;
|
||||
|
||||
pub fn get_empty_epg_response() -> axum::response::Response {
|
||||
@@ -667,24 +670,18 @@ pub(crate) async fn stream_epg_api(
|
||||
/// let router = xmltv_api_register();
|
||||
/// // A GET request to /xmltv.php with valid query parameters will invoke this handler.
|
||||
/// ```
|
||||
async fn xmltv_api(api_req: UserApiRequest, app_state: &Arc<AppState>) -> impl IntoResponse + Send {
|
||||
async fn xmltv_api(fingerprint: &Fingerprint, api_req: UserApiRequest, app_state: &Arc<AppState>) -> impl IntoResponse + Send {
|
||||
api_req.log_sanitized("xmltv_api");
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target(&api_req, app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for xmltv api {}", api_req.username)
|
||||
);
|
||||
|
||||
if user.permission_denied(app_state) {
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
let Some((user, target)) = get_user_target(&api_req, app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
if let Err(e) = resolve_api_user_context(user.clone(), target.clone(), fingerprint.clone(), app_state) {
|
||||
return e.into_player_response(auth_status);
|
||||
}
|
||||
|
||||
let config = &app_state.app_config.config.load();
|
||||
let Some(epg_path) = get_epg_path_for_target(config, &target) else {
|
||||
// No epg configured, No processing or timeshift, epg can't be mapped to the channels.
|
||||
// we do not deliver epg
|
||||
return get_empty_epg_response();
|
||||
};
|
||||
|
||||
@@ -692,34 +689,34 @@ async fn xmltv_api(api_req: UserApiRequest, app_state: &Arc<AppState>) -> impl I
|
||||
}
|
||||
|
||||
async fn xmltv_api_get(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Query(api_req): axum::extract::Query<UserApiRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
xmltv_api(api_req, &app_state).await
|
||||
xmltv_api(&fingerprint, api_req, &app_state).await
|
||||
}
|
||||
|
||||
async fn xmltv_api_post(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
|
||||
) -> impl IntoResponse + Send {
|
||||
xmltv_api(api_req, &app_state).await
|
||||
xmltv_api(&fingerprint, api_req, &app_state).await
|
||||
}
|
||||
|
||||
async fn epg_api_resource(
|
||||
fingerprint: Fingerprint,
|
||||
req_headers: axum::http::HeaderMap,
|
||||
axum::extract::Query(api_req): axum::extract::Query<UserApiRequest>,
|
||||
axum::extract::Path((username, password, resource)): axum::extract::Path<(String, String, String)>,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
) -> impl IntoResponse + Send {
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, _target) = try_option_forbidden!(
|
||||
get_user_target_by_credentials(&username, &password, &api_req, &app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for epg resource {username}")
|
||||
);
|
||||
if user.permission_denied(&app_state) {
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
let Some((user, target)) = get_user_target_by_credentials(&username, &password, &api_req, &app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
if let Err(e) = resolve_api_user_context(user.clone(), target.clone(), fingerprint.clone(), &app_state) {
|
||||
return e.into_player_response(auth_status);
|
||||
}
|
||||
|
||||
let encrypt_secret = app_state.get_encrypt_secret();
|
||||
|
||||
@@ -4,13 +4,15 @@ use crate::{
|
||||
api::{
|
||||
api_utils,
|
||||
api_utils::{
|
||||
admission_failure_response, create_api_proxy_user, create_catchup_session_key, create_session_fingerprint,
|
||||
empty_json_response_as_array, empty_json_response_as_object, force_provider_stream_response,
|
||||
get_session_reservation_ttl_secs, get_user_target, get_user_target_by_credentials, internal_server_error,
|
||||
is_seek_request, is_session_based_playback, is_stream_share_enabled, local_stream_response, redirect,
|
||||
redirect_response, resource_response, separate_number_and_remainder,
|
||||
should_allow_exhausted_shared_reconnect, stream_response, try_option_bad_request, try_option_forbidden,
|
||||
try_result_bad_request, try_result_not_found, try_unwrap_body, RedirectParams,
|
||||
admission_failure_response, create_api_proxy_user, create_catchup_session_key,
|
||||
create_session_fingerprint, empty_json_response_as_array, empty_json_response_as_object,
|
||||
force_provider_stream_response, get_session_reservation_ttl_secs, get_user_target,
|
||||
get_user_target_by_credentials, internal_server_error, is_seek_request, is_session_based_playback,
|
||||
is_stream_share_enabled, local_stream_response,
|
||||
redirect, redirect_response, resource_response,
|
||||
separate_number_and_remainder, should_allow_exhausted_shared_reconnect, stream_response,
|
||||
try_option_bad_request, try_result_bad_request, try_result_not_found,
|
||||
try_unwrap_body, RedirectParams,
|
||||
},
|
||||
endpoints::{
|
||||
hls_api::handle_hls_stream_request,
|
||||
@@ -62,6 +64,9 @@ use std::{
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
use crate::auth::{check_network_access_only, check_permission_and_network_access_only};
|
||||
// https://github.com/tellytv/go.xtream-codes/blob/master/structs.go
|
||||
// Xtream api -> https://9tzx6f0ozj.apidog.io/
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum ApiStreamContext {
|
||||
@@ -207,7 +212,7 @@ async fn xtream_player_api_stream(
|
||||
app_state: &Arc<AppState>,
|
||||
api_req: &UserApiRequest,
|
||||
stream_req: ApiStreamRequest<'_>,
|
||||
user_target: Option<(ProxyUserCredentials, Arc<ConfigTarget>)>,
|
||||
user_target: Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)>,
|
||||
) -> impl IntoResponse + Send {
|
||||
// if log::log_enabled!(log::Level::Debug) {
|
||||
// debug!(
|
||||
@@ -225,14 +230,18 @@ async fn xtream_player_api_stream(
|
||||
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = match user_target {
|
||||
None => try_option_forbidden!(
|
||||
get_user_target_by_credentials(stream_req.username, stream_req.password, api_req, app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for xc stream {}", stream_req.username)
|
||||
),
|
||||
None => {
|
||||
let Some((user, target)) = get_user_target_by_credentials(stream_req.username, stream_req.password, api_req, app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
(user, target)
|
||||
}
|
||||
Some((user, target)) => (user, target),
|
||||
};
|
||||
// Network access check only - permission check is done later with full stream info
|
||||
if let Err(e) = check_network_access_only(&user, fingerprint, app_state) {
|
||||
return e.into_player_response(auth_status);
|
||||
}
|
||||
|
||||
let _guard = app_state.app_config.file_locks.write_lock_str(&user.username).await;
|
||||
|
||||
@@ -713,20 +722,18 @@ async fn xtream_player_api_stream_with_token(
|
||||
}
|
||||
|
||||
async fn xtream_player_api_resource(
|
||||
fingerprint: &Fingerprint,
|
||||
req_headers: &HeaderMap,
|
||||
api_req: &UserApiRequest,
|
||||
app_state: &Arc<AppState>,
|
||||
resource_req: ApiStreamRequest<'_>,
|
||||
) -> impl IntoResponse {
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target_by_credentials(resource_req.username, resource_req.password, api_req, app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user xc resource {}", resource_req.username)
|
||||
);
|
||||
if user.permission_denied(app_state) {
|
||||
return axum::http::StatusCode::FORBIDDEN.into_response();
|
||||
let Some((user, target)) = get_user_target_by_credentials(resource_req.username, resource_req.password, api_req, app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
if let Err(e) = check_permission_and_network_access_only(&user, fingerprint, app_state) {
|
||||
return e.into_player_response(auth_status);
|
||||
}
|
||||
let target_name = &target.name;
|
||||
if !target.has_output(TargetType::Xtream) {
|
||||
@@ -787,6 +794,7 @@ macro_rules! create_xtream_player_api_stream {
|
||||
macro_rules! create_xtream_player_api_resource {
|
||||
($fn_name:ident, $context:expr) => {
|
||||
async fn $fn_name(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::Path((username, password, stream_id, resource)): axum::extract::Path<(
|
||||
String,
|
||||
String,
|
||||
@@ -798,6 +806,7 @@ macro_rules! create_xtream_player_api_resource {
|
||||
req_headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
xtream_player_api_resource(
|
||||
&fingerprint,
|
||||
&req_headers,
|
||||
&api_req,
|
||||
&app_state,
|
||||
@@ -845,12 +854,9 @@ async fn xtream_player_api_timeshift_stream(
|
||||
let api_req = UserApiRequest::merge_prefer_primary(&path_req, &query_req);
|
||||
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target_by_credentials(&api_req.username, &api_req.password, &api_req, &app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user {}", api_req.username)
|
||||
);
|
||||
let Some((user, target)) = get_user_target_by_credentials(&api_req.username, &api_req.password, &api_req, &app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
|
||||
let epg_timeshift = parse_timeshift(user.epg_request_timeshift.as_deref());
|
||||
let start = apply_timeshift(&api_req.start, &epg_timeshift);
|
||||
@@ -894,12 +900,9 @@ async fn xtream_player_api_timeshift_query_stream(
|
||||
}
|
||||
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target_by_credentials(&api_req.username, &api_req.password, &api_req, &app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user {}", api_req.username)
|
||||
);
|
||||
let Some((user, target)) = get_user_target_by_credentials(&api_req.username, &api_req.password, &api_req, &app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
|
||||
let epg_timeshift = parse_timeshift(user.epg_request_timeshift.as_deref());
|
||||
let start = apply_timeshift(&api_req.start, &epg_timeshift);
|
||||
@@ -1292,15 +1295,15 @@ macro_rules! skip_flag_optional {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn xtream_player_api(api_req: UserApiRequest, app_state: &Arc<AppState>) -> impl IntoResponse + Send {
|
||||
async fn xtream_player_api(fingerprint: &Fingerprint, api_req: UserApiRequest, app_state: &Arc<AppState>) -> impl IntoResponse + Send {
|
||||
api_req.log_sanitized("xtream_player_api");
|
||||
let auth_status = app_state.app_config.get_auth_error_status();
|
||||
let (user, target) = try_option_forbidden!(
|
||||
get_user_target(&api_req, app_state),
|
||||
auth_status,
|
||||
false,
|
||||
format!("Could not find any user for xc player api {}", api_req.username)
|
||||
);
|
||||
let Some((user, target)) = get_user_target(&api_req, app_state) else {
|
||||
return auth_status.into_response();
|
||||
};
|
||||
if let Err(e) = check_network_access_only(&user, fingerprint, app_state) {
|
||||
return e.into_player_response(auth_status);
|
||||
}
|
||||
if !target.has_output(TargetType::Xtream) {
|
||||
return get_user_info(&user, app_state).await.map_or_else(
|
||||
|| axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
@@ -1460,17 +1463,19 @@ where
|
||||
}
|
||||
|
||||
async fn xtream_player_api_get(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Query(api_req): axum::extract::Query<UserApiRequest>,
|
||||
) -> impl IntoResponse + Send {
|
||||
xtream_player_api(api_req, &app_state).await
|
||||
xtream_player_api(&fingerprint, api_req, &app_state).await
|
||||
}
|
||||
|
||||
async fn xtream_player_api_post(
|
||||
fingerprint: Fingerprint,
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
UserApiRequestQueryOrBody(api_req): UserApiRequestQueryOrBody,
|
||||
) -> impl IntoResponse + Send {
|
||||
xtream_player_api(api_req, &app_state).await
|
||||
xtream_player_api(&fingerprint, api_req, &app_state).await
|
||||
}
|
||||
|
||||
macro_rules! register_xtream_api {
|
||||
|
||||
@@ -3992,6 +3992,7 @@ mod tests {
|
||||
soft_connections,
|
||||
soft_priority: 0,
|
||||
t_is_api_user: false,
|
||||
network_access: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use shared::model::PlaylistItemType;
|
||||
use std::{fmt, net::SocketAddr, str::FromStr, sync::Arc};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use shared::error::TuliproxError;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
|
||||
pub enum CustomVideoStreamType {
|
||||
@@ -42,7 +43,7 @@ impl fmt::Display for CustomVideoStreamType {
|
||||
}
|
||||
|
||||
impl FromStr for CustomVideoStreamType {
|
||||
type Err = String;
|
||||
type Err = TuliproxError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
@@ -52,7 +53,7 @@ impl FromStr for CustomVideoStreamType {
|
||||
"low_priority_preempted" => Ok(Self::LowPriorityPreempted),
|
||||
"user_account_expired" => Ok(Self::UserAccountExpired),
|
||||
"provisioning" => Ok(Self::Provisioning),
|
||||
_ => Err(format!("Unknown stream type: {s}")),
|
||||
_ => Err(TuliproxError::Config(format!("Unknown stream type: {s}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user