Files
telemt/src/maestro/tls_bootstrap.rs
T
2026-07-19 18:41:23 +03:00

382 lines
13 KiB
Rust

use std::sync::Arc;
use std::time::Duration;
use rand::RngExt;
use tracing::warn;
use crate::config::ProxyConfig;
use crate::error::{ProxyError, Result};
use crate::startup::{COMPONENT_TLS_FRONT_BOOTSTRAP, StartupTracker};
use crate::tls_front::TlsFrontCache;
use crate::tls_front::fetcher::TlsFetchStrategy;
use crate::transport::UpstreamManager;
use super::generation::RuntimeTaskScope;
/// Readiness requirement for TLS-front cache initialization.
#[derive(Clone, Copy)]
pub(crate) enum TlsBootstrapPolicy {
BestEffort,
RequireReady,
}
#[derive(Clone)]
struct TlsFetchContext {
cache: Arc<TlsFrontCache>,
domains: Vec<String>,
mask_host: String,
primary_domain: String,
mask_unix_sock: Option<String>,
tls_fetch_scope: Option<String>,
upstream_manager: Arc<UpstreamManager>,
strategy: TlsFetchStrategy,
port: u16,
proxy_protocol: u8,
}
impl TlsFetchContext {
async fn fetch_all(&self, failure_message: &'static str) {
let mut join = tokio::task::JoinSet::new();
for domain in self.domains.clone() {
let cache = self.cache.clone();
let host = tls_fetch_host_for_domain(&self.mask_host, &self.primary_domain, &domain);
let unix_sock = self.mask_unix_sock.clone();
let scope = self.tls_fetch_scope.clone();
let upstream = self.upstream_manager.clone();
let strategy = self.strategy.clone();
let port = self.port;
let proxy_protocol = self.proxy_protocol;
join.spawn(async move {
match crate::tls_front::fetcher::fetch_real_tls_with_strategy(
&host,
port,
&domain,
&strategy,
Some(upstream),
scope.as_deref(),
proxy_protocol,
unix_sock.as_deref(),
)
.await
{
Ok(result) => cache.update_from_fetch(&domain, result).await,
Err(error) => warn!(domain = %domain, error = %error, failure_message),
}
});
}
while let Some(result) = join.join_next().await {
if let Err(error) = result {
warn!(error = %error, "TLS emulation fetch task join failed");
}
}
}
async fn fetch_all_with_budget(&self, phase: &'static str) {
if tokio::time::timeout(self.strategy.total_budget, self.fetch_all(phase))
.await
.is_err()
{
warn!(
phase,
timeout_ms = self.strategy.total_budget.as_millis(),
"TLS emulation fetch budget exhausted"
);
}
}
}
fn tls_fetch_host_for_domain(mask_host: &str, primary_tls_domain: &str, domain: &str) -> String {
if mask_host.eq_ignore_ascii_case(primary_tls_domain) {
domain.to_string()
} else {
mask_host.to_string()
}
}
fn readiness_error(default_domains: &[String]) -> Option<String> {
(!default_domains.is_empty()).then(|| {
format!(
"TLS-front profiles are not ready for domains: {}",
default_domains.join(", ")
)
})
}
/// Initializes the TLS-front cache and generation-owned refresh tasks.
pub(crate) async fn bootstrap_tls_front(
config: &ProxyConfig,
tls_domains: &[String],
upstream_manager: Arc<UpstreamManager>,
startup_tracker: &Arc<StartupTracker>,
task_scope: RuntimeTaskScope,
policy: TlsBootstrapPolicy,
) -> Result<Option<Arc<TlsFrontCache>>> {
startup_tracker
.start_component(
COMPONENT_TLS_FRONT_BOOTSTRAP,
Some("initialize TLS front cache/bootstrap tasks".to_string()),
)
.await;
if !config.censorship.tls_emulation {
startup_tracker
.skip_component(
COMPONENT_TLS_FRONT_BOOTSTRAP,
Some("censorship.tls_emulation is false".to_string()),
)
.await;
return Ok(None);
}
let cache = Arc::new(TlsFrontCache::new(
tls_domains,
config.censorship.fake_cert_len,
&config.censorship.tls_front_dir,
));
cache.load_from_disk().await;
let tls_fetch = config.censorship.tls_fetch.clone();
let fetch_context = TlsFetchContext {
cache: cache.clone(),
domains: tls_domains.to_vec(),
mask_host: config
.censorship
.mask_host
.clone()
.unwrap_or_else(|| config.censorship.tls_domain.clone()),
primary_domain: config.censorship.tls_domain.clone(),
mask_unix_sock: config.censorship.mask_unix_sock.clone(),
tls_fetch_scope: (!config.censorship.tls_fetch_scope.is_empty())
.then(|| config.censorship.tls_fetch_scope.clone()),
upstream_manager,
strategy: TlsFetchStrategy {
profiles: tls_fetch.profiles,
strict_route: tls_fetch.strict_route,
attempt_timeout: Duration::from_millis(tls_fetch.attempt_timeout_ms.max(1)),
total_budget: Duration::from_millis(tls_fetch.total_budget_ms.max(1)),
grease_enabled: tls_fetch.grease_enabled,
deterministic: tls_fetch.deterministic,
profile_cache_ttl: Duration::from_secs(tls_fetch.profile_cache_ttl_secs),
},
port: config.censorship.mask_port,
proxy_protocol: config.censorship.mask_proxy_protocol,
};
match policy {
TlsBootstrapPolicy::BestEffort => {
let initial_fetch = fetch_context.clone();
let fake_cert_len = config.censorship.fake_cert_len;
task_scope.spawn(async move {
initial_fetch
.fetch_all_with_budget("TLS emulation initial fetch failed")
.await;
for domain in initial_fetch
.cache
.default_profile_domains(&initial_fetch.domains)
.await
{
warn!(
domain = %domain,
timeout_ms = initial_fetch.strategy.total_budget.as_millis(),
fake_cert_len,
"TLS-front fetch not ready within timeout; using cache/default fake cert fallback"
);
}
});
}
TlsBootstrapPolicy::RequireReady => {
fetch_context
.fetch_all_with_budget("TLS emulation initial fetch failed")
.await;
let default_domains = cache.default_profile_domains(tls_domains).await;
if let Some(error) = readiness_error(&default_domains) {
startup_tracker
.fail_component(COMPONENT_TLS_FRONT_BOOTSTRAP, Some(error.clone()))
.await;
return Err(ProxyError::Proxy(error));
}
}
}
let refresh_context = fetch_context;
task_scope.spawn(async move {
loop {
let base_secs = rand::rng().random_range(4 * 3600..=6 * 3600);
let jitter_secs = rand::rng().random_range(0..=7200);
tokio::time::sleep(Duration::from_secs(base_secs + jitter_secs)).await;
refresh_context
.fetch_all_with_budget("TLS emulation refresh failed")
.await;
}
});
startup_tracker
.complete_component(
COMPONENT_TLS_FRONT_BOOTSTRAP,
Some("tls front cache is initialized".to_string()),
)
.await;
Ok(Some(cache))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::startup::StartupComponentStatus;
use crate::stats::Stats;
fn test_config(cache_dir: &std::path::Path) -> ProxyConfig {
let mut config = ProxyConfig::default();
config.censorship.tls_emulation = true;
config.censorship.tls_domain = "front.example".to_string();
config.censorship.mask_host = Some("127.0.0.1".to_string());
config.censorship.mask_port = 1;
config.censorship.tls_front_dir = cache_dir.display().to_string();
config.censorship.tls_fetch.profiles.truncate(1);
config.censorship.tls_fetch.attempt_timeout_ms = 10;
config.censorship.tls_fetch.total_budget_ms = 20;
config
}
fn upstream_manager(config: &ProxyConfig) -> Arc<UpstreamManager> {
Arc::new(UpstreamManager::new(
Vec::new(),
config.general.upstream_connect_retry_attempts,
config.general.upstream_connect_retry_backoff_ms,
config.general.upstream_connect_budget_ms,
config.general.tg_connect,
config.general.upstream_unhealthy_fail_threshold,
config.general.upstream_connect_failfast_hard_errors,
Arc::new(Stats::new()),
))
}
async fn tls_component_status(tracker: &StartupTracker) -> StartupComponentStatus {
tracker
.snapshot()
.await
.components
.into_iter()
.find(|component| component.id == COMPONENT_TLS_FRONT_BOOTSTRAP)
.unwrap()
.status
}
#[test]
fn tls_fetch_host_uses_each_domain_when_mask_host_is_primary_default() {
assert_eq!(
tls_fetch_host_for_domain("a.com", "a.com", "b.com"),
"b.com"
);
}
#[test]
fn tls_fetch_host_preserves_explicit_non_primary_mask_host() {
assert_eq!(
tls_fetch_host_for_domain("origin.example", "a.com", "b.com"),
"origin.example"
);
}
#[test]
fn readiness_rejects_only_default_profiles() {
assert!(readiness_error(&[]).is_none());
assert_eq!(
readiness_error(&["front.example".to_string()]),
Some("TLS-front profiles are not ready for domains: front.example".to_string())
);
}
#[tokio::test]
async fn require_ready_rejects_default_cache_after_bounded_fetch_failure() {
let cache_dir = tempfile::tempdir().unwrap();
let config = test_config(cache_dir.path());
let domains = vec![config.censorship.tls_domain.clone()];
let tracker = Arc::new(StartupTracker::new(1));
let scope = RuntimeTaskScope::new();
let result = bootstrap_tls_front(
&config,
&domains,
upstream_manager(&config),
&tracker,
scope.clone(),
TlsBootstrapPolicy::RequireReady,
)
.await;
assert!(result.is_err());
assert_eq!(
tls_component_status(&tracker).await,
StartupComponentStatus::Failed
);
scope.stop().await;
}
#[tokio::test]
async fn require_ready_accepts_non_default_disk_cache_when_refresh_fails() {
let cache_dir = tempfile::tempdir().unwrap();
let config = test_config(cache_dir.path());
let domains = vec![config.censorship.tls_domain.clone()];
let seed = TlsFrontCache::new(&domains, config.censorship.fake_cert_len, cache_dir.path());
let mut cached = seed.default_entry().as_ref().clone();
cached.domain = domains[0].clone();
tokio::fs::write(
cache_dir.path().join("front.example.json"),
serde_json::to_vec(&cached).unwrap(),
)
.await
.unwrap();
let tracker = Arc::new(StartupTracker::new(1));
let scope = RuntimeTaskScope::new();
let cache = bootstrap_tls_front(
&config,
&domains,
upstream_manager(&config),
&tracker,
scope.clone(),
TlsBootstrapPolicy::RequireReady,
)
.await
.unwrap()
.unwrap();
assert!(cache.default_profile_domains(&domains).await.is_empty());
assert_eq!(
tls_component_status(&tracker).await,
StartupComponentStatus::Ready
);
scope.stop().await;
}
#[tokio::test]
async fn best_effort_returns_ready_and_refresh_tasks_are_scope_owned() {
let cache_dir = tempfile::tempdir().unwrap();
let config = test_config(cache_dir.path());
let domains = vec![config.censorship.tls_domain.clone()];
let tracker = Arc::new(StartupTracker::new(1));
let scope = RuntimeTaskScope::new();
let cache = bootstrap_tls_front(
&config,
&domains,
upstream_manager(&config),
&tracker,
scope.clone(),
TlsBootstrapPolicy::BestEffort,
)
.await
.unwrap();
assert!(cache.is_some());
assert_eq!(
tls_component_status(&tracker).await,
StartupComponentStatus::Ready
);
tokio::time::timeout(Duration::from_secs(1), scope.stop())
.await
.unwrap();
}
}