From 0bd087d288a6e445b92f9bcfd971d3d1f4cb80a8 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:24:02 +0200 Subject: [PATCH] fix(provider): exclude and persist banned/expired accounts (#891) * fix(provider): exclude and persist banned/expired accounts - Store terminal account states in source.yml/CSV; keep Pending runtime-only - On 401/403, only flag the account for the throttled expiry worker - Lift stored bans on panel renewal or credential update - Persist per file in one batch; back up only when content changes - Use the first usable alias for playlists when the root is blocked * fix(provider): exclude and persist banned/expired accounts - Store terminal account states in source.yml/CSV; keep Pending runtime-only - On 401/403, only flag the account for the throttled expiry worker - Lift stored bans on panel renewal or credential update - Persist per file in one batch; back up only when content changes - Use the first usable alias for playlists when the root is blocked --- CHANGELOG.md | 13 + backend/app/src/api/config_file.rs | 24 +- backend/app/src/api/internal_csv.rs | 247 ++++++ backend/app/src/api/main_api.rs | 1 + backend/app/src/api/mod.rs | 2 + backend/app/src/api/model/app_state.rs | 1 + backend/app/src/api/model/app_state_view.rs | 2 +- .../model/streams/provider_stream_factory.rs | 106 +++ backend/app/src/api/panel_api/mod.rs | 168 +++-- backend/app/src/api/source_yml_patch.rs | 311 +++++++- backend/app/src/api/tasks/config_watch.rs | 5 +- backend/app/src/api/tasks/mod.rs | 2 + .../src/api/tasks/provider_account_state.rs | 705 ++++++++++++++++++ backend/app/src/api/tasks/xtream_expiry.rs | 261 +++---- backend/config-loader/src/lib.rs | 108 ++- backend/config-loader/src/source_patch.rs | 2 + backend/core/src/model/config/input.rs | 124 ++- backend/core/src/model/provider.rs | 85 ++- backend/core/src/utils/file/config_backup.rs | 94 +++ .../core/src/utils/file/file_lock_manager.rs | 43 +- backend/core/src/utils/file/mod.rs | 5 +- backend/iptv/src/m3u/client.rs | 9 +- backend/iptv/src/xtream/mod.rs | 65 +- backend/repository/src/alias_repository.rs | 361 ++++++--- .../session/src/active_provider_manager.rs | 32 +- backend/session/src/event_manager.rs | 19 + .../session/src/provider_lineup_manager.rs | 262 ++++++- backend/session/src/stream_ctx.rs | 1 + shared/src/model/config/input.rs | 14 +- shared/src/model/config/proxy_user_status.rs | 27 + shared/src/model/event.rs | 28 + 31 files changed, 2731 insertions(+), 396 deletions(-) create mode 100644 backend/app/src/api/internal_csv.rs create mode 100644 backend/app/src/api/tasks/provider_account_state.rs create mode 100644 backend/core/src/utils/file/config_backup.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index caf026633..76e0aea67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1025,6 +1025,19 @@ ## 🐛 Fixes +- **Banned, disabled or expired provider accounts are excluded from allocation and remembered.** A login or expiry + response that reports `Banned`, `Disabled` or `Expired` excludes the account immediately and stores the exclusion in + `source.yml` (`account_disabled: true` for a root account) or the alias CSV (`enabled` = `0`). `Pending` and other + non-terminal states only exclude the account at runtime until the provider reports it as usable again. A stream + rejected with HTTP 401/403 sends no request of its own; it only asks the Xtream expiry worker to check the account, + which still queries each account at most once per 24 hours and each panel at most every 5 minutes. A stored + exclusion is cleared by a panel renewal or credential update of that account (this also re-enables an alias that was + disabled by hand), or by resetting the flag in the file. An expired root + account no longer disables the whole input; playlist updates use the first usable alias instead. Configuration + backups are now named `---`, and the latest ten are kept per source file. Backups + written in the previous format (`_…`) are not pruned automatically; delete them manually if they are + no longer needed. + - **Stalker playback resolution now says why it failed, retries a rejected session, and can fall back to the stored command.** A playback request that could not be resolved reported a single message naming the requested item's portal id, while the actual cause — no published catalog for the configured portal identity, an item missing from the active diff --git a/backend/app/src/api/config_file.rs b/backend/app/src/api/config_file.rs index 981e03e5d..219c3b8ae 100644 --- a/backend/app/src/api/config_file.rs +++ b/backend/app/src/api/config_file.rs @@ -1,6 +1,6 @@ use crate::{ api::model::{update_app_state_config, update_app_state_sources, AppState, EventMessage}, - config_loader::{prepare_sources_batch, read_sources_file_from_path_with_templates}, + config_loader::{prepare_sources_batch, read_sources_file_with_revision}, model::{CompiledMappings, Config, ProcessTargets, SourcesConfig}, utils, utils::read_mappings_file_with_templates, @@ -38,13 +38,14 @@ struct PreparedSourcesReload { mapping: Option, sources_file: String, templates: Option>, + loaded_revisions: Vec<(PathBuf, blake3::Hash)>, } /// What dependent reload (if any) was prepared alongside a config change. enum PreparedFollowUp { Unchanged, Mapping(Option, Option>), - Sources(PreparedSourcesReload), + Sources(Box), } /// Refreshes CLI-forced target IDs against an incoming `SourcesConfig`. @@ -191,7 +192,7 @@ impl ConfigFile { ) -> Result { let sources_file = paths.sources_file_path.clone(); let prepared_templates = Self::load_prepared_global_templates_with_config(paths, config).await?; - let mut sources_dto = read_sources_file_from_path_with_templates( + let (mut sources_dto, source_revision) = read_sources_file_with_revision( &PathBuf::from(sources_file.as_str()), true, true, @@ -199,11 +200,19 @@ impl ConfigFile { prepared_templates.as_deref(), ) .await?; + let mut loaded_revisions = crate::config_loader::capture_batch_revisions(&sources_dto).await; + loaded_revisions.push((PathBuf::from(&sources_file), source_revision)); prepare_sources_batch(&mut sources_dto, true).await?; let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?; let prepared_mapping = Self::prepare_mapping_reload(paths.mapping_file_path.as_deref(), prepared_templates.as_deref())?; - Ok(PreparedSourcesReload { sources, mapping: prepared_mapping, sources_file, templates: prepared_templates }) + Ok(PreparedSourcesReload { + sources, + mapping: prepared_mapping, + sources_file, + templates: prepared_templates, + loaded_revisions, + }) } /// Apply a fully-prepared sources reload to app state (infallible under normal conditions). @@ -217,6 +226,9 @@ impl ConfigFile { update_app_state_sources(app_state, prepared.sources, Some(validated_forced)).await?; Self::apply_mapping_reload(app_state, prepared.mapping); Self::apply_user_filter_templates(app_state, prepared.templates)?; + for (path, revision) in prepared.loaded_revisions { + app_state.app_config.file_locks.record_loaded_revision(&path, revision).await; + } info!("Loaded sources file {}", prepared.sources_file); Ok(()) } @@ -299,7 +311,7 @@ impl ConfigFile { let follow_up: PreparedFollowUp = if template_changed { // Template path changed -> sources depend on new templates, reload everything. let prepared = Self::prepare_sources_reload_with_config(&config, &effective_paths).await?; - PreparedFollowUp::Sources(prepared) + PreparedFollowUp::Sources(Box::new(prepared)) } else if mapping_changed { // Only mapping path changed; templates are the same -> load templates once. let prepared_templates = @@ -335,7 +347,7 @@ impl ConfigFile { Self::apply_mapping_reload(app_state, prepared); Self::apply_user_filter_templates(app_state, templates) } - PreparedFollowUp::Sources(prepared) => Self::apply_sources_reload(app_state, prepared).await, + PreparedFollowUp::Sources(prepared) => Self::apply_sources_reload(app_state, *prepared).await, }; if let Err(err) = follow_up_result { diff --git a/backend/app/src/api/internal_csv.rs b/backend/app/src/api/internal_csv.rs new file mode 100644 index 000000000..a13107be8 --- /dev/null +++ b/backend/app/src/api/internal_csv.rs @@ -0,0 +1,247 @@ +use crate::{ + api::{ + model::AppState, + source_yml_patch::{merge_runtime_document, runtime_patch_document}, + }, + repository::{self, AliasExpDateSortOrder}, +}; +use shared::{ + error::TuliproxError, + model::{ConfigInputAliasDto, InputType}, +}; +use std::{future::Future, path::Path, sync::Arc}; + +/// Synchronizes runtime sources with a CSV mutation. Callers refresh the provider manager afterwards. +fn sync_runtime_csv_accounts( + app_state: &AppState, + csv_path: &Path, + before: &[ConfigInputAliasDto], + after: &[ConfigInputAliasDto], + renewed_account: Option<&str>, +) -> Result<(), TuliproxError> { + loop { + let current = app_state.app_config.sources.load_full(); + let mut doc = runtime_patch_document(¤t); + for (runtime, input) in current.inputs.iter().zip(&mut doc.inputs) { + if runtime + .t_batch_url + .as_ref() + .and_then(|url| repository::get_csv_file_path(url).ok()) + .is_none_or(|path| path != csv_path) + { + continue; + } + let root = before.iter().find(|alias| { + alias.username == runtime.username && alias.password == runtime.password && alias.url == runtime.url + }); + let root_name = root.map(|alias| &alias.name); + if let Some(updated) = root_name.and_then(|name| after.iter().find(|alias| &alias.name == name)) { + input.url.clone_from(&updated.url); + input.username.clone_from(&updated.username); + input.password.clone_from(&updated.password); + input.exp_date = updated.exp_date; + input.max_connections = updated.max_connections; + input.priority = updated.priority; + let renewed = + renewed_account.is_some_and(|name| name == input.name.as_ref() || name == updated.name.as_ref()); + input.account_disabled = !updated.enabled || (runtime.account_disabled && !renewed); + } else if root.is_some() { + input.account_disabled = true; + } + input.aliases = Some(after.iter().filter(|alias| Some(&alias.name) != root_name).cloned().collect()); + for alias in input.aliases.iter_mut().flatten() { + if renewed_account != Some(alias.name.as_ref()) + && runtime.aliases.iter().flatten().any(|old| old.name == alias.name && !old.enabled) + { + alias.enabled = false; + } + } + } + let next = merge_runtime_document(¤t, doc)?; + let previous = app_state.app_config.sources.compare_and_swap(¤t, Arc::new(next)); + if Arc::ptr_eq(&previous, ¤t) { + if let Some(name) = renewed_account { + app_state.active_provider.reset_account_health(&Arc::from(name)); + } + return Ok(()); + } + } +} + +fn backup_dir(app_state: &AppState) -> String { app_state.app_config.config.load().get_backup_dir().into_owned() } + +fn parse_csv(input_type: InputType, content: &[u8]) -> Result, TuliproxError> { + repository::csv_read_inputs_from_reader( + input_type, + crate::utils::EnvResolvingReader::new(std::io::BufReader::new(std::io::Cursor::new(content))), + ) + .map_err(|err| TuliproxError::ConfigInput(err.to_string())) +} + +/// Runs a repository CSV mutation under the caller's write lock and mirrors the result into the +/// runtime sources. Backups are taken by the repository only when the file actually changes. +async fn mutate_csv>>( + app_state: &AppState, + _guard: &crate::utils::FileWriteGuard, + input_type: InputType, + csv_path: &Path, + mutation: F, + renewed_account: Option<&str>, +) -> Result { + let original = tokio::fs::read(csv_path).await.map_err(|err| TuliproxError::Io(err.to_string()))?; + let synchronized = + app_state.app_config.file_locks.is_internal_revision_hash(csv_path, &blake3::hash(&original)).await; + let result = mutation.await?; + let content = tokio::fs::read(csv_path).await.map_err(|err| TuliproxError::Io(err.to_string()))?; + if original != content || renewed_account.is_some() { + let before = parse_csv(input_type, &original)?; + let after = parse_csv(input_type, &content)?; + sync_runtime_csv_accounts(app_state, csv_path, &before, &after, renewed_account)?; + app_state.active_provider.update_config(&app_state.app_config); + } + if synchronized { + app_state.app_config.file_locks.mark_internal_write_content(csv_path, &content).await; + } + Ok(result) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn csv_patch_batch_append( + app_state: &AppState, + guard: &crate::utils::FileWriteGuard, + csv_path: &Path, + input_type: InputType, + alias_name: &str, + base_url: &str, + username: &str, + password: &str, + exp_date: Option, +) -> Result<(), TuliproxError> { + let backup_dir = backup_dir(app_state); + mutate_csv( + app_state, + guard, + input_type, + csv_path, + repository::csv_patch_batch_append( + csv_path, + input_type, + alias_name, + base_url, + username, + password, + exp_date, + &backup_dir, + ), + None, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn csv_patch_batch_update_exp_date( + app_state: &AppState, + guard: &crate::utils::FileWriteGuard, + input_type: InputType, + csv_path: &Path, + account_name: &Arc, + username: &str, + password: &str, + exp_date: i64, +) -> Result<(), TuliproxError> { + let backup_dir = backup_dir(app_state); + mutate_csv( + app_state, + guard, + input_type, + csv_path, + repository::csv_patch_batch_update_exp_date( + input_type, + csv_path, + account_name, + username, + password, + exp_date, + &backup_dir, + ), + Some(account_name.as_ref()), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn csv_patch_batch_update_credentials( + app_state: &AppState, + guard: &crate::utils::FileWriteGuard, + input_type: InputType, + csv_path: &Path, + account_name: &Arc, + old_username: &str, + old_password: &str, + new_username: &str, + new_password: &str, + exp_date: Option, +) -> Result<(), TuliproxError> { + let backup_dir = backup_dir(app_state); + mutate_csv( + app_state, + guard, + input_type, + csv_path, + repository::csv_patch_batch_update_credentials( + input_type, + csv_path, + account_name, + old_username, + old_password, + new_username, + new_password, + exp_date, + &backup_dir, + ), + Some(account_name.as_ref()), + ) + .await +} + +pub(crate) async fn csv_patch_batch_update_account_states( + app_state: &AppState, + guard: &crate::utils::FileWriteGuard, + input_type: InputType, + csv_path: &Path, + updates: &[repository::BatchAccountStateUpdate], +) -> Result { + let backup_dir = backup_dir(app_state); + mutate_csv( + app_state, + guard, + input_type, + csv_path, + repository::csv_patch_batch_update_account_states(input_type, csv_path, updates, &backup_dir), + None, + ) + .await +} + +pub(crate) async fn csv_patch_batch_remove_expired( + app_state: &AppState, + guard: &crate::utils::FileWriteGuard, + input_type: InputType, + csv_path: &Path, +) -> Result { + let backup_dir = backup_dir(app_state); + let mutation = repository::csv_patch_batch_remove_expired(input_type, csv_path, &backup_dir); + mutate_csv(app_state, guard, input_type, csv_path, mutation, None).await +} + +pub(crate) async fn csv_patch_batch_sort_by_exp_date( + app_state: &AppState, + guard: &crate::utils::FileWriteGuard, + input_type: InputType, + csv_path: &Path, + order: AliasExpDateSortOrder, +) -> Result { + let backup_dir = backup_dir(app_state); + let mutation = repository::csv_patch_batch_sort_by_exp_date(input_type, csv_path, order, &backup_dir); + mutate_csv(app_state, guard, input_type, csv_path, mutation, None).await +} diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index b5f849d8b..cd96e6116 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -402,6 +402,7 @@ async fn create_shared_data( let cache = create_cache(&config); let event_manager = Arc::new(EventManager::with_capacity(config.event_channel_capacity as usize)); let active_provider = Arc::new(ActiveProviderManager::new(app_config, &event_manager)); + active_provider.bind_event_manager(&event_manager); let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); let rewrite_secret = config.reverse_proxy.as_ref().map_or(app_config.encrypt_secret, |reverse_proxy| reverse_proxy.rewrite_secret); diff --git a/backend/app/src/api/mod.rs b/backend/app/src/api/mod.rs index f2438acf5..c465bb54a 100644 --- a/backend/app/src/api/mod.rs +++ b/backend/app/src/api/mod.rs @@ -14,3 +14,5 @@ mod serve; pub(crate) mod static_headers; mod sys_usage; mod tasks; + +pub(crate) mod internal_csv; diff --git a/backend/app/src/api/model/app_state.rs b/backend/app/src/api/model/app_state.rs index 9169037d3..18e357744 100644 --- a/backend/app/src/api/model/app_state.rs +++ b/backend/app/src/api/model/app_state.rs @@ -542,6 +542,7 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc { }); let event_manager = Arc::new(EventManager::new()); let active_provider = Arc::new(ActiveProviderManager::new(&app_config, &event_manager)); + active_provider.bind_event_manager(&event_manager); let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); active_provider.set_shared_stream_manager(&shared_stream_manager); diff --git a/backend/app/src/api/model/app_state_view.rs b/backend/app/src/api/model/app_state_view.rs index d7e75943f..a2ac723f3 100644 --- a/backend/app/src/api/model/app_state_view.rs +++ b/backend/app/src/api/model/app_state_view.rs @@ -103,7 +103,7 @@ app_state_views! { /// The handles the provider side of a stream needs: the redirect-aware /// HTTP clients. provider_stream_ctx => tuliprox_session::stream_ctx::ProviderStreamCtx { - app_config, connection_manager, http_client_no_redirect, public_http_client_no_redirect, + app_config, active_provider, connection_manager, http_client_no_redirect, public_http_client_no_redirect, } /// The handles the background metadata worker reads. diff --git a/backend/app/src/api/model/streams/provider_stream_factory.rs b/backend/app/src/api/model/streams/provider_stream_factory.rs index e22003c89..c322b2804 100644 --- a/backend/app/src/api/model/streams/provider_stream_factory.rs +++ b/backend/app/src/api/model/streams/provider_stream_factory.rs @@ -107,6 +107,7 @@ pub struct ProviderStreamFactoryOptions { cancel_token: Option, completion_token: Option, close_reason: Option>, + account: Option>, } pub(crate) struct ProviderStreamFactoryParams<'a> { @@ -224,6 +225,7 @@ impl ProviderStreamFactoryOptions { cancel_token: None, completion_token: None, close_reason: None, + account: None, } } @@ -997,6 +999,66 @@ async fn provider_stream_request( } } +fn failed_stream_account( + input: &crate::model::ConfigInput, + options: &ProviderStreamFactoryOptions, +) -> Option { + let url = &options.url; + let credentials = shared::utils::get_credentials_from_url(url); + let segments: Vec<_> = url.path_segments().into_iter().flatten().collect(); + let matches = |username: Option<&str>, password: Option<&str>| { + username.zip(password).is_some_and(|(username, password)| { + credentials.0.as_deref() == Some(username) && credentials.1.as_deref() == Some(password) + || segments.windows(2).any(|pair| pair[0] == username && pair[1] == password) + }) + }; + let matches_origin = |base: &str| { + input + .resolve_url(base) + .ok() + .and_then(|base| Url::parse(&base).ok()) + .is_some_and(|base| base.origin() == url.origin()) + }; + let account = if let Some(allocated) = &options.account { + if input.name == allocated.name { + input.clone() + } else { + let alias = input.aliases.iter().flatten().find(|alias| alias.name == allocated.name)?; + input.as_input(alias) + } + } else if matches(input.username.as_deref(), input.password.as_deref()) && matches_origin(&input.url) { + input.clone() + } else { + let alias = input.aliases.iter().flatten().find(|alias| { + matches(alias.username.as_deref(), alias.password.as_deref()) && matches_origin(&alias.url) + })?; + input.as_input(alias) + }; + if options.account.as_ref().is_some_and(|allocated| account.account_identity() != allocated.account_identity()) { + return None; + } + Some(account) +} + +/// Flags the failed account for the Xtream expiry worker. No request is sent here: the worker +/// checks the account only when its per-account, per-panel and cooldown throttles allow it, +/// because frequent account queries can get the account banned. +fn request_provider_account_probe(ctx: &ProviderStreamCtx, options: &ProviderStreamFactoryOptions) { + let Some(channel) = options.stream_channel.as_ref() else { + return; + }; + let sources = ctx.app_config.sources.load_full(); + let Some(input) = sources.get_input_by_name(&channel.input_name) else { + return; + }; + if !input.input_type.is_xtream() { + return; + } + if let Some(account) = failed_stream_account(input, options) { + ctx.active_provider.request_account_probe(&account.name); + } +} + #[allow(clippy::too_many_lines)] async fn get_provider_stream( ctx: &ProviderStreamCtx, @@ -1044,6 +1106,9 @@ async fn get_provider_stream( } } Err(failure) => { + if matches!(failure.status(), StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) { + request_provider_account_probe(ctx, stream_options); + } if failure.should_serve_channel_unavailable() { return Err(failure); } @@ -1239,6 +1304,7 @@ pub struct ProviderStreamOpenLifecycle { cancel_token: Option, completion_token: Option, close_reason: Option>, + account: Option>, } impl ProviderStreamOpenLifecycle { @@ -1250,6 +1316,7 @@ impl ProviderStreamOpenLifecycle { cancel_token: handle.cancel_token.clone(), completion_token: handle.completion_token.clone(), close_reason: Some(Arc::clone(&handle.close_reason)), + account: handle.allocation.get_provider_config(), }) } @@ -1266,6 +1333,7 @@ pub async fn open_provider_stream_with_lifecycle( ) -> Option { if let Some(ref lc) = lifecycle { lc.mark_opening(); + stream_options.account.clone_from(&lc.account); stream_options.set_provider_handle_tokens( lc.cancel_token.clone(), lc.completion_token.clone(), @@ -1341,6 +1409,44 @@ mod tests { }) } + #[test] + fn auth_failure_selects_origin_and_allocated_account() -> Result<(), Box> { + let dto = shared::model::ConfigInputDto { + name: Arc::from("root"), + url: "http://main.example".to_string(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + aliases: Some(vec![shared::model::ConfigInputAliasDto { + name: Arc::from("alias"), + url: "http://alias.example".to_string(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + ..shared::model::ConfigInputAliasDto::default() + }]), + ..shared::model::ConfigInputDto::default() + }; + let mut input = crate::model::ConfigInput::from(&dto); + let mut options = redirect_test_options(&Url::parse("http://alias.example/live/user/pass/1.ts")?); + assert_eq!(failed_stream_account(&input, &options).map(|account| account.name), Some(Arc::from("alias"))); + options.url = Url::parse("http://main.example/live/user/pass/1.ts")?; + assert_eq!(failed_stream_account(&input, &options).map(|account| account.name), Some(Arc::from("root"))); + options.url = Url::parse("http://cdn.example/token")?; + assert!(failed_stream_account(&input, &options).is_none()); + let alias = input.aliases.as_ref().and_then(|aliases| aliases.first()).ok_or("missing alias")?; + options.account = Some(Arc::new(crate::model::ProviderConfig::new_alias( + &input, + alias, + Arc::new(std::sync::RwLock::new(crate::model::ProviderConfigConnection::default())), + Arc::new(|_, _| {}), + ))); + assert_eq!(failed_stream_account(&input, &options).map(|account| account.name), Some(Arc::from("alias"))); + if let Some(alias) = input.aliases.as_mut().and_then(|aliases| aliases.first_mut()) { + alias.password = Some("renewed".to_string()); + } + assert!(failed_stream_account(&input, &options).is_none()); + Ok(()) + } + async fn read_http_request(socket: &mut TcpStream) -> String { let mut request = Vec::new(); while !request.windows(4).any(|window| window == b"\r\n\r\n") { diff --git a/backend/app/src/api/panel_api/mod.rs b/backend/app/src/api/panel_api/mod.rs index fbc5d0fc3..04b0f2b6f 100644 --- a/backend/app/src/api/panel_api/mod.rs +++ b/backend/app/src/api/panel_api/mod.rs @@ -21,24 +21,24 @@ use crate::{ api::{ - config_file::ConfigFile, + internal_csv::{ + csv_patch_batch_append, csv_patch_batch_remove_expired, csv_patch_batch_sort_by_exp_date, + csv_patch_batch_update_credentials, csv_patch_batch_update_exp_date, + }, model::{ create_panel_api_provisioning_stream_with_stop, create_provider_connections_exhausted_stream, AppState, StreamDetails, }, source_yml_patch::{ - derive_unique_alias_name, derive_unique_alias_name_set, execute_source_yml_patches, - resolve_provisioned_account_base_url, SourcesYmlPatch, + derive_unique_alias_name, derive_unique_alias_name_set, resolve_provisioned_account_base_url, + SourcesYmlPatch, }, }, model::{ is_input_expired, is_input_expired_at, ConfigInput, ConfigInputAlias, GracePeriodOptions, InputSource, PanelApiConfig, PanelApiQueryParam, ProxyUserCredentials, }, - repository::{ - csv_patch_batch_append, csv_patch_batch_remove_expired, csv_patch_batch_sort_by_exp_date, - csv_patch_batch_update_credentials, csv_patch_batch_update_exp_date, get_csv_file_path, AliasExpDateSortOrder, - }, + repository::{get_csv_file_path, AliasExpDateSortOrder}, utils::{debug_if_enabled, format_http_status, request}, }; use axum::http::{header, HeaderMap, Method, StatusCode}; @@ -1180,6 +1180,10 @@ fn root_counts_towards_pool_at(accounts: &[AccountCredentials], input_name: &Arc .is_some_and(|acct| acct.exp_date.is_some() && !is_input_expired_at(acct.exp_date, now)) } +fn refresh_internal_sources(app_state: &Arc) { + app_state.active_provider.update_config(&app_state.app_config); +} + fn should_reload_sources_after_internal_write(app_state: &AppState) -> bool { !app_state.app_config.config.load().config_hot_reload } @@ -1338,8 +1342,10 @@ async fn try_renew_expired_account( if is_batch { match require_batch_alias_path(input) { Ok(csv_path) => { - let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; if let Err(err) = csv_patch_batch_update_exp_date( + app_state, + &csv_lock, input.input_type, &csv_path, &acct.name, @@ -1352,6 +1358,8 @@ async fn try_renew_expired_account( debug_if_enabled!("panel_api failed to persist renew exp_date to csv: {}", err); } if let Err(err) = csv_patch_batch_sort_by_exp_date( + app_state, + &csv_lock, input.input_type, &csv_path, AliasExpDateSortOrder::NewestFirst, @@ -1375,8 +1383,12 @@ async fn try_renew_expired_account( order: AliasExpDateSortOrder::NewestFirst, }, ]; - if let Err(err) = - execute_source_yml_patches(&app_state.app_config, sources_path, &patches).await + if let Err(err) = crate::api::source_yml_patch::execute_account_source_patches( + app_state, + sources_path, + &patches, + ) + .await { debug_if_enabled!("panel_api failed to persist renew exp_date to source.yml: {}", err); } @@ -1384,9 +1396,7 @@ async fn try_renew_expired_account( } if should_reload_sources_after_internal_write(app_state.as_ref()) { - if let Err(err) = ConfigFile::load_sources(app_state).await { - debug_if_enabled!("panel_api reload sources failed: {}", err); - } + refresh_internal_sources(app_state); } return Some(PanelApiProvisionOutcome::Renewed); } @@ -1522,9 +1532,11 @@ async fn try_refresh_root_account_on_exhausted( let Ok(csv_path) = require_batch_alias_path(input) else { return None; }; - let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; let result = if credentials_changed { csv_patch_batch_update_credentials( + app_state, + &csv_lock, input.input_type, &csv_path, &input.name, @@ -1537,6 +1549,8 @@ async fn try_refresh_root_account_on_exhausted( .await } else if let Some(exp_date) = exp_date { csv_patch_batch_update_exp_date( + app_state, + &csv_lock, input.input_type, &csv_path, &input.name, @@ -1552,8 +1566,14 @@ async fn try_refresh_root_account_on_exhausted( debug_if_enabled!("panel_api failed to persist root provisioning to csv: {}", err); return None; } - if let Err(err) = - csv_patch_batch_sort_by_exp_date(input.input_type, &csv_path, AliasExpDateSortOrder::NewestFirst).await + if let Err(err) = csv_patch_batch_sort_by_exp_date( + app_state, + &csv_lock, + input.input_type, + &csv_path, + AliasExpDateSortOrder::NewestFirst, + ) + .await { debug_if_enabled!("panel_api failed to sort csv accounts after root provisioning: {}", err); return None; @@ -1565,17 +1585,16 @@ async fn try_refresh_root_account_on_exhausted( password: active_password, exp_date, }; - if let Err(err) = execute_source_yml_patches(&app_state.app_config, sources_path, &[patch]).await { + if let Err(err) = + crate::api::source_yml_patch::execute_account_source_patches(app_state, sources_path, &[patch]).await + { debug_if_enabled!("panel_api failed to persist root provisioning to source.yml: {}", err); return None; } } if should_reload_sources_after_internal_write(app_state.as_ref()) { - if let Err(err) = ConfigFile::load_sources(app_state).await { - debug_if_enabled!("panel_api reload sources failed: {}", err); - return None; - } + refresh_internal_sources(app_state); } Some(outcome) @@ -1646,8 +1665,10 @@ async fn try_create_new_account( } else { InputType::M3uBatch }; - let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; if let Err(err) = csv_patch_batch_append( + app_state, + &csv_lock, &csv_path, batch_type, &alias_name, @@ -1661,9 +1682,14 @@ async fn try_create_new_account( warn!("panel_api failed to append new account to csv: {err}"); return None; } - if let Err(err) = - csv_patch_batch_sort_by_exp_date(batch_type, &csv_path, AliasExpDateSortOrder::NewestFirst) - .await + if let Err(err) = csv_patch_batch_sort_by_exp_date( + app_state, + &csv_lock, + batch_type, + &csv_path, + AliasExpDateSortOrder::NewestFirst, + ) + .await { warn!("panel_api failed to sort csv accounts after append: {err}"); return None; @@ -1693,17 +1719,17 @@ async fn try_create_new_account( order: AliasExpDateSortOrder::NewestFirst, }, ]; - if let Err(err) = execute_source_yml_patches(&app_state.app_config, sources_path, &patches).await { + if let Err(err) = + crate::api::source_yml_patch::execute_account_source_patches(app_state, sources_path, &patches) + .await + { warn!("panel_api failed to persist new alias to source.yml: {err}"); return None; } } if should_reload_sources_after_internal_write(app_state.as_ref()) { - if let Err(err) = ConfigFile::load_sources(app_state).await { - error!("panel_api reload sources failed: {err}"); - return None; - } + refresh_internal_sources(app_state); } Some(PanelApiProvisionOutcome::Created) } @@ -1891,8 +1917,10 @@ async fn ensure_alias_pool_min( acct_mut.exp_date = Some(new_exp); } if let Some(csv_path) = csv_path { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_update_exp_date( + app_state, + &csv_lock, input.input_type, csv_path, &acct.name, @@ -1976,8 +2004,10 @@ async fn ensure_alias_pool_min( } else { input.input_type }; - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_append( + app_state, + &csv_lock, csv_path, batch_type, &alias_name, @@ -2187,8 +2217,10 @@ async fn sync_panel_api_for_input_on_boot( } if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_update_exp_date( + app_state, + &csv_lock, input.input_type, csv_path, &acct.name, @@ -2318,9 +2350,11 @@ async fn sync_panel_api_for_input_on_boot( } else { input.input_type }; - let _csv_lock = + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_append( + app_state, + &csv_lock, csv_path, batch_type, &alias_name, @@ -2366,8 +2400,10 @@ async fn sync_panel_api_for_input_on_boot( } if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_update_credentials( + app_state, + &csv_lock, input.input_type, csv_path, &input.name, @@ -2440,8 +2476,10 @@ async fn sync_panel_api_for_input_on_boot( } else { input.input_type }; - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_append( + app_state, + &csv_lock, csv_path, batch_type, &alias_name, @@ -2487,8 +2525,10 @@ async fn sync_panel_api_for_input_on_boot( } if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_update_credentials( + app_state, + &csv_lock, input.input_type, csv_path, &input.name, @@ -2578,9 +2618,11 @@ async fn sync_panel_api_for_input_on_boot( ); } else if let Some(new_exp) = refreshed_exp { if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; let result = if creds_changed { csv_patch_batch_update_credentials( + app_state, + &csv_lock, input.input_type, csv_path, &input.name, @@ -2593,6 +2635,8 @@ async fn sync_panel_api_for_input_on_boot( .await } else { csv_patch_batch_update_exp_date( + app_state, + &csv_lock, input.input_type, csv_path, &input.name, @@ -2825,8 +2869,10 @@ async fn sync_panel_api_for_input_on_boot( } else { input.input_type }; - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_append( + app_state, + &csv_lock, csv_path, batch_type, &alias_name, @@ -2927,8 +2973,10 @@ async fn sync_panel_api_for_input_on_boot( } else { input.input_type }; - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; if let Err(err) = csv_patch_batch_append( + app_state, + &csv_lock, csv_path, batch_type, &alias_name, @@ -3010,9 +3058,11 @@ async fn sync_panel_api_for_input_on_boot( if let Some(new_exp) = refreshed_exp { if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; let result = if creds_changed { csv_patch_batch_update_credentials( + app_state, + &csv_lock, input.input_type, csv_path, &account_name, @@ -3025,6 +3075,8 @@ async fn sync_panel_api_for_input_on_boot( .await } else { csv_patch_batch_update_exp_date( + app_state, + &csv_lock, input.input_type, csv_path, &account_name, @@ -3135,8 +3187,8 @@ async fn sync_panel_api_for_input_on_boot( if alias_pool_remove_expired(panel_cfg) { if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; - match csv_patch_batch_remove_expired(input.input_type, csv_path).await { + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + match csv_patch_batch_remove_expired(app_state, &csv_lock, input.input_type, csv_path).await { Ok(true) => any_change = true, Ok(false) => {} Err(err) => debug_if_enabled!("panel_api boot sync failed to remove expired csv accounts: {}", err), @@ -3175,8 +3227,15 @@ async fn sync_panel_api_for_input_on_boot( if panel_cfg.alias_pool.is_some() { if let Some(csv_path) = csv_path.as_ref() { - let _csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; - match csv_patch_batch_sort_by_exp_date(input.input_type, csv_path, AliasExpDateSortOrder::NewestFirst).await + let csv_lock = app_state.app_config.file_locks.write_lock(csv_path).await; + match csv_patch_batch_sort_by_exp_date( + app_state, + &csv_lock, + input.input_type, + csv_path, + AliasExpDateSortOrder::NewestFirst, + ) + .await { Ok(true) => any_change = true, Ok(false) => {} @@ -3195,7 +3254,13 @@ async fn sync_panel_api_for_input_on_boot( } if pending_sources_yml { - match execute_source_yml_patches(&app_state.app_config, sources_path, &sources_yml_patches).await { + match crate::api::source_yml_patch::execute_account_source_patches( + app_state, + sources_path, + &sources_yml_patches, + ) + .await + { Ok(true) => any_change = true, Ok(false) => {} Err(err) => debug_if_enabled!("panel_api boot sync failed to persist source.yml patches: {}", err), @@ -3218,11 +3283,8 @@ pub(crate) async fn sync_panel_api_exp_dates(app_state: &Arc) { } if any_change { - // Even with `config_hot_reload=true`, the file watcher reload is asynchronous. - // Reload immediately so subsequent routines use updated credentials (e.g. after root renewal). - if let Err(err) = ConfigFile::load_sources(app_state).await { - debug_if_enabled!("panel_api boot/update reload sources failed: {}", err); - } + // Account writes already synchronize prepared sources; refresh allocation without restarting services. + refresh_internal_sources(app_state); } } @@ -3271,9 +3333,7 @@ pub(crate) async fn sync_panel_api_alias_pool_for_target(app_state: &Arc, + observation: crate::model::ProviderAccountObservation, + }, + SetAccountDisabled { + input_name: Arc, + account_name: Arc, + }, SetFetchedExpiry { input_name: Arc, account_name: Arc, @@ -287,6 +295,65 @@ pub(crate) fn apply_sources_yml_patches( for patch in patches { match patch { + SourcesYmlPatch::SetObservedAccount { input_name, observation } => { + let Some(input) = doc.inputs.iter().find(|input| input.name == *input_name) else { + continue; + }; + let account = if input.name == observation.name { + Some((&input.url, input.username.as_deref(), input.password.as_deref())) + } else { + input + .aliases + .iter() + .flatten() + .find(|alias| alias.name == observation.name) + .map(|alias| (&alias.url, alias.username.as_deref(), alias.password.as_deref())) + }; + let Some((url, username, password)) = account else { + continue; + }; + let url = crate::utils::resolve_env_var(url); + let username = username.map(crate::utils::resolve_env_var); + let password = password.map(crate::utils::resolve_env_var); + if shared::model::ProviderAccountIdentity::new(url.trim(), username.as_deref(), password.as_deref()) + != observation.identity + { + continue; + } + // Only confirmed terminal states are persisted; transient ones stay runtime exclusions. + let disable = observation.is_persistent_block(); + let command = match observation.exp_date { + Some(exp_date) => SourcesYmlPatch::SetFetchedExpiry { + input_name: Arc::clone(input_name), + account_name: Arc::clone(&observation.name), + exp_date, + disable, + }, + None if disable => SourcesYmlPatch::SetAccountDisabled { + input_name: Arc::clone(input_name), + account_name: Arc::clone(&observation.name), + }, + None => continue, + }; + changed |= apply_sources_yml_patches(doc, std::slice::from_ref(&command))?; + } + SourcesYmlPatch::SetAccountDisabled { input_name, account_name } => { + let idx = *inputs_by_name + .get(input_name.as_ref()) + .ok_or_else(|| TuliproxError::ConfigInput(format!("Missing input {input_name}")))?; + let input = &mut doc.inputs[idx]; + if input.name == *account_name { + changed |= !input.account_disabled; + input.account_disabled = true; + continue; + } + let alias = input + .aliases + .as_mut() + .and_then(|aliases| aliases.iter_mut().find(|alias| alias.name == *account_name)) + .ok_or_else(|| TuliproxError::ConfigInput(format!("Missing alias {account_name}")))?; + changed |= std::mem::replace(&mut alias.enabled, false); + } SourcesYmlPatch::SetFetchedExpiry { input_name, account_name, exp_date, disable } => { let idx = *inputs_by_name.get(input_name.as_ref()).ok_or_else(|| { TuliproxError::ConfigPanelApi(format!("source.yml patch target input '{input_name}' was not found")) @@ -303,11 +370,13 @@ pub(crate) fn apply_sources_yml_patches( })?; if account_name == input_name { if doc.inputs[idx].exp_date != Some(*exp_date) + || doc.inputs[idx].account_disabled || !doc.inputs[idx].enabled || doc.inputs[idx].max_connections != 1 { doc.inputs[idx].exp_date = Some(*exp_date); doc.inputs[idx].enabled = true; + doc.inputs[idx].account_disabled = false; doc.inputs[idx].max_connections = 1; changed = true; } @@ -321,9 +390,12 @@ pub(crate) fn apply_sources_yml_patches( let aliases = doc.inputs[idx].aliases.as_mut().ok_or_else(|| { TuliproxError::ConfigPanelApi(format!("source.yml patch: input '{input_name}' has no aliases")) })?; - if aliases[alias_idx].exp_date != Some(*exp_date) || aliases[alias_idx].max_connections != 1 { - aliases[alias_idx].exp_date = Some(*exp_date); - aliases[alias_idx].max_connections = 1; + let alias = &mut aliases[alias_idx]; + // A panel renewal is an explicit decision for this account and lifts a stored exclusion. + if alias.exp_date != Some(*exp_date) || alias.max_connections != 1 || !alias.enabled { + alias.exp_date = Some(*exp_date); + alias.max_connections = 1; + alias.enabled = true; changed = true; } } @@ -367,6 +439,7 @@ pub(crate) fn apply_sources_yml_patches( input.username = Some(username.clone()); input.password = Some(password.clone()); input.enabled = true; + input.account_disabled = false; input.max_connections = 1; if let Some(exp_date) = *exp_date { input.exp_date = Some(exp_date); @@ -416,6 +489,7 @@ pub(crate) fn apply_sources_yml_patches( input.username = Some(username.clone()); input.password = Some(password.clone()); input.enabled = true; + input.account_disabled = false; input.max_connections = 1; if let Some(exp_date) = *exp_date { input.exp_date = Some(exp_date); @@ -441,9 +515,11 @@ pub(crate) fn apply_sources_yml_patches( if alias.username.as_deref() != Some(username.as_str()) || alias.password.as_deref() != Some(password.as_str()) || exp_date_changed + || !alias.enabled { alias.username = Some(username.clone()); alias.password = Some(password.clone()); + alias.enabled = true; alias.max_connections = 1; if let Some(exp_date) = *exp_date { alias.exp_date = Some(exp_date); @@ -713,6 +789,16 @@ fn plan_scalar_field_edits( }; plan_account_scalar_edits(text, &spans, &changes, edits)?; + if before.account_disabled != expected.account_disabled { + plan_field( + text, + opt_span(value.account_disabled.as_ref())?, + &[Some(spans.name.clone())], + "account_disabled", + if expected.account_disabled { "true" } else { "false" }, + edits, + )?; + } plan_panel_api_credits_edit(text, value, before, expected, edits) } @@ -867,10 +953,150 @@ fn apply_patch_planning_step( Ok(true) } -/// Executes a batch of `SourcesYmlPatch` commands as a single atomic transaction. -/// -/// Returns `Ok(true)` if the file was written, `Ok(false)` if no change was needed. -/// The write lock is acquired internally — callers must NOT hold it. +/// Builds the account fields needed for patches without rebuilding prepared inputs. +pub(crate) fn runtime_patch_document(sources: &crate::model::SourcesConfig) -> SourcesConfigDto { + SourcesConfigDto { + inputs: sources + .inputs + .iter() + .map(|input| shared::model::ConfigInputDto { + id: input.id, + name: Arc::clone(&input.name), + input_type: input.input_type, + url: input.url.clone(), + username: input.username.clone(), + password: input.password.clone(), + priority: input.priority, + enabled: input.enabled, + account_disabled: input.account_disabled, + max_connections: input.max_connections, + exp_date: input.exp_date, + panel_api: input.panel_api.as_ref().map(shared::model::PanelApiConfigDto::from), + aliases: input.aliases.as_ref().map(|aliases| { + aliases + .iter() + .map(|alias| shared::model::ConfigInputAliasDto { + id: alias.id, + name: Arc::clone(&alias.name), + url: alias.url.clone(), + username: alias.username.clone(), + password: alias.password.clone(), + enabled: alias.enabled, + max_connections: alias.max_connections, + priority: alias.priority, + exp_date: alias.exp_date, + stalker: None, + }) + .collect() + }), + ..shared::model::ConfigInputDto::default() + }) + .collect(), + ..SourcesConfigDto::default() + } +} + +pub(crate) fn merge_runtime_document( + current: &crate::model::SourcesConfig, + doc: SourcesConfigDto, +) -> Result { + let mut next = current.clone(); + let mut next_id = next + .inputs + .iter() + .flat_map(|input| std::iter::once(input.id).chain(input.aliases.iter().flatten().map(|alias| alias.id))) + .max() + .unwrap_or(0); + for (input, updated) in next.inputs.iter_mut().zip(doc.inputs) { + let input = Arc::make_mut(input); + input.url = updated.url; + input.username = updated.username; + input.password = updated.password; + if let Some(stalker) = input.stalker.as_mut() { + stalker.username.clone_from(&input.username); + stalker.password.clone_from(&input.password); + } + input.exp_date = updated.exp_date; + input.enabled = updated.enabled; + input.account_disabled = updated.account_disabled; + input.max_connections = updated.max_connections; + input.priority = updated.priority; + input.panel_api = updated.panel_api.as_ref().map(crate::model::PanelApiConfig::from); + + if let Some(aliases) = updated.aliases { + let previous = input.aliases.take().unwrap_or_default(); + let mut converted = Vec::with_capacity(aliases.len()); + for alias in aliases { + let mut runtime = crate::model::ConfigInputAlias::from(&alias); + if let Some(old) = previous.iter().find(|old| old.name == alias.name) { + runtime.id = old.id; + runtime.stalker.clone_from(&old.stalker); + if let Some(stalker) = runtime.stalker.as_mut() { + stalker.username.clone_from(&runtime.username); + stalker.password.clone_from(&runtime.password); + } + } else { + next_id = next_id + .checked_add(1) + .ok_or_else(|| TuliproxError::ConfigInput("Provider account ID space exhausted".to_string()))?; + runtime.id = next_id; + } + converted.push(runtime); + } + input.aliases = Some(converted); + } + } + next.group_lookup = shared::model::provider_saturation::build_group_lookup(&next.inputs); + Ok(next) +} + +pub(crate) fn apply_runtime_source_patches( + app_config: &AppConfig, + patches: &[SourcesYmlPatch], +) -> Result<(), TuliproxError> { + loop { + let current = app_config.sources.load_full(); + let mut doc = runtime_patch_document(¤t); + if !apply_sources_yml_patches(&mut doc, patches)? { + return Ok(()); + } + let next = merge_runtime_document(¤t, doc)?; + let previous = app_config.sources.compare_and_swap(¤t, Arc::new(next)); + if Arc::ptr_eq(&previous, ¤t) { + return Ok(()); + } + } +} + +/// Executes account patches (renewals, credential updates) and resets the renewed accounts' +/// health while the file lock is still held, so a queued observation cannot write a stale ban +/// back between the write and the reset. Refreshes the provider lineup afterwards. +pub(crate) async fn execute_account_source_patches( + app_state: &crate::api::model::AppState, + sources_path: &Path, + patches: &[SourcesYmlPatch], +) -> Result { + if patches.is_empty() { + return Ok(false); + } + let guard = app_state.app_config.file_locks.write_lock(sources_path).await; + let written = execute_source_yml_patches_locked(&app_state.app_config, sources_path, patches, &guard).await?; + for patch in patches { + let renewed = match patch { + SourcesYmlPatch::UpdatePanelAccountExpiry { account_name, .. } => account_name, + SourcesYmlPatch::UpdateRootCredentials { input_name, .. } => input_name, + SourcesYmlPatch::UpdateAliasCredentials { alias_name, .. } => alias_name, + _ => continue, + }; + app_state.active_provider.reset_account_health(renewed); + } + drop(guard); + app_state.active_provider.update_config(&app_state.app_config); + Ok(written) +} + +/// Executes patches under the source file's write lock and records the written revision. +#[cfg(test)] pub(crate) async fn execute_source_yml_patches( app_config: &Arc, sources_path: &Path, @@ -880,13 +1106,22 @@ pub(crate) async fn execute_source_yml_patches( return Ok(false); } - let _lock = app_config.file_locks.write_lock(sources_path).await; + let guard = app_config.file_locks.write_lock(sources_path).await; + execute_source_yml_patches_locked(app_config, sources_path, patches, &guard).await +} +pub(crate) async fn execute_source_yml_patches_locked( + app_config: &Arc, + sources_path: &Path, + patches: &[SourcesYmlPatch], + _guard: &crate::utils::FileWriteGuard, +) -> Result { // Read and fingerprint the exact bytes that the transaction is based on. let original_bytes = tokio::fs::read(sources_path) .await .map_err(|err| TuliproxError::ConfigPanelApi(format!("source.yml patch: failed to read file: {err}")))?; let original_revision = blake3::hash(&original_bytes); + let synchronized = app_config.file_locks.is_internal_revision_hash(sources_path, &original_revision).await; let original_text = String::from_utf8(original_bytes) .map_err(|_| TuliproxError::ConfigPanelApi("source.yml patch: file is not valid UTF-8".to_string()))?; @@ -915,6 +1150,7 @@ pub(crate) async fn execute_source_yml_patches( } if !changed { + apply_runtime_source_patches(app_config, patches)?; return Ok(false); } @@ -931,12 +1167,10 @@ pub(crate) async fn execute_source_yml_patches( .await?; if written { - // Let the file watcher distinguish this write from an external edit. - app_config - .file_locks - .mark_internal_write_revision(sources_path) - .await - .map_err(|err| TuliproxError::Io(format!("Failed to track internal source update: {err}")))?; + apply_runtime_source_patches(app_config, patches)?; + if synchronized { + app_config.file_locks.mark_internal_write_content(sources_path, patched_text.as_bytes()).await; + } } Ok(written) @@ -1146,6 +1380,22 @@ mod tests { }) } + async fn execute_fixture_patches( + app_config: &Arc, + source_path: &std::path::Path, + patches: &[SourcesYmlPatch], + ) -> Result { + let text = + tokio::fs::read_to_string(source_path).await.map_err(|err| TuliproxError::Io(err.to_string()))?; + let dto: SourcesConfigDto = + serde_saphyr::from_str(&text).map_err(|err| TuliproxError::ConfigInput(err.to_string()))?; + let mut sources = (*app_config.sources.load_full()).clone(); + sources.inputs = dto.inputs.iter().map(|input| Arc::new(ConfigInput::from(input))).collect(); + app_config.sources.store(Arc::new(sources)); + app_config.file_locks.mark_internal_write_content(source_path, text.as_bytes()).await; + execute_source_yml_patches(app_config, source_path, patches).await + } + const FIXTURE: &str = "\ inputs: - name: provider @@ -1162,7 +1412,7 @@ sources: [] "; fn unique_path(name: &str) -> std::path::PathBuf { - let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos()); std::env::temp_dir().join(format!("tuliprox-source-yml-patch-{nanos}-{name}")) } @@ -1239,7 +1489,7 @@ sources: [] order: AliasExpDateSortOrder::NewestFirst, }, ]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch")); let patched = std::fs::read_to_string(&source_path).expect("read patched"); let mut parsed: SourcesConfigDto = serde_saphyr::from_str(&patched).expect("reparse"); let input = &parsed.inputs[0]; @@ -1294,7 +1544,7 @@ sources: [] password: "pass".into(), exp_date: Some(4_102_444_800), }; - assert!(!execute_source_yml_patches(&app_cfg, &source_path, &[add]).await.expect("identical addition")); + assert!(!execute_fixture_patches(&app_cfg, &source_path, &[add]).await.expect("identical addition")); let patches = [ SourcesYmlPatch::UpdateRootCredentials { input_name: "provider".into(), @@ -1311,7 +1561,7 @@ sources: [] exp_date: Some(4_102_444_800), }, ]; - let error = execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect_err("conflict"); + let error = execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect_err("conflict"); assert!(error.to_string().contains("already exists")); assert!(!error.to_string().contains("conflicting-password")); assert_eq!(std::fs::read(&source_path).expect("unchanged file"), original.as_bytes()); @@ -1346,7 +1596,7 @@ sources: [] exp_date: Some(4_102_445_000), }, ]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("replenish")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("replenish")); let result = std::fs::read_to_string(&source_path).expect("read"); let parsed: SourcesConfigDto = serde_saphyr::from_str(&result).expect("reparse"); assert_eq!(parsed.inputs[0].username.as_deref(), Some("new-root")); @@ -1375,7 +1625,7 @@ sources: [] disable: false, }]; - let written = execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch"); + let written = execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch"); assert!(written, "patch should report a write happened"); let patched_text = tokio::fs::read_to_string(&source_path).await.expect("read patched"); @@ -1422,7 +1672,7 @@ sources: [] }, ]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch")); let patched = tokio::fs::read_to_string(&source_path).await.expect("read patched"); let parsed: SourcesConfigDto = serde_saphyr::from_str(&patched).expect("reparse"); assert_eq!(parsed.inputs[0].exp_date, Some(1_700_000_000)); @@ -1467,7 +1717,7 @@ sources: [] }, ]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &commands).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &commands).await.expect("patch")); let patched_text = tokio::fs::read_to_string(&source_path).await.expect("read"); let parsed: SourcesConfigDto = serde_saphyr::from_str(&patched_text).expect("reparse"); let root = parsed.inputs.iter().find(|input| input.name.as_ref() == "flow-root").expect("root"); @@ -1478,11 +1728,12 @@ sources: [] .and_then(|aliases| aliases.iter().find(|alias| alias.name.as_ref() == "flow-alias")) .expect("alias"); - assert!(!root.enabled); + assert!(root.enabled); + assert!(root.account_disabled); assert_eq!(root.exp_date, Some(100)); assert!(!alias.enabled); assert_eq!(alias.exp_date, Some(200)); - assert!(patched_text.contains("name: flow-root, enabled: false")); + assert!(patched_text.contains("name: flow-root, account_disabled: true")); assert!(patched_text.contains("name: flow-alias, enabled: false")); assert!(patched_text.contains("password: alias-pass, exp_date: 200")); @@ -1520,7 +1771,7 @@ sources: [] disable: false, }]; - let err = execute_source_yml_patches(&app_cfg, &source_path, &patches) + let err = execute_fixture_patches(&app_cfg, &source_path, &patches) .await .expect_err("duplicate alias must be rejected"); let msg = format!("{err:?}"); @@ -1547,7 +1798,7 @@ sources: [] let patches = [SourcesYmlPatch::SortAliases { input_name: name, order: AliasExpDateSortOrder::NewestFirst }]; - let written = execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("noop"); + let written = execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("noop"); assert!(!written, "no-op patch must not report a write"); let _ = std::fs::remove_dir_all(&dir); @@ -1588,7 +1839,7 @@ sources: [] }, ]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch")); let patched = tokio::fs::read_to_string(&source_path).await.expect("read"); assert!(patched.find("name: new").expect("new") < patched.find("name: old").expect("old")); assert_eq!(patched.matches("# old account").count(), 1); @@ -1635,7 +1886,7 @@ sources: [] exp_date: Some(300), }]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch")); let patched = tokio::fs::read_to_string(&source_path).await.expect("read"); let parsed: SourcesConfigDto = serde_saphyr::from_str(&patched).expect("reparse"); @@ -1685,7 +1936,7 @@ sources: [] }, ]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch")); let patched = tokio::fs::read_to_string(&source_path).await.expect("read"); assert!(patched.find("name: first").expect("first") < patched.find("name: second").expect("second")); assert!(patched.contains("exp_date: 300")); @@ -1715,7 +1966,7 @@ sources: [] let app_cfg = build_app_config(&backup_dir); let patches = [SourcesYmlPatch::RemoveExpiredAliases { input_name: Arc::from("provider") }]; - assert!(execute_source_yml_patches(&app_cfg, &source_path, &patches).await.expect("patch")); + assert!(execute_fixture_patches(&app_cfg, &source_path, &patches).await.expect("patch")); let patched = tokio::fs::read_to_string(&source_path).await.expect("read"); assert!(!patched.contains("name: expired")); let parsed: SourcesConfigDto = serde_saphyr::from_str(&patched).expect("reparse"); diff --git a/backend/app/src/api/tasks/config_watch.rs b/backend/app/src/api/tasks/config_watch.rs index 3f9ab1e13..48d2beb20 100644 --- a/backend/app/src/api/tasks/config_watch.rs +++ b/backend/app/src/api/tasks/config_watch.rs @@ -172,6 +172,7 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke async fn has_external_source_write(file_locks: &crate::utils::FileLockManager, paths: &HashSet) -> bool { for path in paths { + let _guard = file_locks.read_lock(path).await; if !file_locks.is_internal_write_revision(path).await { return true; } @@ -246,11 +247,11 @@ mod tests { tokio::fs::write(&internal, b"internal").await?; tokio::fs::write(&external, b"external").await?; let locks = FileLockManager::new(); - locks.mark_internal_write_revision(&internal).await?; + locks.mark_internal_write_content(&internal, &tokio::fs::read(&internal).await?).await; let paths = HashSet::from([internal, external.clone()]); assert!(has_external_source_write(&locks, &paths).await); - locks.mark_internal_write_revision(&external).await?; + locks.mark_internal_write_content(&external, &tokio::fs::read(&external).await?).await; assert!(!has_external_source_write(&locks, &paths).await); Ok(()) } diff --git a/backend/app/src/api/tasks/mod.rs b/backend/app/src/api/tasks/mod.rs index 28ae2b8df..3228363e9 100644 --- a/backend/app/src/api/tasks/mod.rs +++ b/backend/app/src/api/tasks/mod.rs @@ -28,3 +28,5 @@ pub(in crate::api) use library_scan::*; pub(in crate::api) use notification_bridge::*; pub(in crate::api) use scheduler::*; pub(in crate::api) use xtream_expiry::*; + +mod provider_account_state; diff --git a/backend/app/src/api/tasks/provider_account_state.rs b/backend/app/src/api/tasks/provider_account_state.rs new file mode 100644 index 000000000..10f1435dd --- /dev/null +++ b/backend/app/src/api/tasks/provider_account_state.rs @@ -0,0 +1,705 @@ +use crate::{ + api::{ + internal_csv::csv_patch_batch_update_account_states, + model::AppState, + source_yml_patch::{apply_runtime_source_patches, execute_source_yml_patches_locked, SourcesYmlPatch}, + }, + model::{ConfigInput, SourcesConfig}, + repository::{get_csv_file_path, BatchAccountStateUpdate}, +}; +use shared::{ + error::TuliproxError, + model::{EventMessage, InputType, ProviderAccountEvent, ProviderAccountState}, +}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tuliprox_core::model::ProviderAccountObservation; + +/// Accounts expiring within this window trigger an "expiring" notification. +const EXPIRING_WARNING_WINDOW_SECS: i64 = 3 * 24 * 60 * 60; + +/// Result of one persistence pass. +#[derive(Default)] +pub(super) struct PersistReport { + /// Accounts whose observation is stored in the source files. + pub persisted: Vec>, + /// Accounts that could not be stored, with the reason. + pub failed: Vec<(Arc, String)>, + pub first_error: Option, +} + +impl PersistReport { + fn fail(&mut self, names: impl IntoIterator>, err: TuliproxError) { + let message = err.to_string(); + self.failed.extend(names.into_iter().map(|name| (name, message.clone()))); + self.first_error.get_or_insert(err); + } +} + +pub(super) async fn persist_provider_account_observations(app_state: &Arc) { + let pending = app_state.active_provider.pending_account_observations(); + if pending.is_empty() { + return; + } + let report = persist_observations(app_state, &pending).await; + for (name, err) in report.failed { + log::warn!("Could not persist provider account {name}: {err}"); + let event = crate::model::NotificationEvent::new( + shared::model::notification::EventId::new("provider.account.persistence_failed"), + "Provider account update could not be saved", + format!("Account {name} remains excluded when blocked; saving will be retried. {err}"), + ) + .with_severity(shared::model::notification::Severity::Error) + .with_dedup_key(format!("provider-account-persistence:{name}")); + let client = app_state.http_client.load(); + tuliprox_messaging::send_event(&app_state.app_config, &client, event).await; + } +} + +/// Persists a single observation; see [`persist_observations`]. +#[cfg(test)] +pub(super) async fn persist_observation( + app_state: &Arc, + observation: &ProviderAccountObservation, +) -> Result { + let mut report = persist_observations(app_state, std::slice::from_ref(observation)).await; + match report.first_error.take() { + Some(err) => Err(err), + None => Ok(!report.persisted.is_empty()), + } +} + +/// Persists observations grouped by their source file, so each file is locked, backed up +/// and written once per pass and the provider lineup is rebuilt once per file. +pub(super) async fn persist_observations( + app_state: &Arc, + observations: &[ProviderAccountObservation], +) -> PersistReport { + let mut report = PersistReport::default(); + let sources = app_state.app_config.sources.load_full(); + let mut groups: Vec<(PathBuf, Option, Vec<&ProviderAccountObservation>)> = Vec::new(); + for observation in observations { + let Some(owner) = find_owner(&sources, &observation.name) else { + app_state.active_provider.acknowledge_account_observation(observation); + continue; + }; + let path = match &owner.t_batch_url { + Some(batch_url) => match get_csv_file_path(batch_url) { + Ok(path) => path, + Err(err) => { + report.fail([Arc::clone(&observation.name)], TuliproxError::ConfigInput(err.to_string())); + continue; + } + }, + None => PathBuf::from(&app_state.app_config.paths.load().sources_file_path), + }; + match groups.iter_mut().find(|(group_path, _, _)| *group_path == path) { + Some((_, _, group)) => group.push(observation), + None => groups.push((path, owner.t_batch_url.clone(), vec![observation])), + } + } + for (path, batch, group) in groups { + persist_group(app_state, &path, batch.as_deref(), &group, &mut report).await; + } + report +} + +fn find_owner<'a>(sources: &'a SourcesConfig, name: &str) -> Option<&'a ConfigInput> { + sources + .inputs + .iter() + .find(|input| { + input.name.as_ref() == name || input.aliases.iter().flatten().any(|alias| alias.name.as_ref() == name) + }) + .map(AsRef::as_ref) +} + +struct ResolvedAccount<'a> { + input: &'a ConfigInput, + url: &'a str, + username: Option<&'a str>, + password: Option<&'a str>, + exp_date: Option, + disabled: bool, +} + +/// Looks the account up again under the file lock; `None` when it vanished, moved or changed credentials. +fn resolve_account<'a>( + sources: &'a SourcesConfig, + observation: &ProviderAccountObservation, + batch: Option<&str>, +) -> Option> { + let input = find_owner(sources, &observation.name)?; + let account = if input.name == observation.name { + ResolvedAccount { + input, + url: &input.url, + username: input.username.as_deref(), + password: input.password.as_deref(), + exp_date: input.exp_date, + disabled: input.account_disabled, + } + } else { + let alias = input.aliases.iter().flatten().find(|alias| alias.name == observation.name)?; + ResolvedAccount { + input, + url: &alias.url, + username: alias.username.as_deref(), + password: alias.password.as_deref(), + exp_date: alias.exp_date, + disabled: !alias.enabled, + } + }; + let identity = shared::model::ProviderAccountIdentity::new(account.url, account.username, account.password); + (input.t_batch_url.as_deref() == batch && identity == observation.identity).then_some(account) +} + +async fn persist_group( + app_state: &Arc, + path: &Path, + batch: Option<&str>, + group: &[&ProviderAccountObservation], + report: &mut PersistReport, +) { + let guard = app_state.app_config.file_locks.write_lock(path).await; + let sources = app_state.app_config.sources.load_full(); + let mut patches = Vec::with_capacity(group.len()); + let mut csv_updates = Vec::new(); + let mut accepted = Vec::with_capacity(group.len()); + for &observation in group { + let Some(account) = resolve_account(&sources, observation, batch) else { + app_state.active_provider.acknowledge_account_observation(observation); + continue; + }; + // A renewal under this lock clears the pending entry; a stale ban must not be written back. + if !app_state.active_provider.is_observation_pending(observation) { + continue; + } + notify_account_transition(app_state, observation, account.username, account.exp_date, account.disabled); + patches.push(SourcesYmlPatch::SetObservedAccount { + input_name: Arc::clone(&account.input.name), + observation: observation.clone(), + }); + if batch.is_some() { + csv_updates.push(BatchAccountStateUpdate { + identity: observation.identity, + account_name: Arc::clone(&observation.name), + username: account.username.map(ToString::to_string), + password: account.password.map(ToString::to_string), + url: Some(account.url.to_string()), + exp_date: observation.exp_date, + disable: observation.is_persistent_block(), + }); + } + accepted.push(observation); + } + if accepted.is_empty() { + return; + } + let names = || accepted.iter().map(|observation| Arc::clone(&observation.name)); + // Runtime exclusions survive failed writes. Each CAS also checks the identity. + if let Err(err) = apply_runtime_source_patches(&app_state.app_config, &patches) { + report.fail(names(), err); + return; + } + // `Some(matched)` for CSV; the CSV mutation already rebuilds the lineup when it changes the file. + let (written, refreshed) = if batch.is_some() { + match csv_patch_batch_update_account_states(app_state, &guard, InputType::XtreamBatch, path, &csv_updates).await + { + Ok(receipt) => (Ok(Some(receipt.matched)), receipt.changed), + Err(err) => (Err(err), false), + } + } else { + (execute_source_yml_patches_locked(&app_state.app_config, path, &patches, &guard).await.map(|_| None), false) + }; + if !refreshed { + app_state.active_provider.update_config(&app_state.app_config); + } + match written { + Err(err) => report.fail(names(), err), + Ok(matched) => { + for observation in accepted { + if matched.as_ref().is_some_and(|matched| !matched.contains(&observation.identity)) { + report.fail( + [Arc::clone(&observation.name)], + TuliproxError::ConfigInput(format!( + "Account {} is missing or changed in alias CSV", + observation.name + )), + ); + } else { + app_state.active_provider.acknowledge_account_observation(observation); + report.persisted.push(Arc::clone(&observation.name)); + } + } + } + } +} + +fn notify_account_transition( + app_state: &AppState, + observation: &ProviderAccountObservation, + username: Option<&str>, + previous_expiry: Option, + was_disabled: bool, +) { + let newly_blocked = observation.is_blocked() && !was_disabled; + let expiry_changed = observation.exp_date.is_some() && observation.exp_date != previous_expiry; + if !newly_blocked && !expiry_changed { + return; + } + let expired = crate::model::is_input_expired(observation.exp_date); + let expiring = observation + .exp_date + .is_some_and(|expiry| expiry <= chrono::Utc::now().timestamp() + EXPIRING_WARNING_WINDOW_SECS); + if !observation.is_blocked() && !expiring { + return; + } + let (state, summary) = if expired { + (ProviderAccountState::Expired, "expired") + } else if observation.is_persistent_block() { + (ProviderAccountState::StatusChanged, "blocked") + } else if observation.is_blocked() { + (ProviderAccountState::StatusChanged, "temporarily unavailable") + } else { + (ProviderAccountState::Expiring, "expiring") + }; + let recovery = if observation.is_persistent_block() { + "; it stays excluded until it is renewed, its credentials change, or `account_disabled`/`enabled` is reset in the source file" + } else { + "" + }; + app_state.event_manager.send_event(EventMessage::ProviderAccount(ProviderAccountEvent { + state, + username: username.unwrap_or_default().to_string(), + provider: observation.name.to_string(), + status: observation.status.map(|status| status.to_string()), + expires_at: observation.exp_date, + message: format!("Provider account {}: {summary}{recovery}", observation.name), + })); +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use crate::model::{Config, ConfigInput, SourcesConfig}; + use shared::model::ProxyUserStatus; + + pub(in crate::api::tasks) async fn fixture( + batch: bool, + ) -> Result<(tempfile::TempDir, Arc, std::path::PathBuf), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join(if batch { "aliases.csv" } else { "source.yml" }); + let content = if batch { + "#name;username;password;url;enabled;exp_date\ncsv-root;user;pass;http://provider;1;\n" + } else { + "inputs:\n - name: root\n type: xtream\n url: http://provider\n username: user\n password: pass\nsources: []\n" + }; + tokio::fs::write(&path, content).await?; + let app = crate::api::model::create_test_app_state(Config { + backup_dir: Some(dir.path().join("backups").to_string_lossy().into_owned()), + ..Config::default() + }); + let input = ConfigInput { + id: 1, + name: Arc::from("root"), + input_type: InputType::Xtream, + enabled: true, + url: "http://provider".to_string(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + t_batch_url: batch.then(|| path.to_string_lossy().into_owned()), + ..ConfigInput::default() + }; + app.app_config + .sources + .store(Arc::new(SourcesConfig { inputs: vec![Arc::new(input)], ..SourcesConfig::default() })); + app.app_config.paths.rcu(|paths| { + let mut next = (**paths).clone(); + next.sources_file_path = path.to_string_lossy().into_owned(); + next + }); + app.app_config.file_locks.mark_internal_write_content(&path, content.as_bytes()).await; + app.active_provider.update_config(&app.app_config); + Ok((dir, app, path)) + } + + fn banned() -> ProviderAccountObservation { + ProviderAccountObservation { + name: Arc::from("root"), + identity: shared::model::ProviderAccountIdentity::new("http://provider", Some("user"), Some("pass")), + status: Some(ProxyUserStatus::Banned), + exp_date: None, + } + } + + const CSV_WITH_ALIAS: &str = "#name;username;password;url;enabled;exp_date\n\ +csv-root;user;pass;http://provider;1;\n\ +alias;user2;pass2;http://provider;1;\n"; + + const YAML_WITH_ALIAS: &str = "inputs:\n - name: root\n type: xtream\n url: http://provider\n username: user\n password: pass\n aliases:\n - name: alias\n url: http://provider\n username: user2\n password: pass2\nsources: []\n"; + + /// Writes `content` as the synchronized file state and adds the matching runtime alias. + async fn with_alias(app: &Arc, path: &Path, content: &str) -> Result<(), Box> { + tokio::fs::write(path, content).await?; + app.app_config.file_locks.mark_internal_write_content(path, content.as_bytes()).await; + app.app_config.sources.rcu(|sources| { + let mut next = (**sources).clone(); + if let Some(input) = next.inputs.first_mut() { + Arc::make_mut(input).aliases = Some(vec![crate::model::ConfigInputAlias { + id: 2, + name: Arc::from("alias"), + url: "http://provider".to_string(), + username: Some("user2".to_string()), + password: Some("pass2".to_string()), + priority: 0, + max_connections: 0, + exp_date: None, + enabled: true, + stalker: None, + }]); + } + next + }); + app.active_provider.update_config(&app.app_config); + Ok(()) + } + + fn banned_alias() -> ProviderAccountObservation { + ProviderAccountObservation { + name: Arc::from("alias"), + identity: shared::model::ProviderAccountIdentity::new("http://provider", Some("user2"), Some("pass2")), + status: Some(ProxyUserStatus::Banned), + exp_date: None, + } + } + + /// Whether every account in the source file is stored as usable, as a restart would load it. + async fn stored_accounts_enabled(path: &Path, batch: bool) -> Result> { + if batch { + let (_, aliases) = + crate::repository::csv_read_inputs(InputType::XtreamBatch, path.to_string_lossy().as_ref()).await?; + Ok(aliases.iter().all(|alias| alias.enabled)) + } else { + let dto: shared::model::SourcesConfigDto = serde_saphyr::from_str(&tokio::fs::read_to_string(path).await?)?; + Ok(dto + .inputs + .iter() + .all(|input| !input.account_disabled && input.aliases.iter().flatten().all(|alias| alias.enabled))) + } + } + + fn runtime_alias_enabled(app: &AppState) -> bool { + app.app_config.sources.load().inputs[0].aliases.iter().flatten().any(|alias| alias.enabled) + } + + /// Panel renewal through the production entry point for the given file type. + async fn renew( + app: &Arc, + path: &Path, + batch: bool, + account: &str, + credentials: (&str, &str), + ) -> Result<(), TuliproxError> { + if batch { + let guard = app.app_config.file_locks.write_lock(path).await; + crate::api::internal_csv::csv_patch_batch_update_exp_date( + app, + &guard, + InputType::XtreamBatch, + path, + &Arc::from(account), + credentials.0, + credentials.1, + 2_000_000_000, + ) + .await + } else { + crate::api::source_yml_patch::execute_account_source_patches( + app, + path, + &[SourcesYmlPatch::UpdatePanelAccountExpiry { + input_name: Arc::from("root"), + account_name: Arc::from(account), + exp_date: 2_000_000_000, + }], + ) + .await + .map(|_| ()) + } + } + + #[tokio::test] + async fn queued_ban_does_not_override_same_credential_renewal() -> Result<(), Box> { + for batch in [false, true] { + let (_dir, app, path) = fixture(batch).await?; + let observation = banned(); + app.active_provider.observe_account(observation.clone()).ok_or("observation not queued")?; + // The renewal takes the file lock first and holds it across its file I/O ... + let mut renewal = Box::pin(renew(&app, &path, batch, "root", ("user", "pass"))); + assert!(futures::poll!(&mut renewal).is_pending()); + // ... so the queued ban waits for the lock and must notice that it was cleared. + let mut waiting = Box::pin(persist_observation(&app, &observation)); + assert!(futures::poll!(&mut waiting).is_pending()); + renewal.await?; + assert!(!waiting.await?, "stale ban was persisted (batch={batch})"); + assert!(!app.app_config.sources.load().inputs[0].account_disabled); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some()); + assert!(stored_accounts_enabled(&path, batch).await?, "batch={batch}"); + } + Ok(()) + } + + #[tokio::test] + async fn renewal_lifts_persisted_root_ban() -> Result<(), Box> { + for batch in [false, true] { + let (_dir, app, path) = fixture(batch).await?; + let observation = banned(); + app.active_provider.observe_account(observation.clone()).ok_or("observation not queued")?; + assert!(persist_observation(&app, &observation).await?); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_none()); + let account = if batch { "csv-root" } else { "root" }; + renew(&app, &path, batch, account, ("user", "pass")).await?; + assert!(!app.app_config.sources.load().inputs[0].account_disabled, "batch={batch}"); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some(), "batch={batch}"); + assert!(stored_accounts_enabled(&path, batch).await?, "batch={batch}"); + } + Ok(()) + } + + #[tokio::test] + async fn renewal_lifts_persisted_alias_ban() -> Result<(), Box> { + for batch in [false, true] { + let (_dir, app, path) = fixture(batch).await?; + with_alias(&app, &path, if batch { CSV_WITH_ALIAS } else { YAML_WITH_ALIAS }).await?; + let observation = banned_alias(); + app.active_provider.observe_account(observation.clone()).ok_or("observation not queued")?; + assert!(persist_observation(&app, &observation).await?); + assert!(!runtime_alias_enabled(&app)); + renew(&app, &path, batch, "alias", ("user2", "pass2")).await?; + assert!(runtime_alias_enabled(&app), "batch={batch}"); + assert!(!app.active_provider.is_observation_pending(&observation)); + // The file reflects the renewal too, so a restart keeps the alias available. + assert!(stored_accounts_enabled(&path, batch).await?, "batch={batch}"); + } + Ok(()) + } + + #[tokio::test] + async fn login_response_keeps_identity_from_before_the_request() -> Result<(), Box> { + let (_dir, app, _path) = fixture(false).await?; + let input = app.app_config.sources.load().inputs[0].clone(); + let source = crate::model::InputSource::from(input.as_ref()); + let (name, identity) = + crate::iptv::xtream::login_account_identity(&app.app_config, &source, "user").ok_or("no account")?; + // The provider URL changes while the login request is in flight. + app.app_config.sources.rcu(|sources| { + let mut next = (**sources).clone(); + if let Some(input) = next.inputs.first_mut() { + Arc::make_mut(input).url = "http://new-provider".to_string(); + } + next + }); + app.active_provider.update_config(&app.app_config); + let stale = + ProviderAccountObservation { name, identity, status: Some(ProxyUserStatus::Banned), exp_date: None }; + assert!(app.active_provider.observe_account(stale).is_none()); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some()); + Ok(()) + } + + #[tokio::test] + async fn pending_status_is_excluded_at_runtime_but_not_persisted() -> Result<(), Box> { + let (_dir, app, path) = fixture(false).await?; + let pending = ProviderAccountObservation { status: Some(ProxyUserStatus::Pending), ..banned() }; + let queued = app.active_provider.observe_account(pending).ok_or("observation not queued")?; + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_none()); + persist_observation(&app, &queued).await?; + assert!(!tokio::fs::read_to_string(&path).await?.contains("account_disabled")); + assert!(!app.app_config.sources.load().inputs[0].account_disabled); + app.active_provider + .observe_account(ProviderAccountObservation { status: Some(ProxyUserStatus::Active), ..banned() }); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some()); + Ok(()) + } + + #[tokio::test] + async fn observations_for_one_csv_are_written_once() -> Result<(), Box> { + let (dir, app, path) = fixture(true).await?; + with_alias(&app, &path, CSV_WITH_ALIAS).await?; + let observations = [banned(), banned_alias()]; + for observation in &observations { + app.active_provider.observe_account(observation.clone()).ok_or("observation not queued")?; + } + let report = persist_observations(&app, &observations).await; + assert!(report.failed.is_empty(), "{:?}", report.failed); + assert_eq!(report.persisted.len(), 2); + assert_eq!(std::fs::read_dir(dir.path().join("backups"))?.count(), 1); + let (_, aliases) = + crate::repository::csv_read_inputs(InputType::XtreamBatch, path.to_string_lossy().as_ref()).await?; + assert!(aliases.iter().all(|alias| !alias.enabled)); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_none()); + assert!(app.active_provider.pending_account_observations().is_empty()); + Ok(()) + } + + #[tokio::test] + async fn incomplete_response_persists_ban_before_restart() -> Result<(), Box> { + let (_dir, app, path) = fixture(false).await?; + app.active_provider.observe_account(banned()); + app.active_provider.observe_account(ProviderAccountObservation { + status: None, + exp_date: Some(i64::MAX), + ..banned() + }); + persist_provider_account_observations(&app).await; + let dto: shared::model::SourcesConfigDto = serde_saphyr::from_str(&tokio::fs::read_to_string(&path).await?)?; + assert!(dto.inputs[0].account_disabled); + let config = ConfigInput::from(&dto.inputs[0]); + app.app_config + .sources + .store(Arc::new(SourcesConfig { inputs: vec![Arc::new(config)], ..SourcesConfig::default() })); + let restarted = crate::api::model::ActiveProviderManager::new(&app.app_config, &app.event_manager); + assert!(restarted.get_next_provider(&Arc::from("root")).is_none()); + assert!(app.active_provider.pending_account_observations().is_empty()); + Ok(()) + } + + #[tokio::test] + async fn failed_ban_write_survives_noop_csv_sort_and_explicit_renewal() -> Result<(), Box> { + let (dir, app, path) = fixture(true).await?; + let blocked = dir.path().join("blocked"); + tokio::fs::write(&blocked, "not a directory").await?; + app.app_config.config.rcu(|config| { + let mut next = (**config).clone(); + next.backup_dir = Some(blocked.to_string_lossy().into_owned()); + next + }); + let observation = banned(); + app.active_provider.observe_account(observation.clone()); + assert!(persist_observation(&app, &observation).await.is_err()); + app.app_config.config.rcu(|config| { + let mut next = (**config).clone(); + next.backup_dir = Some(dir.path().join("backups").to_string_lossy().into_owned()); + next + }); + let guard = app.app_config.file_locks.write_lock(&path).await; + assert!( + !crate::api::internal_csv::csv_patch_batch_sort_by_exp_date( + &app, + &guard, + InputType::XtreamBatch, + &path, + crate::repository::AliasExpDateSortOrder::NewestFirst + ) + .await? + ); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_none()); + assert_eq!(app.active_provider.pending_account_observations().len(), 1); + crate::api::internal_csv::csv_patch_batch_update_exp_date( + &app, + &guard, + InputType::XtreamBatch, + &path, + &Arc::from("root"), + "user", + "pass", + 2_000_000_000, + ) + .await?; + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some()); + assert!(app.active_provider.pending_account_observations().is_empty()); + Ok(()) + } + + #[tokio::test] + async fn queued_ban_does_not_disable_renewed_credentials() -> Result<(), Box> { + for batch in [false, true] { + let (_dir, app, path) = fixture(batch).await?; + let observation = banned(); + app.active_provider.observe_account(observation.clone()); + let guard = app.app_config.file_locks.write_lock(&path).await; + let mut waiting = Box::pin(persist_observation(&app, &observation)); + assert!(futures::poll!(&mut waiting).is_pending()); + if batch { + crate::api::internal_csv::csv_patch_batch_update_credentials( + &app, + &guard, + InputType::XtreamBatch, + &path, + &Arc::from("root"), + "user", + "pass", + "user", + "renewed", + Some(2_000_000_000), + ) + .await?; + } else { + execute_source_yml_patches_locked( + &app.app_config, + &path, + &[SourcesYmlPatch::UpdateRootCredentials { + input_name: Arc::from("root"), + username: "user".to_string(), + password: "renewed".to_string(), + exp_date: Some(2_000_000_000), + }], + &guard, + ) + .await?; + app.active_provider.update_config(&app.app_config); + } + drop(guard); + assert!(!waiting.await?); + let sources = app.app_config.sources.load(); + assert_eq!(sources.inputs[0].password.as_deref(), Some("renewed")); + assert!(!sources.inputs[0].account_disabled); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some()); + } + Ok(()) + } + + #[tokio::test] + async fn fetched_renewal_unblocks_root_expired_at_load() -> Result<(), Box> { + let (_dir, app, _path) = fixture(false).await?; + app.app_config.sources.rcu(|sources| { + let mut next = (**sources).clone(); + if let Some(input) = next.inputs.first_mut() { + Arc::make_mut(input).exp_date = Some(1); + } + next + }); + app.active_provider.update_config(&app.app_config); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_none()); + let renewed = ProviderAccountObservation { status: None, exp_date: Some(i64::MAX), ..banned() }; + let queued = app.active_provider.observe_account(renewed).ok_or("observation not queued")?; + assert!(persist_observation(&app, &queued).await?); + let sources = app.app_config.sources.load(); + assert_eq!(sources.inputs[0].exp_date, Some(i64::MAX)); + assert!(!sources.inputs[0].account_disabled); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_some()); + Ok(()) + } + + #[tokio::test] + async fn external_yaml_edit_is_not_marked_as_internal() -> Result<(), Box> { + let (_dir, app, path) = fixture(false).await?; + let external = tokio::fs::read_to_string(&path) + .await? + .replace(" password: pass", " password: pass\n persist: external"); + tokio::fs::write(&path, external).await?; + execute_source_yml_patches_locked( + &app.app_config, + &path, + &[SourcesYmlPatch::SetAccountDisabled { input_name: Arc::from("root"), account_name: Arc::from("root") }], + &app.app_config.file_locks.write_lock(&path).await, + ) + .await?; + assert!(tokio::fs::read_to_string(&path).await?.contains("persist: external")); + assert!(!app.app_config.file_locks.is_internal_write_revision(&path).await); + Ok(()) + } +} diff --git a/backend/app/src/api/tasks/xtream_expiry.rs b/backend/app/src/api/tasks/xtream_expiry.rs index 76c1a0d03..4bbe00627 100644 --- a/backend/app/src/api/tasks/xtream_expiry.rs +++ b/backend/app/src/api/tasks/xtream_expiry.rs @@ -1,16 +1,7 @@ -use crate::{ - api::{ - config_file::ConfigFile, - model::AppState, - source_yml_patch::{execute_source_yml_patches, SourcesYmlPatch}, - }, - iptv::xtream::get_xtream_stream_url_base, - repository::{csv_patch_batch_update_exp_dates, get_csv_file_path, BatchExpDateUpdate}, - utils::request, -}; +use crate::{api::model::AppState, iptv::xtream::get_xtream_stream_url_base, utils::request}; use chrono::Utc; use log::{debug, warn}; -use shared::{error::TuliproxError, model::InputType}; +use shared::error::TuliproxError; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -28,7 +19,6 @@ const PERSIST_INTERVAL: Duration = Duration::from_mins(15); struct Account { input_name: Arc, name: Arc, - batch_url: Option, url: String, source_url: String, headers: HashMap, @@ -38,6 +28,12 @@ struct Account { panel: Arc, } +impl Account { + fn identity(&self) -> shared::model::ProviderAccountIdentity { + shared::model::ProviderAccountIdentity::new(&self.source_url, Some(&self.username), Some(&self.password)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FetchError { Panel, @@ -80,6 +76,7 @@ pub fn exec_xtream_expiry_sync(app_state: &Arc, cancel: &CancellationT now, ) .await; + super::provider_account_state::persist_provider_account_observations(&app_state).await; state_dirty |= persist_pending_updates(&app_state, &accounts, &mut state, &mut next_persist, now).await; if state_dirty { match save_state(&app_state, &state).await { @@ -89,7 +86,8 @@ pub fn exec_xtream_expiry_sync(app_state: &Arc, cancel: &CancellationT } tokio::select! { () = cancel.cancelled() => return, - () = tokio::time::sleep(Duration::from_mins(1)) => {} + () = tokio::time::sleep(Duration::from_mins(1)) => {}, + () = app_state.active_provider.account_health_changed() => {} } } }); @@ -108,13 +106,18 @@ async fn fetch_due_accounts( panel_next.retain(|panel, _| active_panels.contains(panel)); for account in accounts { let key = account_key(account); - if !is_expiry_refresh_due( - account.exp_date, - state.last_refresh.get(&key).copied(), - state.last_attempt.get(&key).copied(), - now, - ) { - continue; + let last_refresh = state.last_refresh.get(&key).copied(); + let last_attempt = state.last_attempt.get(&key).copied(); + let probe_requested = app_state.active_provider.is_account_probe_requested(&account.name); + if !is_expiry_refresh_due(account.exp_date, last_refresh, last_attempt, now) { + if !probe_requested { + continue; + } + if !is_probe_due(last_refresh, last_attempt, now) { + // The account was queried within the refresh interval; asking again risks a ban. + app_state.active_provider.clear_account_probe(&account.name); + continue; + } } let url = account_url(account); let panel = panel_key(account); @@ -138,6 +141,7 @@ async fn fetch_due_accounts( } *state_dirty = false; panel_next.insert(panel.clone(), tokio::time::Instant::now() + PANEL_INTERVAL); + app_state.active_provider.clear_account_probe(&account.name); let (urgent, result_changed) = match fetch_expiry_date(app_state, account, &url).await { Ok(exp_date) => { let pending_was_empty = state.pending_expiry.is_empty(); @@ -197,22 +201,16 @@ async fn persist_pending_updates( state.pending_expiry.get(&key).map(|exp_date| (account, *exp_date)) }) .collect::>(); - match persist_updates(app_state, &updates).await { - Ok(updated_accounts) => { - for key in updated_accounts { - state.pending_expiry.remove(&key); - let refreshed_at = state.last_attempt.get(&key).copied().unwrap_or(now); - state.last_refresh.insert(key, refreshed_at); - } - *next_persist = tokio::time::Instant::now() + PERSIST_INTERVAL; - true - } - Err(err) => { - warn!("Failed to persist Xtream expiry dates: {err}"); - *next_persist = tokio::time::Instant::now() + Duration::from_mins(1); - false - } + let (updated_accounts, error) = persist_updates(app_state, &updates).await; + let dirty = !updated_accounts.is_empty(); + for key in updated_accounts { + state.pending_expiry.remove(&key); + let refreshed_at = state.last_attempt.get(&key).copied().unwrap_or(now); + state.last_refresh.insert(key, refreshed_at); } + *next_persist = + tokio::time::Instant::now() + if error.is_some() { Duration::from_mins(1) } else { PERSIST_INTERVAL }; + dirty || error.is_none() } fn is_expiry_refresh_due( @@ -221,9 +219,13 @@ fn is_expiry_refresh_due( last_attempt: Option, now: i64, ) -> bool { - let last_request = last_refresh.max(last_attempt); - exp_date.is_none_or(|expiry| expiry <= now + EXPIRY_WINDOW_SECS) - && last_request.is_none_or(|last| now.saturating_sub(last) >= REFRESH_INTERVAL_SECS) + exp_date.is_none_or(|expiry| expiry <= now + EXPIRY_WINDOW_SECS) && is_probe_due(last_refresh, last_attempt, now) +} + +/// An authorization failure may query an account outside the expiry window, but never more +/// often than the regular refresh interval. +fn is_probe_due(last_refresh: Option, last_attempt: Option, now: i64) -> bool { + last_refresh.max(last_attempt).is_none_or(|last| now.saturating_sub(last) >= REFRESH_INTERVAL_SECS) } fn is_panel_request_due(last_attempt: Option, now: i64) -> bool { @@ -260,13 +262,11 @@ fn collect_accounts(app_state: &AppState) -> Vec { .iter() .filter(|input| input.enabled && input.input_type.is_xtream()) .flat_map(|input| { - let batch_url = input.t_batch_url.clone(); let panel = Arc::::from(panel_identity(&input.url, &input.name)); let root = input.username.as_ref().zip(input.password.as_ref()).and_then(|(username, password)| { input.resolve_url(&input.url).ok().map(|url| Account { input_name: Arc::clone(&input.name), name: Arc::clone(&input.name), - batch_url: batch_url.clone(), url: url.into_owned(), source_url: input.url.clone(), headers: input.headers.clone(), @@ -282,7 +282,6 @@ fn collect_accounts(app_state: &AppState) -> Vec { input.resolve_url(&alias.url).ok().map(|url| Account { input_name: Arc::clone(&input.name), name: Arc::clone(&alias.name), - batch_url: batch_url.clone(), url: url.into_owned(), source_url: alias.url.clone(), headers: input.headers.clone(), @@ -363,7 +362,18 @@ async fn fetch_expiry_date(app_state: &AppState, account: &Account, url: &str) - debug!("Xtream expiry response was invalid JSON: {err}"); FetchError::Account })?; - parse_expiry_date(&value) + let status = + value.pointer("/user_info/status").and_then(serde_json::Value::as_str).and_then(|status| status.parse().ok()); + let exp_date = parse_expiry_date(&value).ok(); + if status.is_some() || exp_date.is_some() { + let _ = app_state.active_provider.observe_account(crate::model::ProviderAccountObservation { + name: Arc::clone(&account.name), + identity: account.identity(), + status, + exp_date, + }); + } + exp_date.ok_or(FetchError::Account) } fn parse_expiry_date(value: &serde_json::Value) -> Result { @@ -378,96 +388,50 @@ fn account_is_current(account: &Account, current_keys: &HashSet) -> bool current_keys.contains(&account_key(account)) } -async fn persist_updates(app_state: &Arc, updates: &[(&Account, i64)]) -> Result, TuliproxError> { +/// Persists every update it can in one grouped pass; returns the persisted keys and the first error. +async fn persist_updates( + app_state: &Arc, + updates: &[(&Account, i64)], +) -> (Vec, Option) { let current_keys = collect_accounts(app_state).iter().map(account_key).collect::>(); - let updates = - updates.iter().copied().filter(|(account, _)| account_is_current(account, ¤t_keys)).collect::>(); - if updates.is_empty() { - return Ok(Vec::new()); - } let now = Utc::now().timestamp(); - let requires_reload = - updates.iter().any(|(account, exp_date)| account.exp_date != Some(*exp_date) || is_expired_at(*exp_date, now)); - let mut source_updates = Vec::new(); - let mut batch_updates = HashMap::>::new(); - let mut updated_accounts = Vec::new(); - for (account, exp_date) in updates { - let disable = is_expired_at(exp_date, now); - if account.exp_date == Some(exp_date) && !disable { - updated_accounts.push(account_key(account)); + let mut persisted = Vec::new(); + let mut queued_keys = Vec::new(); + let mut observations = Vec::new(); + for &(account, exp_date) in updates { + if !account_is_current(account, ¤t_keys) { continue; } - if let Some(batch_url) = &account.batch_url { - batch_updates.entry(batch_url.clone()).or_default().push(BatchExpDateUpdate { - account_key: account_key(account), - account_name: Arc::clone(&account.name), - exp_date, - disable, - }); - } else { - source_updates.push(( - account_key(account), - Arc::clone(&account.input_name), - Arc::clone(&account.name), - exp_date, - )); + if account.exp_date == Some(exp_date) && !is_expired_at(exp_date, now) { + persisted.push(account_key(account)); + continue; + } + // `None` means already persisted or unknown; the next pass sees the stored date. + if let Some(observation) = app_state.active_provider.observe_account(crate::model::ProviderAccountObservation { + name: Arc::clone(&account.name), + identity: account.identity(), + status: None, + exp_date: Some(exp_date), + }) { + queued_keys.push(account_key(account)); + observations.push(observation); } } - let sources_path = app_state.app_config.paths.load().sources_file_path.clone(); - let sources_path = std::path::Path::new(&sources_path); - if !source_updates.is_empty() { - let patches: Vec = source_updates - .iter() - .map(|(_, input_name, account_name, exp_date)| SourcesYmlPatch::SetFetchedExpiry { - input_name: Arc::clone(input_name), - account_name: Arc::clone(account_name), - exp_date: *exp_date, - disable: is_expired_at(*exp_date, now), - }) - .collect(); - match execute_source_yml_patches(&app_state.app_config, sources_path, &patches).await { - Ok(_) => { - updated_accounts.extend(source_updates.iter().map(|(key, _, _, _)| key.clone())); - } - Err(err) => { - // Failed patching leaves pending updates available for retry. - warn!("source.yml expiry patch failed, will retry: {err}"); - } - } + if observations.is_empty() { + return (persisted, None); } - let mut persistence_error = None; - for (batch_url, updates) in batch_updates { - let csv_path = match get_csv_file_path(&batch_url) { - Ok(path) => path, - Err(err) => { - persistence_error = Some(TuliproxError::ConfigInput(format!("{err}"))); - break; - } - }; - let _csv_lock = app_state.app_config.file_locks.write_lock(&csv_path).await; - let backup_dir = app_state.app_config.config.load().get_backup_dir().to_string(); - match csv_patch_batch_update_exp_dates(InputType::XtreamBatch, &csv_path, &updates, &backup_dir).await { - Ok((batch_changed, matched_keys)) => { - if batch_changed { - app_state.app_config.file_locks.mark_internal_write_revision(&csv_path).await.map_err(|err| { - TuliproxError::Io(format!("Failed to track internal alias CSV update: {err}")) - })?; - } - updated_accounts.extend(matched_keys); - } - Err(err) => { - persistence_error = Some(err); - break; - } - } + let report = super::provider_account_state::persist_observations(app_state, &observations).await; + for (name, err) in &report.failed { + warn!("Failed to persist Xtream expiry for {name}: {err}"); } - if requires_reload && !updated_accounts.is_empty() { - ConfigFile::load_sources(app_state).await?; - } - if let Some(err) = persistence_error { - return Err(err); - } - Ok(updated_accounts) + persisted.extend( + queued_keys + .into_iter() + .zip(&observations) + .filter(|(_, observation)| report.persisted.contains(&observation.name)) + .map(|(key, _)| key), + ); + (persisted, report.first_error) } fn state_path(app_state: &AppState) -> std::path::PathBuf { @@ -538,7 +502,6 @@ mod tests { Account { input_name: Arc::from("input"), name: Arc::from("alias"), - batch_url: None, url: "http://panel.example".to_string(), source_url: "http://panel.example".to_string(), headers: HashMap::new(), @@ -549,6 +512,36 @@ mod tests { } } + #[tokio::test] + async fn restored_pending_csv_expiry_updates_runtime_without_reload() -> Result<(), Box> { + let (_dir, app, path) = super::super::provider_account_state::tests::fixture(true).await?; + let account = Account { + input_name: Arc::from("root"), + name: Arc::from("root"), + url: "http://provider".to_string(), + source_url: "http://provider".to_string(), + username: "user".to_string(), + password: "pass".to_string(), + ..account("pass") + }; + let key = super::account_key(&account); + let mut state = + super::ExpiryState { pending_expiry: HashMap::from([(key.clone(), 30)]), ..super::ExpiryState::default() }; + let mut due = tokio::time::Instant::now(); + assert!(super::persist_pending_updates(&app, &[account], &mut state, &mut due, 100).await); + assert!(!state.pending_expiry.contains_key(&key)); + let sources = app.app_config.sources.load(); + assert_eq!(sources.inputs[0].exp_date, Some(30)); + assert!(sources.inputs[0].account_disabled); + assert!(app.active_provider.get_next_provider(&Arc::from("root")).is_none()); + assert!(app.app_config.file_locks.is_internal_write_revision(&path).await); + let (_, aliases) = + crate::repository::csv_read_inputs(shared::model::InputType::XtreamBatch, path.to_string_lossy().as_ref()) + .await?; + assert!(!aliases[0].enabled); + Ok(()) + } + #[test] fn missing_or_soon_expiring_accounts_are_due_once_per_day() { let now = 1_000_000; @@ -559,6 +552,19 @@ mod tests { assert!(!super::is_expiry_refresh_due(None, None, Some(now - 1), now)); } + #[test] + fn authorization_probe_respects_refresh_interval() { + let now = 1_000_000; + let day = 24 * 60 * 60; + // Outside the expiry window an authorization failure may still query the account ... + assert!(!super::is_expiry_refresh_due(Some(now + 30 * day), Some(now - day), None, now)); + assert!(super::is_probe_due(Some(now - day), None, now)); + assert!(super::is_probe_due(None, None, now)); + // ... but never sooner than the regular refresh interval after any earlier request. + assert!(!super::is_probe_due(Some(now - day + 1), None, now)); + assert!(!super::is_probe_due(Some(now - 2 * day), Some(now - 60), now)); + } + #[test] fn persisted_panel_attempt_enforces_spacing_after_restart() { let now = 1_000_000; @@ -691,12 +697,13 @@ mod tests { } #[test] - fn expired_source_account_is_disabled() -> Result<(), Box> { + fn expired_root_is_excluded_without_disabling_input() -> Result<(), Box> { let mut input = ConfigInputDto { name: Arc::from("input"), enabled: true, ..Default::default() }; assert!(input.update_account_expiration_date("input", 30, true)?); assert_eq!(input.exp_date, Some(30)); - assert!(!input.enabled); + assert!(input.enabled); + assert!(input.account_disabled); Ok(()) } diff --git a/backend/config-loader/src/lib.rs b/backend/config-loader/src/lib.rs index 8be258c25..4abdb759f 100644 --- a/backend/config-loader/src/lib.rs +++ b/backend/config-loader/src/lib.rs @@ -111,26 +111,30 @@ async fn parse_sources_file_from_path( sources_file: &Path, resolve_env: bool, ) -> Result { - let sources_file = sources_file.to_path_buf(); - tokio::task::spawn_blocking(move || match open_file(&sources_file) { - Ok(file) => { - let maybe_sources: Result = - serde_saphyr::from_reader(config_file_reader(file, resolve_env)); - match maybe_sources { - Ok(sources) => Ok(sources), - Err(err) => Err(TuliproxError::ConfigSource(format!( - "Can't read the sources-config file: {}: {err}", - sources_file.display() - ))), - } + parse_sources_file_with_revision(sources_file, resolve_env).await.map(|(sources, _)| sources) +} + +/// Parses the sources file and returns the revision of exactly the bytes that were parsed. +async fn parse_sources_file_with_revision( + sources_file: &Path, + resolve_env: bool, +) -> Result<(SourcesConfigDto, blake3::Hash), TuliproxError> { + let read_error = |err: &dyn std::fmt::Display| { + TuliproxError::ConfigSource(format!("Can't read the sources-config file: {}: {err}", sources_file.display())) + }; + let content = fs::read(sources_file).await.map_err(|err| read_error(&err))?; + let revision = blake3::hash(&content); + let parsed = tokio::task::spawn_blocking(move || { + let reader = utils::file_reader(std::io::Cursor::new(content)); + if resolve_env { + serde_saphyr::from_reader::<_, SourcesConfigDto>(utils::EnvResolvingReader::new(reader)) + } else { + serde_saphyr::from_reader::<_, SourcesConfigDto>(reader) } - Err(err) => Err(TuliproxError::ConfigSource(format!( - "Can't read the sources-config file: {}: {err}", - sources_file.display() - ))), }) .await - .map_err(|join_err| TuliproxError::ConfigSource(format!("Failed to read sources-config file: {join_err}")))? + .map_err(|join_err| TuliproxError::ConfigSource(format!("Failed to read sources-config file: {join_err}")))?; + parsed.map(|sources| (sources, revision)).map_err(|err| read_error(&err)) } pub fn resolve_template_and_mapping_paths( @@ -156,7 +160,20 @@ pub async fn read_sources_file_from_path_with_templates( hdhr_config: Option<&HdHomeRunDeviceOverview>, prepared_templates: Option<&[shared::model::PatternTemplate]>, ) -> Result { - let mut sources = parse_sources_file_from_path(sources_file, resolve_env).await?; + read_sources_file_with_revision(sources_file, resolve_env, include_computed, hdhr_config, prepared_templates) + .await + .map(|(sources, _)| sources) +} + +/// Like [`read_sources_file_from_path_with_templates`], plus the revision of the parsed bytes. +pub async fn read_sources_file_with_revision( + sources_file: &Path, + resolve_env: bool, + include_computed: bool, + hdhr_config: Option<&HdHomeRunDeviceOverview>, + prepared_templates: Option<&[shared::model::PatternTemplate]>, +) -> Result<(SourcesConfigDto, blake3::Hash), TuliproxError> { + let (mut sources, revision) = parse_sources_file_with_revision(sources_file, resolve_env).await?; if resolve_env { if let Err(err) = sources.prepare(include_computed, hdhr_config, prepared_templates) { return Err(TuliproxError::Config(format!( @@ -165,7 +182,7 @@ pub async fn read_sources_file_from_path_with_templates( ))); } } - Ok(sources) + Ok((sources, revision)) } pub async fn read_sources_file_from_path_with_options( @@ -428,6 +445,20 @@ fn apply_prepared_mappings( } } +pub async fn capture_batch_revisions(sources: &SourcesConfigDto) -> Vec<(PathBuf, blake3::Hash)> { + let mut revisions = Vec::new(); + for input in &sources.inputs { + if input.input_type.is_batch() { + if let Ok(path) = tuliprox_repository::get_csv_file_path(&input.url) { + if let Ok(content) = fs::read(&path).await { + revisions.push((path, blake3::hash(&content))); + } + } + } + } + revisions +} + pub async fn prepare_sources_batch( sources: &mut SourcesConfigDto, include_computed: bool, @@ -533,7 +564,10 @@ pub async fn read_initial_app_config( paths.template_file_path = Some(utils::resolve_template_file_path(config_path, configured_template_path.as_deref())); - let mut sources_dto = parse_sources_file_from_path(&PathBuf::from(sources_file), resolve_env).await?; + let (mut sources_dto, source_revision) = + parse_sources_file_with_revision(&PathBuf::from(sources_file), resolve_env).await?; + let mut loaded_revisions = capture_batch_revisions(&sources_dto).await; + loaded_revisions.push((PathBuf::from(sources_file), source_revision)); let (mapping_paths, mut mappings_dto) = if let Some(mappings_file) = &paths.mapping_file_path { match read_mappings_file_unprepared(mappings_file.as_str(), resolve_env) { @@ -584,6 +618,9 @@ pub async fn read_initial_app_config( media_tools: Arc::new(MediaToolCapabilities::new()), }; app_config.prepare(include_computed)?; + for (path, revision) in loaded_revisions { + app_config.file_locks.record_loaded_revision(&path, revision).await; + } //print_info(&app_config); if let Some(mappings_file) = paths.mapping_file_path.clone() { @@ -716,7 +753,6 @@ pub async fn write_config_text_file( expected_revision: Option, ) -> Result { let path = PathBuf::from(file_path); - let filename = path.file_name().map_or(default_name.to_string(), |f| f.to_string_lossy().to_string()); let revision_content = if let Some(expected) = expected_revision { let current = fs::read(&path).await.map_err(|err| { @@ -752,12 +788,9 @@ pub async fn write_config_text_file( fs::create_dir_all(backup_dir) .await .map_err(|err| TuliproxError::Config(format!("Could not create backup directory {backup_dir}: {err}")))?; - let backup_path = - PathBuf::from(backup_dir).join(format!("{filename}_{}", Local::now().format("%Y%m%d_%H%M%S%9f"))); - - fs::copy(&path, &backup_path) + tuliprox_core::utils::backup_config_file(&path, Path::new(backup_dir)) .await - .map_err(|err| TuliproxError::Config(format!("Could not backup file {}: {err}", backup_path.display())))?; + .map_err(|err| TuliproxError::Config(format!("Could not back up {}: {err}", path.display())))?; info!("Saving file to {}", path.to_str().unwrap_or("?")); } @@ -777,27 +810,38 @@ pub async fn write_config_text_file( Local::now().timestamp_nanos_opt().unwrap_or_default() )); - if let Err(err) = fs::write(&tmp_path, content).await { - let _ = fs::remove_file(&tmp_path).await; + let result = replace_with_temp_file(&path, &tmp_path, content).await; + if destination_exists { + if let Err(err) = tuliprox_core::utils::prune_config_backups(&path, Path::new(backup_dir)).await { + warn!("Could not prune configuration backups: {err}"); + } + } + result +} + +/// Writes `content` to `tmp_path` and atomically moves it over `path`. +async fn replace_with_temp_file(path: &Path, tmp_path: &Path, content: &str) -> Result { + if let Err(err) = fs::write(tmp_path, content).await { + let _ = fs::remove_file(tmp_path).await; return Err(TuliproxError::Config(format!( "Could not write temp file {}: {err}", tmp_path.to_str().unwrap_or("?") ))); } - match fs::rename(&tmp_path, &path).await { + match fs::rename(tmp_path, path).await { Ok(()) => Ok(true), Err(err) => { // Windows doesn't allow overwriting an existing file via rename. #[cfg(windows)] { - if replace_file_windows(&tmp_path, &path).is_ok() { + if replace_file_windows(tmp_path, path).is_ok() { return Ok(true); } } // Best-effort cleanup; if the temp file can't be removed, ignore it. - let _ = fs::remove_file(&tmp_path).await; + let _ = fs::remove_file(tmp_path).await; Err(TuliproxError::Config(format!( "Could not replace file {} with {}: {err}", path.to_str().unwrap_or("?"), @@ -1624,7 +1668,7 @@ sources: assert_eq!(tokio::fs::read_to_string(&path).await?, "new content"); let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)? .filter_map(std::result::Result::ok) - .filter(|entry| entry.file_name().to_string_lossy().starts_with("source.yml_")) + .filter(|entry| entry.file_name().to_string_lossy().starts_with("source.yml-")) .map(|entry| entry.path()) .collect(); backups.sort(); diff --git a/backend/config-loader/src/source_patch.rs b/backend/config-loader/src/source_patch.rs index a11677f62..82edf488b 100644 --- a/backend/config-loader/src/source_patch.rs +++ b/backend/config-loader/src/source_patch.rs @@ -24,6 +24,8 @@ pub struct PatchInput { #[serde(default)] pub enabled: Option>, #[serde(default)] + pub account_disabled: Option>, + #[serde(default)] pub url: Option>, #[serde(default)] pub username: Option>, diff --git a/backend/core/src/model/config/input.rs b/backend/core/src/model/config/input.rs index 033b43c74..fe42197cf 100644 --- a/backend/core/src/model/config/input.rs +++ b/backend/core/src/model/config/input.rs @@ -11,8 +11,9 @@ use shared::{ model::{ ClusterFlags, ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, ConfigInputStagedDto, FlussonicHlsCatchup, InputFetchMethod, InputType, MediaServerCatalogConfigDto, MediaServerImagePolicy, - MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, StagedInputType, - StalkerAuthMode, StalkerDeviceProfileDto, StalkerEndpointPreference, StalkerInputConfigDto, StalkerMagPreset, + MediaServerInputConfigDto, MediaServerLibrarySelector, MediaServerPlaybackConfigDto, ProviderAccountIdentity, + StagedInputType, StalkerAuthMode, StalkerDeviceProfileDto, StalkerEndpointPreference, StalkerInputConfigDto, + StalkerMagPreset, }, utils::{ get_credentials_from_url, get_credentials_from_url_str, is_non_blank_optional_string, @@ -462,6 +463,12 @@ pub struct ConfigInputAlias { } macros::from_impl!(ConfigInputAlias); +impl ConfigInputAlias { + pub fn account_identity(&self) -> ProviderAccountIdentity { + ProviderAccountIdentity::new(&self.url, self.username.as_deref(), self.password.as_deref()) + } +} + impl From<&ConfigInputAliasDto> for ConfigInputAlias { fn from(dto: &ConfigInputAliasDto) -> Self { Self { @@ -494,6 +501,7 @@ pub struct ConfigInput { pub password: Option, pub persist: Option, pub enabled: bool, + pub account_disabled: bool, pub sequential_group: Option, pub options: Option, pub media_server: Option, @@ -564,7 +572,10 @@ impl ConfigInput { fn apply_expiration(&mut self) { if is_input_expired(self.exp_date) { warn!("Account {} expired for provider: {}", self.username.as_ref().map_or("?", |s| s.as_str()), self.name); - self.enabled = false; + // Expiry stays derived from `exp_date`, so a fetched renewal unblocks without reload. + if !self.aliases.iter().flatten().any(|alias| alias.enabled && !is_input_expired(alias.exp_date)) { + self.enabled = false; + } } } @@ -877,6 +888,7 @@ impl ConfigInput { password: alias.password.clone(), persist: self.persist.clone(), enabled: self.enabled, + account_disabled: !alias.enabled, sequential_group: self.sequential_group, options: self.options.clone(), media_server: self.media_server.clone(), @@ -901,6 +913,23 @@ impl ConfigInput { } } + pub fn account_identity(&self) -> ProviderAccountIdentity { + ProviderAccountIdentity::new(&self.url, self.username.as_deref(), self.password.as_deref()) + } + + /// Account used for playlist downloads: the root, or the first usable alias + /// when the root is excluded or expired. + pub fn playlist_account(&self) -> Option> { + if !self.account_disabled && !is_input_expired(self.exp_date) { + return Some(Cow::Borrowed(self)); + } + self.aliases + .iter() + .flatten() + .find(|alias| alias.enabled && !is_input_expired(alias.exp_date)) + .map(|alias| Cow::Owned(self.as_input(alias))) + } + pub fn has_enabled_aliases(&self) -> bool { self.aliases.as_ref().is_some_and(|aliases| aliases.iter().any(|a| a.enabled)) } @@ -972,6 +1001,7 @@ impl From<&ConfigInputDto> for ConfigInput { password: dto.password.clone(), persist: dto.persist.clone(), enabled: dto.enabled, + account_disabled: dto.account_disabled, sequential_group: dto.sequential_group, options: Some(options), media_server: dto.media_server.as_ref().map(MediaServerInputConfig::from), @@ -1525,6 +1555,94 @@ mod tests { assert!(err.to_string().contains("Malformed provider URL")); } + #[test] + fn playlist_account_falls_back_to_first_usable_alias() { + let alias = |id, name: &str, enabled| ConfigInputAlias { + id, + name: name.into(), + url: format!("http://{name}.example.com/playlist.m3u"), + username: None, + password: None, + priority: 0, + max_connections: 0, + exp_date: None, + enabled, + stalker: None, + }; + let mut input = ConfigInput { + name: "root".into(), + input_type: InputType::M3u, + url: "http://example.com/playlist.m3u".to_string(), + enabled: true, + aliases: Some(vec![alias(1, "disabled", false), alias(2, "usable", true)]), + ..Default::default() + }; + assert!(matches!(input.playlist_account(), Some(Cow::Borrowed(_)))); + input.exp_date = Some(1); + assert_eq!(input.playlist_account().map(|account| account.name.to_string()).as_deref(), Some("usable")); + input.exp_date = None; + input.account_disabled = true; + input.aliases = Some(vec![alias(1, "disabled", false)]); + assert!(input.playlist_account().is_none()); + } + + #[test] + fn expired_root_with_aliases_is_not_persistently_disabled() -> Result<(), TuliproxError> { + let mut input = ConfigInput { + name: "root".into(), + input_type: InputType::M3u, + url: "http://example.com/playlist.m3u".to_string(), + enabled: true, + exp_date: Some(1), + aliases: Some(vec![ConfigInputAlias { + id: 1, + name: "alias".into(), + url: "http://alias.example.com/playlist.m3u".to_string(), + username: None, + password: None, + priority: 0, + max_connections: 0, + exp_date: None, + enabled: true, + stalker: None, + }]), + ..Default::default() + }; + + input.prepare(&[])?; + assert!(input.enabled); + assert!(!input.account_disabled); + Ok(()) + } + + #[test] + fn expired_root_requires_a_usable_alias() { + for (enabled, exp_date, usable) in + [(true, Some(1), false), (false, None, false), (true, None, true), (true, Some(i64::MAX), true)] + { + let mut input = ConfigInput { + enabled: true, + exp_date: Some(1), + aliases: Some(vec![ConfigInputAlias { + id: 1, + name: Arc::from("alias"), + url: "http://alias.example".to_string(), + username: None, + password: None, + priority: 0, + max_connections: 0, + exp_date, + enabled, + stalker: None, + }]), + ..ConfigInput::default() + }; + input.apply_expiration(); + assert_eq!(input.enabled, usable); + assert!(!input.account_disabled); + } + } + #[test] fn test_get_download_input_type_returns_input_type() { let input = ConfigInput { input_type: InputType::Xtream, ..Default::default() }; diff --git a/backend/core/src/model/provider.rs b/backend/core/src/model/provider.rs index 97c7d6660..175540064 100644 --- a/backend/core/src/model/provider.rs +++ b/backend/core/src/model/provider.rs @@ -10,7 +10,11 @@ use crate::{ utils::debug_if_enabled, }; use log::debug; -use shared::{model::InputType, utils::sanitize_sensitive_info, write_if_some}; +use shared::{ + model::{InputType, ProviderAccountIdentity, ProxyUserStatus}, + utils::sanitize_sensitive_info, + write_if_some, +}; use std::{ fmt, net::SocketAddr, @@ -190,10 +194,39 @@ pub enum ProviderConfigAllocation { GracePeriod, } -#[derive(Debug, Default, Copy, Clone)] +#[derive(Debug, Default, Clone)] pub struct ProviderConfigConnection { pub current_connections: usize, pub grace_started_at: Option, + pub health_status: Option, + pub pending_health: Option, + /// Set after an authorization failure; the expiry worker checks the account within its own throttle. + pub probe_requested: bool, +} + +#[derive(Debug, Clone)] +pub struct ProviderAccountObservation { + pub name: Arc, + pub identity: ProviderAccountIdentity, + pub status: Option, + pub exp_date: Option, +} + +impl ProviderAccountObservation { + /// Excludes the account from allocation at runtime. + pub fn is_blocked(&self) -> bool { + self.status.is_some_and(|status| !status.is_usable()) || is_input_expired(self.exp_date) + } + + /// Confirmed exclusion that is written to the source files. + pub fn is_persistent_block(&self) -> bool { + self.status.is_some_and(ProxyUserStatus::is_terminal) || is_input_expired(self.exp_date) + } + + /// Same account and same reported state, ignoring the account name. + pub fn same_state(&self, other: &Self) -> bool { + self.identity == other.identity && self.status == other.status && self.exp_date == other.exp_date + } } /// This struct represents an individual provider configuration with fields like: @@ -213,6 +246,8 @@ pub struct ProviderConfig { max_connections: usize, priority: i16, exp_date: Option, + account_identity: ProviderAccountIdentity, + account_disabled: bool, connection: Arc>, on_connection_change: ProviderConnectionChangeCallback, } @@ -271,6 +306,19 @@ macro_rules! modify_connections { } impl ProviderConfig { + pub fn account_identity(&self) -> ProviderAccountIdentity { self.account_identity } + + pub fn is_account_blocked(&self) -> bool { self.is_account_blocked_with_connection(&self.read_connection()) } + + fn is_account_blocked_with_connection(&self, connection: &ProviderConfigConnection) -> bool { + self.account_disabled + || is_input_expired(self.exp_date) + || connection + .health_status + .as_ref() + .is_some_and(|health| health.identity == self.account_identity && health.is_blocked()) + } + fn read_connection(&self) -> RwLockReadGuard<'_, ProviderConfigConnection> { match self.connection.read() { Ok(guard) => guard, @@ -323,6 +371,8 @@ impl ProviderConfig { max_connections: effective_max_connections, priority: cfg.priority, exp_date: cfg.exp_date, + account_disabled: cfg.account_disabled, + account_identity: cfg.account_identity(), connection, on_connection_change, } @@ -354,6 +404,8 @@ impl ProviderConfig { max_connections: effective_max_connections, priority: alias.priority, exp_date: alias.exp_date, + account_disabled: !alias.enabled, + account_identity: alias.account_identity(), connection, on_connection_change, } @@ -416,11 +468,11 @@ impl ProviderConfig { // } fn try_allocate(&self, grace: bool, grace_period_timeout_secs: u64) -> ProviderConfigAllocation { - if is_input_expired(self.exp_date) { + let mut guard = self.write_connection(); + if self.is_account_blocked_with_connection(&guard) { return ProviderConfigAllocation::Exhausted; } - let mut guard = self.write_connection(); if self.max_connections == 0 { modify_connections!(self, guard, +1); return ProviderConfigAllocation::Available; @@ -454,14 +506,14 @@ impl ProviderConfig { // is intended to use with redirects, to cycle through provider // do not increment and connection counter! fn get_next(&self, grace: bool, grace_period_timeout_secs: u64) -> bool { - if is_input_expired(self.exp_date) { - return false; - } - if self.max_connections == 0 { - return true; + // The unlimited redirect path never touches the counter, so a read lock suffices. + return !self.is_account_blocked(); } let mut guard = self.write_connection(); + if self.is_account_blocked_with_connection(&guard) { + return false; + } let connections = guard.current_connections; if connections < self.max_connections { guard.grace_started_at = None; @@ -571,6 +623,7 @@ mod tests { epg: None, persist: None, enabled: true, + account_disabled: false, sequential_group: None, options: None, media_server: None, @@ -676,4 +729,18 @@ mod tests { assert!(provider.read_connection().grace_started_at.is_none()); assert!(matches!(provider.try_allocate(true, 10), ProviderConfigAllocation::GracePeriod)); } + #[test] + fn unlimited_account_is_unavailable_when_banned() { + let provider = build_test_config(0); + let identity = provider.account_identity; + provider.write_connection().health_status = Some(crate::model::ProviderAccountObservation { + name: Arc::clone(&provider.name), + identity, + status: Some(shared::model::ProxyUserStatus::Banned), + exp_date: None, + }); + assert!(matches!(provider.try_allocate(true, 10), ProviderConfigAllocation::Exhausted)); + assert!(!provider.get_next(true, 10)); + assert_eq!(provider.get_current_connections(), 0); + } } diff --git a/backend/core/src/utils/file/config_backup.rs b/backend/core/src/utils/file/config_backup.rs new file mode 100644 index 000000000..b66f3f289 --- /dev/null +++ b/backend/core/src/utils/file/config_backup.rs @@ -0,0 +1,94 @@ +use chrono::Utc; +use std::{ + io, + path::{Path, PathBuf}, +}; + +/// Retains the latest ten snapshots for each canonical source path. +pub const CONFIG_BACKUP_LIMIT: usize = 10; + +/// Snapshot name prefix: file name plus a hash of the canonical path, so equal names in +/// different directories never share a retention bucket. +async fn backup_prefix(source: &Path) -> io::Result { + let canonical = tokio::fs::canonicalize(source).await?; + let identity = blake3::hash(canonical.to_string_lossy().as_bytes()); + let filename = source.file_name().ok_or_else(|| io::Error::other("Backup source has no filename"))?; + Ok(format!("{}-{}-", filename.to_string_lossy(), identity.to_hex())) +} + +pub async fn backup_config_file(source: &Path, directory: &Path) -> io::Result { + let prefix = backup_prefix(source).await?; + tokio::fs::create_dir_all(directory).await?; + let snapshot = + directory.join(format!("{prefix}{}-{:016x}", Utc::now().format("%Y%m%dT%H%M%S%9f"), fastrand::u64(..))); + let mut input = tokio::fs::File::open(source).await?; + let mut output = tokio::fs::OpenOptions::new().write(true).create_new(true).open(&snapshot).await?; + let result = async { + output.set_permissions(input.metadata().await?.permissions()).await?; + tokio::io::copy(&mut input, &mut output).await?; + output.sync_all().await?; + Ok::<(), io::Error>(()) + } + .await; + if let Err(error) = result { + drop(output); + let _ = tokio::fs::remove_file(snapshot).await; + return Err(error); + } + Ok(snapshot) +} + +pub async fn prune_config_backups(source: &Path, directory: &Path) -> io::Result<()> { + let prefix = backup_prefix(source).await?; + let mut entries = tokio::fs::read_dir(directory).await?; + let mut snapshots: Vec = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await?.is_file() && entry.file_name().to_string_lossy().starts_with(&prefix) { + snapshots.push(entry.path()); + } + } + snapshots.sort_unstable(); + let excess = snapshots.len().saturating_sub(CONFIG_BACKUP_LIMIT); + for snapshot in snapshots.into_iter().take(excess) { + remove_snapshot(&snapshot).await?; + } + Ok(()) +} + +/// Snapshots copy the source permissions; Windows refuses to delete read-only files. +async fn remove_snapshot(snapshot: &Path) -> io::Result<()> { + #[cfg(windows)] + { + let mut permissions = tokio::fs::metadata(snapshot).await?.permissions(); + if permissions.readonly() { + permissions.set_readonly(false); + tokio::fs::set_permissions(snapshot, permissions).await?; + } + } + tokio::fs::remove_file(snapshot).await +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn retains_ten_per_source_and_preserves_unrelated_files() -> io::Result<()> { + let temp = tempfile::tempdir()?; + let source = temp.path().join("source.yml"); + let other = temp.path().join("other.yml"); + let directory = temp.path().join("backups"); + tokio::fs::write(&source, "original").await?; + tokio::fs::write(&other, "other").await?; + backup_config_file(&other, &directory).await?; + for index in 0..15 { + tokio::fs::write(&source, index.to_string()).await?; + backup_config_file(&source, &directory).await?; + prune_config_backups(&source, &directory).await?; + } + tokio::fs::write(directory.join("unrelated"), "keep").await?; + prune_config_backups(&source, &directory).await?; + let count = std::fs::read_dir(&directory)?.count(); + assert_eq!(count, CONFIG_BACKUP_LIMIT + 2); + Ok(()) + } +} diff --git a/backend/core/src/utils/file/file_lock_manager.rs b/backend/core/src/utils/file/file_lock_manager.rs index 6bf51ab12..f0d237dce 100644 --- a/backend/core/src/utils/file/file_lock_manager.rs +++ b/backend/core/src/utils/file/file_lock_manager.rs @@ -74,21 +74,32 @@ impl FileLockManager { } } - pub async fn mark_internal_write_revision(&self, path: &Path) -> io::Result<()> { - let content = tokio::fs::read(path).await?; - self.internal_write_revisions.lock().await.insert(normalize_path(path), blake3::hash(&content)); - Ok(()) + /// Records a loaded snapshot only while the file still contains those bytes. + pub async fn record_loaded_revision(&self, path: &Path, revision: blake3::Hash) { + if tokio::fs::read(path).await.is_ok_and(|content| blake3::hash(&content) == revision) { + self.internal_write_revisions.lock().await.insert(normalize_path(path), revision); + } else { + self.internal_write_revisions.lock().await.remove(&normalize_path(path)); + } + } + + pub async fn mark_internal_write_content(&self, path: &Path, content: &[u8]) { + self.internal_write_revisions.lock().await.insert(normalize_path(path), blake3::hash(content)); } pub async fn is_internal_write_revision(&self, path: &Path) -> bool { - let normalized = normalize_path(path); let Ok(content) = tokio::fs::read(path).await else { - self.internal_write_revisions.lock().await.remove(&normalized); + self.internal_write_revisions.lock().await.remove(&normalize_path(path)); return false; }; - let revision = blake3::hash(&content); + self.is_internal_revision_hash(path, &blake3::hash(&content)).await + } + + /// Checks bytes the caller already read, avoiding a second read that could see a later edit. + pub async fn is_internal_revision_hash(&self, path: &Path, revision: &blake3::Hash) -> bool { + let normalized = normalize_path(path); let mut revisions = self.internal_write_revisions.lock().await; - if revisions.get(&normalized) == Some(&revision) { + if revisions.get(&normalized) == Some(revision) { true } else { revisions.remove(&normalized); @@ -231,6 +242,20 @@ mod tests { use super::{revision_is_missing, FileLockManager}; use std::io; + #[tokio::test] + async fn changed_file_is_not_recorded_as_loaded_snapshot() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("source.yml"); + let locks = FileLockManager::new(); + tokio::fs::write(&path, "loaded").await?; + locks.record_loaded_revision(&path, blake3::hash(b"loaded")).await; + assert!(locks.is_internal_write_revision(&path).await); + tokio::fs::write(&path, "external").await?; + locks.record_loaded_revision(&path, blake3::hash(b"loaded")).await; + assert!(!locks.is_internal_write_revision(&path).await); + Ok(()) + } + #[test] fn revision_probe_errors_are_not_treated_as_missing() { assert!(!revision_is_missing(&Err(io::Error::other("probe failed")))); @@ -244,7 +269,7 @@ mod tests { let locks = FileLockManager::new(); tokio::fs::write(&path, b"first").await?; - locks.mark_internal_write_revision(&path).await?; + locks.mark_internal_write_content(&path, &tokio::fs::read(&path).await?).await; assert!(locks.is_internal_write_revision(&path).await); tokio::fs::write(&path, b"second").await?; diff --git a/backend/core/src/utils/file/mod.rs b/backend/core/src/utils/file/mod.rs index 074120e49..c06b5ec1d 100644 --- a/backend/core/src/utils/file/mod.rs +++ b/backend/core/src/utils/file/mod.rs @@ -1,3 +1,4 @@ +mod config_backup; mod file_utils; // mod multi_file_reader; mod config_reader; @@ -7,6 +8,6 @@ mod mapping_reader; mod template_reader; pub use self::{ - config_reader::*, env_resolving_reader::*, file_lock_manager::*, file_utils::*, mapping_reader::*, - template_reader::*, + config_backup::*, config_reader::*, env_resolving_reader::*, file_lock_manager::*, file_utils::*, + mapping_reader::*, template_reader::*, }; diff --git a/backend/iptv/src/m3u/client.rs b/backend/iptv/src/m3u/client.rs index 54224b455..0110891c3 100644 --- a/backend/iptv/src/m3u/client.rs +++ b/backend/iptv/src/m3u/client.rs @@ -51,7 +51,14 @@ async fn download_reader( explicit_source: Option, ) -> Result { let storage_dir = &cfg.storage_dir; - let input_source: InputSource = explicit_source.unwrap_or_else(|| input.into()); + let input_source: InputSource = match explicit_source { + Some(source) => source, + None => input + .playlist_account() + .ok_or_else(|| TuliproxError::ConfigInput(format!("No available provider account for {}", input.name)))? + .as_ref() + .into(), + }; let persist_file_path = prepare_file_path(input.persist.as_deref(), storage_dir, ""); request::get_input_text_content_as_stream(app_config, client, &input_source, storage_dir, persist_file_path).await } diff --git a/backend/iptv/src/xtream/mod.rs b/backend/iptv/src/xtream/mod.rs index 09ddc1958..1a1d35857 100644 --- a/backend/iptv/src/xtream/mod.rs +++ b/backend/iptv/src/xtream/mod.rs @@ -6,9 +6,9 @@ use shared::{ error::TuliproxError, model::{ EventMessage, EventSink, InputType, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, - ProviderAccountEvent, ProviderAccountState, ProviderId, ProxyUserStatus, SeriesStreamProperties, - StreamProperties, UpdateQualityPolicy, VideoStreamProperties, XtreamCluster, XtreamLoginInfo, - XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo, XtreamVideoInfoDoc, + ProviderAccountEvent, ProviderAccountIdentity, ProviderAccountState, ProviderId, ProxyUserStatus, + SeriesStreamProperties, StreamProperties, UpdateQualityPolicy, VideoStreamProperties, XtreamCluster, + XtreamLoginInfo, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo, XtreamVideoInfoDoc, }, utils::{ extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info, @@ -383,6 +383,29 @@ const ACTIONS: [(XtreamCluster, &str, &str); 3] = [ ), ]; +/// The configured root or alias account a login for `input`/`username` belongs to. +pub fn login_account_identity( + app_config: &AppConfig, + input: &InputSource, + username: &str, +) -> Option<(Arc, ProviderAccountIdentity)> { + let sources = app_config.sources.load(); + let is_login_account = |name: &Arc, user: Option<&str>, pass: &Option| { + *name == input.name && user == Some(username) && *pass == input.password + }; + sources.inputs.iter().find_map(|configured| { + if is_login_account(&configured.name, configured.username.as_deref(), &configured.password) { + return Some((Arc::clone(&configured.name), configured.account_identity())); + } + configured + .aliases + .iter() + .flatten() + .find(|alias| is_login_account(&alias.name, alias.username.as_deref(), &alias.password)) + .map(|alias| (Arc::clone(&alias.name), alias.account_identity())) + }) +} + pub async fn xtream_login( app_config: &Arc, client: &reqwest::Client, @@ -390,6 +413,9 @@ pub async fn xtream_login( input: &InputSource, username: &str, ) -> Result, TuliproxError> { + // Captured before the request: a response must never be attributed to an account whose URL or + // credentials changed while the request was in flight. + let account = login_account_identity(app_config, input, username); let content = if let Ok(content) = request::get_input_json_content(app_config, client, input, None, false).await { content } else { @@ -435,6 +461,11 @@ pub async fn xtream_login( } } + if login_info.exp_date.is_some() || login_info.status.is_some() { + if let Some((name, identity)) = account { + events.observe_provider_account(name, identity, login_info.status, login_info.exp_date); + } + } if login_info.exp_date.is_none() && login_info.status.is_none() { Ok(None) } else { @@ -649,6 +680,21 @@ where Ok(()) } +async fn verify_xtream_playlist_account( + app_config: &Arc, + client: &reqwest::Client, + events: &E, + source: &InputSource, + username: &str, +) -> Result<(), TuliproxError> { + let info = xtream_login(app_config, client, events, source, username).await?; + if info.is_some_and(|info| info.status.is_some_and(|status| !status.is_usable()) || is_input_expired(info.exp_date)) + { + return Err(TuliproxError::ConfigInput(format!("Provider account {} is blocked or expired", source.name))); + } + Ok(()) +} + /// Downloads xtream clusters from a single source (either main input or staged input). async fn download_xtream_from_source( app_config: &Arc, @@ -675,7 +721,7 @@ async fn download_xtream_from_source( let base_url = get_xtream_stream_url_base(&base_input_url, username, password); let input_source_login = input_source.with_url(base_url.clone()); - if let Err(err) = xtream_login(app_config, client, events, &input_source_login, username).await { + if let Err(err) = verify_xtream_playlist_account(app_config, client, events, &input_source_login, username).await { error!("Could not log in with xtream user {username} for provider {}. {err}", input.name); return PlaylistFetch { failed_clusters: clusters.to_vec(), ..PlaylistFetch::failed(err) }; } @@ -777,7 +823,16 @@ async fn download_xtream_playlist_with_scope( if !main_clusters.is_empty() { check_alias_user_state(events, input); - let source: InputSource = input.into(); + let Some(account) = input.playlist_account() else { + return PlaylistFetch { + failed_clusters: main_clusters, + ..PlaylistFetch::failed(TuliproxError::ConfigInput(format!( + "No available provider account for {}", + input.name + ))) + }; + }; + let source: InputSource = account.as_ref().into(); let mut source_fetch = download_xtream_from_source( app_config, client, diff --git a/backend/repository/src/alias_repository.rs b/backend/repository/src/alias_repository.rs index c702d9758..cedbbb70a 100644 --- a/backend/repository/src/alias_repository.rs +++ b/backend/repository/src/alias_repository.rs @@ -1,11 +1,10 @@ -use chrono::Local; use futures::TryFutureExt; use log::{error, warn}; use shared::{ error::{string_to_io_error, to_io_error, TuliproxError}, model::{ - ConfigInputAliasDto, InputType, StalkerAuthMode, StalkerDeviceProfileDto, StalkerEndpointPreference, - StalkerInputConfigDto, StalkerMagPreset, + ConfigInputAliasDto, InputType, ProviderAccountIdentity, StalkerAuthMode, StalkerDeviceProfileDto, + StalkerEndpointPreference, StalkerInputConfigDto, StalkerMagPreset, }, utils::{ get_credentials_from_url, get_credentials_from_url_str, parse_timestamp, sanitize_sensitive_info, Internable, @@ -27,6 +26,7 @@ use tuliprox_core::{ utils::{file_reader, request::get_local_file_content, EnvResolvingReader}, }; use url::Url; +#[cfg(windows)] use uuid::Uuid; const CSV_SEPARATOR: char = ';'; @@ -55,10 +55,13 @@ pub enum AliasExpDateSortOrder { OldestFirst, } -pub struct BatchExpDateUpdate { - pub account_key: String, +pub struct BatchAccountStateUpdate { + pub identity: ProviderAccountIdentity, pub account_name: Arc, - pub exp_date: i64, + pub username: Option, + pub password: Option, + pub url: Option, + pub exp_date: Option, pub disable: bool, } @@ -328,49 +331,22 @@ pub async fn csv_read_inputs( // `repository::mod` so existing call sites are unchanged. pub use tuliprox_core::utils::get_csv_file_path; -pub async fn csv_backup_file(csv_path: &Path, backup_dir: &str) -> Result<(), TuliproxError> { - let filename = csv_path.file_name().and_then(|name| name.to_str()).ok_or_else(|| { - TuliproxError::ConfigInput(format!("Could not derive a filename for alias CSV {}", csv_path.display())) - })?; - let backup_dir = PathBuf::from(backup_dir); - tokio::fs::create_dir_all(&backup_dir) +pub async fn csv_backup_file(csv_path: &Path, backup_dir: &str) -> Result { + let backup = tuliprox_core::utils::backup_config_file(csv_path, Path::new(backup_dir)) .await - .map_err(|err| TuliproxError::ConfigInput(format!("Could not create alias CSV backup directory: {err}")))?; - let backup_path = - backup_dir.join(format!("{filename}_{}_{}", Local::now().format("%Y%m%d_%H%M%S%9f"), Uuid::new_v4())); - let mut source = tokio::fs::File::open(csv_path) - .await - .map_err(|err| TuliproxError::ConfigInput(format!("Could not open alias CSV for backup: {err}")))?; - let source_permissions = source - .metadata() - .await - .map_err(|err| TuliproxError::ConfigInput(format!("Could not read alias CSV metadata: {err}")))? - .permissions(); - let mut backup = - tokio::fs::OpenOptions::new().write(true).create_new(true).open(&backup_path).await.map_err(|err| { - TuliproxError::ConfigInput(format!("Could not create alias CSV backup {}: {err}", backup_path.display())) - })?; - if let Err(err) = tokio::io::copy(&mut source, &mut backup).await { - drop(backup); - let _ = tokio::fs::remove_file(&backup_path).await; - return Err(TuliproxError::ConfigInput(format!( - "Could not backup alias CSV to {}: {err}", - backup_path.display() - ))); - } - drop(backup); - if let Err(err) = tokio::fs::set_permissions(&backup_path, source_permissions).await { - let _ = tokio::fs::remove_file(&backup_path).await; - return Err(TuliproxError::ConfigInput(format!("Could not preserve alias CSV backup permissions: {err}"))); - } - if let Err(err) = copy_csv_acl(csv_path, &backup_path).await { - let _ = tokio::fs::remove_file(&backup_path).await; + .map_err(|err| TuliproxError::ConfigInput(format!("Could not back up alias CSV: {err}")))?; + if let Err(err) = copy_csv_acl(csv_path, &backup).await { + let _ = tokio::fs::remove_file(backup).await; return Err(TuliproxError::ConfigInput(format!("Could not preserve alias CSV backup ACL: {err}"))); } - Ok(()) + Ok(backup) } async fn csv_write_input_to_path(file_path: &Path, aliases: &[ConfigInputAliasDto]) -> Result<(), Error> { + csv_write_content_to_path(file_path, csv_render_aliases(aliases)).await +} + +fn csv_render_aliases(aliases: &[ConfigInputAliasDto]) -> String { let write_stalker_fields = aliases.iter().any(|alias| alias.stalker.is_some()); let mut content = String::new(); content.push(HEADER_PREFIX); @@ -436,8 +412,29 @@ async fn csv_write_input_to_path(file_path: &Path, aliases: &[ConfigInputAliasDt } content.push('\n'); } + content +} - csv_write_content_to_path(file_path, content).await +/// Backs up and replaces the CSV only when `content` differs from the file, then prunes old backups. +/// A failed backup aborts before the original is overwritten. +async fn csv_replace_with_backup(csv_path: &Path, content: String, backup_dir: &str) -> Result { + let current = tokio::fs::read(csv_path) + .await + .map_err(|err| TuliproxError::ConfigInput(format!("Could not read alias CSV {}: {err}", csv_path.display())))?; + if current == content.as_bytes() { + return Ok(false); + } + csv_write_with_backup(csv_path, content, backup_dir).await?; + Ok(true) +} + +async fn csv_write_with_backup(csv_path: &Path, content: String, backup_dir: &str) -> Result<(), TuliproxError> { + csv_backup_file(csv_path, backup_dir).await?; + let written = csv_write_content_to_path(csv_path, content).await; + if let Err(err) = tuliprox_core::utils::prune_config_backups(csv_path, Path::new(backup_dir)).await { + warn!("Could not prune alias CSV backups: {err}"); + } + written.map_err(|err| TuliproxError::ConfigInput(err.to_string())) } async fn csv_write_content_to_path(file_path: &Path, content: String) -> Result<(), Error> { @@ -641,6 +638,7 @@ pub async fn csv_write_inputs(file_uri: &str, aliases: &[ConfigInputAliasDto]) - csv_write_input_to_path(&file_path, aliases).await } +#[allow(clippy::too_many_arguments)] pub async fn csv_patch_batch_append( csv_path: &Path, input_type: InputType, @@ -649,6 +647,7 @@ pub async fn csv_patch_batch_append( username: &str, password: &str, exp_date: Option, + backup_dir: &str, ) -> Result<(), TuliproxError> { // TODO check if alias name exists in any config ? @@ -679,7 +678,7 @@ pub async fn csv_patch_batch_append( }; aliases.push(alias); - csv_write_input_to_path(&file_path, &aliases).map_err(|err| TuliproxError::ConfigInput(format!("{err}"))).await?; + csv_replace_with_backup(&file_path, csv_render_aliases(&aliases), backup_dir).await?; Ok(()) } @@ -690,45 +689,68 @@ pub async fn csv_patch_batch_update_exp_date( username: &str, password: &str, exp_date: i64, + backup_dir: &str, ) -> Result<(), TuliproxError> { let mut matched = false; let (file_path, mut aliases) = csv_read_inputs_from_path(input_type, csv_path) .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) .await?; + let name_matches = aliases.iter().any(|alias| &alias.name == account_name); for alias in &mut aliases { + let url_credentials_match = + || matches!(get_credentials_from_url_str(&alias.url), (Some(u), Some(p)) if u == username && p == password); if &alias.name == account_name - || (alias.username == Some(username.to_string()) && alias.password == Some(password.to_string())) + || (!name_matches + && ((alias.username.as_deref() == Some(username) && alias.password.as_deref() == Some(password)) + || url_credentials_match())) { + // A panel renewal is an explicit decision for this account and lifts a stored exclusion. + alias.enabled = true; alias.exp_date = Some(exp_date); alias.max_connections = 1; matched = true; - } else if let (Some(u), Some(p)) = get_credentials_from_url_str(&alias.url) { - if u == username && p == password { - alias.exp_date = Some(exp_date); - alias.max_connections = 1; - matched = true; - } } } if matched { - csv_write_input_to_path(&file_path, &aliases) - .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) - .await?; + csv_replace_with_backup(&file_path, csv_render_aliases(&aliases), backup_dir).await?; } else { warn!("panel_api: could not find batch csv row for account {account_name}"); } Ok(()) } -pub async fn csv_patch_batch_update_exp_dates( +pub struct CsvAccountPatchResult { + pub changed: bool, + pub matched: Vec, +} + +fn match_csv_account_credentials<'a>( + columns: &[String], + values: &[String], + updates: &'a [BatchAccountStateUpdate], +) -> Option<&'a BatchAccountStateUpdate> { + let username = columns.iter().position(|column| column == FIELD_USERNAME).and_then(|index| values.get(index))?; + let password = columns.iter().position(|column| column == FIELD_PASSWORD).and_then(|index| values.get(index))?; + let url = columns.iter().position(|column| column == FIELD_URL).and_then(|index| values.get(index)); + let username = tuliprox_core::utils::resolve_env_var(username); + let password = tuliprox_core::utils::resolve_env_var(password); + let url = url.map(|url| tuliprox_core::utils::resolve_env_var(url)); + updates.iter().find(|update| { + update.username.as_deref() == Some(username.as_str()) + && update.password.as_deref() == Some(password.as_str()) + && update.url.as_ref().is_none_or(|expected| url.as_ref().is_some_and(|url| url.trim() == expected)) + }) +} + +pub async fn csv_patch_batch_update_account_states( _input_type: InputType, csv_path: &Path, - updates: &[BatchExpDateUpdate], + updates: &[BatchAccountStateUpdate], backup_dir: &str, -) -> Result<(bool, Vec), TuliproxError> { +) -> Result { if updates.is_empty() { - return Ok((false, Vec::new())); + return Ok(CsvAccountPatchResult { changed: false, matched: Vec::new() }); } let content = get_local_file_content(csv_path).await.map_err(|err| TuliproxError::ConfigInput(format!("{err}")))?; @@ -737,7 +759,7 @@ pub async fn csv_patch_batch_update_exp_dates( let mut columns = DEFAULT_COLUMNS.iter().map(|column| (*column).to_string()).collect::>(); let mut header_defined = false; let mut header_extended = false; - let mut matched_account_keys = Vec::new(); + let mut matched = Vec::new(); let mut changed = false; let mut patched = String::with_capacity(content.len()); for raw_line in content.split_inclusive('\n') { @@ -779,16 +801,23 @@ pub async fn csv_patch_batch_update_exp_dates( if header_extended && values.len() < columns.len() { values.resize(columns.len(), String::new()); } - let update = - name_index.and_then(|index| values.get(index)).and_then(|name| updates_by_name.get(name.as_str())).copied(); + let update = name_index + .and_then(|index| values.get(index)) + .and_then(|name| updates_by_name.get(name.as_str())) + .copied() + .filter(|update| { + (update.username.is_none() && update.password.is_none()) + || match_csv_account_credentials(&columns, &values, std::slice::from_ref(*update)).is_some() + }) + .or_else(|| match_csv_account_credentials(&columns, &values, updates)); if let Some(update) = update { if values.len() < columns.len() { values.resize(columns.len(), String::new()); } - matched_account_keys.push(update.account_key.clone()); - if let Some(index) = exp_index { - let expiration = shared::utils::unix_ts_to_str_with_format(update.exp_date, "%Y-%m-%d %H:%M:%S") - .unwrap_or_else(|| update.exp_date.to_string()); + matched.push(update.identity); + if let (Some(index), Some(exp_date)) = (exp_index, update.exp_date) { + let expiration = shared::utils::unix_ts_to_str_with_format(exp_date, "%Y-%m-%d %H:%M:%S") + .unwrap_or_else(|| exp_date.to_string()); if values[index] != expiration { values[index] = expiration; changed = true; @@ -809,12 +838,9 @@ pub async fn csv_patch_batch_update_exp_dates( } } if changed { - csv_backup_file(csv_path, backup_dir).await?; - csv_write_content_to_path(csv_path, patched) - .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) - .await?; + csv_write_with_backup(csv_path, patched, backup_dir).await?; } - Ok((changed, matched_account_keys)) + Ok(CsvAccountPatchResult { changed, matched }) } fn push_csv_row(content: &mut String, values: &[String]) { @@ -846,6 +872,7 @@ pub async fn csv_patch_batch_update_credentials( new_username: &str, new_password: &str, exp_date: Option, + backup_dir: &str, ) -> Result<(), TuliproxError> { let mut matched = false; let (file_path, mut aliases) = csv_read_inputs_from_path(input_type, csv_path) @@ -875,6 +902,7 @@ pub async fn csv_patch_batch_update_credentials( alias.username = Some(new_username.to_string()); alias.password = Some(new_password.to_string()); + alias.enabled = true; alias.max_connections = 1; if let Some(exp_date) = exp_date { alias.exp_date = Some(exp_date); @@ -918,34 +946,34 @@ pub async fn csv_patch_batch_update_credentials( } if matched { - csv_write_input_to_path(&file_path, &aliases) - .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) - .await?; + csv_replace_with_backup(&file_path, csv_render_aliases(&aliases), backup_dir).await?; } else { warn!("panel_api: could not find batch csv row to update credentials for account {account_name}"); } Ok(()) } -pub async fn csv_patch_batch_remove_expired(input_type: InputType, csv_path: &Path) -> Result { +pub async fn csv_patch_batch_remove_expired( + input_type: InputType, + csv_path: &Path, + backup_dir: &str, +) -> Result { let (file_path, mut aliases) = csv_read_inputs_from_path(input_type, csv_path) .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) .await?; let before_len = aliases.len(); aliases.retain(|alias| !is_input_expired(alias.exp_date)); - let changed = before_len != aliases.len(); - if changed { - csv_write_input_to_path(&file_path, &aliases) - .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) - .await?; + if before_len == aliases.len() { + return Ok(false); } - Ok(changed) + csv_replace_with_backup(&file_path, csv_render_aliases(&aliases), backup_dir).await } pub async fn csv_patch_batch_sort_by_exp_date( input_type: InputType, csv_path: &Path, order: AliasExpDateSortOrder, + backup_dir: &str, ) -> Result { let (file_path, mut aliases) = csv_read_inputs_from_path(input_type, csv_path) .map_err(|err| TuliproxError::ConfigInput(format!("{err}"))) @@ -958,8 +986,7 @@ pub async fn csv_patch_batch_sort_by_exp_date( return Ok(false); } aliases.sort_by(compare); - csv_write_input_to_path(&file_path, &aliases).map_err(|err| TuliproxError::ConfigInput(format!("{err}"))).await?; - Ok(true) + csv_replace_with_backup(&file_path, csv_render_aliases(&aliases), backup_dir).await } #[cfg(test)] @@ -967,14 +994,17 @@ mod tests { #[cfg(target_os = "linux")] use super::read_acl_value; use super::{ - csv_backup_file, csv_patch_batch_sort_by_exp_date, csv_patch_batch_update_exp_dates, csv_read_inputs_from_path, - csv_temp_path, csv_write_input_to_path, AliasExpDateSortOrder, BatchExpDateUpdate, + csv_backup_file, csv_patch_batch_sort_by_exp_date, csv_patch_batch_update_account_states, + csv_read_inputs_from_path, csv_temp_path, csv_write_input_to_path, AliasExpDateSortOrder, + BatchAccountStateUpdate, CsvAccountPatchResult, }; use crate::csv_read_inputs_from_reader; - use shared::model::{InputType, StalkerAuthMode, StalkerEndpointPreference, StalkerMagPreset}; + use shared::model::{ + InputType, ProviderAccountIdentity, StalkerAuthMode, StalkerEndpointPreference, StalkerMagPreset, + }; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; - use std::{io::Cursor, path::PathBuf}; + use std::{io::Cursor, path::PathBuf, sync::Arc}; use tuliprox_core::utils::file_reader; const M3U_BATCH: &str = r" @@ -1133,12 +1163,21 @@ missing;missing-user;missing-pass;http://missing.example;1;\n", ) .expect("write csv fixture"); - let changed = - csv_patch_batch_sort_by_exp_date(InputType::XtreamBatch, &path, AliasExpDateSortOrder::NewestFirst) - .await - .expect("sort succeeds"); - - assert!(changed); + let backup_dir = tempfile::tempdir().expect("backup dir"); + let backup_dir_str = backup_dir.path().to_string_lossy(); + let sort = || { + csv_patch_batch_sort_by_exp_date( + InputType::XtreamBatch, + &path, + AliasExpDateSortOrder::NewestFirst, + backup_dir_str.as_ref(), + ) + }; + assert!(sort().await.expect("sort succeeds")); + assert_eq!(std::fs::read_dir(backup_dir.path()).expect("backups").count(), 1); + // A sorted file is neither rewritten nor backed up again. + assert!(!sort().await.expect("noop sort succeeds")); + assert_eq!(std::fs::read_dir(backup_dir.path()).expect("backups").count(), 1); let (_, aliases) = csv_read_inputs_from_path(InputType::XtreamBatch, &path).await.expect("read sorted csv"); assert_eq!(aliases.len(), 3); assert_eq!(aliases[0].name.as_ref(), "new"); @@ -1153,14 +1192,17 @@ missing;missing-user;missing-pass;http://missing.example;1;\n", let csv_path = dir.path().join("aliases.csv"); let backup_dir = dir.path().join("backups"); tokio::fs::write(&csv_path, XTREAM_BATCH).await?; - let update = BatchExpDateUpdate { - account_key: "provider/input_1".to_string(), + let update = BatchAccountStateUpdate { + identity: ProviderAccountIdentity::new("provider/input_1", None, None), account_name: "input_1".into(), - exp_date: 2_000_000_000, + username: None, + password: None, + url: None, + exp_date: Some(2_000_000_000), disable: true, }; - let (changed, updated) = csv_patch_batch_update_exp_dates( + let CsvAccountPatchResult { changed, matched: updated } = csv_patch_batch_update_account_states( InputType::XtreamBatch, &csv_path, &[update], @@ -1169,12 +1211,102 @@ missing;missing-user;missing-pass;http://missing.example;1;\n", .await?; assert!(changed); - assert_eq!(updated, vec!["provider/input_1"]); + assert_eq!(updated, vec![ProviderAccountIdentity::new("provider/input_1", None, None)]); let (_, aliases) = csv_read_inputs_from_path(InputType::XtreamBatch, &csv_path).await?; assert!(!aliases[0].enabled); Ok(()) } + #[tokio::test] + async fn banned_promoted_root_matches_credentials_without_overwriting_expiry( + ) -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("aliases.csv"); + let original = "#name;username;password;url;enabled;exp_date;custom\n#keep this comment\nrow-name;user;pass;http://provider;1;2000000000;keep\n"; + tokio::fs::write(&path, original).await?; + let updates = [super::BatchAccountStateUpdate { + identity: ProviderAccountIdentity::new("key", None, None), + url: Some("http://provider".to_string()), + account_name: "input-name".into(), + username: Some("user".to_string()), + password: Some("pass".to_string()), + exp_date: None, + disable: true, + }]; + let result = super::csv_patch_batch_update_account_states( + InputType::XtreamBatch, + &path, + &updates, + dir.path().join("backups").to_string_lossy().as_ref(), + ) + .await?; + let content = tokio::fs::read(&path).await?; + assert!(result.changed); + assert_eq!(result.matched, vec![ProviderAccountIdentity::new("key", None, None)]); + assert_eq!(String::from_utf8(content)?, original.replace(";1;2000000000;", ";0;2000000000;")); + Ok(()) + } + + #[tokio::test] + async fn csv_renewal_prefers_name_and_falls_back_to_credentials() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("aliases.csv"); + let original = "#name;username;password;url;enabled;max_connections;exp_date\n\ +target;user;pass;http://provider;0;2;\n\ +direct;user;pass;http://other;0;2;\n\ +url;other;other;http://third/get.php?username=user&password=pass;0;2;\n"; + for requested_name in ["target", "missing"] { + tokio::fs::write(&path, original).await?; + super::csv_patch_batch_update_exp_date( + InputType::XtreamBatch, + &path, + &Arc::from(requested_name), + "user", + "pass", + 2_000_000_000, + dir.path().join("backups").to_string_lossy().as_ref(), + ) + .await?; + let (_, aliases) = csv_read_inputs_from_path(InputType::XtreamBatch, &path).await?; + assert_eq!(aliases.len(), 3); + for alias in aliases { + let renewed = requested_name == "missing" || alias.name.as_ref() == "target"; + assert_eq!(alias.enabled, renewed); + assert_eq!(alias.exp_date, renewed.then_some(2_000_000_000)); + assert_eq!(alias.max_connections, if renewed { 1 } else { 2 }); + } + } + Ok(()) + } + + #[tokio::test] + async fn named_account_with_renewed_credentials_ignores_old_ban() -> Result<(), Box> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("aliases.csv"); + let original = "#name;username;password;url;enabled;exp_date\naccount;user;renewed;http://provider;1;\n"; + tokio::fs::write(&path, original).await?; + let update = super::BatchAccountStateUpdate { + identity: ProviderAccountIdentity::new("old", None, None), + account_name: Arc::from("account"), + username: Some("user".to_string()), + password: Some("pass".to_string()), + url: Some("http://provider".to_string()), + exp_date: None, + disable: true, + }; + let receipt = super::csv_patch_batch_update_account_states( + InputType::XtreamBatch, + &path, + &[update], + dir.path().join("backups").to_string_lossy().as_ref(), + ) + .await?; + assert!(!receipt.changed); + assert!(receipt.matched.is_empty()); + assert_eq!(tokio::fs::read_to_string(&path).await?, original); + Ok(()) + } + #[tokio::test] async fn batch_expiry_update_preserves_raw_csv_fields() -> Result<(), Box> { let dir = tempfile::tempdir()?; @@ -1184,14 +1316,17 @@ missing;missing-user;missing-pass;http://missing.example;1;\n", #name;username;password;url;enabled;max_connections;priority;exp_date;custom\n\ input_1;${env:PATH};${env:XTREAM_PASSWORD};http://provider.tv;1;1;0;2028-11-23 13:12:34;keep-me;trailing-a;trailing-b\n"; tokio::fs::write(&csv_path, raw).await?; - let update = BatchExpDateUpdate { - account_key: "provider/input_1".to_string(), + let update = BatchAccountStateUpdate { + identity: ProviderAccountIdentity::new("provider/input_1", None, None), account_name: "input_1".into(), - exp_date: 2_000_000_000, + username: None, + password: None, + url: None, + exp_date: Some(2_000_000_000), disable: true, }; - csv_patch_batch_update_exp_dates( + csv_patch_batch_update_account_states( InputType::XtreamBatch, &csv_path, &[update], @@ -1214,14 +1349,17 @@ input_1;${env:PATH};${env:XTREAM_PASSWORD};http://provider.tv;1;1;0;2028-11-23 1 let raw = "#name;username;password;url;max_connections;priority;custom\n\ input_1;user;password;http://provider.tv;1;0;keep-me;old-enabled;old-expiry;trailing\n"; tokio::fs::write(&csv_path, raw).await?; - let update = BatchExpDateUpdate { - account_key: "provider/input_1".to_string(), + let update = BatchAccountStateUpdate { + identity: ProviderAccountIdentity::new("provider/input_1", None, None), account_name: "input_1".into(), - exp_date: 2_000_000_000, + username: None, + password: None, + url: None, + exp_date: Some(2_000_000_000), disable: true, }; - csv_patch_batch_update_exp_dates( + csv_patch_batch_update_account_states( InputType::XtreamBatch, &csv_path, &[update], @@ -1240,14 +1378,17 @@ input_1;user;password;http://provider.tv;1;0;keep-me;old-enabled;old-expiry;trai let csv_path = dir.path().join("aliases.csv"); let backup_dir = dir.path().join("backups"); tokio::fs::write(&csv_path, XTREAM_BATCH).await?; - let update = BatchExpDateUpdate { - account_key: "provider/missing".to_string(), + let update = BatchAccountStateUpdate { + identity: ProviderAccountIdentity::new("provider/missing", None, None), account_name: "missing".into(), - exp_date: 2_000_000_000, + username: None, + password: None, + url: None, + exp_date: Some(2_000_000_000), disable: true, }; - let (changed, updated) = csv_patch_batch_update_exp_dates( + let CsvAccountPatchResult { changed, matched: updated } = csv_patch_batch_update_account_states( InputType::XtreamBatch, &csv_path, &[update], diff --git a/backend/session/src/active_provider_manager.rs b/backend/session/src/active_provider_manager.rs index 77a9f4576..3c6ca90be 100644 --- a/backend/session/src/active_provider_manager.rs +++ b/backend/session/src/active_provider_manager.rs @@ -289,7 +289,7 @@ pub struct ActiveProviderManagerCore { capacity_transition: std::sync::Mutex<()>, is_shutting_down: AtomicBool, shutdown_token: CancellationToken, - providers: ProviderLineupManager, + pub(crate) providers: ProviderLineupManager, connections: std::sync::RwLock, leases: std::sync::RwLock, next_allocation_id: AtomicU64, @@ -639,6 +639,36 @@ impl ActiveProviderManager { } } + /// Routes account observations reported through `event_manager` to this manager. + /// Only the first binding wins, so test or auxiliary managers cannot hijack the runtime one. + pub fn bind_event_manager(&self, event_manager: &EventManager) { + let _ = event_manager.account_provider.set(Arc::downgrade(&self.core)); + } + + pub fn observe_account( + &self, + observation: tuliprox_core::model::ProviderAccountObservation, + ) -> Option { + self.core.providers.observe_account(observation) + } + + pub fn reset_account_health(&self, name: &Arc) { self.core.providers.forget_account_health(name); } + pub fn pending_account_observations(&self) -> Vec { + self.core.providers.pending_account_observations() + } + pub fn is_observation_pending(&self, observation: &tuliprox_core::model::ProviderAccountObservation) -> bool { + self.core.providers.is_observation_pending(observation) + } + pub fn acknowledge_account_observation(&self, observation: &tuliprox_core::model::ProviderAccountObservation) { + self.core.providers.acknowledge_account_observation(observation); + } + pub fn request_account_probe(&self, name: &Arc) { self.core.providers.request_account_probe(name); } + pub fn is_account_probe_requested(&self, name: &Arc) -> bool { + self.core.providers.is_account_probe_requested(name) + } + pub fn clear_account_probe(&self, name: &Arc) { self.core.providers.clear_account_probe(name); } + pub async fn account_health_changed(&self) { self.core.providers.account_health_changed.notified().await; } + pub fn set_shared_stream_manager(&self, manager: &Arc) { let _ = self.shared_stream_manager.set(Arc::downgrade(manager)); } diff --git a/backend/session/src/event_manager.rs b/backend/session/src/event_manager.rs index ff8bb9ba9..9cfa4ab59 100644 --- a/backend/session/src/event_manager.rs +++ b/backend/session/src/event_manager.rs @@ -155,6 +155,8 @@ struct LatchedEvents { } pub struct EventManager { + pub(crate) account_provider: + std::sync::OnceLock>, channel_tx: tokio::sync::broadcast::Sender, meters: StreamMeterRegistry, stats: Arc, @@ -196,6 +198,7 @@ impl EventManager { let (channel_tx, _channel_rx) = tokio::sync::broadcast::channel(capacity.max(1)); Self { channel_tx, + account_provider: std::sync::OnceLock::new(), meters: StreamMeterRegistry::new(), stats: Arc::new(EventBusStats::default()), last_nudge: Mutex::new(HashMap::new()), @@ -398,6 +401,22 @@ impl EventManager { impl EventSink for EventManager { fn emit(&self, event: EventMessage) { self.send_event(event); } + fn observe_provider_account( + &self, + name: Arc, + identity: shared::model::ProviderAccountIdentity, + status: Option, + exp_date: Option, + ) { + if let Some(provider) = self.account_provider.get().and_then(std::sync::Weak::upgrade) { + let _ = provider.providers.observe_account(tuliprox_core::model::ProviderAccountObservation { + name, + identity, + status, + exp_date, + }); + } + } } impl Default for EventManager { diff --git a/backend/session/src/provider_lineup_manager.rs b/backend/session/src/provider_lineup_manager.rs index d67faf863..8112adb59 100644 --- a/backend/session/src/provider_lineup_manager.rs +++ b/backend/session/src/provider_lineup_manager.rs @@ -482,6 +482,7 @@ pub struct ProviderLineupManager { /// Which priority group last served each input, so a fallback is reported /// on transition rather than once per allocation. served_priority_group: DashMap, usize>, + pub account_health_changed: tokio::sync::Notify, } #[derive(Debug)] @@ -505,6 +506,7 @@ impl ProviderLineupManager { provider_connections, event_manager: Arc::clone(event_manager), served_priority_group: DashMap::new(), + account_health_changed: tokio::sync::Notify::new(), } } @@ -527,8 +529,123 @@ impl ProviderLineupManager { } } + /// Records an observation and returns the merged entry queued for persistence, + /// or `None` when it is unknown or repeats the already persisted state. + pub fn observe_account( + &self, + mut observation: tuliprox_core::model::ProviderAccountObservation, + ) -> Option { + let snapshot = self.snapshot.load(); + let identity_matches = Self::get_provider_config_by_name(&observation.name, &snapshot.providers) + .is_some_and(|(_, config)| config.account_identity() == observation.identity); + if !identity_matches { + return None; + } + let connection = self.provider_connections.get(&observation.name).map(|entry| Arc::clone(entry.value()))?; + let mut state = connection.write().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(previous) = state.health_status.as_ref().filter(|old| old.identity == observation.identity) { + observation.status = observation.status.or(previous.status); + observation.exp_date = observation.exp_date.or(previous.exp_date); + } + if state.pending_health.is_none() + && state.health_status.as_ref().is_some_and(|old| old.same_state(&observation)) + { + return None; + } + if observation.is_blocked() + || observation.status.is_some() + || state.health_status.as_ref().is_none_or(|previous| previous.identity != observation.identity) + { + state.health_status = Some(observation.clone()); + } + state.pending_health = Some(observation.clone()); + drop(state); + self.account_health_changed.notify_one(); + Some(observation) + } + + pub fn pending_account_observations(&self) -> Vec { + self.provider_connections + .iter() + .filter_map(|entry| { + entry.value().read().unwrap_or_else(std::sync::PoisonError::into_inner).pending_health.clone() + }) + .collect() + } + + /// True while exactly this observation still awaits persistence; renewals and newer + /// observations replace or clear it. + pub fn is_observation_pending(&self, observation: &tuliprox_core::model::ProviderAccountObservation) -> bool { + self.provider_connections.get(&observation.name).map(|entry| Arc::clone(entry.value())).is_some_and( + |connection| { + connection + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending_health + .as_ref() + .is_some_and(|pending| pending.same_state(observation)) + }, + ) + } + + pub fn acknowledge_account_observation(&self, observation: &tuliprox_core::model::ProviderAccountObservation) { + if let Some(connection) = + self.provider_connections.get(&observation.name).map(|entry| Arc::clone(entry.value())) + { + let mut state = connection.write().unwrap_or_else(std::sync::PoisonError::into_inner); + if state.pending_health.as_ref().is_some_and(|current| current.same_state(observation)) { + state.pending_health = None; + } + } + } + + /// Asks the expiry worker to check the account; it decides whether its throttle allows a request. + pub fn request_account_probe(&self, name: &Arc) { + let Some(connection) = self.provider_connections.get(name).map(|entry| Arc::clone(entry.value())) else { + return; + }; + let mut state = connection.write().unwrap_or_else(std::sync::PoisonError::into_inner); + if !std::mem::replace(&mut state.probe_requested, true) { + drop(state); + self.account_health_changed.notify_one(); + } + } + + pub fn is_account_probe_requested(&self, name: &Arc) -> bool { + self.provider_connections.get(name).map(|entry| Arc::clone(entry.value())).is_some_and(|connection| { + connection.read().unwrap_or_else(std::sync::PoisonError::into_inner).probe_requested + }) + } + + pub fn clear_account_probe(&self, name: &Arc) { + if let Some(connection) = self.provider_connections.get(name).map(|entry| Arc::clone(entry.value())) { + connection.write().unwrap_or_else(std::sync::PoisonError::into_inner).probe_requested = false; + } + } + + pub fn forget_account_health(&self, name: &Arc) { + if let Some(connection) = self.provider_connections.get(name).map(|entry| Arc::clone(entry.value())) { + let mut state = connection.write().unwrap_or_else(std::sync::PoisonError::into_inner); + state.health_status = None; + state.pending_health = None; + state.probe_requested = false; + } + } + + fn has_pending_health(&self, name: &Arc) -> bool { + self.provider_connections.get(name).map(|entry| Arc::clone(entry.value())).is_some_and(|connection| { + connection.read().unwrap_or_else(std::sync::PoisonError::into_inner).pending_health.is_some() + }) + } + + /// Changed credentials always reset health; a manual re-enable only when no observation awaits persistence. + fn should_forget_health(&self, name: &Arc, reenabled: bool, credentials_changed: bool) -> bool { + credentials_changed || (reenabled && !self.has_pending_health(name)) + } + fn inputs_differ(a: &ConfigInput, b: &ConfigInput) -> bool { - if a.enabled != b.enabled + if a.account_disabled != b.account_disabled + || a.enabled != b.enabled || a.max_connections != b.max_connections || a.priority != b.priority || a.input_type != b.input_type @@ -596,6 +713,34 @@ impl ProviderLineupManager { return; } + { + let previous = self.snapshot.load(); + for input in &new_inputs { + let Some(old) = previous.inputs.iter().find(|old| old.name == input.name) else { + continue; + }; + let root_reenabled = old.account_disabled && !input.account_disabled; + if self.should_forget_health( + &input.name, + root_reenabled, + old.account_identity() != input.account_identity(), + ) { + self.forget_account_health(&input.name); + } + for alias in input.aliases.iter().flatten() { + let Some(old_alias) = old.aliases.iter().flatten().find(|old_alias| old_alias.name == alias.name) + else { + continue; + }; + let reenabled = alias.enabled && !old_alias.enabled; + let credentials_changed = old_alias.account_identity() != alias.account_identity(); + if self.should_forget_health(&alias.name, reenabled, credentials_changed) { + self.forget_account_health(&alias.name); + } + } + } + } + let mut new_lineups: Vec = Vec::with_capacity(new_inputs.len()); for input in &new_inputs { new_lineups.push(Self::create_lineup(input, &self.provider_connections, &self.event_manager)); @@ -1048,6 +1193,7 @@ mod tests { password: None, persist: None, enabled: true, + account_disabled: false, sequential_group: None, input_type: InputType::Xtream, // You can use a default value here max_connections, @@ -1086,6 +1232,120 @@ mod tests { fn dummy_callback(_: &Arc, _: usize) {} + #[test] + fn blocked_root_keeps_alias_capacity_available() { + let mut input = create_config_input(1, &"root".intern(), 0, 0); + input.account_disabled = true; + input.aliases = Some(vec![create_config_input_alias(2, "http://alias", 0, 1)]); + let events = Arc::new(EventManager::new()); + let lineup = ProviderLineupManager::new(vec![Arc::new(input)], GracePeriodOptions::default(), &events); + let allocation = lineup.acquire_connection_with_grace_override(&"root".intern(), false); + assert_eq!(allocation.get_provider_id(), Some(2)); + assert!(matches!( + lineup.acquire_exact_connection_with_grace_override(&"root".intern(), true), + ProviderAllocation::Exhausted + )); + allocation.release(); + } + + #[test] + fn observed_ban_skips_live_and_redirect_allocations() { + let input = create_config_input(1, &"root".intern(), 0, 0); + let identity = input.account_identity(); + let events = Arc::new(EventManager::new()); + let lineup = ProviderLineupManager::new(vec![Arc::new(input)], GracePeriodOptions::default(), &events); + lineup.observe_account(tuliprox_core::model::ProviderAccountObservation { + name: "root".intern(), + identity, + status: Some(shared::model::ProxyUserStatus::Banned), + exp_date: None, + }); + assert!(matches!( + lineup.acquire_connection_with_grace_override(&"root".intern(), true), + ProviderAllocation::Exhausted + )); + let config = + ProviderLineupManager::get_provider_config_by_name(&"root".intern(), &lineup.snapshot.load().providers) + .map(|(_, config)| config.config()); + assert!(config.is_some_and(|config| config.is_account_blocked())); + assert!(lineup.get_next_provider(&"root".intern()).is_none()); + } + + #[test] + fn account_health_preserves_bans_and_rejects_stale_responses() { + let mut input = create_config_input(1, &"root".intern(), 0, 0); + let identity = input.account_identity(); + let events = Arc::new(EventManager::new()); + let lineup = ProviderLineupManager::new(vec![Arc::new(input.clone())], GracePeriodOptions::default(), &events); + let banned = tuliprox_core::model::ProviderAccountObservation { + name: "root".intern(), + identity, + status: Some(shared::model::ProxyUserStatus::Banned), + exp_date: None, + }; + lineup.observe_account(banned.clone()); + lineup.observe_account(tuliprox_core::model::ProviderAccountObservation { + status: None, + exp_date: Some(i64::MAX), + ..banned.clone() + }); + assert!(lineup.get_next_provider(&input.name).is_none()); + lineup.acknowledge_account_observation(&banned); + assert_eq!(lineup.pending_account_observations().len(), 1); + assert!(lineup.pending_account_observations()[0].is_blocked()); + lineup.request_account_probe(&input.name); + assert!(lineup.is_account_probe_requested(&input.name)); + input.password = Some("renewed".to_string()); + lineup.update_config(vec![Arc::new(input.clone())], &GracePeriodOptions::default()); + lineup.observe_account(banned); + assert!(lineup.get_next_provider(&input.name).is_some()); + assert!(lineup.pending_account_observations().is_empty()); + assert!(!lineup.is_account_probe_requested(&input.name)); + } + + #[test] + fn repeated_persisted_observation_is_not_queued_again() { + let input = create_config_input(1, &"root".intern(), 0, 0); + let events = Arc::new(EventManager::new()); + let lineup = ProviderLineupManager::new(vec![Arc::new(input.clone())], GracePeriodOptions::default(), &events); + let active = tuliprox_core::model::ProviderAccountObservation { + name: Arc::clone(&input.name), + identity: input.account_identity(), + status: Some(shared::model::ProxyUserStatus::Active), + exp_date: Some(i64::MAX), + }; + let queued = lineup.observe_account(active.clone()); + assert!(queued.is_some()); + lineup.acknowledge_account_observation(&active); + assert!(lineup.observe_account(active.clone()).is_none()); + assert!(lineup.pending_account_observations().is_empty()); + let banned = tuliprox_core::model::ProviderAccountObservation { + status: Some(shared::model::ProxyUserStatus::Banned), + ..active + }; + assert!(lineup.observe_account(banned).is_some_and(|queued| queued.is_blocked())); + } + + #[test] + fn reload_does_not_forget_an_unpersisted_ban() { + let mut input = create_config_input(1, &"root".intern(), 0, 0); + input.account_disabled = true; + let events = Arc::new(EventManager::new()); + let lineup = ProviderLineupManager::new(vec![Arc::new(input.clone())], GracePeriodOptions::default(), &events); + lineup.observe_account(tuliprox_core::model::ProviderAccountObservation { + name: Arc::clone(&input.name), + identity: input.account_identity(), + status: Some(shared::model::ProxyUserStatus::Banned), + exp_date: None, + }); + input.account_disabled = false; + lineup.update_config(vec![Arc::new(input.clone())], &GracePeriodOptions::default()); + assert!(lineup.get_next_provider(&input.name).is_none()); + assert_eq!(lineup.pending_account_observations().len(), 1); + lineup.forget_account_health(&input.name); + assert!(lineup.get_next_provider(&input.name).is_some()); + } + // Test acquiring with an alias #[test] fn test_provider_with_alias() { diff --git a/backend/session/src/stream_ctx.rs b/backend/session/src/stream_ctx.rs index b143576ee..e4626e02d 100644 --- a/backend/session/src/stream_ctx.rs +++ b/backend/session/src/stream_ctx.rs @@ -13,6 +13,7 @@ use tuliprox_core::model::AppConfig; /// What opening and reading a provider stream needs. #[derive(Clone)] pub struct ProviderStreamCtx { + pub active_provider: Arc, /// Resolved configuration; re-read on each use because it is hot-swapped. pub app_config: Arc, /// Connection admission and teardown for the provider side. diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 4f1463712..db6900523 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -541,6 +541,9 @@ pub struct ConfigInputDto { pub persist: Option, #[serde(default = "default_as_true", skip_serializing_if = "is_true")] pub enabled: bool, + /// Excludes the root account while keeping enabled aliases available. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub account_disabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub sequential_group: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -588,6 +591,7 @@ impl Default for ConfigInputDto { password: None, persist: None, enabled: default_as_true(), + account_disabled: false, sequential_group: None, options: None, media_server: None, @@ -1179,9 +1183,13 @@ impl ConfigInputDto { exp_date: i64, disable: bool, ) -> Result { - let (expiration, enabled) = if self.name.as_ref() == account_name { - (&mut self.exp_date, &mut self.enabled) - } else if let Some(alias) = self + if self.name.as_ref() == account_name { + let changed = self.exp_date != Some(exp_date) || disable && !self.account_disabled; + self.exp_date = Some(exp_date); + self.account_disabled |= disable; + return Ok(changed); + } + let (expiration, enabled) = if let Some(alias) = self .aliases .as_mut() .and_then(|aliases| aliases.iter_mut().find(|alias| alias.name.as_ref() == account_name)) diff --git a/shared/src/model/config/proxy_user_status.rs b/shared/src/model/config/proxy_user_status.rs index 91a4a0696..2ff4bce4c 100644 --- a/shared/src/model/config/proxy_user_status.rs +++ b/shared/src/model/config/proxy_user_status.rs @@ -27,3 +27,30 @@ pub enum ProxyUserStatus { Disabled, // The account is inactive or deliberately disabled by the administrator. Pending, } + +impl ProxyUserStatus { + /// Accounts in these states can serve streams. + pub const fn is_usable(self) -> bool { matches!(self, Self::Active | Self::Trial) } + + /// Confirmed states that justify a persisted exclusion; `Pending` stays runtime-only. + pub const fn is_terminal(self) -> bool { matches!(self, Self::Banned | Self::Disabled | Self::Expired) } +} + +/// Stable fingerprint of a provider account (URL and credentials) without keeping secrets around. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ProviderAccountIdentity(blake3::Hash); + +impl ProviderAccountIdentity { + pub fn new(url: &str, username: Option<&str>, password: Option<&str>) -> Self { + let mut hash = blake3::Hasher::new(); + for value in [url, username.unwrap_or_default(), password.unwrap_or_default()] { + hash.update(value.as_bytes()); + hash.update(b"\0"); + } + Self(hash.finalize()) + } +} + +impl std::fmt::Display for ProviderAccountIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0.to_hex()) } +} diff --git a/shared/src/model/event.rs b/shared/src/model/event.rs index b7c7415ef..6b01a0d10 100644 --- a/shared/src/model/event.rs +++ b/shared/src/model/event.rs @@ -132,6 +132,16 @@ pub trait EventSink: Send + Sync { /// closed channel are all normal and none of them are the emitter's /// problem. fn emit(&self, event: EventMessage); + /// Forward a confirmed account response to the runtime provider manager. + /// Sinks without a provider runtime deliberately ignore the observation. + fn observe_provider_account( + &self, + _name: Arc, + _identity: super::ProviderAccountIdentity, + _status: Option, + _exp_date: Option, + ) { + } } /// The sink that drops everything. @@ -148,10 +158,28 @@ impl EventSink for NoopSink { impl EventSink for Arc { fn emit(&self, event: EventMessage) { (**self).emit(event); } + fn observe_provider_account( + &self, + name: Arc, + identity: super::ProviderAccountIdentity, + status: Option, + exp_date: Option, + ) { + (**self).observe_provider_account(name, identity, status, exp_date); + } } impl EventSink for &T { fn emit(&self, event: EventMessage) { (**self).emit(event); } + fn observe_provider_account( + &self, + name: Arc, + identity: super::ProviderAccountIdentity, + status: Option, + exp_date: Option, + ) { + (**self).observe_provider_account(name, identity, status, exp_date); + } } /// Which event this is, without its payload.