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