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
+20
View File
@@ -93,6 +93,16 @@
happens in `main`, making the path easier to test and reuse.
- **Admission Failure Deduplication**: Repeated admission failure response logic in `hls_api`, `m3u_api`, and `xtream_api`
has been centralized into shared helpers.
- **API User Network Access Restrictions**: API proxy users can now be restricted by source CIDR ranges and/or GeoIP country
codes via `network_access`.
- Matching any configured CIDR or any configured country is sufficient for access.
- Network access checks are centralized in the API user request context so denied requests stop before endpoint handling
or upstream forwarding.
- Country-based checks require the GeoIP database. If GeoIP is unavailable, the secure default is to deny requests that
did not match a configured CIDR.
- Operators can explicitly opt into allowing this GeoIP-unavailable country-rule case with
`reverse_proxy.geoip.unavailable_policy: allow`.
- CIDR-only misses, unknown countries, and country mismatches still deny.
- **QoS Aggregation Efficiency**:
- QoS snapshot listing does not rely on a full unbounded materialization path for filtered UI/API reads.
- Current-day QoS rebuilds are skipped when the history day is unchanged.
@@ -214,6 +224,16 @@
- Added `qos_aggregation` (optional) with:
- `enabled` (`bool`)
- `interval_secs` (`u64`)
- **config.yml (`reverse_proxy.geoip`)**:
- Added `unavailable_policy` (`deny` | `allow`, default `deny`).
- `deny` keeps country-based `network_access` restrictions closed when GeoIP is disabled, missing, or not loaded.
- `allow` is an explicit risk acceptance that allows country-based `network_access` restrictions only when GeoIP is
unavailable. CIDR-only misses, unknown countries, and country mismatches still deny.
- **api-proxy.yml (`user.credentials[].network_access`)**:
- Added optional per-user network restrictions:
- `allowed_networks`: CIDR ranges such as `192.168.0.0/16` or `10.0.0.1/32`.
- `allowed_countries`: ISO-style country codes resolved through GeoIP.
- The rules use OR semantics: any matching CIDR or country allows the request.
## 3.3.0 (2026-04-02)
Generated
+1
View File
@@ -4650,6 +4650,7 @@ dependencies = [
"hyper-util",
"iana-time-zone",
"indexmap",
"ipnet",
"jsonwebtoken",
"libc",
"log",
+8
View File
@@ -162,6 +162,8 @@ Generate all four formats simultaneously from the same source — one setup, eve
- **Download & Recording Manager**: Provider-aware VOD downloads and live recordings with retries, fairness, and RBAC-controlled actions
- **Config Editor**: Direct editing of config.yml, source.yml, mapping.yml in the browser
- **User Management**: API users with category selection, priority, soft-priority, normal/soft connection limits, auto-generated credentials
- **Network Access Policy UI**: API-user network restrictions can be configured with CIDR and GeoIP country rules, including
the global GeoIP-unavailable `deny`/`allow` policy.
- **RBAC Admin Panel**: Tabbed user/group management, permission checkbox grid, write-without-read warnings
- **Stream Table**: Real-time stream monitoring with copy-to-clipboard, bandwidth metrics, episode titles
- **EPG View**: Timeline with channels, now-line, program details
@@ -231,6 +233,12 @@ Generate all four formats simultaneously from the same source — one setup, eve
- **JWT authentication**: Compact bitmask encoding with password-version tracking for automatic token invalidation
- **Rate limiting**: Per-IP rate limiting with configurable burst and period
- **Content Security Policy**: Configurable CSP headers
- **API User Network Access Restrictions**: Per-user `network_access` rules can restrict API proxy accounts by IPv4/IPv6
CIDR ranges and/or GeoIP country codes. Matching any configured CIDR or country allows the request; otherwise the
request is denied before it is forwarded upstream.
- **GeoIP-Unavailable Policy**: Country-based network restrictions default to `deny` when GeoIP is disabled, missing, or not
loaded. Operators can explicitly accept that risk with `reverse_proxy.geoip.unavailable_policy: allow`; CIDR-only misses,
unknown countries, and country mismatches still deny.
- **SSL/TLS support**: Configurable including `accept_insecure_ssl_certificates` option
- **Proxy support**: HTTP, HTTPS, SOCKS5 proxies for all outgoing requests
- **Header stripping**: Configurable removal of referer, Cloudflare, and X-headers
+1
View File
@@ -80,6 +80,7 @@ crc32fast = "1.5.0"
memchr = "2.8.0"
http-body-util = "0.1.3"
lru = "0.12"
ipnet = "2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+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}"))),
}
}
}
+175
View File
@@ -0,0 +1,175 @@
use crate::{
api::{
api_utils::{
evaluate_network_access, log_network_access_allowed_geoip_unavailable, log_network_access_denied,
NetworkAccessDecision, NetworkAccessDenyReason,
},
model::AppState,
},
model::ProxyUserPermissionDenyReason,
};
use axum::response::IntoResponse;
use log::debug;
use shared::utils::sanitize_sensitive_info;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub enum PermissionDenyReason {
Expired,
Disabled,
Banned,
Inactive,
}
impl From<ProxyUserPermissionDenyReason> for PermissionDenyReason {
fn from(reason: ProxyUserPermissionDenyReason) -> Self {
match reason {
ProxyUserPermissionDenyReason::Expired | ProxyUserPermissionDenyReason::ExpiredStatus => {
PermissionDenyReason::Expired
}
ProxyUserPermissionDenyReason::Disabled => PermissionDenyReason::Disabled,
ProxyUserPermissionDenyReason::Banned => PermissionDenyReason::Banned,
ProxyUserPermissionDenyReason::Inactive => PermissionDenyReason::Inactive,
}
}
}
#[derive(Debug)]
pub enum ApiUserAuthError {
AuthFailed,
PermissionDenied(PermissionDenyReason),
NetworkDenied(NetworkAccessDenyReason),
}
impl std::fmt::Display for ApiUserAuthError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ApiUserAuthError::AuthFailed => write!(f, "Authentication failed"),
ApiUserAuthError::PermissionDenied(reason) => match reason {
PermissionDenyReason::Expired => write!(f, "User access denied, expired"),
PermissionDenyReason::Disabled => write!(f, "User access denied, status disabled"),
PermissionDenyReason::Banned => write!(f, "User access denied, status banned"),
PermissionDenyReason::Inactive => write!(f, "User access denied, status inactive"),
},
ApiUserAuthError::NetworkDenied(reason) => match reason {
NetworkAccessDenyReason::NoCidrMatch => write!(f, "Network access denied, no CIDR match"),
NetworkAccessDenyReason::NoCountryMatch => write!(f, "Network access denied, no country match"),
NetworkAccessDenyReason::GeoIpUnavailable => write!(f, "Network access denied, GeoIP unavailable"),
NetworkAccessDenyReason::CountryUnknown => write!(f, "Network access denied, country unknown"),
NetworkAccessDenyReason::MalformedClientIp => write!(f, "Network access denied, malformed client IP"),
},
}
}
}
impl ApiUserAuthError {
/// Returns a player-endpoint compatible response with configured auth error status and empty body.
/// This is used by proxy player endpoints (m3u, xtream, xmltv, etc.) where external players
/// expect empty responses with HTTP status codes only.
pub fn into_player_response(self, auth_error_status: axum::http::StatusCode) -> axum::response::Response {
let status = match &self {
ApiUserAuthError::AuthFailed => auth_error_status,
ApiUserAuthError::PermissionDenied(_) | ApiUserAuthError::NetworkDenied(_) => {
axum::http::StatusCode::FORBIDDEN
}
};
status.into_response()
}
}
#[derive(Debug)]
pub struct ApiUserContext {
pub user: Arc<crate::model::ProxyUserCredentials>,
pub target: Arc<crate::model::ConfigTarget>,
pub fingerprint: crate::auth::Fingerprint,
}
/// Checks only network access (no permission check). Used by stream endpoints
/// where permission check must happen later with full stream info for `admission_failure_response`.
pub fn check_network_access_only(
user: &Arc<crate::model::ProxyUserCredentials>,
fingerprint: &crate::auth::Fingerprint,
app_state: &Arc<AppState>,
) -> Result<(), ApiUserAuthError> {
let geoip_unavailable_policy = app_state.app_config.get_geoip_unavailable_policy();
match evaluate_network_access(user, &fingerprint.client_ip, &app_state.geoip, geoip_unavailable_policy) {
NetworkAccessDecision::Allowed => Ok(()),
NetworkAccessDecision::AllowedGeoIpUnavailable => {
log_network_access_allowed_geoip_unavailable(&user.username, &fingerprint.client_ip);
Ok(())
}
NetworkAccessDecision::Denied(reason) => {
log_network_access_denied(&user.username, &fingerprint.client_ip, reason.as_str());
Err(ApiUserAuthError::NetworkDenied(reason))
}
}
}
/// Checks network access without logging. Used for high-frequency polling endpoints
/// (e.g., `HDHomeRun` `lineup_status`) where repeated log output would be noisy.
pub fn try_check_network_access_only(
user: &Arc<crate::model::ProxyUserCredentials>,
fingerprint: &crate::auth::Fingerprint,
app_state: &Arc<AppState>,
) -> Result<(), ApiUserAuthError> {
let geoip_unavailable_policy = app_state.app_config.get_geoip_unavailable_policy();
match evaluate_network_access(user, &fingerprint.client_ip, &app_state.geoip, geoip_unavailable_policy) {
NetworkAccessDecision::Allowed | NetworkAccessDecision::AllowedGeoIpUnavailable => Ok(()),
NetworkAccessDecision::Denied(reason) => Err(ApiUserAuthError::NetworkDenied(reason)),
}
}
pub fn resolve_api_user_context(
user: Arc<crate::model::ProxyUserCredentials>,
target: Arc<crate::model::ConfigTarget>,
fingerprint: crate::auth::Fingerprint,
app_state: &Arc<AppState>,
) -> Result<ApiUserContext, ApiUserAuthError> {
// Permission check
if let Some(reason) = user.permission_denied_reason(app_state) {
debug!("User access denied for {}: {:?}", sanitize_sensitive_info(&user.username), reason);
return Err(ApiUserAuthError::PermissionDenied(reason.into()));
}
// Network access check with policy
let geoip_unavailable_policy = app_state.app_config.get_geoip_unavailable_policy();
match evaluate_network_access(&user, &fingerprint.client_ip, &app_state.geoip, geoip_unavailable_policy) {
NetworkAccessDecision::Allowed => Ok(ApiUserContext { user, target, fingerprint }),
NetworkAccessDecision::AllowedGeoIpUnavailable => {
log_network_access_allowed_geoip_unavailable(&user.username, &fingerprint.client_ip);
Ok(ApiUserContext { user, target, fingerprint })
}
NetworkAccessDecision::Denied(reason) => {
log_network_access_denied(&user.username, &fingerprint.client_ip, reason.as_str());
Err(ApiUserAuthError::NetworkDenied(reason))
}
}
}
/// Checks permission and network access without taking ownership. Used when
/// user/target are needed after the auth check (avoids unnecessary Arc clones).
pub fn check_permission_and_network_access_only(
user: &Arc<crate::model::ProxyUserCredentials>,
fingerprint: &crate::auth::Fingerprint,
app_state: &Arc<AppState>,
) -> Result<(), ApiUserAuthError> {
// Permission check
if let Some(reason) = user.permission_denied_reason(app_state) {
debug!("User access denied for {}: {:?}", sanitize_sensitive_info(&user.username), reason);
return Err(ApiUserAuthError::PermissionDenied(reason.into()));
}
// Network access check
let geoip_unavailable_policy = app_state.app_config.get_geoip_unavailable_policy();
match evaluate_network_access(user, &fingerprint.client_ip, &app_state.geoip, geoip_unavailable_policy) {
NetworkAccessDecision::Allowed => Ok(()),
NetworkAccessDecision::AllowedGeoIpUnavailable => {
log_network_access_allowed_geoip_unavailable(&user.username, &fingerprint.client_ip);
Ok(())
}
NetworkAccessDecision::Denied(reason) => {
log_network_access_denied(&user.username, &fingerprint.client_ip, reason.as_str());
Err(ApiUserAuthError::NetworkDenied(reason))
}
}
}
+3
View File
@@ -6,6 +6,8 @@ mod auth_bearer;
mod auth_basic;
mod access_token;
mod fingerprint;
mod api_user_context;
type Rejection = (StatusCode, &'static str);
#[macro_export]
@@ -27,3 +29,4 @@ pub use self::password::*;
pub use self::fingerprint::*;
pub use self::auth_basic::*;
pub use self::auth_bearer::*;
pub use self::api_user_context::*;
+7 -7
View File
@@ -218,10 +218,10 @@ impl ApiProxyConfig {
}
}
pub fn get_target_name(&self, username: &str, password: &str) -> Option<(ProxyUserCredentials, String)> {
pub fn get_target_name(&self, username: &str, password: &str) -> Option<(Arc<ProxyUserCredentials>, String)> {
for target_user in &self.user {
if let Some((credentials, target_name)) = target_user.get_target_name(username, password) {
return Some((credentials.clone(), target_name.to_string()));
return Some((Arc::clone(&credentials), target_name.to_string()));
}
}
if log::log_enabled!(log::Level::Debug) && !username.eq(API_USER) {
@@ -230,22 +230,22 @@ impl ApiProxyConfig {
None
}
pub fn get_target_name_by_token(&self, token: &str) -> Option<(ProxyUserCredentials, String)> {
pub fn get_target_name_by_token(&self, token: &str) -> Option<(Arc<ProxyUserCredentials>, String)> {
for target_user in &self.user {
if let Some((credentials, target_name)) = target_user.get_target_name_by_token(token) {
return Some((credentials.clone(), target_name.to_string()));
return Some((Arc::clone(&credentials), target_name.to_string()));
}
}
None
}
pub fn get_user_credentials(&self, username: &str) -> Option<ProxyUserCredentials> {
pub fn get_user_credentials(&self, username: &str) -> Option<Arc<ProxyUserCredentials>> {
let result = self
.user
.iter()
.flat_map(|target_user| &target_user.credentials)
.flat_map(|target_user| target_user.credentials.iter())
.find(|credential| credential.username == username)
.cloned();
.map(Arc::clone);
if result.is_none() && (username != TEST_USER && username != API_USER) {
debug!("Could not find any user credentials for: {username}");
}
+138 -21
View File
@@ -3,14 +3,95 @@ use crate::model::{macros, Config};
use arc_swap::access::Access;
use arc_swap::ArcSwap;
use chrono::Local;
use log::debug;
use log::{debug, warn};
use shared::model::{
ClusterFlags, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus, TargetUserDto, UserConnectionPermission,
XtreamCluster,
ClusterFlags, NetworkAccessDto, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus, TargetUserDto,
UserConnectionPermission, XtreamCluster,
};
use std::sync::Arc;
use zeroize::Zeroize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyUserPermissionDenyReason {
Expired,
Disabled,
Banned,
ExpiredStatus,
Inactive,
}
#[derive(Debug, Clone, Default)]
pub struct NetworkAccess {
pub allowed_countries: Vec<String>,
pub allowed_networks: Vec<ipnet::IpNet>,
}
impl NetworkAccess {
pub fn is_empty(&self) -> bool {
self.allowed_countries.is_empty() && self.allowed_networks.is_empty()
}
}
impl From<&NetworkAccessDto> for NetworkAccess {
fn from(dto: &NetworkAccessDto) -> Self {
let mut seen_countries = std::collections::HashSet::new();
let allowed_countries: Vec<String> = dto
.allowed_countries
.as_ref()
.map(|countries| {
countries
.iter()
.filter_map(|c| {
let upper = c.trim().to_uppercase();
if upper.is_empty() {
None
} else if seen_countries.insert(upper.clone()) {
Some(upper)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
let allowed_networks: Vec<ipnet::IpNet> = dto
.allowed_networks
.as_ref()
.map(|networks| {
networks
.iter()
.filter_map(|n| match n.trim().parse::<ipnet::IpNet>() {
Ok(net) => Some(net),
Err(err) => {
warn!("Skipping invalid CIDR '{n}': {err}");
None
}
})
.collect()
})
.unwrap_or_default();
Self { allowed_countries, allowed_networks }
}
}
impl From<&NetworkAccess> for NetworkAccessDto {
fn from(instance: &NetworkAccess) -> Self {
let allowed_countries = if instance.allowed_countries.is_empty() {
None
} else {
Some(instance.allowed_countries.clone())
};
let allowed_networks = if instance.allowed_networks.is_empty() {
None
} else {
Some(instance.allowed_networks.iter().map(std::string::ToString::to_string).collect())
};
Self { allowed_countries, allowed_networks }
}
}
#[derive(Debug, Clone, Default)]
pub struct ProxyUserCredentials {
pub username: String,
@@ -31,6 +112,7 @@ pub struct ProxyUserCredentials {
pub soft_connections: u16,
pub soft_priority: i8,
pub t_is_api_user: bool,
pub network_access: Option<NetworkAccess>,
}
macros::from_impl!(ProxyUserCredentials);
@@ -55,6 +137,11 @@ impl From<&ProxyUserCredentialsDto> for ProxyUserCredentials {
soft_connections: dto.soft_connections,
soft_priority: dto.soft_priority,
t_is_api_user: false,
network_access: dto
.network_access
.as_ref()
.map(NetworkAccess::from)
.filter(|network_access| !network_access.is_empty()),
}
}
}
@@ -79,6 +166,7 @@ impl From<&ProxyUserCredentials> for ProxyUserCredentialsDto {
priority: instance.priority,
soft_connections: instance.soft_connections,
soft_priority: instance.soft_priority,
network_access: instance.network_access.as_ref().map(NetworkAccessDto::from),
}
}
}
@@ -95,30 +183,50 @@ impl ProxyUserCredentials {
self.username.eq(username) && self.password.eq(password)
}
#[inline]
pub fn has_permissions(&self, app_state: &AppState) -> bool {
self.permission_denied_reason(app_state).is_none()
}
#[inline]
pub fn permission_denied(&self, app_state: &AppState) -> bool {
!self.has_permissions(app_state)
}
pub fn permission_denied_reason(&self, app_state: &AppState) -> Option<ProxyUserPermissionDenyReason> {
let config = <Arc<ArcSwap<Config>> as Access<Config>>::load(&app_state.app_config.config);
if config.user_access_control {
if let Some(exp_date) = self.exp_date.as_ref() {
let now = Local::now();
if (exp_date - now.timestamp()) < 0 {
debug!("User access denied, expired: {}", self.username);
return false;
return Some(ProxyUserPermissionDenyReason::Expired);
}
}
if let Some(status) = &self.status {
if !matches!(status, ProxyUserStatus::Active | ProxyUserStatus::Trial) {
debug!("User access denied, status invalid: {status} for user: {}", self.username);
return false;
match status {
ProxyUserStatus::Disabled => {
debug!("User access denied, status disabled: {}", self.username);
return Some(ProxyUserPermissionDenyReason::Disabled);
}
ProxyUserStatus::Banned => {
debug!("User access denied, status banned: {}", self.username);
return Some(ProxyUserPermissionDenyReason::Banned);
}
ProxyUserStatus::Expired => {
debug!("User access denied, status expired: {}", self.username);
return Some(ProxyUserPermissionDenyReason::ExpiredStatus);
}
ProxyUserStatus::Active | ProxyUserStatus::Trial => {}
ProxyUserStatus::Pending => {
debug!("User access denied, status pending: {}", self.username);
return Some(ProxyUserPermissionDenyReason::Inactive);
}
}
} // NO STATUS SET, ok admins fault, we take this as a valid status
}
}
true
}
#[inline]
pub fn permission_denied(&self, app_state: &AppState) -> bool {
!self.has_permissions(app_state)
None
}
pub fn allows_cluster(&self, cluster: XtreamCluster) -> bool {
@@ -152,30 +260,39 @@ impl Drop for ProxyUserCredentials {
#[derive(Debug, Clone)]
pub struct TargetUser {
pub target: String,
pub credentials: Vec<ProxyUserCredentials>,
pub credentials: Vec<Arc<ProxyUserCredentials>>,
}
macros::from_impl!(TargetUser);
impl From<&TargetUserDto> for TargetUser {
fn from(dto: &TargetUserDto) -> Self {
Self { target: dto.target.clone(), credentials: dto.credentials.iter().map(Into::into).collect() }
Self {
target: dto.target.clone(),
credentials: dto.credentials.iter().map(|c| Arc::new(c.into())).collect(),
}
}
}
impl From<&TargetUser> for TargetUserDto {
fn from(instance: &TargetUser) -> Self {
Self { target: instance.target.clone(), credentials: instance.credentials.iter().map(Into::into).collect() }
Self {
target: instance.target.clone(),
credentials: instance.credentials.iter().map(|c| c.as_ref().into()).collect(),
}
}
}
impl TargetUser {
pub fn get_target_name(&self, username: &str, password: &str) -> Option<(&ProxyUserCredentials, &str)> {
pub fn get_target_name(&self, username: &str, password: &str) -> Option<(Arc<ProxyUserCredentials>, &str)> {
self.credentials
.iter()
.find(|c| c.matches(username, password))
.map(|credentials| (credentials, self.target.as_str()))
.map(|credentials| (Arc::clone(credentials), self.target.as_str()))
}
pub fn get_target_name_by_token(&self, token: &str) -> Option<(&ProxyUserCredentials, &str)> {
self.credentials.iter().find(|c| c.matches_token(token)).map(|credentials| (credentials, self.target.as_str()))
pub fn get_target_name_by_token(&self, token: &str) -> Option<(Arc<ProxyUserCredentials>, &str)> {
self.credentials
.iter()
.find(|c| c.matches_token(token))
.map(|credentials| (Arc::clone(credentials), self.target.as_str()))
}
}
+14 -10
View File
@@ -1,15 +1,15 @@
use crate::api::model::TransportStreamBuffer;
use crate::model::{
ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, CustomStreamResponse,
GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, MediaToolCapabilities, ProxyUserCredentials,
ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput,
GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, MediaToolCapabilities,
ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput,
};
use crate::utils;
use arc_swap::{ArcSwap, ArcSwapOption};
use log::{error, warn};
use rand::Rng;
use shared::error::TuliproxError;
use shared::model::ConfigPaths;
use shared::model::{ConfigPaths, GeoIpUnavailablePolicy};
use shared::utils::{
CHANNEL_UNAVAILABLE, LOW_PRIORITY_PREEMPTED, PANEL_API_PROVISIONING, PROVIDER_CONNECTIONS_EXHAUSTED,
USER_ACCOUNT_EXPIRED, USER_CONNECTIONS_EXHAUSTED,
@@ -153,14 +153,14 @@ impl AppConfig {
config.reverse_proxy.as_ref().map(|r| r.rewrite_secret)
}
fn intern_get_target_for_user(&self, user_target: Option<(ProxyUserCredentials, String)>) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
fn intern_get_target_for_user(&self, user_target: Option<(Arc<ProxyUserCredentials>, String)>) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
match user_target {
Some((user, target_name)) => {
let sources = self.sources.load();
for source in &sources.sources {
for target in &source.targets {
if target_name.eq_ignore_ascii_case(&target.name) {
return Some((user, Arc::clone(target)));
return Some((Arc::clone(&user), Arc::clone(target)));
}
}
}
@@ -186,7 +186,7 @@ impl AppConfig {
None
}
pub fn get_target_for_username(&self, username: &str) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
pub fn get_target_for_username(&self, username: &str) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
if let Some(credentials) = self.get_user_credentials(username) {
return self.api_proxy.load().as_ref()
.and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name(&credentials.username, &credentials.password)));
@@ -194,15 +194,15 @@ impl AppConfig {
None
}
pub fn get_target_for_user(&self, username: &str, password: &str) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
pub fn get_target_for_user(&self, username: &str, password: &str) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
self.api_proxy.load().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name(username, password)))
}
pub fn get_target_for_user_by_token(&self, token: &str) -> Option<(ProxyUserCredentials, Arc<ConfigTarget>)> {
self.api_proxy.load().as_ref().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name_by_token(token)))
pub fn get_target_for_user_by_token(&self, token: &str) -> Option<(Arc<ProxyUserCredentials>, Arc<ConfigTarget>)> {
self.api_proxy.load().as_ref().and_then(|api_proxy| self.intern_get_target_for_user(api_proxy.get_target_name_by_token(token)))
}
pub fn get_user_credentials(&self, username: &str) -> Option<ProxyUserCredentials> {
pub fn get_user_credentials(&self, username: &str) -> Option<Arc<ProxyUserCredentials>> {
self.api_proxy.load().as_ref().as_ref().and_then(|api_proxy| api_proxy.get_user_credentials(username))
}
@@ -466,6 +466,10 @@ impl AppConfig {
self.config.load().get_grace_options()
}
pub fn get_geoip_unavailable_policy(&self) -> GeoIpUnavailablePolicy {
self.config.load().get_geoip_unavailable_policy()
}
pub async fn is_ffprobe_enabled(&self) -> bool {
let ffprobe_enabled_in_config = {
let config = self.config.load();
+63 -5
View File
@@ -1,13 +1,13 @@
use crate::model::{
macros, ConfigApi, HdHomeRunConfig, HdHomeRunFlags, IpCheckConfig, LibraryConfig, LogConfig, MetadataUpdateConfig,
MessagingConfig, ProxyConfig, ReverseProxyConfig, ReverseProxyDisabledHeaderConfig, ScheduleConfig, VideoConfig,
WebUiConfig,
macros, ConfigApi, HdHomeRunConfig, HdHomeRunFlags, IpCheckConfig, LibraryConfig,
LogConfig, MetadataUpdateConfig, MessagingConfig, ProxyConfig, ReverseProxyConfig,
ReverseProxyDisabledHeaderConfig, ScheduleConfig, VideoConfig, WebUiConfig,
};
use crate::utils;
use log::{error, info};
use path_clean::PathClean;
use shared::error::TuliproxError;
use shared::model::{ConfigDto, HdHomeRunDeviceOverview};
use shared::model::{ConfigDto, GeoIpUnavailablePolicy, HdHomeRunDeviceOverview};
use shared::utils::{default_grace_period_millis, default_grace_period_timeout_secs, set_sanitize_sensitive_info, DEFAULT_BACKUP_DIR, DEFAULT_CACHE_DIR, DEFAULT_DOWNLOAD_DIR, DEFAULT_STORAGE_DIR, DEFAULT_STORAGE_TEMP_DIR, DEFAULT_USER_CONFIG_DIR};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
@@ -262,6 +262,13 @@ impl Config {
self.reverse_proxy.as_ref().is_some_and(|r| r.geoip.as_ref().is_some_and(|g| g.enabled))
}
pub fn get_geoip_unavailable_policy(&self) -> GeoIpUnavailablePolicy {
self.reverse_proxy
.as_ref()
.and_then(|r| r.geoip.as_ref())
.map_or(GeoIpUnavailablePolicy::Deny, |g| g.unavailable_policy)
}
pub fn get_disabled_headers(&self) -> Option<ReverseProxyDisabledHeaderConfig> {
self.reverse_proxy
.as_ref()
@@ -321,7 +328,7 @@ impl From<&ConfigDto> for Config {
#[cfg(test)]
mod tests {
use super::Config;
use shared::model::ConfigDto;
use shared::model::{ConfigDto, GeoIpUnavailablePolicy};
use tempfile::tempdir;
#[test]
@@ -402,4 +409,55 @@ mod tests {
assert_eq!(stream_history.stream_history_retention_days, 14);
assert_eq!(stream_history.stream_history_directory, "/var/lib/tuliprox/history");
}
#[test]
fn get_geoip_unavailable_policy_no_reverse_proxy_returns_deny() {
let dto = ConfigDto::default();
let config = Config::from(&dto);
assert_eq!(config.get_geoip_unavailable_policy(), GeoIpUnavailablePolicy::Deny);
}
#[test]
fn get_geoip_unavailable_policy_no_geoip_returns_deny() {
let dto = ConfigDto {
reverse_proxy: Some(shared::model::ReverseProxyConfigDto::default()),
..Default::default()
};
let config = Config::from(&dto);
assert_eq!(config.get_geoip_unavailable_policy(), GeoIpUnavailablePolicy::Deny);
}
#[test]
fn get_geoip_unavailable_policy_missing_field_returns_deny() {
let dto = ConfigDto {
reverse_proxy: Some(shared::model::ReverseProxyConfigDto {
geoip: Some(shared::model::GeoIpConfigDto {
enabled: true,
url: "https://example.com/db.csv".to_string(),
..shared::model::GeoIpConfigDto::default()
}),
..Default::default()
}),
..Default::default()
};
let config = Config::from(&dto);
assert_eq!(config.get_geoip_unavailable_policy(), GeoIpUnavailablePolicy::Deny);
}
#[test]
fn get_geoip_unavailable_policy_allow_returns_allow() {
let dto = ConfigDto {
reverse_proxy: Some(shared::model::ReverseProxyConfigDto {
geoip: Some(shared::model::GeoIpConfigDto {
enabled: true,
url: "https://example.com/db.csv".to_string(),
unavailable_policy: shared::model::GeoIpUnavailablePolicy::Allow,
}),
..Default::default()
}),
..Default::default()
};
let config = Config::from(&dto);
assert_eq!(config.get_geoip_unavailable_policy(), GeoIpUnavailablePolicy::Allow);
}
}
+42 -1
View File
@@ -1,10 +1,11 @@
use shared::model::GeoIpConfigDto;
use shared::model::{GeoIpConfigDto, GeoIpUnavailablePolicy};
use crate::model::macros;
#[derive(Debug, Clone)]
pub struct GeoIpConfig {
pub(crate) enabled: bool,
pub(crate) url: String,
pub(crate) unavailable_policy: GeoIpUnavailablePolicy,
}
macros::from_impl!(GeoIpConfig);
@@ -14,6 +15,7 @@ impl From<&GeoIpConfigDto> for GeoIpConfig {
Self {
enabled: dto.enabled,
url: dto.url.clone(),
unavailable_policy: dto.unavailable_policy,
}
}
}
@@ -23,6 +25,45 @@ impl From<&GeoIpConfig> for GeoIpConfigDto {
Self {
enabled: instance.enabled,
url: instance.url.clone(),
unavailable_policy: instance.unavailable_policy,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn geoip_unavailable_policy_dto_default_converts_to_deny() {
let dto = GeoIpConfigDto {
enabled: false,
url: String::new(),
unavailable_policy: GeoIpUnavailablePolicy::Deny,
};
let config: GeoIpConfig = (&dto).into();
assert_eq!(config.unavailable_policy, GeoIpUnavailablePolicy::Deny);
}
#[test]
fn geoip_unavailable_policy_dto_allow_converts_to_allow() {
let dto = GeoIpConfigDto {
enabled: true,
url: "https://example.com/db.csv".to_string(),
unavailable_policy: GeoIpUnavailablePolicy::Allow,
};
let config: GeoIpConfig = (&dto).into();
assert_eq!(config.unavailable_policy, GeoIpUnavailablePolicy::Allow);
}
#[test]
fn geoip_unavailable_policy_domain_allow_converts_to_dto_allow() {
let config = GeoIpConfig {
enabled: true,
url: "https://example.com/db.csv".to_string(),
unavailable_policy: GeoIpUnavailablePolicy::Allow,
};
let dto: GeoIpConfigDto = (&config).into();
assert_eq!(dto.unavailable_policy, GeoIpUnavailablePolicy::Allow);
}
}
+184 -64
View File
@@ -1,8 +1,10 @@
use super::bplustree::{BPlusTree, MAGIC, STORAGE_VERSION};
use super::storage_const;
use super::{
bplustree::{BPlusTree, MAGIC, STORAGE_VERSION},
storage_const,
};
use fs2::FileExt as _;
use log::{info, trace, warn};
use shared::model::{ClusterFlags, ConfigPaths, ProxyType, ProxyUserStatus};
use shared::model::{ClusterFlags, ConfigPaths, NetworkAccessDto, ProxyType, ProxyUserStatus};
use std::{
collections::{HashSet, VecDeque},
ffi::OsStr,
@@ -19,7 +21,7 @@ const HEADER_FLAG_HAS_TOMBSTONES: u32 = 1 << 30;
const HEADER_METADATA_LEN_MASK: u32 = !(HEADER_FLAG_HAS_METADATA_FLAGS | HEADER_FLAG_HAS_TOMBSTONES);
const MARKER_FILE_GUARD_PREFIX: &str = ".db_mergeto_v";
const MARKER_FILE_GUARD_PREFIX_LEGACY_ALT: &str = ".db_mergedto";
const MARKER_FILE_API_USER_GUARD: &str = ".userdb_mergeto_v5";
const MARKER_FILE_API_USER_GUARD: &str = ".userdb_mergeto_v6";
const MARKER_VERSION_KEY: &str = "migrated_to";
const MARKER_ROOTS_FINGERPRINT_KEY: &str = "roots_fingerprint";
@@ -38,9 +40,7 @@ struct BPlusTreeStartupMigrator {
}
impl BPlusTreeStartupMigrator {
pub fn new(roots: Vec<PathBuf>) -> Self {
Self { roots, migration_marker_path: None }
}
pub fn new(roots: Vec<PathBuf>) -> Self { Self { roots, migration_marker_path: None } }
pub fn new_with_marker(roots: Vec<PathBuf>, migration_marker_path: PathBuf) -> Self {
Self { roots, migration_marker_path: Some(migration_marker_path) }
@@ -380,9 +380,7 @@ pub fn migrate_bplustree_databases(roots: &[PathBuf]) -> io::Result<BPlusTreeMig
BPlusTreeStartupMigrator::new(roots.to_vec()).run()
}
pub fn bplustree_migration_marker_path(marker_dir: &Path) -> PathBuf {
marker_dir.join(marker_file_name())
}
pub fn bplustree_migration_marker_path(marker_dir: &Path) -> PathBuf { marker_dir.join(marker_file_name()) }
pub fn migrate_bplustree_databases_with_marker(
roots: &[PathBuf],
@@ -392,9 +390,7 @@ pub fn migrate_bplustree_databases_with_marker(
BPlusTreeStartupMigrator::new_with_marker(roots.to_vec(), marker_path).run()
}
fn marker_file_name() -> String {
format!("{MARKER_FILE_GUARD_PREFIX}{STORAGE_VERSION}")
}
fn marker_file_name() -> String { format!("{MARKER_FILE_GUARD_PREFIX}{STORAGE_VERSION}") }
//
// The user database has gone through six serialization schemas (MessagePack,
@@ -412,6 +408,7 @@ fn marker_file_name() -> String {
// overwrite the freshly migrated data.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredApiUserV1 {
pub target: String,
pub username: String,
@@ -429,6 +426,7 @@ struct StoredApiUserV1 {
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredApiUserV2 {
pub target: String,
pub username: String,
@@ -449,6 +447,7 @@ struct StoredApiUserV2 {
// V3 mirror — same layout as user_repository::StoredProxyUserCredentials.
// Defined here so the migration has no dependency on user_repository internals.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredApiUserV3 {
pub target: String,
pub username: String,
@@ -512,6 +511,7 @@ impl StoredApiUserV3 {
// V4 mirror — same layout as user_repository::StoredProxyUserCredentials.
// Defined here so the migration has no dependency on user_repository internals.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredApiUserV4 {
pub target: String,
pub username: String,
@@ -555,18 +555,15 @@ impl StoredApiUserV4 {
}
}
fn from_v2(v2: &StoredApiUserV2) -> Self {
Self::from_v3(&StoredApiUserV3::from_v2(v2))
}
fn from_v2(v2: &StoredApiUserV2) -> Self { Self::from_v3(&StoredApiUserV3::from_v2(v2)) }
fn from_v1(v1: &StoredApiUserV1) -> Self {
Self::from_v3(&StoredApiUserV3::from_v1(v1))
}
fn from_v1(v1: &StoredApiUserV1) -> Self { Self::from_v3(&StoredApiUserV3::from_v1(v1)) }
}
// V5 mirror — same layout as user_repository::StoredProxyUserCredentials.
// V5 mirror — same layout as the previous user_repository::StoredProxyUserCredentials.
// Defined here so the migration has no dependency on user_repository internals.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredApiUserV5 {
pub target: String,
pub username: String,
@@ -612,17 +609,71 @@ impl StoredApiUserV5 {
}
}
fn from_v3(v3: &StoredApiUserV3) -> Self {
Self::from_v4(&StoredApiUserV4::from_v3(v3))
fn from_v3(v3: &StoredApiUserV3) -> Self { Self::from_v4(&StoredApiUserV4::from_v3(v3)) }
fn from_v2(v2: &StoredApiUserV2) -> Self { Self::from_v4(&StoredApiUserV4::from_v2(v2)) }
fn from_v1(v1: &StoredApiUserV1) -> Self { Self::from_v4(&StoredApiUserV4::from_v1(v1)) }
}
// V6 mirror — same layout as user_repository::StoredProxyUserCredentials.
// Defined here so the migration has no dependency on user_repository internals.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct StoredApiUserV6 {
pub target: String,
pub username: String,
pub password: String,
pub token: Option<String>,
pub proxy: ProxyType,
pub server: Option<String>,
pub epg_timeshift: Option<String>,
pub epg_request_timeshift: Option<String>,
pub created_at: Option<i64>,
pub exp_date: Option<i64>,
pub max_connections: Option<u32>,
pub status: Option<ProxyUserStatus>,
pub output_clusters: ClusterFlags,
pub ui_enabled: bool,
pub comment: Option<String>,
pub priority: Option<i8>,
pub soft_connections: Option<u16>,
pub soft_priority: Option<i8>,
pub network_access: Option<NetworkAccessDto>,
}
impl StoredApiUserV6 {
fn from_v5(v5: &StoredApiUserV5) -> Self {
Self {
target: v5.target.clone(),
username: v5.username.clone(),
password: v5.password.clone(),
token: v5.token.clone(),
proxy: v5.proxy,
server: v5.server.clone(),
epg_timeshift: v5.epg_timeshift.clone(),
epg_request_timeshift: v5.epg_request_timeshift.clone(),
created_at: v5.created_at,
exp_date: v5.exp_date,
max_connections: v5.max_connections,
status: v5.status,
output_clusters: v5.output_clusters,
ui_enabled: v5.ui_enabled,
comment: v5.comment.clone(),
priority: v5.priority,
soft_connections: v5.soft_connections,
soft_priority: v5.soft_priority,
network_access: None,
}
}
fn from_v2(v2: &StoredApiUserV2) -> Self {
Self::from_v4(&StoredApiUserV4::from_v2(v2))
}
fn from_v4(v4: &StoredApiUserV4) -> Self { Self::from_v5(&StoredApiUserV5::from_v4(v4)) }
fn from_v1(v1: &StoredApiUserV1) -> Self {
Self::from_v4(&StoredApiUserV4::from_v1(v1))
}
fn from_v3(v3: &StoredApiUserV3) -> Self { Self::from_v5(&StoredApiUserV5::from_v3(v3)) }
fn from_v2(v2: &StoredApiUserV2) -> Self { Self::from_v5(&StoredApiUserV5::from_v2(v2)) }
fn from_v1(v1: &StoredApiUserV1) -> Self { Self::from_v5(&StoredApiUserV5::from_v1(v1)) }
}
fn create_user_db_merge_guard(merge_guard_path: &Path) -> io::Result<()> {
@@ -632,68 +683,76 @@ fn create_user_db_merge_guard(merge_guard_path: &Path) -> io::Result<()> {
Ok(())
}
pub(crate) fn user_db_merge_guard_path(config_dir: &Path) -> PathBuf {
config_dir.join(MARKER_FILE_API_USER_GUARD)
}
pub(crate) fn user_db_merge_guard_path(config_dir: &Path) -> PathBuf { config_dir.join(MARKER_FILE_API_USER_GUARD) }
/// Migrates the user database file from V1, V2, V3, or V4 schema to V5 (current) in
/// Migrates the user database file from V1-V5 schema to V6 (current) in
/// place and creates a merge-guard file so config-driven merges are skipped
/// until the operator explicitly removes it.
///
/// Returns `true` when a migration was performed, `false` when the file was
/// already in V5 format or did not exist.
/// already in V6 format or did not exist.
fn migrate_user_db_schema(db_path: &Path, merge_guard_path: &Path) -> io::Result<bool> {
if !db_path.exists() {
return Ok(false);
}
if BPlusTree::<String, StoredApiUserV5>::load(db_path).is_ok() {
if let Ok(tree) = BPlusTree::<String, StoredApiUserV5>::load(db_path) {
let mut v6_tree: BPlusTree<String, StoredApiUserV6> = BPlusTree::new();
for (key, v5) in &tree {
v6_tree.insert(key.clone(), StoredApiUserV6::from_v5(v5));
}
create_user_db_merge_guard(merge_guard_path)?;
v6_tree.store(db_path)?;
return Ok(true);
}
if BPlusTree::<String, StoredApiUserV6>::load(db_path).is_ok() {
return Ok(false);
}
if let Ok(tree) = BPlusTree::<String, StoredApiUserV4>::load(db_path) {
let mut v5_tree: BPlusTree<String, StoredApiUserV5> = BPlusTree::new();
let mut v6_tree: BPlusTree<String, StoredApiUserV6> = BPlusTree::new();
for (key, v4) in &tree {
v5_tree.insert(key.clone(), StoredApiUserV5::from_v4(v4));
v6_tree.insert(key.clone(), StoredApiUserV6::from_v4(v4));
}
create_user_db_merge_guard(merge_guard_path)?;
v5_tree.store(db_path)?;
v6_tree.store(db_path)?;
return Ok(true);
}
if let Ok(tree) = BPlusTree::<String, StoredApiUserV3>::load(db_path) {
let mut v5_tree: BPlusTree<String, StoredApiUserV5> = BPlusTree::new();
let mut v6_tree: BPlusTree<String, StoredApiUserV6> = BPlusTree::new();
for (key, v3) in &tree {
v5_tree.insert(key.clone(), StoredApiUserV5::from_v3(v3));
v6_tree.insert(key.clone(), StoredApiUserV6::from_v3(v3));
}
create_user_db_merge_guard(merge_guard_path)?;
v5_tree.store(db_path)?;
v6_tree.store(db_path)?;
return Ok(true);
}
if let Ok(tree) = BPlusTree::<String, StoredApiUserV2>::load(db_path) {
let mut v5_tree: BPlusTree<String, StoredApiUserV5> = BPlusTree::new();
let mut v6_tree: BPlusTree<String, StoredApiUserV6> = BPlusTree::new();
for (key, v2) in &tree {
v5_tree.insert(key.clone(), StoredApiUserV5::from_v2(v2));
v6_tree.insert(key.clone(), StoredApiUserV6::from_v2(v2));
}
create_user_db_merge_guard(merge_guard_path)?;
v5_tree.store(db_path)?;
v6_tree.store(db_path)?;
return Ok(true);
}
if let Ok(tree) = BPlusTree::<String, StoredApiUserV1>::load(db_path) {
let mut v5_tree: BPlusTree<String, StoredApiUserV5> = BPlusTree::new();
let mut v6_tree: BPlusTree<String, StoredApiUserV6> = BPlusTree::new();
for (key, v1) in &tree {
v5_tree.insert(key.clone(), StoredApiUserV5::from_v1(v1));
v6_tree.insert(key.clone(), StoredApiUserV6::from_v1(v1));
}
create_user_db_merge_guard(merge_guard_path)?;
v5_tree.store(db_path)?;
v6_tree.store(db_path)?;
return Ok(true);
}
Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("User DB at '{}' exists but could not be read as V1, V2, V3, V4, or V5 format", db_path.display()),
format!("User DB at '{}' exists but could not be read as V1, V2, V3, V4, V5, or V6 format", db_path.display()),
))
}
@@ -705,7 +764,7 @@ pub struct AllStartupMigrationStats {
/// Runs all startup migrations in sequence:
/// 1. B+Tree storage-format migration (V1 -> current binary format)
/// 2. User DB schema migration (V1/V2/V3/V4 -> V5 `MessagePack` layout)
/// 2. User DB schema migration (V1-V5 -> V6 `MessagePack` layout)
///
/// `config_dir` is the directory that contains `api_user.db` and the merge-guard
/// marker. `storage_dir` is used for the B+Tree migration marker.
@@ -754,7 +813,7 @@ pub fn run_startup_migrations(config_paths: &ConfigPaths) {
);
}
if stats.user_db_migrated {
info!("User DB schema migrated to V5");
info!("User DB schema migrated to V6");
}
}
Err(err) => {
@@ -948,7 +1007,7 @@ mod tests {
}
#[test]
fn user_db_schema_migration_v2_to_v5_creates_merge_guard() -> io::Result<()> {
fn user_db_schema_migration_v2_to_v6_creates_merge_guard() -> io::Result<()> {
let temp = tempdir()?;
let db_path = temp.path().join(storage_const::API_USER_DB_FILE);
let merge_guard_path = user_db_merge_guard_path(temp.path());
@@ -980,8 +1039,8 @@ mod tests {
assert!(migrated);
assert!(merge_guard_path.exists());
let v5_tree = BPlusTree::<String, StoredApiUserV5>::load(&db_path)?;
let user = v5_tree
let v6_tree = BPlusTree::<String, StoredApiUserV6>::load(&db_path)?;
let user = v6_tree
.query(&"alice".to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "alice missing after migration"))?;
assert_eq!(user.username, "alice");
@@ -990,12 +1049,13 @@ mod tests {
assert_eq!(user.priority, None);
assert_eq!(user.soft_connections, None);
assert_eq!(user.soft_priority, None);
assert_eq!(user.network_access, None);
Ok(())
}
#[test]
fn user_db_schema_migration_v3_to_v5_creates_merge_guard() -> io::Result<()> {
fn user_db_schema_migration_v3_to_v6_creates_merge_guard() -> io::Result<()> {
let temp = tempdir()?;
let db_path = temp.path().join(storage_const::API_USER_DB_FILE);
let merge_guard_path = user_db_merge_guard_path(temp.path());
@@ -1028,20 +1088,21 @@ mod tests {
assert!(migrated);
assert!(merge_guard_path.exists());
let v5_tree = BPlusTree::<String, StoredApiUserV5>::load(&db_path)?;
let user = v5_tree
let v6_tree = BPlusTree::<String, StoredApiUserV6>::load(&db_path)?;
let user = v6_tree
.query(&"bob".to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bob missing after migration"))?;
assert_eq!(user.output_clusters, ClusterFlags::all());
assert_eq!(user.priority, Some(5));
assert_eq!(user.soft_connections, None);
assert_eq!(user.soft_priority, None);
assert_eq!(user.network_access, None);
Ok(())
}
#[test]
fn user_db_schema_migration_v4_to_v5_creates_merge_guard() -> io::Result<()> {
fn user_db_schema_migration_v4_to_v6_creates_merge_guard() -> io::Result<()> {
let temp = tempdir()?;
let db_path = temp.path().join(storage_const::API_USER_DB_FILE);
let merge_guard_path = user_db_merge_guard_path(temp.path());
@@ -1076,19 +1137,20 @@ mod tests {
assert!(migrated);
assert!(merge_guard_path.exists());
let v5_tree = BPlusTree::<String, StoredApiUserV5>::load(&db_path)?;
let user = v5_tree
let v6_tree = BPlusTree::<String, StoredApiUserV6>::load(&db_path)?;
let user = v6_tree
.query(&"carol".to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "carol missing after migration"))?;
assert_eq!(user.output_clusters, ClusterFlags::all());
assert_eq!(user.soft_connections, Some(2));
assert_eq!(user.soft_priority, Some(-4));
assert_eq!(user.network_access, None);
Ok(())
}
#[test]
fn user_db_schema_v5_is_detected_without_writing_merge_guard() -> io::Result<()> {
fn user_db_schema_migration_v5_to_v6_creates_merge_guard() -> io::Result<()> {
let temp = tempdir()?;
let db_path = temp.path().join(storage_const::API_USER_DB_FILE);
let merge_guard_path = user_db_merge_guard_path(temp.path());
@@ -1121,17 +1183,75 @@ mod tests {
assert!(!merge_guard_path.exists());
let migrated = migrate_user_db_schema(&db_path, &merge_guard_path)?;
assert!(!migrated);
assert!(!merge_guard_path.exists());
assert!(migrated);
assert!(merge_guard_path.exists());
let v5_tree = BPlusTree::<String, StoredApiUserV5>::load(&db_path)?;
let user = v5_tree
let v6_tree = BPlusTree::<String, StoredApiUserV6>::load(&db_path)?;
let user = v6_tree
.query(&"dave".to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "dave missing after v5 detection"))?;
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "dave missing after v6 migration"))?;
assert_eq!(user.output_clusters, ClusterFlags::Live | ClusterFlags::Vod);
assert_eq!(user.priority, Some(5));
assert_eq!(user.soft_connections, Some(2));
assert_eq!(user.soft_priority, Some(-4));
assert_eq!(user.network_access, None);
Ok(())
}
#[test]
fn user_db_schema_v6_is_detected_without_writing_merge_guard() -> io::Result<()> {
let temp = tempdir()?;
let db_path = temp.path().join(storage_const::API_USER_DB_FILE);
let merge_guard_path = user_db_merge_guard_path(temp.path());
let mut v6_tree: BPlusTree<String, StoredApiUserV6> = BPlusTree::new();
v6_tree.insert(
"erin".to_string(),
StoredApiUserV6 {
target: "channels".to_string(),
username: "erin".to_string(),
password: "secret".to_string(),
token: None,
proxy: ProxyType::Reverse(None),
server: None,
epg_timeshift: None,
epg_request_timeshift: None,
created_at: None,
exp_date: None,
max_connections: Some(1),
status: Some(ProxyUserStatus::Active),
output_clusters: ClusterFlags::Live | ClusterFlags::Vod,
ui_enabled: true,
comment: None,
priority: Some(5),
soft_connections: Some(2),
soft_priority: Some(-4),
network_access: Some(NetworkAccessDto {
allowed_countries: Some(vec!["DE".to_string()]),
allowed_networks: Some(vec!["192.168.0.0/16".to_string()]),
}),
},
);
let _ = v6_tree.store(&db_path)?;
assert!(!merge_guard_path.exists());
let migrated = migrate_user_db_schema(&db_path, &merge_guard_path)?;
assert!(!migrated);
assert!(!merge_guard_path.exists());
let v6_tree = BPlusTree::<String, StoredApiUserV6>::load(&db_path)?;
let user = v6_tree
.query(&"erin".to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "erin missing after v6 detection"))?;
assert_eq!(user.output_clusters, ClusterFlags::Live | ClusterFlags::Vod);
assert_eq!(user.priority, Some(5));
assert_eq!(user.soft_connections, Some(2));
assert_eq!(user.soft_priority, Some(-4));
assert_eq!(
user.network_access.as_ref().and_then(|value| value.allowed_countries.as_ref()),
Some(&vec!["DE".to_string()])
);
Ok(())
}
File diff suppressed because it is too large Load Diff
+33 -13
View File
@@ -1,6 +1,6 @@
use crate::model::Config;
use crate::model::PlaylistXtreamCategory;
use crate::model::{Config, NetworkAccess, PlaylistXtreamCategory};
use crate::model::{AppConfig, ProxyUserCredentials, TargetUser};
use std::sync::Arc;
use crate::repository::storage_const;
use crate::repository::xtream_get_playlist_categories;
use crate::repository::BPlusTree;
@@ -16,7 +16,7 @@ use std::io::Error;
use std::path::{Path, PathBuf};
use tokio::task;
// V5 (current): added output_clusters. V1-V4 are migrated to V5 at startup
// V6 (current): added network_access. V1-V5 are migrated to V6 at startup
// by `bplustree_migration::run_all_startup_migrations`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct StoredProxyUserCredentials {
@@ -38,6 +38,8 @@ struct StoredProxyUserCredentials {
pub priority: Option<i8>,
pub soft_connections: Option<u16>,
pub soft_priority: Option<i8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_access: Option<shared::model::NetworkAccessDto>,
}
impl StoredProxyUserCredentials {
@@ -61,6 +63,7 @@ impl StoredProxyUserCredentials {
priority: if proxy.priority != 0 { Some(proxy.priority) } else { None },
soft_connections: if proxy.soft_connections > 0 { Some(proxy.soft_connections) } else { None },
soft_priority: if proxy.soft_priority != 0 { Some(proxy.soft_priority) } else { None },
network_access: proxy.network_access.as_ref().map(Into::into),
}
}
@@ -84,6 +87,7 @@ impl StoredProxyUserCredentials {
soft_connections: stored.soft_connections.unwrap_or(0),
soft_priority: stored.soft_priority.unwrap_or(0),
t_is_api_user: false,
network_access: stored.network_access.as_ref().map(NetworkAccess::from),
}
}
}
@@ -167,10 +171,10 @@ fn collect_target_users(user_tree: &BPlusTree<String, StoredProxyUserCredentials
let target_name = stored_user.target.clone();
match target_users.entry(target_name) {
std::collections::hash_map::Entry::Occupied(mut entry) => {
entry.get_mut().credentials.push(proxy_user);
entry.get_mut().credentials.push(Arc::new(proxy_user));
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(TargetUser { target: stored_user.target.clone(), credentials: vec![proxy_user] });
entry.insert(TargetUser { target: stored_user.target.clone(), credentials: vec![Arc::new(proxy_user)] });
}
}
}
@@ -505,7 +509,7 @@ mod tests {
let user = TargetUser {
target: "test".to_string(),
credentials: vec![
ProxyUserCredentials {
Arc::new(ProxyUserCredentials {
username: "Test".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
@@ -524,8 +528,9 @@ mod tests {
soft_connections: 0,
soft_priority: 0,
t_is_api_user: false,
},
ProxyUserCredentials {
network_access: None,
}),
Arc::new(ProxyUserCredentials {
username: "Test2".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
@@ -544,8 +549,9 @@ mod tests {
soft_connections: 0,
soft_priority: 0,
t_is_api_user: false,
},
ProxyUserCredentials {
network_access: None,
}),
Arc::new(ProxyUserCredentials {
username: "Test3".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
@@ -564,8 +570,9 @@ mod tests {
soft_connections: 0,
soft_priority: 0,
t_is_api_user: false,
},
ProxyUserCredentials {
network_access: None,
}),
Arc::new(ProxyUserCredentials {
username: "Test4".to_string(),
password: "Test".to_string(),
token: Some("Test".to_string()),
@@ -584,7 +591,11 @@ mod tests {
soft_connections: 2,
soft_priority: -3,
t_is_api_user: false,
},
network_access: Some(crate::model::NetworkAccess {
allowed_countries: vec!["DE".to_string(), "AT".to_string()],
allowed_networks: vec!["10.0.0.0/8".parse().unwrap(), "192.168.1.0/24".parse().unwrap()],
}),
}),
],
};
@@ -626,6 +637,15 @@ mod tests {
assert_eq!(test4.soft_connections, 2);
assert_eq!(test4.soft_priority, -3);
assert_eq!(test4.output_clusters, ClusterFlags::Live | ClusterFlags::Vod);
// Verify network_access round-trip (allowed_countries + allowed_networks).
let test4_na = test4.network_access.as_ref().expect("Test4 network_access should be set");
let mut countries = test4_na.allowed_countries.clone();
countries.sort();
assert_eq!(countries, vec!["AT", "DE"]);
let networks: Vec<String> = test4_na.allowed_networks.iter().map(std::string::ToString::to_string).collect();
assert_eq!(networks.len(), 2);
assert!(networks.iter().any(|n| n == "10.0.0.0/8"));
assert!(networks.iter().any(|n| n == "192.168.1.0/24"));
}
#[tokio::test]
+16 -5
View File
@@ -12,7 +12,6 @@ pub struct GeoIp {
tree: BPlusTree<u32, (u32, String)>,
}
impl GeoIp {
fn seed_private_ranges(tree: &mut BPlusTree<u32, (u32, String)>) {
@@ -91,14 +90,26 @@ impl Default for GeoIp {
}
#[cfg(test)]
mod test {
// https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn-country/asn-country-ipv4.csv
impl GeoIp {
/// Creates a `GeoIp` instance for testing that returns the specified country for any IPv4 address.
#[cfg(test)]
pub fn test_new(country: &str) -> Self {
let mut tree = BPlusTree::new();
tree.insert(0, (u32::MAX, country.to_string()));
Self { tree }
}
}
// use crate::utils::geoip::GeoIp;
// #[cfg(test)]
// mod test {
// use std::fs::File;
// use std::path::PathBuf;
// use crate::utils::file_reader;
// https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn-country/asn-country-ipv4.csv
// #[test]
// pub fn test_csv() {
// let db_file = PathBuf::from("/projects/m3u-test/asn-country-ipv4.db");
@@ -115,4 +126,4 @@ mod test {
// panic!("GeoIP lookup returned no result");
// }
// }
}
// }
+1 -1
View File
@@ -9,7 +9,7 @@ mod trakt;
mod json_utils;
mod binary_utils;
mod telegram;
mod geoip;
pub(crate) mod geoip;
mod db_viewer;
pub(crate) mod stream_history_viewer;
mod epg_parser;
+39
View File
@@ -24,7 +24,46 @@ user:
max_connections: 0
status: Active
ui_enabled: true
# network_access:
# allowed_networks:
# - "192.168.0.0/16"
# - "10.0.0.0/8"
- username: vpn-only
password: vpnsecret
proxy: reverse
output_clusters: [live, vod, series]
server: external
max_connections: 2
status: Active
# Network access restriction — uses OR logic: matching ANY allowed_networks
# OR ANY allowed_countries is sufficient for access.
#
# Private/VPN ranges must use CIDR notation (/16, /24, /32 for single IPv4s,
# /128 for a single IPv6, e.g. 2001:db8::/32).
# Country-based restrictions require GeoIP database to be configured.
# If GeoIP is unavailable and country restrictions exist, access is denied by default.
# To explicitly accept that risk globally, set this in config.yml:
#
# reverse_proxy:
# geoip:
# unavailable_policy: allow
#
# The policy is global, not per user. CIDR-only misses, unknown countries,
# and country mismatches still deny.
#
# Client IP is derived from X-Real-IP / X-Forwarded-For headers when behind
# a reverse proxy. Ensure your reverse proxy is configured to set these,
# otherwise network restrictions may not function correctly.
network_access:
allowed_networks:
- "10.200.0.0/16" # WireGuard VPN range
allowed_countries:
- DE # Germany
- AT # Austria
ui_enabled: false
# No network restrictions — allow from any source
- username: external
password: externalsecret
token: "77418"
+7
View File
@@ -83,6 +83,13 @@ reverse_proxy:
max_attempts: 3
backoff_millis: 250
backoff_multiplier: 1.0
# geoip:
# enabled: true
# # unavailable_policy: deny # default. If GeoIP is disabled, missing, or not loaded,
# # country-based network_access rules deny non-CIDR-matching requests.
# # allow: explicit risk acceptance. Country-based network_access rules
# # allow when GeoIP is unavailable; CIDR-only misses still deny.
# unavailable_policy: deny
stream:
shared_burst_buffer_mb: 12 # default 12 MB
# stream_history:
+50
View File
@@ -127,6 +127,56 @@ in your `config.yml`. Without it, these fields are purely cosmetic!
| `exp_date` | UnixTs | No | `None` | Locks the user out after this Unix timestamp. **Requires** `user_access_control: true` in `config.yml` to be enforced. |
| `ui_enabled` | Bool | No | `true` | Allows this specific user to log into the Web UI to manage their own favorites/bouquets. |
| `priority` | Int (i8) | No | `0` | Stream preemption priority. Priority range: `-128` to `127`, where `-128` has the highest priority. Negative numbers are explicitly allowed for top-tier access. (see [user priority](#user-priorities-priority) below) |
| `network_access` | Block | No | `None` | Per-user network/country access restrictions. Uses OR logic — matching ANY `allowed_networks` (CIDR) OR ANY `allowed_countries` grants access. Requires GeoIP for country checks. Client IP from `X-Real-IP` / `X-Forwarded-For`. See [Network Access Restrictions](#network-access-restrictions) below. |
---
### Network Access Restrictions
Tuliprox supports per-user network access restrictions to limit streaming based on the client's IP address or geographic location.
**How it works:**
- Uses OR logic — matching ANY `allowed_networks` (CIDR range) OR ANY `allowed_countries` grants access
- `allowed_networks`: CIDR notation for IPv4 and IPv6
(e.g., `192.168.0.0/16`, `10.0.0.0/8`, `192.168.1.1/32` for a single IPv4,
`2001:db8::/32` or `2001:db8::1/128` for IPv6)
- `allowed_countries`: ISO 3166-1 alpha-2 country codes (e.g., `DE`, `US`). Requires GeoIP database
- If no restrictions are configured, all IPs are allowed
- If GeoIP is unavailable and country restrictions exist, access is denied by default
- The global `reverse_proxy.geoip.unavailable_policy` setting can explicitly change only the GeoIP-unavailable case:
- `deny` (default): country-based restrictions deny when GeoIP is disabled, missing, or not loaded
- `allow`: explicit risk acceptance; country-based restrictions allow when GeoIP is unavailable
- CIDR-only misses, unknown countries, and country mismatches still deny
**Reverse proxy dependency:** Client IP is derived from `X-Real-IP` or `X-Forwarded-For` headers. If your reverse proxy
doesn't set these, network restrictions will not work correctly.
**Example:**
```yaml
network_access:
allowed_networks:
- "10.200.0.0/16" # VPN range
- "192.168.1.1/32" # Single IP
- "2001:db8::/32" # IPv6 range
allowed_countries:
- DE # Germany
- AT # Austria
```
Global GeoIP-unavailable policy example:
```yaml
reverse_proxy:
geoip:
enabled: true
unavailable_policy: deny # default; use allow only as explicit risk acceptance
```
**Operator logging:** Denied requests emit structured logs:
`Network access denied: user="john" client_ip="203.0.113.5" reason=no_country_match`. Possible reasons:
`no_cidr_match`, `no_country_match`, `geoip_unavailable`, `country_unknown`, `malformed_client_ip`.
---
+16 -1
View File
@@ -403,7 +403,8 @@
},
"GEO_IP_CONFIG": {
"ENABLED": "If enabled, Geo-IP lookup is performed for incoming requests.",
"URL": "URL to download the Geo-IP database (e.g. MaxMind GeoLite2-City.mmdb)."
"URL": "URL to download the Geo-IP database (e.g. MaxMind GeoLite2-City.mmdb).",
"UNAVAILABLE_POLICY": "`deny` is the secure default. If Geo-IP is disabled, missing, or not loaded, country-based network access rules deny requests that did not match a configured CIDR.\n\n`allow` is an explicit risk acceptance. It allows country-based network access rules when Geo-IP is unavailable. CIDR-only misses, unknown countries, and country mismatches still deny."
},
"HD_HOME_RUN_CONFIG": {
"AUTH": "Authentication string or token required for HDHomeRun API access.",
@@ -582,6 +583,8 @@
"SOFT_CONNECTIONS": "Additional provider slots above max_connections. Soft connections can be preempted by any normal connection or by a higher-priority soft connection.",
"SOFT_PRIORITY": "Priority used while this user's connection is consuming a soft slot. Once promoted back to a normal slot, the regular priority applies again.",
"PASSWORD": "Access password for this user's playlist and streams.",
"NETWORK_ACCESS_COUNTRIES": "Add one ISO 3166-1 alpha-2 country code per entry, for example `NL`, `FR`, or `IT`.\n\nIf set, requests from other countries are denied unless they also match an allowed network.",
"NETWORK_ACCESS_NETWORKS": "Add one allowed client network per entry in CIDR notation. Both IPv4 and IPv6 are supported, for example `192.168.1.5/32`, `192.168.0.0/16`, `10.0.0.0/8`, or `2001:db8::/32`.\n\nNetworks are checked before GeoIP country rules.",
"PLAYLIST": "Playlist specifically assigned to the user proxy context.\n\nThe `L`, `V`, and `S` toggles control which output clusters this user should receive from that target:\n`L` = Live, `V` = VOD, `S` = Series.\n\nSelect at least one cluster to activate the filter. If no cluster is selected, the cluster filter is inactive and all clusters are delivered for the assigned target.",
"PROXY": "Proxy access definitions or roles for the designated user.",
"SERVER": "Target proxy server address or definition.",
@@ -810,6 +813,8 @@
"ADD_EXTENSION": "Add Extension",
"ADD_FORMAT": "Add Format",
"ADD_HEADER": "Add header",
"ADD_COUNTRY": "Add country",
"ADD_NETWORK": "Add network",
"ADD_MAPPING": "Add Mapping",
"ADD_PATTERN": "Add Pattern",
"ADD_PROPERTY": "Add Property",
@@ -831,6 +836,8 @@
"ALIASES": "Aliases",
"ALIAS_NAME": "Alias Name",
"ALL": "All",
"ALLOWED_COUNTRIES": "Allowed Countries",
"ALLOWED_NETWORKS": "Allowed Networks",
"API": "Api",
"API_CONFIG": "API",
"API_CONFIGURATION": "API Configuration",
@@ -982,6 +989,9 @@
"FRIENDLY_NAME": "Friendly Name",
"FUZZY_MATCHING": "Fuzzy Matching",
"GEOIP": "Geo-IP",
"GEOIP_UNAVAILABLE_POLICY_DENY": "Deny",
"GEOIP_UNAVAILABLE_POLICY_ALLOW": "Allow",
"GEOIP_UNAVAILABLE_POLICY": "Geo-IP unavailable policy",
"GITHUB": "GitHub",
"GRACE_PERIOD": "Grace period ms",
"GRACE_PERIOD_HOLD_STREAM": "Grace Period Hold Stream",
@@ -1552,6 +1562,11 @@
},
"TARGET_NOT_EXISTS": "Target does not exist",
"USER_DELETED": "User successfully deleted"
,
"VALIDATION": {
"NETWORK_ACCESS_COUNTRIES": "Enter one 2-letter ISO country code per entry, for example DE.",
"NETWORK_ACCESS_NETWORKS": "Enter one CIDR per entry, for example 192.168.0.0/16 or 2001:db8::/32."
}
},
"SETUP": {
"DESC": {
@@ -0,0 +1,299 @@
use crate::{i18n::YewI18n, utils::t_safe};
use shared::model::{AdmissionStrategy, StreamConfigDto};
use std::str::FromStr;
const LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_OLDEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_OLDEST";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_LATEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_LATEST";
const LABEL_ADMISSION_STRATEGY_GRACE_INSTANT_STREAM: &str = "LABEL.ADMISSION_STRATEGY_GRACE_INSTANT_STREAM";
const LABEL_ADMISSION_STRATEGY_GRACE_HOLD_STREAM: &str = "LABEL.ADMISSION_STRATEGY_GRACE_HOLD_STREAM";
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AdmissionStrategiesDto {
pub strategies: Option<Vec<String>>,
}
pub(crate) fn admission_strategy_label_key(strategy: AdmissionStrategy) -> &'static str {
match strategy {
AdmissionStrategy::EvictUserSameIpOldest => LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST,
AdmissionStrategy::EvictUserSameIpLatest => LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST,
AdmissionStrategy::EvictUserOldest => LABEL_ADMISSION_STRATEGY_EVICT_USER_OLDEST,
AdmissionStrategy::EvictUserLatest => LABEL_ADMISSION_STRATEGY_EVICT_USER_LATEST,
AdmissionStrategy::GraceInstantStream => LABEL_ADMISSION_STRATEGY_GRACE_INSTANT_STREAM,
AdmissionStrategy::GraceHoldStream => LABEL_ADMISSION_STRATEGY_GRACE_HOLD_STREAM,
}
}
pub(crate) fn admission_strategy_label(translate: &YewI18n, strategy: AdmissionStrategy) -> String {
t_safe(translate, admission_strategy_label_key(strategy)).unwrap_or_else(|| match strategy {
AdmissionStrategy::EvictUserSameIpOldest => "Evict same-IP oldest stream".to_string(),
AdmissionStrategy::EvictUserSameIpLatest => "Evict same-IP latest stream".to_string(),
AdmissionStrategy::EvictUserOldest => "Evict user oldest stream".to_string(),
AdmissionStrategy::EvictUserLatest => "Evict user latest stream".to_string(),
AdmissionStrategy::GraceInstantStream => "Grace instant stream".to_string(),
AdmissionStrategy::GraceHoldStream => "Grace hold stream".to_string(),
})
}
fn is_grace_strategy(strategy: AdmissionStrategy) -> bool {
matches!(strategy, AdmissionStrategy::GraceInstantStream | AdmissionStrategy::GraceHoldStream)
}
pub(crate) fn is_grace_strategy_tag(tag: &str) -> bool { tag.trim().starts_with("grace_") }
pub(crate) fn admission_strategy_tags(strategies: Option<&Vec<AdmissionStrategy>>) -> Option<Vec<String>> {
strategies.map(|entries| entries.iter().map(|entry| (*entry).to_string()).collect())
}
pub(crate) fn parse_admission_strategy_tags(tags: Option<&[String]>) -> Option<Vec<AdmissionStrategy>> {
let tags = tags?;
let mut parsed = Vec::new();
for tag in tags {
if let Ok(strategy) = AdmissionStrategy::from_str(tag) {
if !parsed.contains(&strategy) {
parsed.push(strategy);
}
}
}
Some(parsed)
}
pub(crate) fn filter_disabled_grace_strategy_tags(tags: Vec<String>, grace_period_millis: u64) -> Vec<String> {
if grace_period_millis == 0 {
tags.into_iter().filter(|tag| !is_grace_strategy_tag(tag)).collect()
} else {
tags
}
}
pub(crate) fn filter_disabled_grace_strategies(
strategies: Option<Vec<AdmissionStrategy>>,
grace_period_millis: u64,
) -> Option<Vec<AdmissionStrategy>> {
strategies.map(|entries| {
if grace_period_millis == 0 {
entries.into_iter().filter(|strategy| !is_grace_strategy(*strategy)).collect()
} else {
entries
}
})
}
pub(crate) fn admission_strategy_tag_label(translate: &YewI18n, tag: &str) -> String {
AdmissionStrategy::from_str(tag)
.ok()
.map(|strategy| admission_strategy_label(translate, strategy))
.unwrap_or_else(|| tag.to_string())
}
pub(crate) fn legacy_admission_strategy_tags(stream: &StreamConfigDto) -> Vec<String> {
if stream.grace_period_millis == 0 {
Vec::new()
} else {
vec![(if stream.grace_period_hold_stream {
AdmissionStrategy::GraceHoldStream
} else {
AdmissionStrategy::GraceInstantStream
})
.to_string()]
}
}
pub(crate) fn displayed_admission_strategy_tags(
state: &AdmissionStrategiesDto,
stream: &StreamConfigDto,
) -> Vec<String> {
filter_disabled_grace_strategy_tags(
state.strategies.clone().unwrap_or_else(|| {
admission_strategy_tags(stream.admission_strategies.as_ref())
.unwrap_or_else(|| legacy_admission_strategy_tags(stream))
}),
stream.grace_period_millis,
)
}
pub(crate) fn available_admission_strategies(
selected_tags: &[String],
grace_period_millis: u64,
) -> Vec<AdmissionStrategy> {
let has_grace = selected_tags.iter().filter_map(|tag| AdmissionStrategy::from_str(tag).ok()).any(is_grace_strategy);
[
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::EvictUserSameIpLatest,
AdmissionStrategy::EvictUserOldest,
AdmissionStrategy::EvictUserLatest,
AdmissionStrategy::GraceInstantStream,
AdmissionStrategy::GraceHoldStream,
]
.into_iter()
.filter(|strategy| {
let tag = (*strategy).to_string();
let grace_available = grace_period_millis > 0 || !is_grace_strategy(*strategy);
!selected_tags.iter().any(|selected| selected == &tag)
&& grace_available
&& (!has_grace || !is_grace_strategy(*strategy))
})
.collect()
}
pub(crate) fn add_admission_strategy_tag(current: &[String], strategy: AdmissionStrategy) -> Vec<String> {
let mut next = current.to_vec();
let tag = strategy.to_string();
if next.iter().any(|selected| selected == &tag) {
return next;
}
// When adding a same-IP eviction rule, insert it before any broader user-wide
// rule (if present) so the backend ordering validation is satisfied.
let is_narrower =
matches!(strategy, AdmissionStrategy::EvictUserSameIpOldest | AdmissionStrategy::EvictUserSameIpLatest);
if is_narrower {
let broader_oldest = AdmissionStrategy::EvictUserOldest.to_string();
let broader_latest = AdmissionStrategy::EvictUserLatest.to_string();
let earliest_pos = next.iter().position(|t| t == &broader_oldest || t == &broader_latest);
if let Some(pos) = earliest_pos {
next.insert(pos, tag);
return next;
}
}
next.push(tag);
next
}
pub(crate) fn remove_admission_strategy_tag(current: &[String], index: usize) -> Vec<String> {
let mut next = current.to_vec();
if index < next.len() {
next.remove(index);
}
next
}
pub(crate) fn move_admission_strategy_tag(current: &[String], index: usize, delta: isize) -> Vec<String> {
let mut next = current.to_vec();
if let Some(target_index) = index.checked_add_signed(delta) {
if index < next.len() && target_index < next.len() {
next.swap(index, target_index);
// Reject the move if it would create an invalid ordering (broader before narrower).
let strategy_dtos: Vec<AdmissionStrategy> =
next.iter().filter_map(|t| AdmissionStrategy::from_str(t).ok()).collect();
if !shared::model::is_valid_admission_strategy_order(&strategy_dtos) {
next.swap(index, target_index); // revert
}
}
}
next
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn admission_strategy_tags_roundtrip() {
let tags = admission_strategy_tags(Some(&vec![
AdmissionStrategy::EvictUserOldest,
AdmissionStrategy::GraceHoldStream,
]))
.unwrap_or_default();
assert_eq!(
parse_admission_strategy_tags(Some(&tags)),
Some(vec![AdmissionStrategy::EvictUserOldest, AdmissionStrategy::GraceHoldStream,])
);
}
#[test]
fn admission_strategy_tags_roundtrip_evict_user_latest() {
let tags = admission_strategy_tags(Some(&vec![AdmissionStrategy::EvictUserLatest])).unwrap_or_default();
assert_eq!(parse_admission_strategy_tags(Some(&tags)), Some(vec![AdmissionStrategy::EvictUserLatest]));
}
#[test]
fn invalid_admission_strategy_tags_are_ignored() {
let tags = vec!["evict_user_latest".to_string(), "not-a-strategy".to_string(), "evict_user_latest".to_string()];
assert_eq!(parse_admission_strategy_tags(Some(&tags)), Some(vec![AdmissionStrategy::EvictUserLatest]));
}
#[test]
fn displayed_admission_strategies_fall_back_to_legacy_grace() {
let state = AdmissionStrategiesDto::default();
let stream = StreamConfigDto {
grace_period_millis: 2_000,
grace_period_hold_stream: true,
..StreamConfigDto::default()
};
assert_eq!(displayed_admission_strategy_tags(&state, &stream), vec!["grace_hold_stream".to_string()]);
}
#[test]
fn available_admission_strategies_hide_second_grace_option() {
let available = available_admission_strategies(&["grace_hold_stream".to_string()], 2_000);
assert!(!available.contains(&AdmissionStrategy::GraceInstantStream));
assert!(!available.contains(&AdmissionStrategy::GraceHoldStream));
assert!(available.contains(&AdmissionStrategy::EvictUserSameIpOldest));
assert!(available.contains(&AdmissionStrategy::EvictUserOldest));
}
#[test]
fn available_admission_strategies_hide_grace_when_disabled() {
let available = available_admission_strategies(&[], 0);
assert!(!available.contains(&AdmissionStrategy::GraceInstantStream));
assert!(!available.contains(&AdmissionStrategy::GraceHoldStream));
assert!(available.contains(&AdmissionStrategy::EvictUserSameIpOldest));
assert!(available.contains(&AdmissionStrategy::EvictUserSameIpLatest));
assert!(available.contains(&AdmissionStrategy::EvictUserOldest));
assert!(available.contains(&AdmissionStrategy::EvictUserLatest));
}
#[test]
fn displayed_admission_strategies_hide_disabled_grace_tags() {
let state = AdmissionStrategiesDto { strategies: Some(vec!["grace_hold_stream".to_string()]) };
let stream = StreamConfigDto { grace_period_millis: 0, ..StreamConfigDto::default() };
assert_eq!(displayed_admission_strategy_tags(&state, &stream), Vec::<String>::new());
}
#[test]
fn filtered_admission_strategies_drop_grace_when_disabled() {
let parsed = parse_admission_strategy_tags(Some(&[
"evict_user_same_ip_oldest".to_string(),
"grace_hold_stream".to_string(),
]));
assert_eq!(filter_disabled_grace_strategies(parsed, 0), Some(vec![AdmissionStrategy::EvictUserSameIpOldest]));
}
#[test]
fn add_admission_strategy_enforces_narrower_before_broader() {
let current = vec!["evict_user_oldest".to_string()];
let new_tags = add_admission_strategy_tag(&current, AdmissionStrategy::EvictUserSameIpOldest);
assert_eq!(new_tags, vec!["evict_user_same_ip_oldest".to_string(), "evict_user_oldest".to_string()]);
}
#[test]
fn add_admission_strategy_inserts_between_existing_narrower_and_broader_rules() {
let current =
vec![AdmissionStrategy::EvictUserSameIpOldest.to_string(), AdmissionStrategy::EvictUserOldest.to_string()];
let new_tags = add_admission_strategy_tag(&current, AdmissionStrategy::EvictUserSameIpLatest);
assert_eq!(
new_tags,
vec![
AdmissionStrategy::EvictUserSameIpOldest.to_string(),
AdmissionStrategy::EvictUserSameIpLatest.to_string(),
AdmissionStrategy::EvictUserOldest.to_string(),
]
);
}
#[test]
fn move_admission_strategy_reverts_invalid_order() {
let current = vec!["evict_user_same_ip_oldest".to_string(), "evict_user_oldest".to_string()];
// Attempt to move broader EvictUserOldest up before narrower EvictUserSameIpOldest
let next = move_admission_strategy_tag(&current, 1, -1);
assert_eq!(next, current);
}
}
@@ -693,6 +693,27 @@ macro_rules! edit_field_list_option {
</div>
}
}};
($state:expr, $label:expr, $field_id:expr, $placeholder:expr, $create_tag:expr) => {{
let state = $state.clone();
let create_tag = $create_tag.clone();
html! {
<div class="tp__form-field tp__form-field__list">
<$crate::app::components::FieldLabel
label={$label.to_string()}
field_id={$field_id.to_string()}
/>
<$crate::app::components::TagList
tags={(*state).clone()}
placeholder={$placeholder}
readonly={false}
create_tag={create_tag}
on_change={Callback::from(move |value: Vec<std::rc::Rc<$crate::app::components::Tag>>| {
state.set(value);
})}
/>
</div>
}
}};
}
#[macro_export]
@@ -1,5 +1,6 @@
mod macros;
mod admission_strategies;
mod api_config_view;
mod config_page;
mod config_update;
@@ -21,6 +22,7 @@ mod schedules_config_view;
mod video_config_view;
mod webui_config_view;
pub(crate) use admission_strategies::*;
pub use api_config_view::*;
pub use config_page::*;
pub use config_view::*;
@@ -4,11 +4,15 @@ use crate::{
app::{
components::{
config::{
add_admission_strategy_tag, admission_strategy_label, admission_strategy_tag_label,
admission_strategy_tags, available_admission_strategies,
config_page::{ConfigForm, LABEL_REVERSE_PROXY_CONFIG},
config_view_context::ConfigViewContext,
use_emit_mapped_option,
displayed_admission_strategy_tags, filter_disabled_grace_strategies, move_admission_strategy_tag,
parse_admission_strategy_tags, remove_admission_strategy_tag, use_emit_mapped_option,
AdmissionStrategiesDto,
},
Card, Chip, IconButton, TextButton,
Card, Chip, IconButton, RadioButtonGroup, TextButton,
},
context::ConfigContext,
},
@@ -16,18 +20,18 @@ use crate::{
edit_field_bool, edit_field_list, edit_field_number, edit_field_number_f64, edit_field_number_u16,
edit_field_number_u64, edit_field_number_usize, edit_field_text, edit_field_text_option, generate_form_reducer,
i18n::{use_translation, YewI18n},
utils::t_safe,
};
use enum_iterator::all;
use shared::{
model::{
AdmissionStrategy, CacheConfigDto, GeoIpConfigDto, QosAggregationConfigDto, RateLimitConfigDto,
CacheConfigDto, GeoIpConfigDto, GeoIpUnavailablePolicy, QosAggregationConfigDto, RateLimitConfigDto,
ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamBufferConfigDto,
StreamConfigDto, StreamHistoryConfigDto,
},
utils::{default_secret, format_float_localized},
};
use std::{rc::Rc, str::FromStr};
use yew::prelude::*;
const LABEL_CACHE: &str = "LABEL.CACHE";
const LABEL_ENABLED: &str = "LABEL.ENABLED";
const LABEL_SIZE: &str = "LABEL.SIZE";
@@ -71,6 +75,7 @@ const LABEL_CF_HEADER: &str = "LABEL.CF_HEADER";
const LABEL_CUSTOM_HEADERS: &str = "LABEL.CUSTOM_HEADERS";
const LABEL_ADD_HEADER: &str = "LABEL.ADD_HEADER";
const LABEL_GEOIP: &str = "LABEL.GEOIP";
const LABEL_GEOIP_UNAVAILABLE_POLICY: &str = "LABEL.GEOIP_UNAVAILABLE_POLICY";
const LABEL_URL: &str = "LABEL.URL";
const LABEL_STREAM_HISTORY: &str = "LABEL.STREAM_HISTORY";
@@ -80,12 +85,6 @@ const LABEL_STREAM_HISTORY_RETENTION_DAYS: &str = "LABEL.STREAM_HISTORY_RETENTIO
const LABEL_QOS_AGGREGATION: &str = "LABEL.QOS_AGGREGATION";
const LABEL_QOS_AGGREGATION_ENABLED: &str = "LABEL.QOS_AGGREGATION_ENABLED";
const LABEL_INTERVAL_SECS: &str = "LABEL.INTERVAL_SECS";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_OLDEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_OLDEST";
const LABEL_ADMISSION_STRATEGY_EVICT_USER_LATEST: &str = "LABEL.ADMISSION_STRATEGY_EVICT_USER_LATEST";
const LABEL_ADMISSION_STRATEGY_GRACE_INSTANT_STREAM: &str = "LABEL.ADMISSION_STRATEGY_GRACE_INSTANT_STREAM";
const LABEL_ADMISSION_STRATEGY_GRACE_HOLD_STREAM: &str = "LABEL.ADMISSION_STRATEGY_GRACE_HOLD_STREAM";
generate_form_reducer!(
state: CacheConfigFormState { form: CacheConfigDto },
@@ -127,202 +126,6 @@ impl FailoverPatternsDto {
pub fn is_empty(&self) -> bool { self.patterns.is_empty() }
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AdmissionStrategiesDto {
pub strategies: Option<Vec<String>>,
}
fn admission_strategy_tag(strategy: AdmissionStrategy) -> &'static str {
match strategy {
AdmissionStrategy::EvictUserSameIpOldest => "evict_user_same_ip_oldest",
AdmissionStrategy::EvictUserSameIpLatest => "evict_user_same_ip_latest",
AdmissionStrategy::EvictUserOldest => "evict_user_oldest",
AdmissionStrategy::EvictUserLatest => "evict_user_latest",
AdmissionStrategy::GraceInstantStream => "grace_instant_stream",
AdmissionStrategy::GraceHoldStream => "grace_hold_stream",
}
}
fn parse_admission_strategy_tag(tag: &str) -> Option<AdmissionStrategy> {
match tag.trim() {
"evict_user_same_ip_oldest" => Some(AdmissionStrategy::EvictUserSameIpOldest),
"evict_user_same_ip_latest" => Some(AdmissionStrategy::EvictUserSameIpLatest),
"evict_user_oldest" => Some(AdmissionStrategy::EvictUserOldest),
"evict_user_latest" => Some(AdmissionStrategy::EvictUserLatest),
"grace_instant_stream" => Some(AdmissionStrategy::GraceInstantStream),
"grace_hold_stream" => Some(AdmissionStrategy::GraceHoldStream),
_ => None,
}
}
fn admission_strategy_label_key(strategy: AdmissionStrategy) -> &'static str {
match strategy {
AdmissionStrategy::EvictUserSameIpOldest => LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_OLDEST,
AdmissionStrategy::EvictUserSameIpLatest => LABEL_ADMISSION_STRATEGY_EVICT_USER_SAME_IP_LATEST,
AdmissionStrategy::EvictUserOldest => LABEL_ADMISSION_STRATEGY_EVICT_USER_OLDEST,
AdmissionStrategy::EvictUserLatest => LABEL_ADMISSION_STRATEGY_EVICT_USER_LATEST,
AdmissionStrategy::GraceInstantStream => LABEL_ADMISSION_STRATEGY_GRACE_INSTANT_STREAM,
AdmissionStrategy::GraceHoldStream => LABEL_ADMISSION_STRATEGY_GRACE_HOLD_STREAM,
}
}
fn admission_strategy_label(translate: &YewI18n, strategy: AdmissionStrategy) -> String {
t_safe(translate, admission_strategy_label_key(strategy)).unwrap_or_else(|| match strategy {
AdmissionStrategy::EvictUserSameIpOldest => "Evict same-IP oldest stream".to_string(),
AdmissionStrategy::EvictUserSameIpLatest => "Evict same-IP latest stream".to_string(),
AdmissionStrategy::EvictUserOldest => "Evict user oldest stream".to_string(),
AdmissionStrategy::EvictUserLatest => "Evict user latest stream".to_string(),
AdmissionStrategy::GraceInstantStream => "Grace instant stream".to_string(),
AdmissionStrategy::GraceHoldStream => "Grace hold stream".to_string(),
})
}
fn is_grace_strategy(strategy: AdmissionStrategy) -> bool {
matches!(strategy, AdmissionStrategy::GraceInstantStream | AdmissionStrategy::GraceHoldStream)
}
fn is_grace_strategy_tag(tag: &str) -> bool {
let tag = tag.trim();
tag.starts_with("grace_") || parse_admission_strategy_tag(tag).is_some_and(is_grace_strategy)
}
fn admission_strategy_tags(strategies: Option<&Vec<AdmissionStrategy>>) -> Option<Vec<String>> {
strategies.map(|entries| entries.iter().map(|entry| admission_strategy_tag(*entry).to_string()).collect())
}
fn parse_admission_strategy_tags(tags: Option<&[String]>) -> Option<Vec<AdmissionStrategy>> {
let tags = tags?;
let mut parsed = Vec::new();
for tag in tags {
if let Some(strategy) = parse_admission_strategy_tag(tag) {
if !parsed.contains(&strategy) {
parsed.push(strategy);
}
}
}
Some(parsed)
}
fn filter_disabled_grace_strategy_tags(tags: Vec<String>, grace_period_millis: u64) -> Vec<String> {
if grace_period_millis == 0 {
tags.into_iter().filter(|tag| !is_grace_strategy_tag(tag)).collect()
} else {
tags
}
}
fn filter_disabled_grace_strategies(
strategies: Option<Vec<AdmissionStrategy>>,
grace_period_millis: u64,
) -> Option<Vec<AdmissionStrategy>> {
strategies.map(|entries| {
if grace_period_millis == 0 {
entries.into_iter().filter(|strategy| !is_grace_strategy(*strategy)).collect()
} else {
entries
}
})
}
fn admission_strategy_tag_label(translate: &YewI18n, tag: &str) -> String {
parse_admission_strategy_tag(tag)
.map(|strategy| admission_strategy_label(translate, strategy))
.unwrap_or_else(|| tag.to_string())
}
fn legacy_admission_strategy_tags(stream: &StreamConfigDto) -> Vec<String> {
if stream.grace_period_millis == 0 {
Vec::new()
} else {
vec![admission_strategy_tag(if stream.grace_period_hold_stream {
AdmissionStrategy::GraceHoldStream
} else {
AdmissionStrategy::GraceInstantStream
})
.to_string()]
}
}
fn displayed_admission_strategy_tags(state: &AdmissionStrategiesDto, stream: &StreamConfigDto) -> Vec<String> {
filter_disabled_grace_strategy_tags(
state.strategies.clone().unwrap_or_else(|| {
admission_strategy_tags(stream.admission_strategies.as_ref())
.unwrap_or_else(|| legacy_admission_strategy_tags(stream))
}),
stream.grace_period_millis,
)
}
fn available_admission_strategies(selected_tags: &[String], grace_period_millis: u64) -> Vec<AdmissionStrategy> {
let has_grace = selected_tags.iter().filter_map(|tag| parse_admission_strategy_tag(tag)).any(is_grace_strategy);
[
AdmissionStrategy::EvictUserSameIpOldest,
AdmissionStrategy::EvictUserSameIpLatest,
AdmissionStrategy::EvictUserOldest,
AdmissionStrategy::EvictUserLatest,
AdmissionStrategy::GraceInstantStream,
AdmissionStrategy::GraceHoldStream,
]
.into_iter()
.filter(|strategy| {
let tag = admission_strategy_tag(*strategy);
let grace_available = grace_period_millis > 0 || !is_grace_strategy(*strategy);
!selected_tags.iter().any(|selected| selected == tag)
&& grace_available
&& (!has_grace || !is_grace_strategy(*strategy))
})
.collect()
}
fn add_admission_strategy_tag(current: &[String], strategy: AdmissionStrategy) -> Vec<String> {
let mut next = current.to_vec();
let tag = admission_strategy_tag(strategy).to_string();
if next.iter().any(|selected| selected == &tag) {
return next;
}
// When adding a same-IP eviction rule, insert it before any broader user-wide
// rule (if present) so the backend ordering validation is satisfied.
let is_narrower =
matches!(strategy, AdmissionStrategy::EvictUserSameIpOldest | AdmissionStrategy::EvictUserSameIpLatest);
if is_narrower {
let broader_oldest = admission_strategy_tag(AdmissionStrategy::EvictUserOldest);
let broader_latest = admission_strategy_tag(AdmissionStrategy::EvictUserLatest);
let earliest_pos = next.iter().position(|t| t == broader_oldest || t == broader_latest);
if let Some(pos) = earliest_pos {
next.insert(pos, tag);
return next;
}
}
next.push(tag);
next
}
fn remove_admission_strategy_tag(current: &[String], index: usize) -> Vec<String> {
let mut next = current.to_vec();
if index < next.len() {
next.remove(index);
}
next
}
fn move_admission_strategy_tag(current: &[String], index: usize, delta: isize) -> Vec<String> {
let mut next = current.to_vec();
if let Some(target_index) = index.checked_add_signed(delta) {
if index < next.len() && target_index < next.len() {
next.swap(index, target_index);
// Reject the move if it would create an invalid ordering (broader before narrower).
let strategy_dtos: Vec<AdmissionStrategy> =
next.iter().filter_map(|t| parse_admission_strategy_tag(t)).collect();
if !shared::model::is_valid_admission_strategy_order(&strategy_dtos) {
next.swap(index, target_index); // revert
}
}
}
next
}
generate_form_reducer!(
state: FailoverPatternsFormState { form: FailoverPatternsDto },
action_name: FailoverPatternsFormAction,
@@ -371,6 +174,7 @@ generate_form_reducer!(
fields {
Enabled => enabled: bool,
Url => url: String,
UnavailablePolicy => unavailable_policy: GeoIpUnavailablePolicy,
}
);
@@ -414,6 +218,24 @@ generate_form_reducer!(
}
);
fn geoip_unavailable_policy_options() -> Rc<Vec<String>> {
Rc::new(all::<GeoIpUnavailablePolicy>().map(|policy| policy.to_string()).collect())
}
pub(crate) fn geoip_unavailable_policy_label(translate: &YewI18n, policy: GeoIpUnavailablePolicy) -> String {
match policy {
GeoIpUnavailablePolicy::Deny => translate.t("LABEL.GEOIP_UNAVAILABLE_POLICY_DENY"),
GeoIpUnavailablePolicy::Allow => translate.t("LABEL.GEOIP_UNAVAILABLE_POLICY_ALLOW"),
}
}
fn geoip_unavailable_policy_labels(translate: &YewI18n) -> Rc<Vec<String>> {
Rc::new(vec![
geoip_unavailable_policy_label(translate, GeoIpUnavailablePolicy::Deny),
geoip_unavailable_policy_label(translate, GeoIpUnavailablePolicy::Allow),
])
}
#[component]
pub fn ReverseProxyConfigView() -> Html {
let translate = use_translation();
@@ -816,6 +638,13 @@ pub fn ReverseProxyConfigView() -> Html {
<h1>{translate.t(LABEL_GEOIP)}</h1>
{ config_field_bool!(geoip_state.form, translate.t(LABEL_ENABLED), enabled) }
{ config_field!(geoip_state.form, translate.t(LABEL_URL), url) }
{ config_field_child!(translate.t(LABEL_GEOIP_UNAVAILABLE_POLICY), "GEO_IP_CONFIG.UNAVAILABLE_POLICY", {
html! {
<span class="tp__form-field__value">
{geoip_unavailable_policy_label(&translate, geoip_state.form.unavailable_policy)}
</span>
}
}) }
</Card>
}
};
@@ -910,11 +739,31 @@ pub fn ReverseProxyConfigView() -> Html {
};
let render_geoip_edit = || {
let geoip_policy_state = geoip_state.clone();
let selected_policy = Rc::new(vec![geoip_state.form.unavailable_policy.to_string()]);
html! {
<Card class="tp__config-view__card">
<h1>{translate.t(LABEL_GEOIP)}</h1>
{ edit_field_bool!(geoip_state, translate.t(LABEL_ENABLED), enabled, GeoIpConfigFormAction::Enabled) }
{ edit_field_text!(geoip_state, translate.t(LABEL_URL), url, GeoIpConfigFormAction::Url) }
{ config_field_child!(translate.t(LABEL_GEOIP_UNAVAILABLE_POLICY), "GEO_IP_CONFIG.UNAVAILABLE_POLICY", {
html! {
<RadioButtonGroup
multi_select={false}
none_allowed={false}
options={geoip_unavailable_policy_options()}
labels={Some(geoip_unavailable_policy_labels(&translate))}
selected={selected_policy}
on_select={Callback::from(move |selections: Rc<Vec<String>>| {
if let Some(selection) = selections.first() {
geoip_policy_state.dispatch(GeoIpConfigFormAction::UnavailablePolicy(
GeoIpUnavailablePolicy::from_str(selection).unwrap_or(GeoIpUnavailablePolicy::Deny),
));
}
})}
/>
}
}) }
</Card>
}
};
@@ -1156,92 +1005,10 @@ mod tests {
use super::*;
#[test]
fn admission_strategy_tags_roundtrip() {
let tags = admission_strategy_tags(Some(&vec![
AdmissionStrategy::EvictUserOldest,
AdmissionStrategy::GraceHoldStream,
]))
.unwrap_or_default();
assert_eq!(
parse_admission_strategy_tags(Some(&tags)),
Some(vec![AdmissionStrategy::EvictUserOldest, AdmissionStrategy::GraceHoldStream,])
);
}
#[test]
fn admission_strategy_tags_roundtrip_evict_user_latest() {
let tags = admission_strategy_tags(Some(&vec![AdmissionStrategy::EvictUserLatest])).unwrap_or_default();
assert_eq!(parse_admission_strategy_tags(Some(&tags)), Some(vec![AdmissionStrategy::EvictUserLatest]));
}
#[test]
fn invalid_admission_strategy_tags_are_ignored() {
let tags = vec!["evict_user_latest".to_string(), "not-a-strategy".to_string(), "evict_user_latest".to_string()];
assert_eq!(parse_admission_strategy_tags(Some(&tags)), Some(vec![AdmissionStrategy::EvictUserLatest]));
}
#[test]
fn displayed_admission_strategies_fall_back_to_legacy_grace() {
let state = AdmissionStrategiesDto::default();
let stream = StreamConfigDto {
grace_period_millis: 2_000,
grace_period_hold_stream: true,
..StreamConfigDto::default()
};
assert_eq!(displayed_admission_strategy_tags(&state, &stream), vec!["grace_hold_stream".to_string()]);
}
#[test]
fn available_admission_strategies_hide_second_grace_option() {
let available = available_admission_strategies(&["grace_hold_stream".to_string()], 2_000);
assert!(!available.contains(&AdmissionStrategy::GraceInstantStream));
assert!(!available.contains(&AdmissionStrategy::GraceHoldStream));
assert!(available.contains(&AdmissionStrategy::EvictUserSameIpOldest));
assert!(available.contains(&AdmissionStrategy::EvictUserOldest));
}
#[test]
fn available_admission_strategies_hide_grace_when_disabled() {
let available = available_admission_strategies(&[], 0);
assert!(!available.contains(&AdmissionStrategy::GraceInstantStream));
assert!(!available.contains(&AdmissionStrategy::GraceHoldStream));
assert!(available.contains(&AdmissionStrategy::EvictUserSameIpOldest));
assert!(available.contains(&AdmissionStrategy::EvictUserSameIpLatest));
assert!(available.contains(&AdmissionStrategy::EvictUserOldest));
assert!(available.contains(&AdmissionStrategy::EvictUserLatest));
}
#[test]
fn displayed_admission_strategies_hide_disabled_grace_tags() {
let state = AdmissionStrategiesDto { strategies: Some(vec!["grace_hold_stream".to_string()]) };
let stream = StreamConfigDto { grace_period_millis: 0, ..StreamConfigDto::default() };
assert_eq!(displayed_admission_strategy_tags(&state, &stream), Vec::<String>::new());
}
#[test]
fn filtered_admission_strategies_drop_grace_when_disabled() {
let parsed = parse_admission_strategy_tags(Some(&[
"evict_user_same_ip_oldest".to_string(),
"grace_hold_stream".to_string(),
]));
assert_eq!(filter_disabled_grace_strategies(parsed, 0), Some(vec![AdmissionStrategy::EvictUserSameIpOldest]));
}
#[test]
fn add_admission_strategy_enforces_narrower_before_broader() {
let current = vec!["evict_user_oldest".to_string()];
let new_tags = add_admission_strategy_tag(&current, AdmissionStrategy::EvictUserSameIpOldest);
assert_eq!(new_tags, vec!["evict_user_same_ip_oldest".to_string(), "evict_user_oldest".to_string()]);
}
#[test]
fn move_admission_strategy_reverts_invalid_order() {
let current = vec!["evict_user_same_ip_oldest".to_string(), "evict_user_oldest".to_string()];
// Attempt to move broader EvictUserOldest up before narrower EvictUserSameIpOldest
let next = move_admission_strategy_tag(&current, 1, -1);
assert_eq!(next, current);
fn geoip_unavailable_policy_roundtrips_through_string_representation() {
for policy in all::<GeoIpUnavailablePolicy>() {
let parsed = GeoIpUnavailablePolicy::from_str(&policy.to_string()).unwrap_or(GeoIpUnavailablePolicy::Deny);
assert_eq!(parsed, policy);
}
}
}
@@ -7,6 +7,8 @@ pub struct RadioButtonGroupProps {
pub options: Rc<Vec<String>>,
pub selected: Rc<Vec<String>>,
pub on_select: Callback<Rc<Vec<String>>>,
#[prop_or_default]
pub labels: Option<Rc<Vec<String>>>, // optional localized labels, same length as options
#[prop_or(false)]
pub multi_select: bool,
#[prop_or(false)]
@@ -62,14 +64,26 @@ pub fn RadioButtonGroup(props: &RadioButtonGroupProps) -> Html {
})
};
let display_label = |option: &String| -> String {
if let Some(labels) = &props.labels {
if let Some(pos) = props.options.iter().position(|o| o == option) {
if let Some(label) = labels.get(pos) {
return label.clone();
}
}
}
option.clone()
};
html! {
<div class="tp__radio-button-group">
{ for props.options.iter().map(|option| {
let is_selected = (*selections).contains(option);
let class = if is_selected { "primary" } else { "" };
let onclick = on_click.clone();
let label = display_label(option);
html! {
<TextButton {onclick} class={class} name={ option.clone() } title={ option.clone() }></TextButton>
<TextButton {onclick} class={class} name={ option.clone() } title={ label } />
}
}) }
</div>
+18 -7
View File
@@ -9,11 +9,15 @@ pub struct Tag {
pub class: Option<String>,
}
fn default_create_tag(value: String) -> Option<Tag> { Some(Tag { label: value, class: None }) }
#[derive(Properties, Clone, PartialEq)]
pub struct TagListProps {
pub tags: Vec<Rc<Tag>>,
#[prop_or_else(Callback::noop)]
pub on_change: Callback<Vec<Rc<Tag>>>,
#[prop_or_else(|| Callback::from(default_create_tag))]
pub create_tag: Callback<String, Option<Tag>>,
#[prop_or(true)]
pub readonly: bool,
#[prop_or_else(|| "Add tag...".to_string())]
@@ -22,7 +26,7 @@ pub struct TagListProps {
#[component]
pub fn TagList(props: &TagListProps) -> Html {
let TagListProps { tags, on_change, readonly, placeholder } = props.clone();
let TagListProps { tags, on_change, create_tag, readonly, placeholder } = props.clone();
let tag_state = use_state(|| tags.clone());
let new_tag = use_state(String::default);
@@ -61,15 +65,22 @@ pub fn TagList(props: &TagListProps) -> Html {
let new_tag = new_tag.clone();
let tag_state = tag_state.clone();
let on_change = on_change.clone();
let create_tag = create_tag.clone();
Callback::from(move |()| {
let val = (*new_tag).trim().to_string();
if !val.is_empty() && !tag_state.iter().any(|t| t.label == val) {
let mut updated = (*tag_state).clone();
updated.push(Rc::new(Tag { label: val.clone(), class: None }));
on_change.emit(updated.clone());
tag_state.set(updated);
if val.is_empty() {
return;
}
if let Some(next_tag) = create_tag.emit(val.clone()) {
if !tag_state.iter().any(|t| t.label == next_tag.label) {
let mut updated = (*tag_state).clone();
updated.push(Rc::new(next_tag));
on_change.emit(updated.clone());
tag_state.set(updated);
new_tag.set(String::new());
}
}
new_tag.set(String::new());
})
};
@@ -2,12 +2,13 @@ use crate::{
app::{
components::{
config::HasFormData, select::Select, userlist::proxy_type_input::ProxyTypeInput, ClusterFlagsInput,
ClusterFlagsInputMode, DropDownOption, DropDownSelection, TextButton, UserStatus,
ClusterFlagsInputMode, DropDownOption, DropDownSelection, Tag, TextButton, UserStatus,
},
TargetUser,
},
config_field_child, config_field_custom, edit_field_bool, edit_field_date, edit_field_number, edit_field_number_i8,
edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer,
config_field_child, config_field_custom, edit_field_bool, edit_field_date, edit_field_list_option,
edit_field_number, edit_field_number_i8, edit_field_number_u16, edit_field_text, edit_field_text_option,
generate_form_reducer,
hooks::use_service_context,
html_if,
i18n::use_translation,
@@ -15,17 +16,83 @@ use crate::{
use chrono::{Duration, Utc};
use shared::{
model::{
permission::Permission, ApiProxyServerInfoDto, ClusterFlags, ConfigTargetDto, ProxyType,
permission::Permission, ApiProxyServerInfoDto, ClusterFlags, ConfigTargetDto, NetworkAccessDto, ProxyType,
ProxyUserCredentialsDto, ProxyUserStatus,
},
utils::generate_random_string,
};
use std::rc::Rc;
use std::{net::IpAddr, rc::Rc};
use yew::prelude::*;
const DEFAULT_MAX_CONNECTIONS: u32 = 1;
const DEFAULT_EXPIRATION_DAYS: i64 = 365;
fn normalize_country_entry(input: &str) -> Result<String, &'static str> {
let normalized = input.trim().to_ascii_uppercase();
if normalized.len() == 2 && normalized.chars().all(|ch| ch.is_ascii_alphabetic()) {
Ok(normalized)
} else {
Err("MESSAGES.VALIDATION.NETWORK_ACCESS_COUNTRIES")
}
}
fn normalize_network_entry(input: &str) -> Result<String, &'static str> {
let normalized = input.trim().to_string();
let Some((address, prefix)) = normalized.split_once('/') else {
return Err("MESSAGES.VALIDATION.NETWORK_ACCESS_NETWORKS");
};
let address = address.trim().to_ascii_lowercase();
let prefix = prefix.trim();
let Ok(ip) = address.parse::<IpAddr>() else {
return Err("MESSAGES.VALIDATION.NETWORK_ACCESS_NETWORKS");
};
let Ok(prefix) = prefix.parse::<u8>() else {
return Err("MESSAGES.VALIDATION.NETWORK_ACCESS_NETWORKS");
};
let prefix_valid = match ip {
IpAddr::V4(_) => prefix <= 32,
IpAddr::V6(_) => prefix <= 128,
};
if prefix_valid {
Ok(normalized)
} else {
Err("MESSAGES.VALIDATION.NETWORK_ACCESS_NETWORKS")
}
}
fn build_network_access(countries: &[String], networks: &[String]) -> Result<Option<NetworkAccessDto>, String> {
let countries_list = countries.iter().filter(|s| !s.trim().is_empty()).cloned().collect::<Vec<_>>();
let networks_list = networks.iter().filter(|s| !s.trim().is_empty()).cloned().collect::<Vec<_>>();
if countries_list.is_empty() && networks_list.is_empty() {
return Ok(None);
}
let mut dto = NetworkAccessDto {
allowed_countries: if countries_list.is_empty() { None } else { Some(countries_list) },
allowed_networks: if networks_list.is_empty() { None } else { Some(networks_list) },
};
dto.prepare().map_err(|e| e.to_string())?;
Ok(Some(dto))
}
fn network_access_changed(
original: Option<&NetworkAccessDto>,
countries: &[String],
networks: &[String],
) -> Result<bool, String> {
let built = build_network_access(countries, networks)?;
Ok(original != built.as_ref())
}
fn validate_network_access(countries: &[String], networks: &[String]) -> Result<(), &'static str> {
for country in countries {
normalize_country_entry(country)?;
}
for network in networks {
normalize_network_entry(network)?;
}
Ok(())
}
generate_form_reducer!(
state: UserFormState { form: ProxyUserCredentialsDto },
action_name: UserFormAction,
@@ -64,6 +131,8 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let service_ctx = use_service_context();
let selected_target = use_state(|| None);
let update = use_state(|| false);
let allowed_countries = use_state(Vec::<Rc<Tag>>::new);
let allowed_networks = use_state(Vec::<Rc<Tag>>::new);
let form_state: UseReducerHandle<UserFormState> =
use_reducer(|| UserFormState { form: ProxyUserCredentialsDto::default(), modified: false });
@@ -104,14 +173,30 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let form_state = form_state.clone();
let set_selected_target = selected_target.clone();
let set_update = update.clone();
let set_allowed_countries = allowed_countries.clone();
let set_allowed_networks = allowed_networks.clone();
use_effect_with((props.user.clone(), props.server.clone()), move |(user, server)| {
if let Some(u) = user.clone() {
set_update.set(true);
set_selected_target.set(Some(u.target.clone()));
form_state.dispatch(UserFormAction::SetAll((*u.credentials).clone()));
let creds = (*u.credentials).clone();
if let Some(na) = &creds.network_access {
set_allowed_countries.set(na.allowed_countries.as_ref().map_or_else(Vec::new, |countries| {
countries.iter().map(|country| Rc::new(Tag { label: country.clone(), class: None })).collect()
}));
set_allowed_networks.set(na.allowed_networks.as_ref().map_or_else(Vec::new, |networks| {
networks.iter().map(|network| Rc::new(Tag { label: network.clone(), class: None })).collect()
}));
} else {
set_allowed_countries.set(Vec::new());
set_allowed_networks.set(Vec::new());
}
form_state.dispatch(UserFormAction::SetAll(creds));
} else {
set_update.set(false);
set_selected_target.set(None);
set_allowed_countries.set(Vec::new());
set_allowed_networks.set(Vec::new());
let mut user = ProxyUserCredentialsDto::default();
if let Some(api_server) = (*server).first() {
user.server = Some(api_server.name.clone());
@@ -154,21 +239,47 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let target = selected_target.clone();
let onsave = props.on_save.clone();
let is_update = update.clone();
let countries = allowed_countries.clone();
let networks = allowed_networks.clone();
Callback::from(move |_| {
let nothing_to_save = || services.toastr.warning(translate_clone.t("MESSAGES.SAVE.USER.NOTHING_TO_SAVE"));
if let Some(target_name) = (*target).as_ref().cloned() {
let original_target = original.as_ref().map(|u| u.target.clone()).unwrap_or_default();
let target_changed = target_name != original_target;
if target_changed || user.modified() {
let user = user.data();
let countries_value = countries.iter().map(|tag| tag.label.clone()).collect::<Vec<_>>();
let networks_value = networks.iter().map(|tag| tag.label.clone()).collect::<Vec<_>>();
let network_access_changed = match network_access_changed(
original.as_ref().and_then(|u| u.credentials.network_access.as_ref()),
&countries_value,
&networks_value,
) {
Ok(changed) => changed,
Err(err) => {
services.toastr.error(err);
return;
}
};
if target_changed || user.modified() || network_access_changed {
let mut user = user.data().clone();
if let Err(message_key) = validate_network_access(&countries_value, &networks_value) {
services.toastr.error(translate_clone.t(message_key));
return;
}
user.network_access = match build_network_access(&countries_value, &networks_value) {
Ok(na) => na,
Err(err) => {
services.toastr.error(err);
return;
}
};
if let Err(err) = user.validate() {
services.toastr.error(err.to_string());
} else {
match original.as_ref().map(|t| t.credentials.clone()) {
None => onsave.emit((*is_update, target_name, user.clone())),
None => onsave.emit((*is_update, target_name, user)),
Some(original_user) => {
if target_changed || &(*original_user) != user {
onsave.emit((*is_update, target_name, user.clone()));
if target_changed || (*original_user) != user {
onsave.emit((*is_update, target_name, user));
} else {
nothing_to_save();
}
@@ -190,6 +301,24 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
let instance_proxy = form_state.clone();
let instance_server = form_state.clone();
let instance_output_clusters = form_state.clone();
let country_services = service_ctx.clone();
let country_translate = translate.clone();
let create_country_tag = Callback::from(move |value: String| match normalize_country_entry(&value) {
Ok(normalized) => Some(Tag { label: normalized, class: None }),
Err(message_key) => {
country_services.toastr.error(country_translate.t(message_key));
None
}
});
let network_services = service_ctx.clone();
let network_translate = translate.clone();
let create_network_tag = Callback::from(move |value: String| match normalize_network_entry(&value) {
Ok(normalized) => Some(Tag { label: normalized, class: None }),
Err(message_key) => {
network_services.toastr.error(network_translate.t(message_key));
None
}
});
html! {
<div class="tp__proxy-user-credentials-form tp__form-page">
<div class="tp__proxy-user-credentials-form__body tp__form-page__body">
@@ -199,7 +328,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
<div class="tp__proxy-user-credentials-form__playlist-target">
<Select name="target"
multi_select={false}
on_select={Callback::from(move |(_name, selections):(String, DropDownSelection)| {
on_select={Callback::from(move |(_, selections): (String, DropDownSelection)| {
let target = match selections {
DropDownSelection::Empty => None,
DropDownSelection::Single(option) => Some(option),
@@ -215,7 +344,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
value={form_state.data().output_clusters}
mode={ClusterFlagsInputMode::NoneIsAll}
short_labels={true}
on_change={Callback::from(move |(_name, flags):(String, Option<ClusterFlags>)| {
on_change={Callback::from(move |(_, flags): (String, Option<ClusterFlags>)| {
instance_output_clusters.dispatch(UserFormAction::OutputClusters(flags));
})}
/>
@@ -225,7 +354,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
{ config_field_child!(translate.t("LABEL.STATUS"), "PROXY_USER_CREDENTIALS.STATUS", {
html! { <Select name="status"
multi_select={false}
on_select={Callback::from(move |(_name, selections):(String, DropDownSelection)| {
on_select={Callback::from(move |(_, selections): (String, DropDownSelection)| {
let status = match selections {
DropDownSelection::Empty => None,
DropDownSelection::Single(option) => option.parse::<ProxyUserStatus>().ok(),
@@ -256,7 +385,7 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
html! {
<Select name="server"
multi_select={false}
on_select={Callback::from(move |(_name, selections):(String, DropDownSelection)| {
on_select={Callback::from(move |(_, selections): (String, DropDownSelection)| {
let server = match selections {
DropDownSelection::Empty => None,
DropDownSelection::Single(option) => Some(option.clone()),
@@ -276,6 +405,8 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
{ edit_field_text_option!(form_state, translate.t("LABEL.EPG_REQUEST_TIMESHIFT"), epg_request_timeshift, UserFormAction::EpgRequestTimeshift) }
{ edit_field_bool!(form_state, translate.t("LABEL.USER_UI_ENABLED"), ui_enabled, UserFormAction::UiEnabled) }
{ edit_field_text_option!(form_state, translate.t("LABEL.COMMENT"), comment, UserFormAction::Comment) }
{edit_field_list_option!(allowed_countries, translate.t("LABEL.ALLOWED_COUNTRIES"), "PROXY_USER_CREDENTIALS.NETWORK_ACCESS_COUNTRIES", translate.t("LABEL.ADD_COUNTRY"), create_country_tag)}
{edit_field_list_option!(allowed_networks, translate.t("LABEL.ALLOWED_NETWORKS"), "PROXY_USER_CREDENTIALS.NETWORK_ACCESS_NETWORKS", translate.t("LABEL.ADD_NETWORK"), create_network_tag)}
</div>
<div class="tp__proxy-user-credentials-form__toolbar tp__form-page__toolbar">
@@ -293,3 +424,84 @@ pub fn ProxyUserCredentialsForm(props: &ProxyUserCredentialsFormProps) -> Html {
</div>
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_network_access_returns_none_for_empty_inputs() {
assert_eq!(build_network_access(&[], &[]), Ok(None));
}
#[test]
fn network_access_changed_detects_network_only_edit() {
let original = None;
assert!(network_access_changed(original, &["DE".to_string()], &[]).unwrap());
}
#[test]
fn network_access_changed_is_false_for_equivalent_values() {
let original = Some(&NetworkAccessDto {
allowed_countries: Some(vec!["DE".to_string(), "AT".to_string()]),
allowed_networks: Some(vec!["10.0.0.0/8".to_string()]),
});
assert!(!network_access_changed(original, &["DE".to_string(), "AT".to_string()], &["10.0.0.0/8".to_string()])
.unwrap());
}
#[test]
fn build_network_access_prepares_countries_for_storage() {
let result = build_network_access(&["de".to_string(), "DE".to_string()], &[]);
let dto = result.unwrap().unwrap();
assert_eq!(dto.allowed_countries, Some(vec!["DE".to_string()]));
}
#[test]
fn build_network_access_propagates_invalid_cidr_error() {
let result = build_network_access(&[], &["not-a-cidr".to_string()]);
assert!(result.is_err());
}
#[test]
fn validate_network_access_rejects_invalid_country_codes() {
assert_eq!(
validate_network_access(&["DEU".to_string()], &[]),
Err("MESSAGES.VALIDATION.NETWORK_ACCESS_COUNTRIES")
);
}
#[test]
fn validate_network_access_rejects_invalid_networks() {
assert_eq!(
validate_network_access(&[], &["192.168.1.1".to_string()]),
Err("MESSAGES.VALIDATION.NETWORK_ACCESS_NETWORKS")
);
}
#[test]
fn validate_network_access_accepts_basic_cidr_lists() {
assert_eq!(
validate_network_access(
&["de".to_string(), "at".to_string()],
&["192.168.0.0/16".to_string(), "2001:db8::/32".to_string()],
),
Ok(())
);
}
#[test]
fn normalize_country_entry_uppercases_valid_values() {
assert_eq!(normalize_country_entry("de"), Ok("DE".to_string()));
}
#[test]
fn normalize_network_entry_accepts_basic_cidr() {
assert_eq!(normalize_network_entry("192.168.0.0/16"), Ok("192.168.0.0/16".to_string()));
}
#[test]
fn normalize_network_entry_rejects_invalid_prefix_range() {
assert_eq!(normalize_network_entry("192.168.0.0/33"), Err("MESSAGES.VALIDATION.NETWORK_ACCESS_NETWORKS"));
}
}
+3 -1
View File
@@ -122,7 +122,9 @@ impl ApiProxyConfigDto {
let mut tokens = HashSet::new();
for target_user in &mut self.user {
for user in &mut target_user.credentials {
user.prepare();
if let Err(err) = user.prepare() {
errors.push(err.to_string());
}
if usernames.contains(&user.username) {
errors.push(format!("Non unique username found {}", &user.username));
} else {
+83 -2
View File
@@ -1,6 +1,6 @@
use crate::{
error::TuliproxError,
model::{ClusterFlags, ProxyType, ProxyUserStatus, XtreamCluster},
model::{ClusterFlags, NetworkAccessDto, ProxyType, ProxyUserStatus, XtreamCluster},
utils::{
default_as_true, default_user_priority, deserialize_timestamp, is_blank_optional_string, is_cluster_optional,
is_default_user_priority, is_true,
@@ -49,10 +49,21 @@ pub struct ProxyUserCredentialsDto {
pub soft_connections: u16,
#[serde(default = "default_user_priority", skip_serializing_if = "is_default_user_priority")]
pub soft_priority: i8,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network_access: Option<NetworkAccessDto>,
}
impl ProxyUserCredentialsDto {
pub fn prepare(&mut self) { self.trim(); }
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
self.trim();
if let Some(na) = &mut self.network_access {
na.prepare()?;
if na.is_empty() {
self.network_access = None;
}
}
Ok(())
}
fn trim(&mut self) {
self.username = self.username.trim().to_string();
@@ -125,6 +136,7 @@ impl Default for ProxyUserCredentialsDto {
priority: default_user_priority(),
soft_connections: 0,
soft_priority: default_user_priority(),
network_access: None,
}
}
}
@@ -176,4 +188,73 @@ mod tests {
assert_eq!(deserialized.output_clusters, Some(ClusterFlags::empty()));
}
#[test]
fn prepare_rejects_invalid_country_in_network_access() {
let mut user = ProxyUserCredentialsDto {
username: "alice".to_string(),
password: "secret".to_string(),
network_access: Some(NetworkAccessDto {
allowed_countries: Some(vec!["INVALID".to_string()]),
allowed_networks: None,
}),
..Default::default()
};
assert!(user.prepare().is_err());
}
#[test]
fn prepare_rejects_invalid_cidr_in_network_access() {
let mut user = ProxyUserCredentialsDto {
username: "alice".to_string(),
password: "secret".to_string(),
network_access: Some(NetworkAccessDto {
allowed_countries: None,
allowed_networks: Some(vec!["not-a-cidr".to_string()]),
}),
..Default::default()
};
assert!(user.prepare().is_err());
}
#[test]
fn prepare_normalizes_empty_network_access_to_none() {
let mut user = ProxyUserCredentialsDto {
username: "alice".to_string(),
password: "secret".to_string(),
network_access: Some(NetworkAccessDto { allowed_countries: Some(vec![]), allowed_networks: Some(vec![]) }),
..Default::default()
};
user.prepare().unwrap();
assert_eq!(user.network_access, None);
}
#[test]
fn prepare_normalizes_and_deduplicates_network_access() {
let mut user = ProxyUserCredentialsDto {
username: "alice".to_string(),
password: "secret".to_string(),
network_access: Some(NetworkAccessDto {
allowed_countries: Some(vec!["de".to_string(), "DE".to_string(), "at".to_string()]),
allowed_networks: Some(vec![
"10.0.0.1/8".to_string(), // non-canonical, normalized to 10.0.0.0/8
"10.0.0.0/8".to_string(), // duplicate after normalization
"192.168.1.0/24".to_string(),
]),
}),
..Default::default()
};
user.prepare().unwrap();
let na = user.network_access.as_ref();
assert!(na.is_some());
if let Some(na) = na {
assert_eq!(na.allowed_countries, Some(vec!["DE".to_string(), "AT".to_string()]));
assert_eq!(na.allowed_networks.as_ref().map(|n| n.len()), Some(2));
assert!(na.allowed_networks.as_ref().is_some_and(|networks| networks.iter().any(|n| n == "10.0.0.0/8")));
assert!(na
.allowed_networks
.as_ref()
.is_some_and(|networks| networks.iter().any(|n| n == "192.168.1.0/24")));
}
}
}
+92 -1
View File
@@ -1,9 +1,45 @@
use crate::error::TuliproxError;
use enum_iterator::Sequence;
use std::fmt;
pub fn default_geoip_url() -> String {
String::from(
"https://raw.githubusercontent.com/sapics/ip-location-db/refs/heads/main/asn-country/asn-country-ipv4.csv",
)
}
#[derive(Debug, Copy, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default, Sequence)]
#[serde(rename_all = "lowercase")]
pub enum GeoIpUnavailablePolicy {
#[default]
Deny,
Allow,
}
impl fmt::Display for GeoIpUnavailablePolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GeoIpUnavailablePolicy::Deny => write!(f, "deny"),
GeoIpUnavailablePolicy::Allow => write!(f, "allow"),
}
}
}
impl std::str::FromStr for GeoIpUnavailablePolicy {
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"deny" => Ok(GeoIpUnavailablePolicy::Deny),
"allow" => Ok(GeoIpUnavailablePolicy::Allow),
_ => Err(TuliproxError::Config(format!("Unknown GeoIpUnavailablePolicy {s}"))),
}
}
}
pub const fn is_default_unavailable_policy(policy: &GeoIpUnavailablePolicy) -> bool {
matches!(policy, GeoIpUnavailablePolicy::Deny)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct GeoIpConfigDto {
@@ -11,8 +47,63 @@ pub struct GeoIpConfigDto {
pub enabled: bool,
#[serde(default = "default_geoip_url")]
pub url: String,
#[serde(default, skip_serializing_if = "is_default_unavailable_policy")]
pub unavailable_policy: GeoIpUnavailablePolicy,
}
impl GeoIpConfigDto {
pub fn is_empty(&self) -> bool { !self.enabled && self.url.trim().is_empty() }
pub fn is_empty(&self) -> bool {
!self.enabled && self.url.trim().is_empty() && self.unavailable_policy == GeoIpUnavailablePolicy::Deny
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn geoip_unavailable_policy_defaults_to_deny() {
let dto: GeoIpConfigDto = serde_json::from_str(r#"{"enabled": false}"#).unwrap();
assert_eq!(dto.unavailable_policy, GeoIpUnavailablePolicy::Deny);
}
#[test]
fn geoip_unavailable_policy_accepts_allow() {
let dto: GeoIpConfigDto = serde_json::from_str(r#"{"enabled": false, "unavailable_policy": "allow"}"#).unwrap();
assert_eq!(dto.unavailable_policy, GeoIpUnavailablePolicy::Allow);
}
#[test]
fn geoip_unavailable_policy_accepts_deny() {
let dto: GeoIpConfigDto = serde_json::from_str(r#"{"enabled": true, "unavailable_policy": "deny"}"#).unwrap();
assert_eq!(dto.unavailable_policy, GeoIpUnavailablePolicy::Deny);
}
#[test]
fn geoip_unavailable_policy_unknown_fails() {
let result: Result<GeoIpConfigDto, _> =
serde_json::from_str(r#"{"enabled": false, "unavailable_policy": "invalid"}"#);
assert!(result.is_err());
}
#[test]
fn geoip_config_default_has_unavailable_policy_deny() {
let dto = GeoIpConfigDto::default();
assert_eq!(dto.unavailable_policy, GeoIpUnavailablePolicy::Deny);
}
#[test]
fn geoip_config_with_allow_is_not_empty() {
let dto: GeoIpConfigDto =
serde_json::from_str(r#"{"enabled": false, "unavailable_policy": "allow", "url": ""}"#).unwrap();
assert!(!dto.is_empty(), "is_empty() should return false for explicit allow policy");
}
#[test]
fn geoip_config_is_empty_respects_policy() {
// URL default means we need an explicit empty string to test is_empty with deny policy
let dto: GeoIpConfigDto =
serde_json::from_str(r#"{"enabled": false, "url": "", "unavailable_policy": "deny"}"#).unwrap();
assert!(dto.is_empty(), "disabled config with deny policy and empty url should be empty");
}
}
+3 -1
View File
@@ -7,6 +7,7 @@ mod cache;
mod config_type;
mod epg;
mod epg_smart_match;
mod geoip;
mod hdhomerun;
mod input;
mod ipcheck;
@@ -36,8 +37,8 @@ mod web_auth;
mod web_ui;
mod favourites;
mod geoip;
mod library;
mod network_access;
mod proxy_user_status;
mod qos_aggregation;
@@ -60,6 +61,7 @@ pub use library::*;
pub use log::*;
pub use messaging::*;
pub use metadata_update::*;
pub use network_access::*;
pub use panel_api::*;
pub use paths::*;
pub use pattern_template::*;
+257
View File
@@ -0,0 +1,257 @@
use crate::error::TuliproxError;
fn is_empty_vec<T>(v: &Option<Vec<T>>) -> bool { v.as_ref().is_none_or(|v| v.is_empty()) }
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct NetworkAccessDto {
#[serde(default, skip_serializing_if = "is_empty_vec")]
pub allowed_countries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "is_empty_vec")]
pub allowed_networks: Option<Vec<String>>,
}
impl NetworkAccessDto {
pub fn is_empty(&self) -> bool { is_empty_vec(&self.allowed_countries) && is_empty_vec(&self.allowed_networks) }
/// Prepares the DTO for storage/comparison: trim whitespace, uppercase country codes,
/// deduplicate country codes, validate CIDRs, normalize empty lists to None.
pub fn prepare(&mut self) -> Result<(), TuliproxError> {
if let Some(countries) = &mut self.allowed_countries {
let mut seen = std::collections::HashSet::new();
let mut deduped = Vec::new();
for country in countries.drain(..) {
let upper = country.trim().to_uppercase();
if upper.is_empty() {
continue;
}
if upper.len() != 2 || !upper.chars().all(|ch| ch.is_ascii_alphabetic()) {
return Err(TuliproxError::ProxyUser(format!(
"Invalid network_access.allowed_countries entry '{upper}', expected 2-letter ISO code"
)));
}
if seen.insert(upper.clone()) {
deduped.push(upper);
}
}
*countries = deduped;
}
if let Some(networks) = &mut self.allowed_networks {
let mut deduped = Vec::new();
let mut seen = std::collections::HashSet::new();
for network in networks.drain(..) {
let trimmed = network.trim();
if trimmed.is_empty() {
continue;
}
let network_str = trimmed.to_string();
let ip: std::net::IpAddr = match network_str.split('/').next() {
Some(addr) => addr.trim().parse().map_err(|_| {
TuliproxError::ProxyUser(format!(
"Invalid network_access.allowed_networks entry '{network_str}', expected CIDR"
))
})?,
None => {
return Err(TuliproxError::ProxyUser(format!(
"Invalid network_access.allowed_networks entry '{network_str}', expected CIDR"
)));
}
};
let prefix: u8 = match network_str.split('/').nth(1) {
Some(p) => p.trim().parse().map_err(|_| {
TuliproxError::ProxyUser(format!(
"Invalid network_access.allowed_networks entry '{network_str}', expected CIDR"
))
})?,
None => {
return Err(TuliproxError::ProxyUser(format!(
"Invalid network_access.allowed_networks entry '{network_str}', expected CIDR"
)));
}
};
let prefix_valid = match ip {
std::net::IpAddr::V4(_) => prefix <= 32,
std::net::IpAddr::V6(_) => prefix <= 128,
};
if !prefix_valid {
return Err(TuliproxError::ProxyUser(format!(
"Invalid network_access.allowed_networks entry '{network_str}', expected CIDR"
)));
}
// Normalize to canonical network representation (network address / prefix)
let canonical_str = canonicalize_cidr(ip, prefix);
if seen.insert(canonical_str.clone()) {
deduped.push(canonical_str);
}
}
*networks = deduped;
}
if self.allowed_countries.as_ref().is_some_and(|c| c.is_empty()) {
self.allowed_countries = None;
}
if self.allowed_networks.as_ref().is_some_and(|n| n.is_empty()) {
self.allowed_networks = None;
}
Ok(())
}
}
fn canonicalize_cidr(ip: std::net::IpAddr, prefix: u8) -> String {
match ip {
std::net::IpAddr::V4(ipv4) => {
let mask = if prefix == 0 { 0 } else { u32::MAX << (32 - prefix) };
let network_addr = u32::from(ipv4) & mask;
let network_ip = std::net::Ipv4Addr::from(network_addr);
format!("{network_ip}/{prefix}")
}
std::net::IpAddr::V6(ipv6) => {
let mask = if prefix == 0 { 0 } else { u128::MAX << (128 - prefix) };
let network_addr = u128::from(ipv6) & mask;
let network_ip = std::net::Ipv6Addr::from(network_addr);
format!("{network_ip}/{prefix}")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_empty() {
let dto = NetworkAccessDto::default();
assert_eq!(dto.allowed_countries, None);
assert_eq!(dto.allowed_networks, None);
assert!(dto.is_empty());
}
#[test]
fn empty_vecs_is_empty() {
let dto = NetworkAccessDto { allowed_countries: Some(vec![]), allowed_networks: Some(vec![]) };
assert!(dto.is_empty());
}
#[test]
fn non_empty_is_not_empty() {
let dto = NetworkAccessDto { allowed_countries: Some(vec!["DE".to_string()]), allowed_networks: None };
assert!(!dto.is_empty());
}
#[test]
fn prepare_uppercases_and_deduplicates_countries() {
let mut dto = NetworkAccessDto {
allowed_countries: Some(vec!["de".to_string(), "DE".to_string(), "At".to_string()]),
allowed_networks: None,
};
dto.prepare().unwrap();
let countries = dto.allowed_countries.unwrap();
assert_eq!(countries, vec!["DE", "AT"]);
}
#[test]
fn prepare_normalizes_empty_to_none() {
let mut dto = NetworkAccessDto { allowed_countries: Some(vec![]), allowed_networks: Some(vec![]) };
dto.prepare().unwrap();
assert_eq!(dto.allowed_countries, None);
assert_eq!(dto.allowed_networks, None);
assert!(dto.is_empty());
}
#[test]
fn deserialize_from_yaml_fragment() {
let value = serde_json::json!({
"allowed_countries": ["DE", "AT", "CH"],
"allowed_networks": ["192.168.1.0/24", "10.0.0.0/8"]
});
let dto: NetworkAccessDto = serde_json::from_value(value).unwrap();
assert_eq!(dto.allowed_countries, Some(vec!["DE".to_string(), "AT".to_string(), "CH".to_string()]));
assert_eq!(dto.allowed_networks, Some(vec!["192.168.1.0/24".to_string(), "10.0.0.0/8".to_string()]));
}
#[test]
fn serialize_omits_none_fields() {
let dto = NetworkAccessDto::default();
let serialized = serde_json::to_value(&dto).unwrap();
assert!(!serialized.as_object().unwrap().contains_key("allowed_countries"));
assert!(!serialized.as_object().unwrap().contains_key("allowed_networks"));
}
#[test]
fn serialize_omits_empty_vec_fields() {
let dto = NetworkAccessDto { allowed_countries: Some(vec![]), allowed_networks: Some(vec![]) };
let serialized = serde_json::to_value(&dto).unwrap();
assert!(!serialized.as_object().unwrap().contains_key("allowed_countries"));
assert!(!serialized.as_object().unwrap().contains_key("allowed_networks"));
}
#[test]
fn roundtrip_preserves_content() {
let dto = NetworkAccessDto {
allowed_countries: Some(vec!["DE".to_string()]),
allowed_networks: Some(vec!["10.0.0.0/8".to_string()]),
};
let serialized = serde_json::to_value(&dto).unwrap();
let deserialized: NetworkAccessDto = serde_json::from_value(serialized).unwrap();
assert_eq!(dto, deserialized);
}
#[test]
fn prepare_trims_whitespace() {
let mut dto = NetworkAccessDto {
allowed_countries: Some(vec![" de ".to_string()]),
allowed_networks: Some(vec![" 10.0.0.0/8 ".to_string()]),
};
dto.prepare().unwrap();
assert_eq!(dto.allowed_countries, Some(vec!["DE".to_string()]));
assert_eq!(dto.allowed_networks, Some(vec!["10.0.0.0/8".to_string()]));
}
#[test]
fn prepare_rejects_invalid_country_code() {
let mut dto = NetworkAccessDto { allowed_countries: Some(vec!["DEU".to_string()]), allowed_networks: None };
assert!(dto.prepare().is_err());
}
#[test]
fn prepare_rejects_invalid_cidr() {
let mut dto =
NetworkAccessDto { allowed_countries: None, allowed_networks: Some(vec!["not-a-cidr".to_string()]) };
assert!(dto.prepare().is_err());
}
#[test]
fn prepare_normalizes_and_deduplicates_cidrs() {
// "10.0.0.1/8" should be normalized to "10.0.0.0/8" (canonical network form)
// and deduplicated against an explicit "10.0.0.0/8"
let mut dto = NetworkAccessDto {
allowed_networks: Some(vec![
"10.0.0.1/8".to_string(), // non-canonical, equivalent to 10.0.0.0/8
"10.0.0.0/8".to_string(), // canonical, duplicate after normalization
"192.168.1.42/24".to_string(), // canonical
" 192.168.1.0/24 ".to_string(), // needs trimming
]),
..Default::default()
};
dto.prepare().unwrap();
let networks = dto.allowed_networks.unwrap();
assert_eq!(networks.len(), 2, "expected 2 unique networks, got {networks:?}");
assert!(networks.iter().any(|n| n == "10.0.0.0/8"), "missing 10.0.0.0/8 in {networks:?}");
assert!(networks.iter().any(|n| n == "192.168.1.0/24"), "missing 192.168.1.0/24 in {networks:?}");
}
#[test]
fn prepare_rejects_invalid_prefix() {
let mut dto =
NetworkAccessDto { allowed_networks: Some(vec!["192.168.1.0/33".to_string()]), ..Default::default() };
assert!(dto.prepare().is_err());
}
#[test]
fn prepare_accepts_and_canonicalizes_zero_prefix_networks() {
let mut dto = NetworkAccessDto {
allowed_networks: Some(vec!["10.1.2.3/0".to_string(), "2001:db8::1/0".to_string()]),
..Default::default()
};
assert!(dto.prepare().is_ok());
assert_eq!(dto.allowed_networks, Some(vec!["0.0.0.0/0".to_string(), "::/0".to_string()]));
}
}
+2 -2
View File
@@ -111,14 +111,14 @@ impl fmt::Display for PanelApiProvisioningMethod {
}
impl FromStr for PanelApiProvisioningMethod {
type Err = String;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_uppercase().as_str() {
"HEAD" => Ok(Self::Head),
"GET" => Ok(Self::Get),
"POST" => Ok(Self::Post),
_ => Err("Unknown provisioning method: {s}".to_string()),
_ => Err(TuliproxError::Config(format!("Unknown provisioning method: {s}"))),
}
}
}
+37
View File
@@ -8,6 +8,10 @@ use crate::{
is_true, parse_to_kbps,
},
};
use std::{
fmt::{Display, Formatter},
str::FromStr,
};
const STREAM_QUEUE_SIZE: usize = 1024; // mpsc channel holding messages. with 8192byte chunks and 2Mbit/s approx 8MB
const MIN_SHARED_BURST_BUFFER_MB: u64 = 1;
@@ -34,6 +38,39 @@ impl AdmissionStrategy {
pub fn is_grace_hold(&self) -> bool { matches!(self, Self::GraceHoldStream) }
}
impl Display for AdmissionStrategy {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
AdmissionStrategy::EvictUserSameIpOldest => "evict_user_same_ip_oldest",
AdmissionStrategy::EvictUserSameIpLatest => "evict_user_same_ip_latest",
AdmissionStrategy::EvictUserOldest => "evict_user_oldest",
AdmissionStrategy::EvictUserLatest => "evict_user_latest",
AdmissionStrategy::GraceInstantStream => "grace_instant_stream",
AdmissionStrategy::GraceHoldStream => "grace_hold_stream",
}
)
}
}
impl FromStr for AdmissionStrategy {
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim() {
"evict_user_same_ip_oldest" => Ok(AdmissionStrategy::EvictUserSameIpOldest),
"evict_user_same_ip_latest" => Ok(AdmissionStrategy::EvictUserSameIpLatest),
"evict_user_oldest" => Ok(AdmissionStrategy::EvictUserOldest),
"evict_user_latest" => Ok(AdmissionStrategy::EvictUserLatest),
"grace_instant_stream" => Ok(AdmissionStrategy::GraceInstantStream),
"grace_hold_stream" => Ok(AdmissionStrategy::GraceHoldStream),
_ => Err(TuliproxError::Config(format!("Unknown admission strategy: {s}"))),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct StreamBufferConfigDto {
+8 -5
View File
@@ -1,6 +1,9 @@
use crate::utils::{
default_as_true, default_trakt_fuzzy_threshold, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY, TRAKT_API_URL,
TRAKT_API_VERSION,
use crate::{
error::TuliproxError,
utils::{
default_as_true, default_trakt_fuzzy_threshold, is_true, DEFAULT_USER_AGENT, TRAKT_API_KEY, TRAKT_API_URL,
TRAKT_API_VERSION,
},
};
use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
@@ -29,14 +32,14 @@ impl fmt::Display for TraktContentType {
}
impl FromStr for TraktContentType {
type Err = String;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"vod" => Ok(TraktContentType::Vod),
"series" => Ok(TraktContentType::Series),
"both" => Ok(TraktContentType::Both),
_ => Err(format!("Invalid TraktContentType: {}", s)),
_ => Err(TuliproxError::Config(format!("Invalid TraktContentType: {}", s))),
}
}
}
+5 -4
View File
@@ -1,5 +1,6 @@
use crate::{
create_bitset,
error::TuliproxError,
model::{
xtream_const, ClusterFlags, CommonPlaylistItem, ConfigTargetOptions, EpisodeStreamProperties,
SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamProperties, XtreamInfoDocument,
@@ -48,14 +49,14 @@ impl XtreamCluster {
}
impl FromStr for XtreamCluster {
type Err = String;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"live" => Ok(XtreamCluster::Live),
"video" | "vod" | "movie" => Ok(XtreamCluster::Video),
"series" => Ok(XtreamCluster::Series),
_ => Err(format!("Invalid XtreamCluster: {s}")),
_ => Err(TuliproxError::Config(format!("Invalid XtreamCluster: {s}"))),
}
}
}
@@ -120,7 +121,7 @@ impl From<XtreamCluster> for PlaylistItemType {
}
impl FromStr for PlaylistItemType {
type Err = String;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
@@ -135,7 +136,7 @@ impl FromStr for PlaylistItemType {
"LiveUnknown" => Ok(PlaylistItemType::LiveUnknown),
"LiveHls" => Ok(PlaylistItemType::LiveHls),
"LiveDash" => Ok(PlaylistItemType::LiveDash),
_ => Err(format!("Invalid PlaylistItemType: {s}")),
_ => Err(TuliproxError::Config(format!("Invalid PlaylistItemType: {s}"))),
}
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::error::TuliproxError;
use enum_iterator::Sequence;
use std::{fmt::Display, str::FromStr};
@@ -45,7 +46,7 @@ impl Display for ProcessingOrder {
}
impl FromStr for ProcessingOrder {
type Err = &'static str;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim().to_lowercase();
@@ -67,7 +68,7 @@ impl FromStr for ProcessingOrder {
x if x == Self::MFR => Ok(Self::Mfr),
x if x == Self::MRF => Ok(Self::Mrf),
_ => Err("invalid processing order"),
_ => Err(TuliproxError::Config(format!("Invalid processing order {s}"))),
}
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::error::TuliproxError;
use enum_iterator::Sequence;
use std::{fmt::Display, str::FromStr};
@@ -37,7 +38,7 @@ impl Display for StrmExportStyle {
}
impl FromStr for StrmExportStyle {
type Err = String;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
@@ -45,7 +46,7 @@ impl FromStr for StrmExportStyle {
Self::PLEX => Ok(Self::Plex),
Self::EMBY => Ok(Self::Emby),
Self::JELLYFIN => Ok(Self::Jellyfin),
_ => Err(format!("Unknown StrmExportStyle: {}", s)),
_ => Err(TuliproxError::Config(format!("Unknown StrmExportStyle: {s}"))),
}
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
use crate::error::TuliproxError;
use enum_iterator::Sequence;
use std::{fmt::Display, str::FromStr};
@@ -36,7 +37,7 @@ impl Display for TargetType {
}
impl FromStr for TargetType {
type Err = String;
type Err = TuliproxError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
@@ -44,7 +45,7 @@ impl FromStr for TargetType {
Self::XTREAM => Ok(Self::Xtream),
Self::STRM => Ok(Self::Strm),
Self::HDHOMERUN => Ok(Self::HdHomeRun),
_ => Err(format!("Unknown TargetType: {}", s)),
_ => Err(TuliproxError::Config(format!("Unknown TargetType: {s}"))),
}
}
}