Network access restriction for api user (#728)

* Network access restriction for api user
* Fixed strm provider:// url bug
This commit is contained in:
euzu
2026-04-23 21:15:59 +02:00
committed by GitHub
parent 60cabc71fc
commit eb2a017fa3
50 changed files with 3054 additions and 935 deletions
+461 -7
View File
@@ -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()
+63 -40
View File
@@ -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()
+15 -18
View File
@@ -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(&params.username, &params.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(&params.username, &params.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,
+95 -49
View File
@@ -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),
}
}
};
}
+3 -1
View File
@@ -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},
+6 -4
View File
@@ -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);
+26 -29
View File
@@ -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();
+48 -43
View File
@@ -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}"))),
}
}
}