mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-23 16:22:31 +02:00
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
This commit is contained in:
@@ -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 `<file>-<path hash>-<timestamp>-<random>`, and the latest ten are kept per source file. Backups
|
||||
written in the previous format (`<file>_<timestamp>…`) 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
|
||||
|
||||
@@ -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<PreparedMappingsReload>,
|
||||
sources_file: String,
|
||||
templates: Option<Vec<PatternTemplate>>,
|
||||
loaded_revisions: Vec<(PathBuf, blake3::Hash)>,
|
||||
}
|
||||
|
||||
/// What dependent reload (if any) was prepared alongside a config change.
|
||||
enum PreparedFollowUp {
|
||||
Unchanged,
|
||||
Mapping(Option<PreparedMappingsReload>, Option<Vec<PatternTemplate>>),
|
||||
Sources(PreparedSourcesReload),
|
||||
Sources(Box<PreparedSourcesReload>),
|
||||
}
|
||||
|
||||
/// Refreshes CLI-forced target IDs against an incoming `SourcesConfig`.
|
||||
@@ -191,7 +192,7 @@ impl ConfigFile {
|
||||
) -> Result<PreparedSourcesReload, TuliproxError> {
|
||||
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 {
|
||||
|
||||
@@ -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<Vec<ConfigInputAliasDto>, 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<T, F: Future<Output = Result<T, TuliproxError>>>(
|
||||
app_state: &AppState,
|
||||
_guard: &crate::utils::FileWriteGuard,
|
||||
input_type: InputType,
|
||||
csv_path: &Path,
|
||||
mutation: F,
|
||||
renewed_account: Option<&str>,
|
||||
) -> Result<T, TuliproxError> {
|
||||
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<i64>,
|
||||
) -> 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<str>,
|
||||
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<str>,
|
||||
old_username: &str,
|
||||
old_password: &str,
|
||||
new_username: &str,
|
||||
new_password: &str,
|
||||
exp_date: Option<i64>,
|
||||
) -> 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<repository::CsvAccountPatchResult, TuliproxError> {
|
||||
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<bool, TuliproxError> {
|
||||
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<bool, TuliproxError> {
|
||||
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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -14,3 +14,5 @@ mod serve;
|
||||
pub(crate) mod static_headers;
|
||||
mod sys_usage;
|
||||
mod tasks;
|
||||
|
||||
pub(crate) mod internal_csv;
|
||||
|
||||
@@ -542,6 +542,7 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc<AppState> {
|
||||
});
|
||||
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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -107,6 +107,7 @@ pub struct ProviderStreamFactoryOptions {
|
||||
cancel_token: Option<CancellationToken>,
|
||||
completion_token: Option<CancellationToken>,
|
||||
close_reason: Option<Arc<AtomicU8>>,
|
||||
account: Option<Arc<crate::model::ProviderConfig>>,
|
||||
}
|
||||
|
||||
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<crate::model::ConfigInput> {
|
||||
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<CancellationToken>,
|
||||
completion_token: Option<CancellationToken>,
|
||||
close_reason: Option<Arc<AtomicU8>>,
|
||||
account: Option<Arc<crate::model::ProviderConfig>>,
|
||||
}
|
||||
|
||||
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<ProviderStreamFactoryResponse> {
|
||||
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<dyn std::error::Error>> {
|
||||
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") {
|
||||
|
||||
@@ -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<AppState>) {
|
||||
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<AppState>) {
|
||||
}
|
||||
|
||||
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<AppStat
|
||||
}
|
||||
|
||||
if any_change && 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 user sync reload sources failed: {}", err);
|
||||
}
|
||||
refresh_internal_sources(app_state);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,14 @@ use url::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum SourcesYmlPatch {
|
||||
SetObservedAccount {
|
||||
input_name: Arc<str>,
|
||||
observation: crate::model::ProviderAccountObservation,
|
||||
},
|
||||
SetAccountDisabled {
|
||||
input_name: Arc<str>,
|
||||
account_name: Arc<str>,
|
||||
},
|
||||
SetFetchedExpiry {
|
||||
input_name: Arc<str>,
|
||||
account_name: Arc<str>,
|
||||
@@ -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<crate::model::SourcesConfig, TuliproxError> {
|
||||
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<bool, TuliproxError> {
|
||||
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<AppConfig>,
|
||||
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<AppConfig>,
|
||||
sources_path: &Path,
|
||||
patches: &[SourcesYmlPatch],
|
||||
_guard: &crate::utils::FileWriteGuard,
|
||||
) -> Result<bool, TuliproxError> {
|
||||
// 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<AppConfig>,
|
||||
source_path: &std::path::Path,
|
||||
patches: &[SourcesYmlPatch],
|
||||
) -> Result<bool, TuliproxError> {
|
||||
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");
|
||||
|
||||
@@ -172,6 +172,7 @@ fn start_config_watch(app_state: &Arc<AppState>, cancel_token: &CancellationToke
|
||||
|
||||
async fn has_external_source_write(file_locks: &crate::utils::FileLockManager, paths: &HashSet<PathBuf>) -> 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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Arc<str>>,
|
||||
/// Accounts that could not be stored, with the reason.
|
||||
pub failed: Vec<(Arc<str>, String)>,
|
||||
pub first_error: Option<TuliproxError>,
|
||||
}
|
||||
|
||||
impl PersistReport {
|
||||
fn fail(&mut self, names: impl IntoIterator<Item = Arc<str>>, 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<AppState>) {
|
||||
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<AppState>,
|
||||
observation: &ProviderAccountObservation,
|
||||
) -> Result<bool, TuliproxError> {
|
||||
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<AppState>,
|
||||
observations: &[ProviderAccountObservation],
|
||||
) -> PersistReport {
|
||||
let mut report = PersistReport::default();
|
||||
let sources = app_state.app_config.sources.load_full();
|
||||
let mut groups: Vec<(PathBuf, Option<String>, 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<i64>,
|
||||
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<ResolvedAccount<'a>> {
|
||||
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<AppState>,
|
||||
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<i64>,
|
||||
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<AppState>, std::path::PathBuf), Box<dyn std::error::Error>> {
|
||||
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<AppState>, path: &Path, content: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<bool, Box<dyn std::error::Error>> {
|
||||
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<AppState>,
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<str>,
|
||||
name: Arc<str>,
|
||||
batch_url: Option<String>,
|
||||
url: String,
|
||||
source_url: String,
|
||||
headers: HashMap<String, String>,
|
||||
@@ -38,6 +28,12 @@ struct Account {
|
||||
panel: Arc<str>,
|
||||
}
|
||||
|
||||
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<AppState>, 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<AppState>, 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::<Vec<_>>();
|
||||
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<i64>,
|
||||
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<i64>, last_attempt: Option<i64>, 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<i64>, now: i64) -> bool {
|
||||
@@ -260,13 +262,11 @@ fn collect_accounts(app_state: &AppState) -> Vec<Account> {
|
||||
.iter()
|
||||
.filter(|input| input.enabled && input.input_type.is_xtream())
|
||||
.flat_map(|input| {
|
||||
let batch_url = input.t_batch_url.clone();
|
||||
let panel = Arc::<str>::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<Account> {
|
||||
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<i64, FetchError> {
|
||||
@@ -378,96 +388,50 @@ fn account_is_current(account: &Account, current_keys: &HashSet<String>) -> bool
|
||||
current_keys.contains(&account_key(account))
|
||||
}
|
||||
|
||||
async fn persist_updates(app_state: &Arc<AppState>, updates: &[(&Account, i64)]) -> Result<Vec<String>, 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<AppState>,
|
||||
updates: &[(&Account, i64)],
|
||||
) -> (Vec<String>, Option<TuliproxError>) {
|
||||
let current_keys = collect_accounts(app_state).iter().map(account_key).collect::<HashSet<_>>();
|
||||
let updates =
|
||||
updates.iter().copied().filter(|(account, _)| account_is_current(account, ¤t_keys)).collect::<Vec<_>>();
|
||||
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::<String, Vec<BatchExpDateUpdate>>::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<SourcesYmlPatch> = 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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
fn expired_root_is_excluded_without_disabling_input() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -111,26 +111,30 @@ async fn parse_sources_file_from_path(
|
||||
sources_file: &Path,
|
||||
resolve_env: bool,
|
||||
) -> Result<SourcesConfigDto, TuliproxError> {
|
||||
let sources_file = sources_file.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || match open_file(&sources_file) {
|
||||
Ok(file) => {
|
||||
let maybe_sources: Result<SourcesConfigDto, _> =
|
||||
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<SourcesConfigDto, TuliproxError> {
|
||||
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<blake3::Hash>,
|
||||
) -> Result<bool, TuliproxError> {
|
||||
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<bool, TuliproxError> {
|
||||
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();
|
||||
|
||||
@@ -24,6 +24,8 @@ pub struct PatchInput {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<serde_saphyr::Spanned<bool>>,
|
||||
#[serde(default)]
|
||||
pub account_disabled: Option<serde_saphyr::Spanned<bool>>,
|
||||
#[serde(default)]
|
||||
pub url: Option<serde_saphyr::Spanned<String>>,
|
||||
#[serde(default)]
|
||||
pub username: Option<serde_saphyr::Spanned<String>>,
|
||||
|
||||
@@ -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<String>,
|
||||
pub persist: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub account_disabled: bool,
|
||||
pub sequential_group: Option<u32>,
|
||||
pub options: Option<ConfigInputOptions>,
|
||||
pub media_server: Option<MediaServerInputConfig>,
|
||||
@@ -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<Cow<'_, ConfigInput>> {
|
||||
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() };
|
||||
|
||||
@@ -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<Instant>,
|
||||
pub health_status: Option<ProviderAccountObservation>,
|
||||
pub pending_health: Option<ProviderAccountObservation>,
|
||||
/// 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<str>,
|
||||
pub identity: ProviderAccountIdentity,
|
||||
pub status: Option<ProxyUserStatus>,
|
||||
pub exp_date: Option<i64>,
|
||||
}
|
||||
|
||||
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<i64>,
|
||||
account_identity: ProviderAccountIdentity,
|
||||
account_disabled: bool,
|
||||
connection: Arc<RwLock<ProviderConfigConnection>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> = 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
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?;
|
||||
|
||||
@@ -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::*,
|
||||
};
|
||||
|
||||
@@ -51,7 +51,14 @@ async fn download_reader(
|
||||
explicit_source: Option<InputSource>,
|
||||
) -> Result<request::DynReader, TuliproxError> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<str>, ProviderAccountIdentity)> {
|
||||
let sources = app_config.sources.load();
|
||||
let is_login_account = |name: &Arc<str>, user: Option<&str>, pass: &Option<String>| {
|
||||
*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<E: EventSink>(
|
||||
app_config: &Arc<AppConfig>,
|
||||
client: &reqwest::Client,
|
||||
@@ -390,6 +413,9 @@ pub async fn xtream_login<E: EventSink>(
|
||||
input: &InputSource,
|
||||
username: &str,
|
||||
) -> Result<Option<XtreamLoginInfo>, 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<E: EventSink>(
|
||||
}
|
||||
}
|
||||
|
||||
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<E: EventSink>(
|
||||
app_config: &Arc<AppConfig>,
|
||||
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<E: EventSink>(
|
||||
app_config: &Arc<AppConfig>,
|
||||
@@ -675,7 +721,7 @@ async fn download_xtream_from_source<E: EventSink>(
|
||||
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<E: EventSink>(
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<str>,
|
||||
pub exp_date: i64,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub exp_date: Option<i64>,
|
||||
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<PathBuf, TuliproxError> {
|
||||
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<bool, TuliproxError> {
|
||||
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<i64>,
|
||||
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<ProviderAccountIdentity>,
|
||||
}
|
||||
|
||||
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<String>), TuliproxError> {
|
||||
) -> Result<CsvAccountPatchResult, TuliproxError> {
|
||||
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::<Vec<_>>();
|
||||
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<i64>,
|
||||
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<bool, TuliproxError> {
|
||||
pub async fn csv_patch_batch_remove_expired(
|
||||
input_type: InputType,
|
||||
csv_path: &Path,
|
||||
backup_dir: &str,
|
||||
) -> Result<bool, TuliproxError> {
|
||||
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<bool, TuliproxError> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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],
|
||||
|
||||
@@ -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<Connections>,
|
||||
leases: std::sync::RwLock<ProviderLeaseTable>,
|
||||
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<tuliprox_core::model::ProviderAccountObservation> {
|
||||
self.core.providers.observe_account(observation)
|
||||
}
|
||||
|
||||
pub fn reset_account_health(&self, name: &Arc<str>) { self.core.providers.forget_account_health(name); }
|
||||
pub fn pending_account_observations(&self) -> Vec<tuliprox_core::model::ProviderAccountObservation> {
|
||||
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<str>) { self.core.providers.request_account_probe(name); }
|
||||
pub fn is_account_probe_requested(&self, name: &Arc<str>) -> bool {
|
||||
self.core.providers.is_account_probe_requested(name)
|
||||
}
|
||||
pub fn clear_account_probe(&self, name: &Arc<str>) { 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<SharedStreamManager>) {
|
||||
let _ = self.shared_stream_manager.set(Arc::downgrade(manager));
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ struct LatchedEvents {
|
||||
}
|
||||
|
||||
pub struct EventManager {
|
||||
pub(crate) account_provider:
|
||||
std::sync::OnceLock<std::sync::Weak<crate::active_provider_manager::ActiveProviderManagerCore>>,
|
||||
channel_tx: tokio::sync::broadcast::Sender<EventMessage>,
|
||||
meters: StreamMeterRegistry,
|
||||
stats: Arc<EventBusStats>,
|
||||
@@ -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<str>,
|
||||
identity: shared::model::ProviderAccountIdentity,
|
||||
status: Option<shared::model::ProxyUserStatus>,
|
||||
exp_date: Option<i64>,
|
||||
) {
|
||||
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 {
|
||||
|
||||
@@ -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<Arc<str>, 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<tuliprox_core::model::ProviderAccountObservation> {
|
||||
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<tuliprox_core::model::ProviderAccountObservation> {
|
||||
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<str>) {
|
||||
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<str>) -> 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<str>) {
|
||||
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<str>) {
|
||||
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<str>) -> 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<str>, 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<ProviderLineup> = 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<str>, _: 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() {
|
||||
|
||||
@@ -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<crate::ActiveProviderManager>,
|
||||
/// Resolved configuration; re-read on each use because it is hot-swapped.
|
||||
pub app_config: Arc<AppConfig>,
|
||||
/// Connection admission and teardown for the provider side.
|
||||
|
||||
@@ -541,6 +541,9 @@ pub struct ConfigInputDto {
|
||||
pub persist: Option<String>,
|
||||
#[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<u32>,
|
||||
#[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<bool, TuliproxError> {
|
||||
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))
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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<str>,
|
||||
_identity: super::ProviderAccountIdentity,
|
||||
_status: Option<super::ProxyUserStatus>,
|
||||
_exp_date: Option<i64>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sink that drops everything.
|
||||
@@ -148,10 +158,28 @@ impl EventSink for NoopSink {
|
||||
|
||||
impl<T: EventSink + ?Sized> EventSink for Arc<T> {
|
||||
fn emit(&self, event: EventMessage) { (**self).emit(event); }
|
||||
fn observe_provider_account(
|
||||
&self,
|
||||
name: Arc<str>,
|
||||
identity: super::ProviderAccountIdentity,
|
||||
status: Option<super::ProxyUserStatus>,
|
||||
exp_date: Option<i64>,
|
||||
) {
|
||||
(**self).observe_provider_account(name, identity, status, exp_date);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: EventSink> EventSink for &T {
|
||||
fn emit(&self, event: EventMessage) { (**self).emit(event); }
|
||||
fn observe_provider_account(
|
||||
&self,
|
||||
name: Arc<str>,
|
||||
identity: super::ProviderAccountIdentity,
|
||||
status: Option<super::ProxyUserStatus>,
|
||||
exp_date: Option<i64>,
|
||||
) {
|
||||
(**self).observe_provider_account(name, identity, status, exp_date);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which event this is, without its payload.
|
||||
|
||||
Reference in New Issue
Block a user