mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-19 06:12:24 +02:00
feat: add provider DNS IP-connect, resolved persistence, and SNI-safe failover (#624)
* feat: add provider DNS IP-connect, resolved persistence, and SNI-safe failover
This commit is contained in:
@@ -55,22 +55,22 @@ impl ConfigFile {
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Load and merge global templates using the current app state.
|
||||
fn load_prepared_global_templates(
|
||||
async fn load_prepared_global_templates(
|
||||
app_state: &Arc<AppState>,
|
||||
) -> Result<Option<Vec<PatternTemplate>>, TuliproxError> {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
let config = app_state.app_config.config.load();
|
||||
Self::load_prepared_global_templates_with_config(&paths, &config)
|
||||
Self::load_prepared_global_templates_with_config(&paths, &config).await
|
||||
}
|
||||
|
||||
/// Load and merge global templates using explicitly-provided config/paths.
|
||||
/// Used in the prepare phase when the new config has not yet been applied to `app_state`.
|
||||
fn load_prepared_global_templates_with_config(
|
||||
async fn load_prepared_global_templates_with_config(
|
||||
paths: &ConfigPaths,
|
||||
config: &Config,
|
||||
) -> Result<Option<Vec<PatternTemplate>>, TuliproxError> {
|
||||
let sources_inline_templates =
|
||||
read_sources_file(paths.sources_file_path.as_str(), false, false, None, None)?.templates;
|
||||
read_sources_file(paths.sources_file_path.as_str(), false, false, None, None).await?.templates;
|
||||
|
||||
// Use robust fallbacks for mapping and template paths
|
||||
let (effective_template_path, effective_mapping_path) = resolve_template_and_mapping_paths(paths, config.template_path.as_deref(), config.mapping_path.as_deref());
|
||||
@@ -136,8 +136,8 @@ impl ConfigFile {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_mapping(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
||||
let prepared_templates = Self::load_prepared_global_templates(app_state)?;
|
||||
async fn load_mapping(app_state: &Arc<AppState>) -> Result<(), TuliproxError> {
|
||||
let prepared_templates = Self::load_prepared_global_templates(app_state).await?;
|
||||
Self::load_mapping_with_templates(app_state, prepared_templates.as_deref())
|
||||
}
|
||||
|
||||
@@ -151,14 +151,15 @@ impl ConfigFile {
|
||||
paths: &ConfigPaths,
|
||||
) -> Result<PreparedSourcesReload, TuliproxError> {
|
||||
let sources_file = paths.sources_file_path.clone();
|
||||
let prepared_templates = Self::load_prepared_global_templates_with_config(paths, config)?;
|
||||
let prepared_templates = Self::load_prepared_global_templates_with_config(paths, config).await?;
|
||||
let mut sources_dto = read_sources_file_from_path_with_templates(
|
||||
&PathBuf::from(sources_file.as_str()),
|
||||
true,
|
||||
true,
|
||||
config.get_hdhr_device_overview().as_ref(),
|
||||
prepared_templates.as_deref(),
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
prepare_sources_batch(&mut sources_dto, true).await?;
|
||||
let sources: SourcesConfig = SourcesConfig::try_from(sources_dto)?;
|
||||
let prepared_mapping =
|
||||
@@ -253,7 +254,7 @@ impl ConfigFile {
|
||||
PreparedFollowUp::Sources(prepared)
|
||||
} else if mapping_changed {
|
||||
// Only mapping path changed; templates are the same → load templates once.
|
||||
let prepared_templates = Self::load_prepared_global_templates_with_config(&effective_paths, &config)?;
|
||||
let prepared_templates = Self::load_prepared_global_templates_with_config(&effective_paths, &config).await?;
|
||||
let prepared = Self::prepare_mapping_reload(
|
||||
effective_paths.mapping_file_path.as_deref(),
|
||||
prepared_templates.as_deref(),
|
||||
@@ -331,7 +332,7 @@ impl ConfigFile {
|
||||
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::ApiProxy));
|
||||
}
|
||||
ConfigFile::Mapping => {
|
||||
ConfigFile::load_mapping(app_state)?;
|
||||
ConfigFile::load_mapping(app_state).await?;
|
||||
app_state.event_manager.send_event(EventMessage::ConfigChange(ConfigType::Mapping));
|
||||
}
|
||||
ConfigFile::Template | ConfigFile::Sources => {
|
||||
|
||||
@@ -20,6 +20,37 @@ use shared::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn inject_provider_dns_resolved(sources_dto: &mut SourcesConfigDto, runtime_sources: &crate::model::SourcesConfig) {
|
||||
let Some(provider_dtos) = sources_dto.provider.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for provider_dto in provider_dtos {
|
||||
let Some(runtime_provider) = runtime_sources.get_provider_by_name(provider_dto.name.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(dns_dto) = provider_dto.dns.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if runtime_provider.get_dns_config().is_none() {
|
||||
dns_dto.resolved = None;
|
||||
continue;
|
||||
}
|
||||
let snapshot = runtime_provider.snapshot_resolved();
|
||||
dns_dto.resolved = (!snapshot.is_empty()).then_some(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_provider_dns_resolved(sources_dto: &mut SourcesConfigDto) {
|
||||
let Some(provider_dtos) = sources_dto.provider.as_mut() else {
|
||||
return;
|
||||
};
|
||||
for provider_dto in provider_dtos {
|
||||
if let Some(dns_dto) = provider_dto.dns.as_mut() {
|
||||
dns_dto.resolved = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::api::endpoints) async fn intern_save_config_api_proxy(
|
||||
backup_dir: &str,
|
||||
api_proxy: &ApiProxyConfigDto,
|
||||
@@ -78,9 +109,12 @@ async fn save_config_main(
|
||||
|
||||
async fn save_config_sources(
|
||||
axum::extract::State(app_state): axum::extract::State<Arc<AppState>>,
|
||||
axum::extract::Json(sources): axum::extract::Json<SourcesConfigDto>,
|
||||
axum::extract::Json(mut sources): axum::extract::Json<SourcesConfigDto>,
|
||||
) -> impl axum::response::IntoResponse + Send {
|
||||
let templates_to_persist = match utils::validate_source_config_for_persist(&app_state, &sources) {
|
||||
// `dns.resolved` is runtime-managed and must not be accepted from API input.
|
||||
strip_provider_dns_resolved(&mut sources);
|
||||
|
||||
let templates_to_persist = match utils::validate_source_config_for_persist(&app_state, &sources).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
error!("Failed to validate source.yml {err}");
|
||||
@@ -179,7 +213,7 @@ async fn save_config_api_proxy_config(
|
||||
|
||||
async fn config(axum::extract::State(app_state): axum::extract::State<Arc<AppState>>) -> impl IntoResponse + Send {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
match utils::read_app_config_dto(&paths, true, false) {
|
||||
match utils::read_app_config_dto(&paths, true, false).await {
|
||||
Ok(mut app_config) => {
|
||||
if let Err(err) = prepare_sources_batch(&mut app_config.sources, false).await {
|
||||
error!("Failed to prepare sources batch: {err}");
|
||||
@@ -188,6 +222,8 @@ async fn config(axum::extract::State(app_state): axum::extract::State<Arc<AppSta
|
||||
error!("Failed to prepare users: {err}");
|
||||
internal_server_error!()
|
||||
} else {
|
||||
let runtime_sources = app_state.app_config.sources.load();
|
||||
inject_provider_dns_resolved(&mut app_config.sources, &runtime_sources);
|
||||
axum::response::Json(app_config).into_response()
|
||||
}
|
||||
}
|
||||
@@ -243,3 +279,123 @@ pub fn v1_api_config_register(router: Router<Arc<AppState>>) -> axum::Router<Arc
|
||||
.route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config))
|
||||
.route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{inject_provider_dns_resolved, strip_provider_dns_resolved};
|
||||
use crate::model::{ConfigProvider, SourcesConfig};
|
||||
use shared::model::{ConfigProviderDto, DnsScheme, ProviderDnsDto, SourcesConfigDto};
|
||||
use std::{collections::HashMap, net::IpAddr, sync::Arc};
|
||||
|
||||
#[test]
|
||||
fn inject_provider_dns_resolved_populates_runtime_snapshot() {
|
||||
let mut dto = SourcesConfigDto {
|
||||
provider: Some(vec![ConfigProviderDto {
|
||||
name: "p1".into(),
|
||||
urls: vec!["http://example.com".into()],
|
||||
dns: Some(ProviderDnsDto {
|
||||
enabled: true,
|
||||
schemes: Some(vec![DnsScheme::Http]),
|
||||
..ProviderDnsDto::default()
|
||||
}),
|
||||
}]),
|
||||
..SourcesConfigDto::default()
|
||||
};
|
||||
|
||||
let runtime_provider = Arc::new(ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "p1".into(),
|
||||
urls: vec!["http://example.com".into()],
|
||||
dns: Some(ProviderDnsDto {
|
||||
enabled: true,
|
||||
schemes: Some(vec![DnsScheme::Http]),
|
||||
..ProviderDnsDto::default()
|
||||
}),
|
||||
}));
|
||||
runtime_provider.store_resolved(
|
||||
"example.com",
|
||||
vec!["203.0.113.10".parse::<IpAddr>().expect("ip parse should work")],
|
||||
);
|
||||
let runtime_sources = SourcesConfig {
|
||||
provider: vec![runtime_provider],
|
||||
..SourcesConfig::default()
|
||||
};
|
||||
|
||||
inject_provider_dns_resolved(&mut dto, &runtime_sources);
|
||||
|
||||
let resolved = dto.provider.as_ref()
|
||||
.and_then(|providers| providers.first())
|
||||
.and_then(|provider| provider.dns.as_ref())
|
||||
.and_then(|dns| dns.resolved.as_ref())
|
||||
.expect("resolved dns snapshot should be present");
|
||||
assert_eq!(
|
||||
resolved.get("example.com"),
|
||||
Some(&vec!["203.0.113.10".parse::<IpAddr>().expect("ip parse should work")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_provider_dns_resolved_clears_value_when_runtime_dns_disabled() {
|
||||
let mut dto = SourcesConfigDto {
|
||||
provider: Some(vec![ConfigProviderDto {
|
||||
name: "p1".into(),
|
||||
urls: vec!["http://example.com".into()],
|
||||
dns: Some(ProviderDnsDto {
|
||||
enabled: true,
|
||||
resolved: Some(HashMap::from([(
|
||||
"example.com".to_string(),
|
||||
vec!["203.0.113.10".parse::<IpAddr>().expect("ip parse should work")],
|
||||
)])),
|
||||
..ProviderDnsDto::default()
|
||||
}),
|
||||
}]),
|
||||
..SourcesConfigDto::default()
|
||||
};
|
||||
|
||||
let runtime_provider = Arc::new(ConfigProvider::from(&ConfigProviderDto {
|
||||
name: "p1".into(),
|
||||
urls: vec!["http://example.com".into()],
|
||||
dns: None,
|
||||
}));
|
||||
let runtime_sources = SourcesConfig {
|
||||
provider: vec![runtime_provider],
|
||||
..SourcesConfig::default()
|
||||
};
|
||||
|
||||
inject_provider_dns_resolved(&mut dto, &runtime_sources);
|
||||
|
||||
let resolved = dto.provider.as_ref()
|
||||
.and_then(|providers| providers.first())
|
||||
.and_then(|provider| provider.dns.as_ref())
|
||||
.and_then(|dns| dns.resolved.as_ref());
|
||||
assert!(resolved.is_none(), "resolved output must be empty when runtime dns is disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_provider_dns_resolved_removes_payload_values() {
|
||||
let mut dto = SourcesConfigDto {
|
||||
provider: Some(vec![ConfigProviderDto {
|
||||
name: "p1".into(),
|
||||
urls: vec!["http://example.com".into()],
|
||||
dns: Some(ProviderDnsDto {
|
||||
enabled: true,
|
||||
resolved: Some(HashMap::from([(
|
||||
"example.com".to_string(),
|
||||
vec!["203.0.113.10".parse::<IpAddr>().expect("ip parse should work")],
|
||||
)])),
|
||||
..ProviderDnsDto::default()
|
||||
}),
|
||||
}]),
|
||||
..SourcesConfigDto::default()
|
||||
};
|
||||
|
||||
strip_provider_dns_resolved(&mut dto);
|
||||
|
||||
let resolved = dto
|
||||
.provider
|
||||
.as_ref()
|
||||
.and_then(|providers| providers.first())
|
||||
.and_then(|provider| provider.dns.as_ref())
|
||||
.and_then(|dns| dns.resolved.as_ref());
|
||||
assert!(resolved.is_none(), "resolved must be stripped from incoming payload");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ use crate::{
|
||||
hdhomerun_proprietary::spawn_proprietary_tasks,
|
||||
hdhomerun_ssdp::spawn_ssdp_discover_task,
|
||||
model::{
|
||||
create_cache, create_http_client, create_http_client_no_redirect, ActiveProviderManager, ActiveUserManager,
|
||||
AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, EventMessage, HdHomerunAppState,
|
||||
MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard,
|
||||
create_cache, create_http_client, create_http_client_no_redirect, exec_provider_dns, ActiveProviderManager,
|
||||
ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadQueue, EventManager, EventMessage,
|
||||
HdHomerunAppState, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard,
|
||||
},
|
||||
panel_api::sync_panel_api_exp_dates_on_boot,
|
||||
scheduler::{exec_interner_prune, exec_scheduler},
|
||||
@@ -287,9 +287,14 @@ pub async fn start_server(app_config: Arc<AppConfig>, targets: Arc<ProcessTarget
|
||||
// Keep using the original `app_state` below, which is valid because `Arc::clone` borrows.
|
||||
let shared_data = Arc::clone(&app_state);
|
||||
|
||||
let (cancel_token_scheduler, cancel_token_hdhomerun, cancel_token_file_watch) = {
|
||||
let (cancel_token_scheduler, cancel_token_hdhomerun, cancel_token_file_watch, cancel_token_provider_dns) = {
|
||||
let cancel_tokens = app_state.cancel_tokens.load();
|
||||
(cancel_tokens.scheduler.clone(), cancel_tokens.hdhomerun.clone(), cancel_tokens.file_watch.clone())
|
||||
(
|
||||
cancel_tokens.scheduler.clone(),
|
||||
cancel_tokens.hdhomerun.clone(),
|
||||
cancel_tokens.file_watch.clone(),
|
||||
cancel_tokens.provider_dns.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
if let Err(err) = load_playlists_into_memory_cache(&app_state).await {
|
||||
@@ -311,6 +316,7 @@ pub async fn start_server(app_config: Arc<AppConfig>, targets: Arc<ProcessTarget
|
||||
exec_interner_prune(&app_state);
|
||||
|
||||
exec_config_watch(&app_state, &cancel_token_file_watch);
|
||||
exec_provider_dns(&app_state, &cancel_token_provider_dns);
|
||||
|
||||
let web_auth_enabled = is_web_auth_enabled(&cfg, web_ui_enabled);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::{
|
||||
api::{
|
||||
config_watch::exec_config_watch,
|
||||
model::provider_dns_manager::exec_provider_dns,
|
||||
model::{
|
||||
metadata_update_manager::MetadataUpdateManager, ActiveProviderManager, ActiveUserManager,
|
||||
ConnectionManager, DownloadQueue, EventManager, PlaylistStorage, PlaylistStorageState, SharedStreamManager,
|
||||
@@ -9,8 +10,8 @@ use crate::{
|
||||
scheduler::exec_scheduler,
|
||||
},
|
||||
model::{
|
||||
AppConfig, Config, ConfigTarget, GracePeriodOptions, HdHomeRunConfig, HdHomeRunDeviceConfig, ProcessTargets,
|
||||
ReverseProxyDisabledHeaderConfig, ScheduleConfig, SourcesConfig,
|
||||
AppConfig, Config, ConfigProvider, ConfigTarget, GracePeriodOptions, HdHomeRunConfig, HdHomeRunDeviceConfig,
|
||||
ProcessTargets, ReverseProxyDisabledHeaderConfig, ScheduleConfig, SourcesConfig,
|
||||
},
|
||||
repository::{get_geoip_path, load_target_into_memory_cache},
|
||||
tools::lru_cache::LRUResourceCache,
|
||||
@@ -69,7 +70,7 @@ struct TargetChanges {
|
||||
target: Arc<ConfigTarget>,
|
||||
}
|
||||
|
||||
create_bitset!(u8, UpdateChangesFlags, Scheduler, Hdhomerun, FileWatch, Geoip);
|
||||
create_bitset!(u8, UpdateChangesFlags, Scheduler, Hdhomerun, FileWatch, Geoip, ProviderDns);
|
||||
|
||||
pub(in crate::api) struct UpdateChanges {
|
||||
flags: UpdateChangesFlagsSet,
|
||||
@@ -149,8 +150,14 @@ fn cancel_services(app_state: &Arc<AppState>, changes: &UpdateChanges) {
|
||||
let scheduler = cancel_service!(scheduler, UpdateChangesFlags::Scheduler, changes, cancel_tokens);
|
||||
let hdhomerun = cancel_service!(hdhomerun, UpdateChangesFlags::Hdhomerun, changes, cancel_tokens);
|
||||
let file_watch = cancel_service!(file_watch, UpdateChangesFlags::FileWatch, changes, cancel_tokens);
|
||||
let provider_dns = cancel_service!(provider_dns, UpdateChangesFlags::ProviderDns, changes, cancel_tokens);
|
||||
|
||||
let tokens = CancelTokens { scheduler, hdhomerun, file_watch };
|
||||
let tokens = CancelTokens {
|
||||
scheduler,
|
||||
hdhomerun,
|
||||
file_watch,
|
||||
provider_dns,
|
||||
};
|
||||
|
||||
app_state.cancel_tokens.store(Arc::new(tokens));
|
||||
}
|
||||
@@ -181,6 +188,10 @@ fn start_services(app_state: &Arc<AppState>, changes: &UpdateChanges) {
|
||||
if changes.flags.contains(UpdateChangesFlags::FileWatch) {
|
||||
exec_config_watch(app_state, &app_state.cancel_tokens.load().file_watch);
|
||||
}
|
||||
|
||||
if changes.flags.contains(UpdateChangesFlags::ProviderDns) {
|
||||
exec_provider_dns(app_state, &app_state.cancel_tokens.load().provider_dns);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the default HTTP client.
|
||||
@@ -285,6 +296,7 @@ pub struct CancelTokens {
|
||||
pub(crate) scheduler: CancellationToken,
|
||||
pub(crate) hdhomerun: CancellationToken,
|
||||
pub(crate) file_watch: CancellationToken,
|
||||
pub(crate) provider_dns: CancellationToken,
|
||||
}
|
||||
impl Default for CancelTokens {
|
||||
fn default() -> Self {
|
||||
@@ -292,6 +304,7 @@ impl Default for CancelTokens {
|
||||
scheduler: CancellationToken::new(),
|
||||
hdhomerun: CancellationToken::new(),
|
||||
file_watch: CancellationToken::new(),
|
||||
provider_dns: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,9 +438,10 @@ impl AppState {
|
||||
}
|
||||
|
||||
fn detect_changes_for_sources(&self, sources: &SourcesConfig) -> UpdateChanges {
|
||||
let (file_watch_changed, target_changes) = {
|
||||
let (file_watch_changed, provider_dns_changed, target_changes) = {
|
||||
let old_sources = self.app_config.sources.load();
|
||||
let file_watch_changed = old_sources.get_input_files() != sources.get_input_files();
|
||||
let provider_dns_changed = providers_changed(&old_sources.provider, &sources.provider);
|
||||
|
||||
let mut target_changes = HashMap::new();
|
||||
for source in &old_sources.sources {
|
||||
@@ -477,11 +491,12 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
(file_watch_changed, target_changes)
|
||||
(file_watch_changed, provider_dns_changed, target_changes)
|
||||
};
|
||||
|
||||
let mut changes = UpdateChanges { flags: UpdateChangesFlagsSet::new(), targets: Some(target_changes) };
|
||||
changes.set_flag_if(file_watch_changed, UpdateChangesFlags::FileWatch);
|
||||
changes.set_flag_if(provider_dns_changed, UpdateChangesFlags::ProviderDns);
|
||||
changes
|
||||
}
|
||||
|
||||
@@ -590,6 +605,21 @@ fn hdhomerun_changed(a: &HdHomeRunConfig, b: &HdHomeRunConfig) -> bool {
|
||||
|
||||
fn string_changed(a: &str, b: &str) -> bool { a != b }
|
||||
|
||||
fn providers_changed(a: &[Arc<ConfigProvider>], b: &[Arc<ConfigProvider>]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return true;
|
||||
}
|
||||
for lhs in a {
|
||||
let Some(rhs) = b.iter().find(|candidate| candidate.name == lhs.name) else {
|
||||
return true;
|
||||
};
|
||||
if lhs.urls != rhs.urls || lhs.dns != rhs.dns {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HdHomerunAppState {
|
||||
pub app_state: Arc<AppState>,
|
||||
|
||||
@@ -8,6 +8,7 @@ mod metadata_update_manager;
|
||||
mod model_utils;
|
||||
mod playlist_mem_cache;
|
||||
mod provider_config;
|
||||
mod provider_dns_manager;
|
||||
mod provider_lineup_manager;
|
||||
mod request;
|
||||
mod stream;
|
||||
@@ -19,7 +20,7 @@ mod xtream;
|
||||
pub(crate) use self::streams::*;
|
||||
pub use self::{
|
||||
active_provider_manager::*, app_state::*, connection_manager::*, event_manager::*, metadata_update_manager::*,
|
||||
playlist_mem_cache::*, provider_lineup_manager::*, stream::*, update_guard::*,
|
||||
playlist_mem_cache::*, provider_dns_manager::*, provider_lineup_manager::*, stream::*, update_guard::*,
|
||||
};
|
||||
pub(in crate::api) use self::{
|
||||
active_user_manager::*, download::*, model_utils::*, provider_config::*, request::*, stream_error::*, xtream::*,
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
use crate::api::model::AppState;
|
||||
use crate::model::ConfigProvider;
|
||||
use crate::utils::read_sources_file_from_path;
|
||||
use log::{debug, warn};
|
||||
use shared::model::{DnsPrefer, OnResolveErrorPolicy, SourcesConfigDto};
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::fs;
|
||||
use tokio::net::lookup_host;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn filter_by_preference(ips: Vec<IpAddr>, prefer: DnsPrefer) -> Vec<IpAddr> {
|
||||
match prefer {
|
||||
DnsPrefer::System => ips,
|
||||
DnsPrefer::Ipv4 => ips.into_iter().filter(IpAddr::is_ipv4).collect(),
|
||||
DnsPrefer::Ipv6 => ips.into_iter().filter(IpAddr::is_ipv6).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dedup_keep_order(ips: Vec<IpAddr>) -> Vec<IpAddr> {
|
||||
let mut seen = HashSet::new();
|
||||
ips.into_iter().filter(|ip| seen.insert(*ip)).collect()
|
||||
}
|
||||
|
||||
async fn resolve_hostname(hostname: &str, prefer: DnsPrefer, max_addrs: Option<usize>) -> std::io::Result<Vec<IpAddr>> {
|
||||
let addrs = lookup_host((hostname, 0)).await?;
|
||||
let mut ips: Vec<IpAddr> = addrs.map(|addr| addr.ip()).collect();
|
||||
ips = dedup_keep_order(ips);
|
||||
ips = filter_by_preference(ips, prefer);
|
||||
if let Some(max) = max_addrs.filter(|max| *max > 0) {
|
||||
ips.truncate(max);
|
||||
}
|
||||
Ok(ips)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ProviderResolveStats {
|
||||
total: usize,
|
||||
overridden: usize,
|
||||
resolved: usize,
|
||||
empty: usize,
|
||||
failed: usize,
|
||||
}
|
||||
|
||||
async fn resolve_provider(provider: &Arc<ConfigProvider>) -> ProviderResolveStats {
|
||||
let mut stats = ProviderResolveStats::default();
|
||||
let Some(dns_cfg) = provider.get_dns_config().cloned() else {
|
||||
return stats;
|
||||
};
|
||||
if !dns_cfg.enabled {
|
||||
return stats;
|
||||
}
|
||||
|
||||
let hostnames = provider.hostnames_from_urls();
|
||||
stats.total = hostnames.len();
|
||||
if hostnames.is_empty() {
|
||||
debug!(
|
||||
"Provider dns task '{}' found no hostname URLs to resolve (urls={:?})",
|
||||
provider.name, provider.urls
|
||||
);
|
||||
return stats;
|
||||
}
|
||||
|
||||
for host in hostnames {
|
||||
if let Some(overridden) = dns_cfg.overrides.get(&host) {
|
||||
provider.store_resolved(&host, overridden.clone());
|
||||
stats.overridden += 1;
|
||||
stats.resolved += 1;
|
||||
debug!("Provider dns '{}' host '{}' resolved from override: {:?}", provider.name, host, overridden);
|
||||
continue;
|
||||
}
|
||||
|
||||
match resolve_hostname(&host, dns_cfg.prefer, dns_cfg.max_addrs).await {
|
||||
Ok(ips) if !ips.is_empty() => {
|
||||
debug!("Provider dns '{}' host '{}' resolved: {:?}", provider.name, host, ips);
|
||||
provider.store_resolved(&host, ips);
|
||||
stats.resolved += 1;
|
||||
}
|
||||
Ok(_) => {
|
||||
stats.empty += 1;
|
||||
provider.mark_resolve_error(&host, "DNS resolution returned no addresses");
|
||||
if dns_cfg.on_resolve_error == OnResolveErrorPolicy::FallbackToHostname {
|
||||
provider.clear_resolved(&host);
|
||||
}
|
||||
warn!(
|
||||
"Provider dns '{}' host '{}' returned empty address set (policy={:?})",
|
||||
provider.name, host, dns_cfg.on_resolve_error
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
stats.failed += 1;
|
||||
provider.mark_resolve_error(&host, err.to_string());
|
||||
if dns_cfg.on_resolve_error == OnResolveErrorPolicy::FallbackToHostname {
|
||||
provider.clear_resolved(&host);
|
||||
}
|
||||
warn!("provider dns resolve failed for '{}' host '{}': {err}", provider.name, host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
}
|
||||
|
||||
fn serialize_sources_for_persist(sources: &SourcesConfigDto) -> io::Result<String> {
|
||||
let mut serialized = String::new();
|
||||
let options = serde_saphyr::SerializerOptions {
|
||||
prefer_block_scalars: false,
|
||||
..Default::default()
|
||||
};
|
||||
serde_saphyr::to_fmt_writer_with_options(&mut serialized, sources, options)
|
||||
.map_err(|err| io::Error::other(format!("Could not serialize source.yml: {err}")))?;
|
||||
Ok(serialized)
|
||||
}
|
||||
|
||||
async fn write_sources_file_force(path: &Path, sources: &SourcesConfigDto) -> io::Result<()> {
|
||||
let serialized = serialize_sources_for_persist(sources)?;
|
||||
let parent_dir = path.parent().ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"Could not write source.yml '{}': missing parent directory",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
let dest_file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("source.yml");
|
||||
let mut tmp_path = parent_dir.to_path_buf();
|
||||
tmp_path.push(format!(
|
||||
".{dest_file_name}.tmp-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Local::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
));
|
||||
|
||||
fs::write(&tmp_path, serialized).await?;
|
||||
match fs::rename(&tmp_path, path).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Try to rename again after removing destination (Windows often needs this)
|
||||
if let Ok(()) = fs::remove_file(path).await {
|
||||
if fs::rename(&tmp_path, path).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
// Rename still failed after removing dest - fall through to clean up
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&tmp_path).await;
|
||||
Err(io::Error::other(format!(
|
||||
"Could not replace '{}' with '{}': {err}",
|
||||
path.display(),
|
||||
tmp_path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_provider_resolved_to_source_file(app_state: &Arc<AppState>, provider: &Arc<ConfigProvider>) {
|
||||
let source_file = {
|
||||
let paths = app_state.app_config.paths.load();
|
||||
paths.sources_file_path.clone()
|
||||
};
|
||||
let source_path = PathBuf::from(&source_file);
|
||||
let _lock = app_state.app_config.file_locks.write_lock(&source_path).await;
|
||||
|
||||
let mut sources_dto = match read_sources_file_from_path(&source_path, false, false, None).await {
|
||||
Ok(dto) => dto,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Provider dns '{}' failed to read source.yml '{}': {err}",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some(provider_dtos) = sources_dto.provider.as_mut() else {
|
||||
debug!(
|
||||
"Provider dns '{}' source.yml '{}' has no provider section to persist resolved values",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(provider_dto) = provider_dtos.iter_mut().find(|dto| dto.name.as_ref() == provider.name.as_ref()) else {
|
||||
warn!(
|
||||
"Provider dns '{}' not found in source.yml '{}', cannot persist resolved values",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(dns_dto) = provider_dto.dns.as_mut() else {
|
||||
debug!(
|
||||
"Provider dns '{}' has no dns section in source.yml '{}', skipping resolved persist",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let resolved_hosts = {
|
||||
let snapshot = provider.snapshot_resolved();
|
||||
dns_dto.resolved = (!snapshot.is_empty()).then_some(snapshot);
|
||||
dns_dto.resolved.as_ref().map_or(0, std::collections::HashMap::len)
|
||||
};
|
||||
|
||||
match write_sources_file_force(&source_path, &sources_dto).await {
|
||||
Ok(()) => {
|
||||
debug!(
|
||||
"Provider dns '{}' persisted dns.resolved to '{}' (hosts={resolved_hosts})",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Provider dns '{}' failed to persist dns.resolved to '{}': {err}",
|
||||
provider.name,
|
||||
source_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_provider_dns_task(app_state: Arc<AppState>, provider_name: Arc<str>, cancel: CancellationToken) {
|
||||
tokio::spawn(async move {
|
||||
let mut refresh_secs = 300_u64;
|
||||
{
|
||||
let sources = app_state.app_config.sources.load();
|
||||
if let Some(provider) = sources.get_provider_by_name(provider_name.as_ref()) {
|
||||
refresh_secs = provider.get_dns_config().map_or(300, |dns| dns.refresh_secs.max(10));
|
||||
}
|
||||
}
|
||||
// Add initial jitter (0-10% of refresh interval) to prevent thundering herd
|
||||
let jitter_ms = (rand::random::<u64>() % (refresh_secs * 100)).min(5000);
|
||||
tokio::time::sleep(Duration::from_millis(jitter_ms)).await;
|
||||
debug!("Starting provider dns task for '{provider_name}' (refresh={refresh_secs}s)");
|
||||
loop {
|
||||
tokio::select! {
|
||||
() = cancel.cancelled() => {
|
||||
debug!("Stopping provider dns task for '{provider_name}'");
|
||||
break;
|
||||
}
|
||||
() = async {
|
||||
let start = Instant::now();
|
||||
debug!("Provider dns tick '{provider_name}' started");
|
||||
let provider = {
|
||||
let sources = app_state.app_config.sources.load();
|
||||
sources.get_provider_by_name(provider_name.as_ref()).cloned()
|
||||
};
|
||||
let Some(provider) = provider else {
|
||||
warn!("Provider dns '{provider_name}' not found in runtime sources, retrying");
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
return;
|
||||
};
|
||||
refresh_secs = provider.get_dns_config().map_or(300, |dns| dns.refresh_secs.max(10));
|
||||
let stats = resolve_provider(&provider).await;
|
||||
persist_provider_resolved_to_source_file(&app_state, &provider).await;
|
||||
let cache_hosts = provider.snapshot_resolved().len();
|
||||
debug!(
|
||||
"Provider dns tick '{}' finished: total_hosts={} resolved={} overridden={} empty={} failed={} cache_hosts={} elapsed_ms={}",
|
||||
provider.name,
|
||||
stats.total,
|
||||
stats.resolved,
|
||||
stats.overridden,
|
||||
stats.empty,
|
||||
stats.failed,
|
||||
cache_hosts,
|
||||
start.elapsed().as_millis(),
|
||||
);
|
||||
debug!("Provider dns '{}' next tick in {}s", provider.name, refresh_secs);
|
||||
tokio::time::sleep(Duration::from_secs(refresh_secs)).await;
|
||||
} => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn exec_provider_dns(app_state: &Arc<AppState>, cancel: &CancellationToken) {
|
||||
let sources = app_state.app_config.sources.load();
|
||||
let provider_names: Vec<_> = sources
|
||||
.provider
|
||||
.iter()
|
||||
.filter(|provider| provider.get_dns_config().is_some_and(|dns| dns.enabled))
|
||||
.map(|provider| provider.name.clone())
|
||||
.collect();
|
||||
drop(sources);
|
||||
|
||||
if provider_names.is_empty() {
|
||||
debug!("Provider dns manager: no enabled providers found");
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("Provider dns manager: starting {} provider task(s)", provider_names.len());
|
||||
|
||||
for provider_name in provider_names {
|
||||
spawn_provider_dns_task(Arc::clone(app_state), provider_name, cancel.clone());
|
||||
}
|
||||
}
|
||||
@@ -1120,7 +1120,7 @@ async fn patch_source_yml_add_alias(
|
||||
password: &str,
|
||||
exp_date: Option<i64>,
|
||||
) -> Result<(), TuliproxError> {
|
||||
let mut sources = match read_sources_file_from_path(source_file_path, false, false, None) {
|
||||
let mut sources = match read_sources_file_from_path(source_file_path, false, false, None).await {
|
||||
Ok(sources) => sources,
|
||||
Err(e) => return info_err_res!("panel_api: failed to read source file: {e}"),
|
||||
};
|
||||
@@ -1416,6 +1416,7 @@ async fn persist_sources_yml_with_patches(
|
||||
return Ok(false);
|
||||
}
|
||||
let mut sources = read_sources_file_from_path(sources_path, false, false, None)
|
||||
.await
|
||||
.map_err(|e| info_err!("panel_api: failed to read source file: {e}"))?;
|
||||
|
||||
let changed = apply_sources_yml_patches(&mut sources, patches)?;
|
||||
@@ -1434,7 +1435,7 @@ async fn patch_source_yml_update_exp_date(
|
||||
account_name: &Arc<str>,
|
||||
exp_date: i64,
|
||||
) -> Result<(), TuliproxError> {
|
||||
let mut sources = match read_sources_file_from_path(source_file_path, false, false, None) {
|
||||
let mut sources = match read_sources_file_from_path(source_file_path, false, false, None).await {
|
||||
Ok(sources) => sources,
|
||||
Err(e) => return info_err_res!("panel_api: failed to read source file: {e}"),
|
||||
};
|
||||
|
||||
@@ -203,7 +203,7 @@ fn create_default_draft() -> AppConfigDto {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_initial_draft(paths: &ConfigPaths) -> AppConfigDto {
|
||||
async fn build_initial_draft(paths: &ConfigPaths) -> AppConfigDto {
|
||||
let mut draft = create_default_draft();
|
||||
|
||||
if file_exists(&paths.config_file_path) {
|
||||
@@ -219,8 +219,10 @@ fn build_initial_draft(paths: &ConfigPaths) -> AppConfigDto {
|
||||
false,
|
||||
false,
|
||||
draft.config.get_hdhr_device_overview().as_ref(),
|
||||
None,
|
||||
) {
|
||||
None
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(src) => draft.sources = src,
|
||||
Err(err) => warn!("Setup mode: failed to load existing source.yml: {err}"),
|
||||
}
|
||||
@@ -980,7 +982,7 @@ fn create_compression_layer() -> tower_http::compression::CompressionLayer {
|
||||
}
|
||||
|
||||
pub async fn start_setup_server(paths: &ConfigPaths, missing_files: &[String]) -> Result<(), TuliproxError> {
|
||||
let draft = build_initial_draft(paths);
|
||||
let draft = build_initial_draft(paths).await;
|
||||
let (host, port, web_root) = setup_bind_values(&draft);
|
||||
let web_dir = resolve_setup_web_dir(&web_root)
|
||||
.ok_or_else(|| info_err!("Setup mode requires a web directory. Tried '{}'", web_root.display(),))?;
|
||||
|
||||
Reference in New Issue
Block a user