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:
euzu
2026-10-01 14:24:02 +02:00
committed by GitHub
parent 93b1603412
commit 0bd087d288
31 changed files with 2731 additions and 396 deletions
+13
View File
@@ -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
+18 -6
View File
@@ -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 {
+247
View File
@@ -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(&current);
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(&current, doc)?;
let previous = app_state.app_config.sources.compare_and_swap(&current, Arc::new(next));
if Arc::ptr_eq(&previous, &current) {
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
}
+1
View File
@@ -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);
+2
View File
@@ -14,3 +14,5 @@ mod serve;
pub(crate) mod static_headers;
mod sys_usage;
mod tasks;
pub(crate) mod internal_csv;
+1
View File
@@ -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);
+1 -1
View File
@@ -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") {
+114 -54
View File
@@ -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);
}
}
+281 -30
View File
@@ -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(&current);
if !apply_sources_yml_patches(&mut doc, patches)? {
return Ok(());
}
let next = merge_runtime_document(&current, doc)?;
let previous = app_config.sources.compare_and_swap(&current, Arc::new(next));
if Arc::ptr_eq(&previous, &current) {
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");
+3 -2
View File
@@ -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(())
}
+2
View File
@@ -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(())
}
}
+134 -127
View File
@@ -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, &current_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, &current_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(())
}
+76 -32
View File
@@ -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>>,
+121 -3
View File
@@ -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() };
+76 -9
View File
@@ -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?;
+3 -2
View File
@@ -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::*,
};
+8 -1
View File
@@ -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
}
+60 -5
View File
@@ -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,
+251 -110
View File
@@ -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],
+31 -1
View File
@@ -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));
}
+19
View File
@@ -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 {
+261 -1
View File
@@ -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() {
+1
View File
@@ -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.
+11 -3
View File
@@ -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()) }
}
+28
View File
@@ -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.