mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-18 13:52:20 +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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user